API Errors
Every error code the API can return, the HTTP status it pairs with, what triggers it, and what to do about it. If you hit a code that isn't on this page, treat it as a bug and file an issue — we'd rather fix the docs than have you guessing.
Response shape
Standard errors:
{
"ok": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"details": [ /* optional: per-field validation issues */ ]
}
}
License-validation errors (only on POST /v1/licenses/authorize) use a different envelope so a denial isn't confused with a transport failure:
{
"ok": false,
"allow": false,
"reasonCode": "HWID_MISMATCH",
"message": "Hardware ID does not match bound device"
}
The status code on a denial is 403, but the body's allow: false is the field you should branch on. See the reason codes below.
How to read this page
- Code — the literal string in
error.code. Match on this, not on the message. - Status — the HTTP status that comes with it.
- Triggered by — what your client did wrong (or what the server is telling you about its own state).
- Fix — the smallest action that gets you past it.
- Retry? —
nomeans retrying the same request will get the same error; fix the request first.yesmeans transient; back off and try again.clockmeans retry after a clock fix or fresh nonce.
Authentication & authorization
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
UNAUTHORIZED | 401 | Missing credential, malformed Authorization header, signature mismatch, or a token that doesn't correspond to a real user. On /v1/dashboard/* this is almost always "I sent an API key on a JWT-only endpoint" or "token never existed / was forged." | Check the auth matrix. Use Authorization: Bearer <accessToken> from /v1/auth/login for dashboard routes; use X-Api-Key for /v1/licenses/authorize and /v1/whoami. | no — re-login |
EXPIRED_TOKEN | 401 | The JWT was valid but past its exp claim. Distinct from UNAUTHORIZED so you can branch precisely. | Call POST /v1/auth/refresh with your refresh token to get a fresh access token, then retry the original request once. If refresh itself returns 401 UNAUTHORIZED, the refresh token is gone — re-login. | yes — refresh once, then retry the original call |
FORBIDDEN | 403 | Valid credential but you don't have access to the target resource (wrong product, wrong org, insufficient role). | Verify the resource's orgId/productId matches your token. For org operations, check your role with GET /v1/dashboard/me. | no |
API_KEY_NO_PERMISSIONS | 403 | The API key was created without any permissions (legacy state). | Re-issue the key from the dashboard with at least one permission (e.g. license:authorize). | no |
PERMISSION_DENIED | 403 | The API key lacks the specific scope required for this endpoint. The error message names the missing scope (e.g. "API key does not have permission: license:create"). | Edit the key in the dashboard (API Keys → [key] → Edit Permissions) and check the missing scope, then retry. No need to re-issue unless you've also rotated the secret. | no |
INSUFFICIENT_SCOPE | 403 | Same idea as PERMISSION_DENIED but for JWT scopes. | Use a token issued for a user with the required role, or re-auth. | no |
APIKEY_NOT_ALLOWED | 403 | You used an API key on an endpoint that's user-only (e.g. POST /v1/products outside bootstrap). | Use a JWT access token from /v1/auth/login. | no |
CSRF_BLOCKED | 403 | Browser-style request without a valid Authorization header, missing Sec-Fetch-Site, or Origin/Referer not on the allow-list. | Server-to-server callers: send Authorization: Bearer <jwt> and the check is bypassed. | no |
Request signing (license validation)
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
SIGNATURE_REQUIRED | 401 | One or more of X-GG-Timestamp / X-GG-Nonce / X-GG-Signature is missing on a /v1/licenses/authorize call. | Sign the request — see the signing recipe. Or use an official SDK. | no |
INVALID_SIGNATURE | 401 | Bad timestamp format, bad nonce length (must be 16–64 chars), or HMAC didn't match what the server computed. | Recompute the canonical string exactly: ${METHOD}\n${PATH}\n${TS}\n${NONCE}\n${BODY_SHA256_HEX}. Make sure path has no query string. | no |
SIGNATURE_EXPIRED | 401 | X-GG-Timestamp is more than ±300s from server time. | Sync your client clock (NTP). Don't cache signatures. | clock — re-sign with current timestamp + fresh nonce |
NONCE_REUSED | 401 | The nonce was already seen within the last 10 minutes. | Generate a fresh random nonce per request — never reuse. | clock — re-sign with a fresh nonce |
SIGNING_NOT_CONFIGURED | 500 | Server-side: no signing secret on the deployment. | Contact support. | no |
SIGNING_UNAVAILABLE | 503 | Server-side: nonce store (Redis) is unreachable. The server fails closed when signing is required. | Retry with backoff. If persistent, contact support. | yes |
Validation
All validation errors are no for retry — the server's verdict won't change unless your request changes.
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
VALIDATION_ERROR | 400 | Request body or query failed schema validation. error.details lists each {field, message} pair. | Fix the offending fields. | no |
BAD_REQUEST | 400 | Generic semantic error (e.g. count out of range, ill-formed file path). | Read error.message. | no |
NULL_CONSTRAINT | 400 | Required DB column missing. | Same as VALIDATION_ERROR — fill the field. | no |
BAD_IDEMPOTENCY_KEY | 400 | Idempotency-Key doesn't match [A-Za-z0-9_-]{8,128}. | Use a longer / cleaner key. | no |
NO_FILE | 400 | File-upload endpoint called without a multipart/form-data file. | Send the file. | no |
NO_ACTIVE_ORG | 400 | An endpoint that requires an active org context was called without one. | Set the active org via POST /v1/dashboard/active-org. | no |
MISSING_HOSTNAME | 400 | Storefront resolver called without a hostname query param. | Add ?hostname=<your-storefront-domain>. | no |
ENDPOINT_DISABLED | 400 | Webhook replay called against a disabled endpoint. | Re-enable the endpoint, then retry. | no |
Resource state
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
NOT_FOUND | 404 | The requested resource doesn't exist (or you can't see it). | Verify the ID. For :orgId/:productId/:licenseId, all are UUIDs — slugs aren't accepted. | no |
USER_NOT_FOUND | 404 | User lookup returned nothing (only on user-mutation endpoints). | Verify the user ID; the user may have been deleted. | no |
PRODUCT_NOT_FOUND | 404 | Product not found, or not in your org. | Verify the product UUID and that it's assigned to your active org. | no |
LICENSE_NOT_FOUND | 404 | License lookup returned nothing. | Verify the license key or ID. | no |
LISTING_NOT_FOUND | 404 | Storefront listing not found. | — | no |
STOREFRONT_NOT_FOUND | 404 | Storefront not configured for this hostname. | — | no |
ORG_NOT_FOUND | 404 | Org doesn't exist for the given hostname / ID. | — | no |
CONFLICT | 409 | Generic conflict — duplicate, illegal state transition, would-be orphan. | Read the message. Common cases: assigning a product already attached to another org, demoting/removing the last OWNER, accepting an already-accepted invite. | no |
IDEMPOTENCY_KEY_REUSE | 409 | Same Idempotency-Key was used with a different request body. | Use a fresh key for a new request, or send the original body for a replay. | no — change the key first |
CLOSED | 409 | Tried to act on a closed support ticket. | — | no |
LICENSE_NOT_OWNED | 409 | End-user tried to act on a license that isn't theirs. | — | no |
GONE | 410 | Invite expired. | Ask the inviter to send a fresh invite. | no |
STOREFRONT_NOT_SETUP | 400 | Tried to add listings before completing storefront setup. | Finish storefront onboarding first. | no |
STRIPE_NOT_READY | 400 | Storefront isn't connected to a payments processor yet. | Complete Stripe Connect onboarding. | no |
TARGET_LICENSE_REQUIRED | 400 | Listing requires a target license ID. | Provide it in the body. | no |
Rate limiting
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
RATE_LIMITED | 429 | Per-IP, per-key, per-license, login, signup, password-reset, or 2FA limit hit. | Honor the Retry-After header (always set on 429s — see below). Implement exponential backoff on top. Check the X-RateLimit-Remaining-* headers to learn which budget you're exhausting. | yes — after Retry-After |
PRODUCT_RATE_LIMITED | 429 | Per-product ceiling hit (rare; means cumulative traffic against one product is too high). | Honor Retry-After. Check whether another integration is hammering the same product. Contact support if you need a higher product ceiling. | yes — after Retry-After |
RATE_LIMIT_UNAVAILABLE | 503 | Rate-limit store down. Server fails closed when SDK signing is required. | Retry with backoff. | yes |
End-user portal
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
USER_BANNED | 403 | The end-user account has been banned by the merchant. | Contact the merchant. | no |
LICENSE_INACTIVE | 403 | Tried to download a file or use a feature against a non-active license. | Re-activate or check expiration. | no |
LIMIT_REACHED | 403 | HWID-reset budget exhausted on a self-service reset. | Wait for the budget to reset, or ask the merchant to reset manually. | no |
DNS & domains
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
DNS_NOT_FOUND | 400 | Domain verification couldn't find the expected DNS record. | Add the record exactly as shown in the dashboard, then retry verification. DNS can take up to 24h to propagate. | yes — after DNS propagates |
TOKEN_MISMATCH | 400 | DNS record exists but contains the wrong verification token. | Replace the record with the current token from the dashboard. | yes — after fixing the record |
Server / infrastructure
| Code | Status | Triggered by | Fix | Retry? |
|---|---|---|---|---|
INTERNAL | 500 | Unhandled exception. | Retry with backoff. If persistent, file an issue with the x-request-id header. | yes |
CONFIG_ERROR | 500 | Server is misconfigured (e.g. missing env var). | Contact support. | no |
DATABASE_ERROR | 500 | Database state needs migration or is otherwise inconsistent. | Contact support. | no |
WEBHOOK_ERROR | 500 | Outbound webhook delivery failed. | Check your endpoint's logs; we retry per the webhook delivery policy. | n/a — server-side retry |
STAGING_BILLING_DISABLED | 503 | Billing endpoints are off in staging. | Use the production environment, or your local dev stack. | no |
License authorize reason codes
These appear as reasonCode on POST /v1/licenses/authorize when allow: false. The response status is 403. Branch on reasonCode, not status, since the same status can mean a network or signing failure.
| Reason code | Meaning |
|---|---|
LICENSE_NOT_FOUND | License key doesn't exist for this product |
PRODUCT_MISMATCH | License exists but belongs to a different product |
LICENSE_REVOKED | License is REVOKED or FROZEN |
LICENSE_EXPIRED | Past the fixed expiry date |
LICENSE_EXPIRED_RELATIVE | Past the days-after-activation window |
HWID_MISMATCH | Sticky-mode: hardware ID doesn't match the bound device |
HWID_LIMIT_EXCEEDED | Limit-mode: too many distinct hardware IDs |
IP_MISMATCH | Sticky-mode: IP doesn't match the bound address |
IP_LIMIT_EXCEEDED | Limit-mode: too many distinct IPs in the time window |
IP_REGION_MISMATCH | Region-locked: client IP is outside the allowed region(s) |
CONCURRENCY_LIMIT_EXCEEDED | More active sessions than the policy allows |
HWID_BLACKLISTED | The hardware ID is on the product's blacklist |
IP_BLACKLISTED | The IP address is on the product's blacklist |
Recommended client handling
const result = await response.json();
if (result.allow) {
return { valid: true, expiresAt: result.effectiveExpiresAt };
}
switch (result.reasonCode) {
case 'LICENSE_NOT_FOUND':
case 'PRODUCT_MISMATCH':
return { valid: false, message: 'Invalid license key' };
case 'LICENSE_EXPIRED':
case 'LICENSE_EXPIRED_RELATIVE':
return { valid: false, message: 'License has expired' };
case 'LICENSE_REVOKED':
return { valid: false, message: 'License has been revoked' };
case 'HWID_MISMATCH':
case 'HWID_LIMIT_EXCEEDED':
return { valid: false, message: 'License is bound to a different device' };
case 'HWID_BLACKLISTED':
case 'IP_BLACKLISTED':
return { valid: false, message: 'Access denied' };
case 'CONCURRENCY_LIMIT_EXCEEDED':
return { valid: false, message: 'Too many active sessions. Close other instances first.' };
default:
return { valid: false, message: result.message || 'Validation failed' };
}
Retry guidance
Retry with exponential backoff (start at 1s, double each attempt, jitter, cap at 60s, max 5 attempts):
429 RATE_LIMITED/429 PRODUCT_RATE_LIMITED— honorRetry-After500 INTERNAL— transient503 SIGNING_UNAVAILABLE/503 RATE_LIMIT_UNAVAILABLE— Redis backing-store hiccup502/504— gateway transient
Do not retry these — fix the request instead:
400(any) — your payload is wrong401(any) — your credential is wrong403 FORBIDDEN,403 PERMISSION_DENIED— you don't have access; retrying won't help404— the resource doesn't exist409— resolve the conflict (or use a fresh idempotency key)410 GONE— invite expired; ask for a new one
Diagnostic header
Every response includes x-request-id. Capture and log it — when you contact support, that one string lets us pull the exact request out of our logs.
Retry-After is guaranteed on 429
Every 429 response from GeckoGuard sets a Retry-After header (in seconds). You do not need a fallback for the missing-header case — it's always present, on every limiter (per-IP, per-key, per-license, login, signup, forgot-password, TOTP). Default value is 60; longer-window limiters (signup, password-reset) emit the full window length. Honor it as your floor and add exponential backoff on top.