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?no means retrying the same request will get the same error; fix the request first. yes means transient; back off and try again. clock means retry after a clock fix or fresh nonce.

Authentication & authorization

CodeStatusTriggered byFixRetry?
UNAUTHORIZED401Missing 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_TOKEN401The 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
FORBIDDEN403Valid 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_PERMISSIONS403The 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_DENIED403The 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_SCOPE403Same 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_ALLOWED403You 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_BLOCKED403Browser-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)

CodeStatusTriggered byFixRetry?
SIGNATURE_REQUIRED401One 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_SIGNATURE401Bad 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_EXPIRED401X-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_REUSED401The 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_CONFIGURED500Server-side: no signing secret on the deployment.Contact support.no
SIGNING_UNAVAILABLE503Server-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.

CodeStatusTriggered byFixRetry?
VALIDATION_ERROR400Request body or query failed schema validation. error.details lists each {field, message} pair.Fix the offending fields.no
BAD_REQUEST400Generic semantic error (e.g. count out of range, ill-formed file path).Read error.message.no
NULL_CONSTRAINT400Required DB column missing.Same as VALIDATION_ERROR — fill the field.no
BAD_IDEMPOTENCY_KEY400Idempotency-Key doesn't match [A-Za-z0-9_-]{8,128}.Use a longer / cleaner key.no
NO_FILE400File-upload endpoint called without a multipart/form-data file.Send the file.no
NO_ACTIVE_ORG400An endpoint that requires an active org context was called without one.Set the active org via POST /v1/dashboard/active-org.no
MISSING_HOSTNAME400Storefront resolver called without a hostname query param.Add ?hostname=<your-storefront-domain>.no
ENDPOINT_DISABLED400Webhook replay called against a disabled endpoint.Re-enable the endpoint, then retry.no

Resource state

CodeStatusTriggered byFixRetry?
NOT_FOUND404The 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_FOUND404User lookup returned nothing (only on user-mutation endpoints).Verify the user ID; the user may have been deleted.no
PRODUCT_NOT_FOUND404Product not found, or not in your org.Verify the product UUID and that it's assigned to your active org.no
LICENSE_NOT_FOUND404License lookup returned nothing.Verify the license key or ID.no
LISTING_NOT_FOUND404Storefront listing not found.no
STOREFRONT_NOT_FOUND404Storefront not configured for this hostname.no
ORG_NOT_FOUND404Org doesn't exist for the given hostname / ID.no
CONFLICT409Generic 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_REUSE409Same 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
CLOSED409Tried to act on a closed support ticket.no
LICENSE_NOT_OWNED409End-user tried to act on a license that isn't theirs.no
GONE410Invite expired.Ask the inviter to send a fresh invite.no
STOREFRONT_NOT_SETUP400Tried to add listings before completing storefront setup.Finish storefront onboarding first.no
STRIPE_NOT_READY400Storefront isn't connected to a payments processor yet.Complete Stripe Connect onboarding.no
TARGET_LICENSE_REQUIRED400Listing requires a target license ID.Provide it in the body.no

Rate limiting

CodeStatusTriggered byFixRetry?
RATE_LIMITED429Per-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_LIMITED429Per-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_UNAVAILABLE503Rate-limit store down. Server fails closed when SDK signing is required.Retry with backoff.yes

End-user portal

CodeStatusTriggered byFixRetry?
USER_BANNED403The end-user account has been banned by the merchant.Contact the merchant.no
LICENSE_INACTIVE403Tried to download a file or use a feature against a non-active license.Re-activate or check expiration.no
LIMIT_REACHED403HWID-reset budget exhausted on a self-service reset.Wait for the budget to reset, or ask the merchant to reset manually.no

DNS & domains

CodeStatusTriggered byFixRetry?
DNS_NOT_FOUND400Domain 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_MISMATCH400DNS 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

CodeStatusTriggered byFixRetry?
INTERNAL500Unhandled exception.Retry with backoff. If persistent, file an issue with the x-request-id header.yes
CONFIG_ERROR500Server is misconfigured (e.g. missing env var).Contact support.no
DATABASE_ERROR500Database state needs migration or is otherwise inconsistent.Contact support.no
WEBHOOK_ERROR500Outbound webhook delivery failed.Check your endpoint's logs; we retry per the webhook delivery policy.n/a — server-side retry
STAGING_BILLING_DISABLED503Billing 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 codeMeaning
LICENSE_NOT_FOUNDLicense key doesn't exist for this product
PRODUCT_MISMATCHLicense exists but belongs to a different product
LICENSE_REVOKEDLicense is REVOKED or FROZEN
LICENSE_EXPIREDPast the fixed expiry date
LICENSE_EXPIRED_RELATIVEPast the days-after-activation window
HWID_MISMATCHSticky-mode: hardware ID doesn't match the bound device
HWID_LIMIT_EXCEEDEDLimit-mode: too many distinct hardware IDs
IP_MISMATCHSticky-mode: IP doesn't match the bound address
IP_LIMIT_EXCEEDEDLimit-mode: too many distinct IPs in the time window
IP_REGION_MISMATCHRegion-locked: client IP is outside the allowed region(s)
CONCURRENCY_LIMIT_EXCEEDEDMore active sessions than the policy allows
HWID_BLACKLISTEDThe hardware ID is on the product's blacklist
IP_BLACKLISTEDThe IP address is on the product's blacklist
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 — honor Retry-After
  • 500 INTERNAL — transient
  • 503 SIGNING_UNAVAILABLE / 503 RATE_LIMIT_UNAVAILABLE — Redis backing-store hiccup
  • 502 / 504 — gateway transient

Do not retry these — fix the request instead:

  • 400 (any) — your payload is wrong
  • 401 (any) — your credential is wrong
  • 403 FORBIDDEN, 403 PERMISSION_DENIED — you don't have access; retrying won't help
  • 404 — the resource doesn't exist
  • 409 — 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.