Authentication
GeckoGuard uses different credentials for different operations. This page tells you exactly which credential each endpoint accepts so you can stop guessing.
Auth matrix
The single most important table in the docs. Pick the row that matches what you're doing:
| Operation | Endpoint prefix | Credential | How to send it |
|---|---|---|---|
| Validate a license at runtime | POST /v1/licenses/authorize | API key + signed request | X-Api-Key: gg_… plus X-GG-Timestamp, X-GG-Nonce, X-GG-Signature headers |
| Token introspection ("is my key valid?") | GET /v1/whoami | API key | X-Api-Key: gg_… |
| Backend integration (recommended path): create/list/revoke licenses, manage blacklists, read analytics | /v1/products/:productId/* | API key with management scopes | X-Api-Key: gg_… |
| List API keys for a product | GET /v1/products/:id/api-keys | API key | X-Api-Key: gg_… |
| Dashboard UI operations (alt path used by the web UI) | /v1/dashboard/* | JWT access token from /v1/auth/login | Authorization: Bearer <accessToken> |
| Manage the current user (orgs, profile, active org) | /v1/dashboard/me, /v1/dashboard/orgs | JWT access token | Authorization: Bearer <accessToken> |
| Bootstrap the very first product / API key | POST /v1/products, POST /v1/api-keys | Bootstrap admin token | X-Admin-Token: <BOOTSTRAP_ADMIN_TOKEN> |
| End-user portal (your customers' accounts) | /v1/enduser/* | End-user JWT | Authorization: Bearer <endUserToken> |
| Status / health checks | GET /health, GET /v1/status | none (public) | — |
For backend integrations (Discord bots, admin tools, CRMs), use /v1/products/:productId/* with an API key — the recommended path. API keys carry granular scopes (license:create, license:revoke, blacklist:write, etc.) and are long-lived. The dashboard offers a scope picker so you grant only what your bot needs.
API keys are NOT accepted on /v1/dashboard/*. Those endpoints are JWT-only (used by the GeckoGuard web UI). Sending an API key there returns 401 UNAUTHORIZED. They exist mainly for the dashboard itself; new backend integrations should target /v1/products/:productId/*.
X-Admin-Tokenis for one-time bootstrap only. It exists to break the chicken-and-egg of "you need a product to create an API key, but you need an API key to create a product." Use it once at install time and then never again. Ongoing automation must use an API key — not the bootstrap token. The endpoint also requiresBOOTSTRAP_ENABLED=trueon the server, which is normally off in production.
Backend integrations: API key with management scopes
This is the path you want for any server-side automation that mints, lists, revokes, or otherwise manages licenses on behalf of your customers.
Step 1 — issue an API key in the dashboard. Go to API Keys → Create, pick the product, and check the management scopes you need:
| Scope | What it lets the key do |
|---|---|
license:read | List + read licenses (GET /v1/products/:productId/licenses, GET .../licenses/:id) |
license:create | Create new licenses (POST .../licenses) |
license:update | Update expiry, policy, metadata (PATCH .../licenses/:id) |
license:delete | Permanently delete a license (destructive — prefer revoke) |
license:revoke / license:unrevoke | Toggle a license's REVOKED status |
license:reset_hwid / license:reset_ip | Clear hardware-ID / IP bindings on a license |
blacklist:read / blacklist:write | List / add / delete HWID & IP blacklist entries |
end_user:read | Look up license owners (org-wide via /v1/end-users, per-product via /v1/products/:productId/end-users) and include endUserId / endUserUsername / endUserEmail on license reads |
analytics:read | Read product-scoped license counts grouped by status |
Step 2 — call the API directly with X-Api-Key. No login flow, no refresh loop, no 2FA dance:
curl -X POST https://api.geckoguard.net/v1/products/$PRODUCT_ID/licenses \
-H "X-Api-Key: $GECKO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: bot-$(date +%s)" \
-d '{"expirationMode":"afterActivation","expiresAfterDays":30}'
Each key is bound to one product. To work across multiple products, issue one key per product. Routes return 403 FORBIDDEN if the key's productId doesn't match the path's :productId. Routes return 403 PERMISSION_DENIED (with the missing-scope name in the error message) if the key lacks the required scope — re-issue or edit the key to grant it.
Step 3 — verify your key with /v1/whoami before writing code.
curl -H "X-Api-Key: $GECKO_API_KEY" https://api.geckoguard.net/v1/whoami
# → { "ok": true, "data": { "apiKeyId": "…", "productId": "…" } }
If this returns 200, your key is valid. The next call you should make is GET /v1/products/$PRODUCT_ID/licenses?pageSize=1 — confirms the key has license:read and is bound to the right product.
For the full list of management routes, see API Reference → Management endpoints.
Two credentials, two flows
1. JWT access token (management)
For everything under /v1/dashboard/*. Get one with email + password:
curl -X POST https://api.geckoguard.net/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"…"}'
Response (no 2FA):
{
"ok": true,
"data": {
"user": { "id": "…", "email": "you@example.com", "emailVerified": true },
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"refreshToken": "eyJhbGciOiJIUzI1NiIs…"
}
}
}
Use accessToken in Authorization: Bearer … on every dashboard call.
Token lifecycle
| Token | Default TTL | Behavior |
|---|---|---|
| Access | 15 minutes | Stateless JWT. Verified on every request. |
| Refresh | 30 days | DB-backed and revocable. Single-use — each /v1/auth/refresh call revokes the presented token and issues a new access+refresh pair (sliding rotation). |
Sample refresh loop: refresh roughly every 10 minutes (well before the 15-min access TTL), or lazily when a request returns 401.
curl -X POST https://api.geckoguard.net/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"…"}'
POST /v1/auth/logout revokes the refresh token immediately and adds it to a Redis denylist for the remainder of its TTL — useful when a credential is suspected compromised. Access tokens are stateless and remain valid until they expire (worst case: 15 min after logout). To invalidate access tokens immediately you must rotate JWT_ACCESS_SECRET — contact support.
If the same refresh token is presented twice, the second call returns 401 UNAUTHORIZED (the first call revoked it). This catches token theft as long as the legitimate client is also actively rotating: whichever party loses the race gets logged out.
2FA-enabled accounts (interactive only)
If 2FA is enabled, /v1/auth/login returns a challenge instead:
{
"ok": true,
"data": {
"requires2FA": true,
"challengeToken": "…",
"user": { "id": "…", "email": "…" }
}
}
You must complete the challenge by calling POST /v1/auth/totp/verify-login with the challengeToken plus a current TOTP code (or a one-shot backup code) to receive real tokens.
Footgun for bots: there is no programmatic way to bypass 2FA. A backend integration that depends on
/v1/auth/loginwill break the moment the user enables 2FA. Until service tokens ship, bots should authenticate as a dedicated service-account user with 2FA disabled — not the human's primary account.
2. API key (license validation)
API keys authenticate runtime calls from your application — primarily /v1/licenses/authorize. Each key is scoped to a single product and to a set of permissions.
Creating an API key
API keys are managed from the dashboard UI ("API Keys" → "Create API Key"). You must select a product and at least one permission — keys with no permissions are rejected at request time with 403 API_KEY_NO_PERMISSIONS. The available permissions:
| Permission | Description |
|---|---|
license:authorize | Validate license keys via /v1/licenses/authorize |
license:read | Read license details |
license:write | Create and update licenses |
license:delete | Delete licenses |
product:read | Read product details |
product:write | Update product settings |
The key string is shown once at creation. Copy it immediately.
Sending an API key
Use X-Api-Key (case-insensitive variants and Authorization: Bearer gg_… are also accepted, but standardize on X-Api-Key):
curl -H 'X-Api-Key: gg_live_…' https://api.geckoguard.net/v1/whoami
Token introspection: /v1/whoami
Before writing code against an unfamiliar key, confirm what it can do:
curl -H 'X-Api-Key: gg_live_…' https://api.geckoguard.net/v1/whoami
Response:
{
"ok": true,
"data": {
"apiKeyId": "9f1c…",
"productId": "abc-123-…"
}
}
If the call returns 401 UNAUTHORIZED, the key is invalid, revoked, or you're sending it on the wrong header. If it returns 403 API_KEY_NO_PERMISSIONS, the key exists but has no scopes — re-issue it from the dashboard with at least one permission.
For JWT introspection (which user, which orgs, which roles), use GET /v1/dashboard/me with the access token in Authorization: Bearer ….
Signed requests for /v1/licenses/authorize
/v1/licenses/authorize is the only endpoint that requires HMAC signing in addition to an API key, because it's the brute-force target for stolen keys. Signing is enforced by default in production (SDK_SIGNING_REQUIRED=true). Unsigned requests are rejected with 401 SIGNATURE_REQUIRED.
You have two options:
Option A — use a GeckoGuard SDK. Every official SDK signs requests for you. If you're not building a CLI/Discord bot/internal tool yourself, this is the path.
Option B — sign requests yourself. The recipe below is the same one the SDKs implement.
Required headers
| Header | Value |
|---|---|
X-GG-Timestamp | Unix seconds (current time). Must be within ±300 seconds of server time. |
X-GG-Nonce | Random string, 16–64 chars. Each nonce can be used once within a 10-minute window. |
X-GG-Signature | Hex-encoded HMAC-SHA256 over the canonical string below. |
Canonical string
METHOD\n
PATH\n
TIMESTAMP\n
NONCE\n
BODY_SHA256_HEX
METHOD— uppercase HTTP method (POST)PATH— the path the request was sent to, with query string stripped (e.g./v1/licenses/authorize)TIMESTAMP— the same value sent inX-GG-TimestampNONCE— the same value sent inX-GG-NonceBODY_SHA256_HEX—sha256(rawRequestBody)as lowercase hex. For an empty body, hash an empty string.
Lines are joined with literal \n (LF, single byte). HMAC-SHA256 the canonical string with your API key's signing secret (or SDK_SIGNING_SECRET if your deployment uses a global secret). Hex-encode the result and put it in X-GG-Signature.
Reference implementation (Node.js)
import crypto from 'node:crypto';
function signRequest({ method, path, body, signingSecret }) {
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const bodyRaw = body ? JSON.stringify(body) : '';
const bodyHash = crypto.createHash('sha256').update(bodyRaw).digest('hex');
const canonical = `${method.toUpperCase()}\n${path}\n${ts}\n${nonce}\n${bodyHash}`;
const signature = crypto.createHmac('sha256', signingSecret).update(canonical).digest('hex');
return {
headers: {
'X-GG-Timestamp': ts,
'X-GG-Nonce': nonce,
'X-GG-Signature': signature
},
body: bodyRaw
};
}
const body = { productId: 'abc-123', licenseKey: 'GK-…' };
const { headers, body: raw } = signRequest({
method: 'POST',
path: '/v1/licenses/authorize',
body,
signingSecret: process.env.GECKO_SIGNING_SECRET
});
await fetch('https://api.geckoguard.net/v1/licenses/authorize', {
method: 'POST',
headers: {
'X-Api-Key': process.env.GECKO_API_KEY,
'Content-Type': 'application/json',
...headers
},
body: raw
});
Signing-related error codes
| Code | HTTP | Meaning |
|---|---|---|
SIGNATURE_REQUIRED | 401 | Signing is required and one of the three headers was missing |
INVALID_SIGNATURE | 401 | Bad timestamp, bad nonce format, or signature didn't match |
SIGNATURE_EXPIRED | 401 | X-GG-Timestamp was outside the ±300s skew window — clock drift |
NONCE_REUSED | 401 | The nonce was already seen within the 10-minute window |
SIGNING_NOT_CONFIGURED | 500 | Server-side: no signing secret configured. Contact support. |
SIGNING_UNAVAILABLE | 503 | Server-side: nonce store is down. Retry shortly. |
Rate limits
Limits are enforced as separate buckets: per-IP, per-API-key, and per-product. Which buckets apply depends on what credential you sent.
| Credential on the request | Buckets enforced |
|---|---|
API key (X-Api-Key) | per-IP + per-key (plan-based) + per-product |
JWT access token (Authorization: Bearer <jwt>) on /v1/dashboard/* | per-IP only |
/v1/licenses/authorize | all of the above + per-license-key |
| No credential (public endpoints) | per-IP only |
The default per-IP limit is 120 requests/minute (API_KEY_IP_LIMIT_PER_MIN, env-configurable per deployment). A bot batching dashboard operations from a single IP shares that bucket with everything else from that IP — pace at well below 120/min, or run from multiple egress IPs.
Per-key limits are plan-based. Free tier defaults to 60/min; higher tiers go up to unlimited. Check your plan in the dashboard.
Per-product ceiling defaults to 10,000/min and stops a single product from monopolizing capacity even across many keys. You shouldn't notice it unless you're operating at scale.
Headers on every response:
| Header | Counts |
|---|---|
X-RateLimit-Limit-IP / X-RateLimit-Remaining-IP | This minute's per-IP budget |
X-RateLimit-Limit-Key / X-RateLimit-Remaining-Key | This minute's per-key budget (only when API key sent) |
X-RateLimit-Limit-Product / X-RateLimit-Remaining-Product | This minute's per-product budget |
Retry-After | Seconds to wait before retrying (only on 429) |
A value of -1 means unlimited.
/v1/licenses/authorize adds a per-license-key limit of 20 requests/minute. Real clients re-authorize once per session, so this comfortably absorbs retries while making credential-stuffing infeasible against a single license.
429 responses use one of:
RATE_LIMITED— IP, key, or per-license limit hitPRODUCT_RATE_LIMITED— per-product ceiling hit (rare; usually means another integration is hammering the same product)
Always honor Retry-After. Implement exponential backoff on top of it.
Idempotency
Mutating endpoints support Idempotency-Key. If the network drops mid-request, replay the call with the same key and you'll get the same response back instead of creating a duplicate.
curl -X POST https://api.geckoguard.net/v1/dashboard/orgs/$ORG_ID/licenses \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: create-license-2026-04-29-abc' \
-d '{"productId":"abc-123","count":1}'
Rules:
- Key must match
[A-Za-z0-9_-]{8,128} - Cached for 24h, scoped per API key (or per user for JWT calls)
- Replays return the original response with
Idempotent-Replayed: true - Reusing the same key with a different body returns
409 IDEMPOTENCY_KEY_REUSE - Bad key format returns
400 BAD_IDEMPOTENCY_KEY
CSRF
/v1/dashboard/* enforces CSRF defense-in-depth, but server-to-server callers don't need to do anything special — any request with a Bearer JWT in the Authorization header is allowed through. The CSRF check only kicks in for cookie-authenticated browser requests, which use the Sec-Fetch-Site header or fall back to an Origin allow-list.
If your call returns 403 CSRF_BLOCKED, you almost certainly forgot the Authorization: Bearer header.
Security checklist
- Never commit API keys, signing secrets, or refresh tokens to version control. Use environment variables or a secret manager.
- Use minimal permissions. A license-validator key only needs
license:authorize. - Use different keys per environment. Dev / staging / prod should never share keys.
- Rotate suspected-compromised keys immediately in the dashboard. Rotation issues a new secret while keeping the same key ID.
- Verify with
/v1/whoamiafter deploying, so a misconfigured key fails fast at startup, not on the first user.