This page is not translated yet and is shown in English.

Errors and status codes

The error envelope, every code the verification API returns, and why your integration should branch on code rather than message.

Last updated: 2026-09-04

Every failing request answers with the same JSON envelope, whatever went wrong.

The envelope

{
  "error": {
    "message": "invalid_merchant_key",
    "code": "INVALID_MERCHANT_KEY",
    "statusCode": 401
  }
}
FieldWhat it is
codeThe stable, machine-readable token. This is what your code should branch on.
messageHuman-readable text for a log line or a support ticket. Wording can change.
statusCodeThe HTTP status, repeated in the body so a client that only reads the body still has it.

Branch on code, not on message

message is prose written for a person. It gets reworded when the wording is unclear, and rewording it is not a breaking change. code is a token that exists to be compared:

const body = await response.json();

switch (body.error?.code) {
    case "NO_SUBSCRIPTION":
        return showBillingNotice();
    case "BROKER_UNAVAILABLE":
        return retryWithBackoff();
    case "RATE_LIMIT_EXCEEDED":
        return retryAfter(response.headers.get("Retry-After"));
    default:
        return failClosed(body.error?.message);
}

RATE_LIMIT_EXCEEDED is not a verdict: the ceiling clears itself once the window resets, so treat it as a signal to back off rather than as a hard failure that turns a shopper away.

If you are reading an existing integration, expect message matching

The shipped WooCommerce and Shopify clients branch on error.message today. code was added alongside it, not in place of it: message is preserved verbatim, so those clients keep working and nothing needs a coordinated release. code is what to migrate onto, at your own pace and in new code from the start.

Codes

statusCodecodeMeaning
400VALIDATION_ERRORThe request body or a path parameter failed validation: a missing or invalid verificationType, an unknown property, a malformed session id, a method or verification type the active path cannot serve, or a failed CAPTCHA check.
400REDIRECT_HOST_NOT_ALLOWEDredirectUrl is not https on a public domain name.
401INVALID_MERCHANT_KEYKey missing, unknown, revoked, or expired. One identical response for all of them, so the endpoint cannot be used to discover which keys exist.
402NO_SUBSCRIPTIONThe key is valid; the account has no active subscription.
403LAYER_NOT_ENTITLEDActive subscription that does not include the electronic-identity verification layer.
404NOT_FOUNDNo such session — or, on the authenticated result endpoint, a session belonging to another merchant.
429RATE_LIMIT_EXCEEDEDA per-minute ceiling was reached. Back off and retry — see rate limiting below for the headers and body field that carry how long.
500INTERNAL_SERVER_ERRORAn unexpected fault. In production the message is replaced with generic text, so there is nothing to parse — report the failing request instead.
503METHOD_UNAVAILABLEThe deployment has no credentials configured for the requested verification method, so it fails closed rather than guessing.
503BROKER_UNAVAILABLEThe identity provider could not be reached right now — a transport failure or a tripped circuit breaker, not a verdict. Retryable.
503BILLING_UNAVAILABLEThe subscription could not be checked. The key is valid; this is not a billing problem. Retryable.

Two of those deserve emphasis, because reading them as verdicts causes real damage:

  • BROKER_UNAVAILABLE is not a rejection. Nobody failed a check; the request never reached one. Retry rather than turning a shopper away.
  • BILLING_UNAVAILABLE is not 402. It is deliberately a 503 so that a timed-out lookup is never reported to a paying merchant as "payment required", which is a claim about their account that an integration will act on.

Rate limiting

A 429 uses the same envelope as everything else:

{
  "error": {
    "message": "Too many requests. Please try again later.",
    "code": "RATE_LIMIT_EXCEEDED",
    "statusCode": 429
  },
  "retryAfter": "42"
}

The one addition is retryAfter, a top-level field carrying the same seconds-to-wait value as the Retry-After header, for a client that only reads the body.

The backoff signal is on the response either way:

WhereWhat it carries
Retry-After headerSeconds to wait before retrying. Set on every 429.
retryAfter body fieldThe same value as the header.
RateLimit-Limit headerThe ceiling for the window.
RateLimit-Remaining headerRequests left in the current window.
RateLimit-Reset headerSeconds until the window resets.

On the /api/verification/* routes the three RateLimit-* headers are present on successful responses too, not just on a 429, so a client can pace itself before it is ever throttled rather than backing off after the fact. They come from the limiters mounted on those routes rather than from a global one, so a route outside them — /api/config, for instance — carries none of them. There are no X-RateLimit-* headers anywhere; the legacy spelling is disabled.

Codes you have not seen before

Codes are added as the API grows, so treat the table as the current set rather than a closed one. Handle an unrecognised code by its statusCode class: a 4xx means the request as sent will not succeed if you send it again unchanged, a 503 is retryable, and so is a 429, and a 500 should be reported.

For an error whose message is human prose rather than a token, the code falls back to a generic name for the status — BAD_REQUEST, UNAUTHORIZED, PAYMENT_REQUIRED, FORBIDDEN, NOT_FOUND, SERVICE_UNAVAILABLE, and so on. Those are still stable to compare against; they just carry less information than a specific token.

Errors inside a session's payload

Two of the codes above describe a failing request. A session that ran and did not pass is not a failing request: GET /:sessionId/status answers 200 with a terminal status and an error object of its own inside the payload, carrying VERIFICATION_REJECTED or SESSION_EXPIRED. See session status.

So the complete rule stays simple: read error.code for every failure, on every route — including a 429 and a malformed session id on /events.