# Error codes

> Every stable error and reason code AgentGate returns, from the browser SDK, /v1/siteverify, the gateway, the agent API and the console API.

Codes are stable and safe to branch on. Messages are for people and may
change. Codes never reveal which rule matched or any score.

## Browser SDK

Returned by `/v1/browser/challenge`, `/v1/browser/instrument` and
`/v1/browser/verify` as `{"error": "<code>", "message": "…"}`, and surfaced
by the SDK as `AgentGate.Error.code` (the rejection of `execute()`, or the
argument of `errorCallback`). Refusals decided by the rule engine also
carry a `decisionId`.

| Code | HTTP | Meaning | Fix |
| --- | --- | --- | --- |
| `site_invalid` | 400 | unknown or malformed site key | check `data-sitekey` / `siteKey` |
| `action_invalid` | 400 | the action is not `[a-z0-9_]{1,64}` or not configured for the site | configure the action on the site, or fix `data-action` |
| `origin_denied` | 403 | the page's `Origin` is missing or not in the site's allowed origins, or the challenge was issued to another origin | add the origin to the site |
| `site_disabled` | 403 | the site's account was deleted, so the key no longer issues challenges | use a site of an active account |
| `rate_limited` | 429 | over the per-address limit (60 challenges or 120 verifications per minute) or the site's limit | wait for `Retry-After` (60 s) |
| `challenge_invalid` | 400 | unknown challenge, or one issued for another action | call `execute()` again |
| `challenge_expired` | 410 | the challenge is older than five minutes | call `execute()` again |
| `challenge_used` | 409 | the challenge already produced a token | call `execute()` again |
| `too_many_attempts` | 429 | the challenge's four verify attempts are used up | call `execute()` again |
| `challenge_failed` | 403 | the evidence was declined, the step-up answer was invalid, or the visitor was still challenged after passing the check | let the person retry; check the decision in the dashboard |
| `schema_unsupported` | 400 | the SDK's signal schema is not the server's | load the SDK from the same AgentGate you verify against |
| `payload_too_large` | 413 | the request body is over the limit | a bug; report it |
| `bad_request` | 400 | malformed JSON or fields | a bug; report it |
| `unavailable` | 503 | a dependency failed | retry after `Retry-After` (5 s) |

Codes the SDK adds itself (no HTTP status):

| Code | Meaning |
| --- | --- |
| `timeout` | `timeout` (default 30 s) or, once the check is shown, `checkTimeout` (default 5 min) passed |
| `aborted` | the `signal` you passed was aborted |
| `canceled` | the person closed the check with Escape or Cancel |
| `network` | the request failed: offline, DNS, TLS, CORS or a CSP `connect-src` block |
| `error` | anything else |

With the testing site key `site_test_always_fail` the SDK always fails with
`challenge_failed` (HTTP 403). See [Testing](/docs/testing).

## Server-side validation

`POST /v1/siteverify` answers `{"success": false, "error": "<code>",
"message": "…"}`:

| Code | HTTP | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | malformed JSON (over 4 KiB included), missing `token` or `expectedAction`, or a `requestId` outside `[A-Za-z0-9._:-]{1,128}` |
| `invalid_credential` | 401 | the backend secret is missing, malformed, revoked or another site's, or `Authorization` and `secret` differ |
| `invalid_token` | 422 | not a token, bad signature, unknown site or key; also a test token sent with a real secret, or a real token sent with a test secret |
| `expired_token` | 422 | past its expiry (default two minutes after issue) |
| `action_mismatch` | 422 | issued for another action than `expectedAction` |
| `origin_mismatch` | 422 | issued to another origin than `expectedOrigin` |
| `token_used` | 409 | already redeemed, by another (or no) `requestId` |
| `unavailable` | 503 | the store failed; retry with the same `requestId` |

The testing secrets return fixed answers for test tokens:
`ags_test_always_fail` gives `422 invalid_token` and `ags_test_token_spent`
gives `409 token_used`.

## Gateway

`GET /v1/gateway/check` answers with a status and the headers
`X-AgentGate-Decision` (`allow`, `challenge` or `block`) and
`X-AgentGate-Reason`; denials also carry a small JSON body
`{"decision": "…", "reason": "…"}`.

| Reason | Status | Meaning |
| --- | --- | --- |
| `allowed` | 204 | enforce mode allowed the request |
| `monitor` | 204 | monitor mode; `X-AgentGate-Would-Decision` says what enforce would do |
| `receipt_required` | 401 | a browser route and no token (or clearance) was sent |
| `invalid_token` | 401 | the token is not valid for this site (a test token included) |
| `expired_token` | 401 | the token expired |
| `token_used` | 401 | the token was already spent |
| `action_mismatch` | 401 | the token is for another action than the route's |
| `origin_mismatch` | 401 | the token or clearance is for another origin |
| `invalid_clearance` | 401 | the clearance is malformed, badly signed or for another site |
| `expired_clearance` | 401 | the clearance expired |
| `agent_signature_invalid` | 403 | a Web Bot Auth signature failed (bad, expired, replayed, or the body digest does not match) |
| `rate_limited` | 403 | over the gateway rate budget or the agent's quota; the nginx example answers the client `429` |
| `payment_required` | 403 | the action has a price and no acceptable offer was sent; the nginx example answers `402` with `Crawler-Price` |
| `payment_invalid` | 403 | the price offer is malformed, conflicting or unsigned |
| `agent_denied` | 403 | the site's agent policy does not allow this agent this action |
| `agent_unverified` | 401 or 403 | a User-Agent claims an AI agent or crawler without a valid signature (challenge or block, per the site's policy) |
| `policy` | 401 or 403 | any other rule (the rule name is never exposed) |
| `invalid_credential` | 403 | the caller's backend secret is wrong; nothing was evaluated |
| `invalid_request` | 403 | the adapter's `X-Original-*` headers are missing or malformed |
| `service_unavailable` | 503, or 204 in monitor mode | a dependency failed; enforce mode fails closed |

The gateway never answers `429` itself. The complete header contract is on
[Gateway overview](/docs/gateway#2-the-check-api).

## Agents

`/agent/submit` and gateway routes answer agents with plain HTTP statuses:

| Status | Meaning |
| --- | --- |
| 400 | invalid request body, or a malformed price offer (`crawler-error`, below) |
| 401 | no valid Web Bot Auth signature or agent key ("invalid agent credential") |
| 402 | payment required: see `crawler-price` and `crawler-error` |
| 403 | the site's policy does not permit this agent this action ("not permitted"), or a challenge; a challenge for a verified agent carries a `handoff` object |
| 409 | the submission `id` was already used for different content |
| 429 | over the agent's per-site quota; `Retry-After: 60` |
| 503 | a dependency failed |

Pay-per-crawl errors, in the `crawler-error` header:

| `crawler-error` | Status | Meaning |
| --- | --- | --- |
| `MissingCrawlerPrice` | 402 | no offer sent |
| `InvalidCrawlerExactPrice` | 402 | the exact offer differs from the price (value or currency) |
| `InvalidCrawlerMaxPrice` | 402 | the maximum is below the price |
| `InvalidCrawlerPriceValue` | 400 | not `CUR 0.00` |
| `ConflictingPriceHeaders` | 400 | both offer headers sent |
| `StrongAuthRequired` | 400 or 402 | the offer is not covered by a signature, or the agent is not verified |

Human handoff polling (`GET /v1/handoff/{id}`): `404` when anyone other than
the session or agent that received the handoff polls, `410` once it is
redeemed or expired. See [Human handoff](/docs/human-handoff).

## Console and admin API

`/v1/console/*`, `/v1/account/*` and `/v1/admin/*` answer
`{"error": "<code>", "message": "…"}`. The codes you are likely to meet:

| Code | HTTP | Meaning |
| --- | --- | --- |
| `unauthorized` | 401 | not signed in, or a wrong operator credential |
| `session_expired` | 401 | the session ended (console sessions last 12 h, or 2 h without use); sign in again |
| `invalid_credentials` | 401 | wrong email or password |
| `mfa_required` | 401 | the password was right; enter the two-factor code (`POST /v1/account/login/mfa`) |
| `invalid_code` | 401 | wrong or already used two-factor or recovery code |
| `invalid_token` | 401 | the API token is invalid, revoked or expired |
| `csrf` | 403 | the `X-CSRF-Token` header is missing or wrong |
| `forbidden` | 403 | your role in the account, or the API token's scopes, lack the permission `message` names |
| `account_suspended` | 403 | the account is suspended: console and API are closed, its sites keep protecting |
| `user_suspended` | 403 | your user is suspended |
| `no_account` | 403 | you belong to no account; create one or ask for an invitation |
| `token_not_allowed` | 403 | API tokens work only on `/v1/console` |
| `mfa_setup_required` | 403 | the account requires two-factor authentication; turn it on first |
| `invalid_password` | 403 | the current password is wrong (password change, turning 2FA off) |
| `cross_origin` | 403 | the request came from another site |
| `browser_check_failed` | 403 | the request-access form's browser check did not pass |
| `not_found`, `site_not_found` | 404 | no such object, or not yours |
| `invalid_link` | 404 | an invite or reset link is invalid, used or expired |
| `invalid_request`, `invalid_site`, `invalid_filter`, `invalid_limit`, `invalid_range`, `invalid_cursor` | 400 | the request or a parameter is malformed; `message` says which |
| `forbidden_field`, `immutable_field` | 400 | a field the server sets, or that cannot change (`siteKey`, customer) |
| `weak_password` | 400 | the password does not meet the rules |
| `site_limit`, `credential_limit`, `member_limit` | 409 | the account's limits (default 5 sites, 3 live secrets per site, 10 members) |
| `account_limit` | 409 | a user can own at most 3 accounts |
| `not_member` | 404 | switching to an account you do not belong to |
| `already_member`, `already_active` | 409 | the person is already a member, or has already set a password |
| `last_owner` | 409 | every account keeps an owner; make someone else owner first |
| `sites_enforcing` | 409 | switch every site to monitor mode before deleting the account |
| `confirm_mismatch` | 400 | type the account name exactly to confirm deletion |
| `site_key_taken` | 409 | the site key exists |
| `revision_conflict` | 409 | the rules changed since you loaded them; reload and retry |
| `token_limit` | 409 | at most 25 live API tokens per account |
| `mfa_already_enabled`, `mfa_not_enabled`, `mfa_not_pending`, `mfa_required_by_account` | 409 | the two-factor state does not allow that step |
| `rate_limited` | 429 | too many attempts; wait |
| `store_unavailable` | 503 | the database did not answer; retry |

The full contracts are in the [API reference](/docs/api-reference).
