# Error code reference

Every stable `error_code` BillerAPI returns, with its HTTP status, category, cause, and fix. Each error response links here through its `docs_url`. The list is additive-only — codes are never removed or renamed.

Branch on `error_code`, never on `error_message`. The full envelope shape and retry semantics are described in [/docs/concepts/errors.md](/docs/concepts/errors.md).

## General

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`INTERNAL_ERROR`](/docs/errors/INTERNAL_ERROR) | 500 | `api_error` | An unexpected error occurred on our end |
| [`VALIDATION_ERROR`](/docs/errors/VALIDATION_ERROR) | 400 | `invalid_request` | One or more request fields are invalid |
| [`NOT_FOUND`](/docs/errors/NOT_FOUND) | 404 | `invalid_request` | The requested resource does not exist |
| [`CONFLICT`](/docs/errors/CONFLICT) | 409 | `invalid_request` | The request conflicts with the current resource state |
| [`INVALID_CURSOR`](/docs/errors/INVALID_CURSOR) | 400 | `invalid_request` | The pagination cursor is malformed or stale |
| [`IDEMPOTENCY_KEY_MISMATCH`](/docs/errors/IDEMPOTENCY_KEY_MISMATCH) | 409 | `invalid_request` | The Idempotency-Key was reused with a different body |
| [`IDEMPOTENCY_KEY_NOT_REPLAYABLE`](/docs/errors/IDEMPOTENCY_KEY_NOT_REPLAYABLE) | 409 | `invalid_request` | The one-time response for this Idempotency-Key cannot be replayed |
| [`RATE_LIMITED`](/docs/errors/RATE_LIMITED) | 429 | `rate_limit` | You have exceeded the allowed request rate |
| [`SERVICE_UNAVAILABLE`](/docs/errors/SERVICE_UNAVAILABLE) | 503 | `upstream` | An upstream dependency is temporarily unavailable |
| [`WEBHOOK_NOT_FOUND`](/docs/errors/WEBHOOK_NOT_FOUND) | 404 | `invalid_request` | No webhook with that id |
| [`WEBHOOK_ENDPOINT_NOT_FOUND`](/docs/errors/WEBHOOK_ENDPOINT_NOT_FOUND) | 404 | `invalid_request` | No webhook endpoint with that id |
| [`INVALID_WEBHOOK_UPDATE`](/docs/errors/INVALID_WEBHOOK_UPDATE) | 400 | `invalid_request` | The webhook patch failed validation |
| [`WEBHOOK_REGISTRATION_FAILED`](/docs/errors/WEBHOOK_REGISTRATION_FAILED) | 500 | `api_error` | The webhook could not be persisted |
| [`WEBHOOK_CONFIGURATION_NOT_FOUND`](/docs/errors/WEBHOOK_CONFIGURATION_NOT_FOUND) | 404 | `invalid_request` | This client has no webhook configuration for that environment |
| [`INVALID_IDEMPOTENCY_KEY`](/docs/errors/INVALID_IDEMPOTENCY_KEY) | 400 | `invalid_request` | The Idempotency-Key header is missing or malformed |
| [`IDEMPOTENCY_KEY_REUSED`](/docs/errors/IDEMPOTENCY_KEY_REUSED) | 409 | `invalid_request` | The Idempotency-Key was reused with different parameters |
| [`WEBHOOK_LOOKUP_FAILED`](/docs/errors/WEBHOOK_LOOKUP_FAILED) | 500 | `api_error` | The webhook ownership row could not be read |
| [`WEBHOOK_UPDATE_FAILED`](/docs/errors/WEBHOOK_UPDATE_FAILED) | 500 | `api_error` | The endpoint update could not be persisted |
| [`WEBHOOK_UPDATE_OUTCOME_UNKNOWN`](/docs/errors/WEBHOOK_UPDATE_OUTCOME_UNKNOWN) | 500 | `api_error` | The update committed but its stored state could not be read back |
| [`WEBHOOK_ROTATION_FAILED`](/docs/errors/WEBHOOK_ROTATION_FAILED) | 500 | `api_error` | The signing-secret rotation could not be persisted |
| [`INVALID_WEBHOOK_OUTCOME_ID`](/docs/errors/INVALID_WEBHOOK_OUTCOME_ID) | 400 | `invalid_request` | The delivery-outcome identity is missing or malformed |
| [`WEBHOOK_OUTCOME_UNKNOWN`](/docs/errors/WEBHOOK_OUTCOME_UNKNOWN) | 500 | `api_error` | A prior delivery outcome could not be resolved |
| [`WEBHOOK_OUTCOME_ID_REUSED`](/docs/errors/WEBHOOK_OUTCOME_ID_REUSED) | 409 | `invalid_request` | The delivery-outcome identity was reused with different parameters |
| [`WEBHOOK_DISABLE_NOTIFICATION_PENDING`](/docs/errors/WEBHOOK_DISABLE_NOTIFICATION_PENDING) | 500 | `api_error` | The endpoint was auto-disabled but the owner was not notified |
| [`WEBHOOK_UPDATE_CONFLICT`](/docs/errors/WEBHOOK_UPDATE_CONFLICT) | 409 | `invalid_request` | A concurrent writer updated this endpoint first |
| [`WEBHOOK_SAVE_OUTCOME_UNKNOWN`](/docs/errors/WEBHOOK_SAVE_OUTCOME_UNKNOWN) | 500 | `api_error` | The write may or may not have committed |
| [`WEBHOOK_REGISTRATION_CONFLICT`](/docs/errors/WEBHOOK_REGISTRATION_CONFLICT) | 409 | `invalid_request` | A concurrent registration for this environment won the race |
| [`WEBHOOK_REGISTRATION_OUTCOME_UNKNOWN`](/docs/errors/WEBHOOK_REGISTRATION_OUTCOME_UNKNOWN) | 500 | `api_error` | The registration may have committed without a confirmed result |
| [`WEBHOOK_ROTATION_CONFLICT`](/docs/errors/WEBHOOK_ROTATION_CONFLICT) | 409 | `invalid_request` | A concurrent rotation replaced the signing secret first |
| [`WEBHOOK_ROTATION_OUTCOME_UNKNOWN`](/docs/errors/WEBHOOK_ROTATION_OUTCOME_UNKNOWN) | 500 | `api_error` | The rotation may have committed without a confirmed result |
| [`NOT_IMPLEMENTED`](/docs/errors/NOT_IMPLEMENTED) | 500 | `api_error` | The operation is declared but not implemented on this service |
| [`UNKNOWN`](/docs/errors/UNKNOWN) | 500 | `api_error` | The identity provider refused the operation for an unclassified reason |
| [`BAD_REQUEST`](/docs/errors/BAD_REQUEST) | 400 | `invalid_request` | A caller-side precondition failed |

### INTERNAL_ERROR

- **Status:** 500
- **Type:** `api_error`
- **Summary:** An unexpected error occurred on our end
- **Cause:** An unhandled condition inside BillerAPI. Not caused by your request shape; it is a server-side fault.
- **Fix:** Retry with backoff. If it persists, contact support and quote the request_id from the response (also on the X-Request-Id header).

### VALIDATION_ERROR

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** One or more request fields are invalid
- **Cause:** The request body or query parameters failed validation. The per-field problems are in the errors[] array.
- **Fix:** Inspect errors[] — each item names the offending snake_case param, a machine code, and a message — then resend a corrected request.

### NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The requested resource does not exist
- **Cause:** The resource does not exist, or is not visible to this caller (cross-tenant lookups collapse to 404 — there is no existence oracle).
- **Fix:** Verify the identifier and that it belongs to your account. For webhook follow-up GETs, treat 404 as "gone is gone" and return 200.

### CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The request conflicts with the current resource state
- **Cause:** The resource changed since you last read it, or the operation is not valid in its current state.
- **Fix:** Re-fetch the resource, reconcile, and retry.

### INVALID_CURSOR

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The pagination cursor is malformed or stale
- **Cause:** The opaque cursor could not be decoded (truncated, edited, or from a different list).
- **Fix:** Drop the cursor and re-sync from zero (omit the cursor parameter). Never construct or mutate cursor values yourself.

### IDEMPOTENCY_KEY_MISMATCH

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The Idempotency-Key was reused with a different body
- **Cause:** A prior request used the same Idempotency-Key but a different request body.
- **Fix:** Use a fresh Idempotency-Key for a new request. An identical retry is replayed only when the original response is replayable.

### IDEMPOTENCY_KEY_NOT_REPLAYABLE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The one-time response for this Idempotency-Key cannot be replayed
- **Cause:** The original request returned a one-time secret. BillerAPI stored a secret-free completion receipt, not the plaintext response.
- **Fix:** Use a fresh Idempotency-Key to generate or rotate again. The previously delivered secret cannot be recovered.

### RATE_LIMITED

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** You have exceeded the allowed request rate
- **Cause:** Too many requests in the current window.
- **Fix:** Back off and retry after the retry_after seconds (also on the Retry-After header). Add jitter to avoid thundering-herd retries.

### SERVICE_UNAVAILABLE

- **Status:** 503
- **Type:** `upstream`
- **Summary:** An upstream dependency is temporarily unavailable
- **Cause:** A downstream service BillerAPI depends on is briefly unavailable.
- **Fix:** This is safe to retry with exponential backoff.

### WEBHOOK_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No webhook with that id
- **Cause:** The id does not resolve under your client. Ids owned by another client also collapse to not-found rather than leaking existence.
- **Fix:** Verify the id against a webhook list call before referencing it.

### WEBHOOK_ENDPOINT_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No webhook endpoint with that id
- **Cause:** The endpoint id does not resolve under your client, so there is nothing to patch or rotate.
- **Fix:** Re-list your endpoints and use an id from that response.

### INVALID_WEBHOOK_UPDATE

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The webhook patch failed validation
- **Cause:** Usually a malformed or non-HTTPS url, or an event name that is not a recognized event type.
- **Fix:** Send an absolute HTTPS url, and only documented dot.lowercase event names in the events list.

### WEBHOOK_REGISTRATION_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The webhook could not be persisted
- **Cause:** The registration write failed server-side, leaving the endpoint possibly half-applied.
- **Fix:** Retry with the SAME Idempotency-Key so a partial registration is recovered instead of duplicated.

### WEBHOOK_CONFIGURATION_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** This client has no webhook configuration for that environment
- **Cause:** Webhook configuration is per environment, and none has been created for the one you targeted.
- **Fix:** Register an endpoint for that environment first, then retry the call.

### INVALID_IDEMPOTENCY_KEY

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The Idempotency-Key header is missing or malformed
- **Cause:** Webhook write calls require the header, and the value must be printable ASCII of bounded length.
- **Fix:** Send 1-255 printable ASCII characters — a UUID minted per logical request is the recommended shape.

### IDEMPOTENCY_KEY_REUSED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The Idempotency-Key was reused with different parameters
- **Cause:** Replaying the key would silently change what the original request meant, so it is refused.
- **Fix:** Use a fresh key for the new parameters, or resend the original body to replay the stored result.

### WEBHOOK_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The webhook ownership row could not be read
- **Cause:** An infrastructure fault reading the owner record. The operation is unproven rather than refused, so nothing was decided.
- **Fix:** Retry with backoff and the same Idempotency-Key.

### WEBHOOK_UPDATE_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The endpoint update could not be persisted
- **Cause:** The update could not be resolved or written, and nothing was committed.
- **Fix:** Retry with the SAME Idempotency-Key; re-read the endpoint first if you need to confirm its current state.

### WEBHOOK_UPDATE_OUTCOME_UNKNOWN

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The update committed but its stored state could not be read back
- **Cause:** Returning a response here would have reported endpoint state that may not match what is actually stored, so the call failed instead.
- **Fix:** Retry with the SAME Idempotency-Key to receive the authoritative stored row.

### WEBHOOK_ROTATION_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The signing-secret rotation could not be persisted
- **Cause:** The rotation could not be resolved or written, so the signing secret is unchanged.
- **Fix:** Keep verifying signatures with the current secret and retry with the SAME Idempotency-Key.

### INVALID_WEBHOOK_OUTCOME_ID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The delivery-outcome identity is missing or malformed
- **Cause:** The outcome identity is the idempotency handle for the delivery record, so it cannot be blank.
- **Fix:** Send 1-255 printable ASCII characters, unique per delivery attempt.

### WEBHOOK_OUTCOME_UNKNOWN

- **Status:** 500
- **Type:** `api_error`
- **Summary:** A prior delivery outcome could not be resolved
- **Cause:** The stored record for this outcome identity could not be read, so it is neither confirmed nor rejected.
- **Fix:** Retry with the same outcome identity rather than minting a new one.

### WEBHOOK_OUTCOME_ID_REUSED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The delivery-outcome identity was reused with different parameters
- **Cause:** That identity already recorded a different outcome, so accepting it would rewrite delivery history.
- **Fix:** Use a distinct identity per delivery attempt, or resend the original parameters to replay the stored outcome.

### WEBHOOK_DISABLE_NOTIFICATION_PENDING

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The endpoint was auto-disabled but the owner was not notified
- **Cause:** Repeated delivery failures disabled the endpoint, and that stands — only the notification to the account owner did not durably complete.
- **Fix:** Retry with the same outcome identity to redrive the notification; the disable itself needs no action.

### WEBHOOK_UPDATE_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** A concurrent writer updated this endpoint first
- **Cause:** Your version check lost the race, so none of your changes were applied.
- **Fix:** Re-read the endpoint, reapply your changes on top of the current state, and retry.

### WEBHOOK_SAVE_OUTCOME_UNKNOWN

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The write may or may not have committed
- **Cause:** The store did not confirm either way, so treating the call as a success or a failure would both be guesses.
- **Fix:** Retry with the SAME Idempotency-Key, which recovers the receipt instead of writing twice.

### WEBHOOK_REGISTRATION_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** A concurrent registration for this environment won the race
- **Cause:** Another registration landed first for this client and environment, so yours was not applied.
- **Fix:** Re-list your endpoints and patch the existing one instead of registering a duplicate.

### WEBHOOK_REGISTRATION_OUTCOME_UNKNOWN

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The registration may have committed without a confirmed result
- **Cause:** The write was issued but its result could not be read back, so the endpoint may already exist.
- **Fix:** Retry with the SAME Idempotency-Key so the stored receipt is returned rather than a second endpoint created.

### WEBHOOK_ROTATION_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** A concurrent rotation replaced the signing secret first
- **Cause:** Your rotation lost the race, so it was not applied and no new secret was issued to you.
- **Fix:** Re-read the endpoint to pick up the signing secret that is actually live.

### WEBHOOK_ROTATION_OUTCOME_UNKNOWN

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The rotation may have committed without a confirmed result
- **Cause:** The result could not be read back, so which signing secret is live is uncertain right now.
- **Fix:** Accept signatures from both secrets until it resolves, and retry with the SAME Idempotency-Key.

### NOT_IMPLEMENTED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The operation is declared but not implemented on this service
- **Cause:** The call reached a capability that exists in the interface but has no behavior behind it yet.
- **Fix:** Retrying cannot help. Confirm you are calling the documented endpoint for this capability, and tell support if the docs say it should exist.

### UNKNOWN

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The identity provider refused the operation for an unclassified reason
- **Cause:** The upstream failure did not map to any code this service recognizes; the accompanying error_message is the safe, user-facing sentence.
- **Fix:** Retry once, then contact support with the request_id.

### BAD_REQUEST

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** A caller-side precondition failed
- **Cause:** Most often an unknown user or client id on an email or verification call, caught before any work was attempted.
- **Fix:** Verify the identifiers you passed and retry.

## Authentication

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`UNAUTHORIZED`](/docs/errors/UNAUTHORIZED) | 401 | `auth` | Authentication failed or is missing |
| [`FORBIDDEN`](/docs/errors/FORBIDDEN) | 403 | `auth` | Authenticated, but not authorized for this resource |
| [`CLIENT_ID_MISMATCH`](/docs/errors/CLIENT_ID_MISMATCH) | 403 | `auth` | The supplied client_id does not match your session |
| [`NEEDS_RESIGNIN`](/docs/errors/NEEDS_RESIGNIN) | 401 | `auth` | No authenticated client context was resolved |
| [`SIGNIN_CREDENTIALS_REJECTED`](/docs/errors/SIGNIN_CREDENTIALS_REJECTED) | 401 | `auth` | Sign-in was refused |
| [`AUTHENTICATION_INCOMPLETE`](/docs/errors/AUTHENTICATION_INCOMPLETE) | 401 | `auth` | Sign-in stopped short of issuing tokens |
| [`USER_NOT_CONFIRMED`](/docs/errors/USER_NOT_CONFIRMED) | 401 | `auth` | The account was never confirmed after signup |
| [`INVALID_REFRESH_TOKEN`](/docs/errors/INVALID_REFRESH_TOKEN) | 401 | `auth` | The refresh token was rejected |
| [`INVALID_CONFIRMATION_CODE`](/docs/errors/INVALID_CONFIRMATION_CODE) | 400 | `invalid_request` | The signup confirmation code did not match |
| [`PASSWORD_RESET_FAILED`](/docs/errors/PASSWORD_RESET_FAILED) | 400 | `invalid_request` | The password reset could not be applied |
| [`FORGOT_PASSWORD_FAILED`](/docs/errors/FORGOT_PASSWORD_FAILED) | 500 | `api_error` | The forgot-password flow could not be started |
| [`SIGNUP_FAILED`](/docs/errors/SIGNUP_FAILED) | 400 | `invalid_request` | The account could not be created |
| [`WRONG_PASSWORD`](/docs/errors/WRONG_PASSWORD) | 401 | `auth` | The current password on a change-password call is wrong |
| [`WEAK_PASSWORD`](/docs/errors/WEAK_PASSWORD) | 400 | `invalid_request` | The proposed password fails the password policy |
| [`USER_NOT_FOUND`](/docs/errors/USER_NOT_FOUND) | 404 | `invalid_request` | No user with that id under this client |
| [`CLIENT_NOT_FOUND`](/docs/errors/CLIENT_NOT_FOUND) | 404 | `invalid_request` | No client account with that client_id |
| [`EMAIL_ALREADY_VERIFIED`](/docs/errors/EMAIL_ALREADY_VERIFIED) | 409 | `invalid_request` | The address is already verified |
| [`VERIFICATION_COOLDOWN`](/docs/errors/VERIFICATION_COOLDOWN) | 429 | `rate_limit` | A verification code was requested too soon after the last one |
| [`VERIFICATION_CODE_INVALID`](/docs/errors/VERIFICATION_CODE_INVALID) | 400 | `invalid_request` | The email verification code did not match |
| [`VERIFICATION_CODE_EXPIRED`](/docs/errors/VERIFICATION_CODE_EXPIRED) | 400 | `invalid_request` | The email verification code has expired |
| [`VERIFICATION_SEND_FAILED`](/docs/errors/VERIFICATION_SEND_FAILED) | 500 | `api_error` | The verification email could not be sent |
| [`EMAIL_NOT_VERIFIED`](/docs/errors/EMAIL_NOT_VERIFIED) | 400 | `invalid_request` | Minting a secret requires a verified email |
| [`OPERATOR_VERIFICATION_REQUIRED`](/docs/errors/OPERATOR_VERIFICATION_REQUIRED) | 400 | `invalid_request` | Production secrets require completed business verification |
| [`SANDBOX_CLAIM_NOT_FOUND`](/docs/errors/SANDBOX_CLAIM_NOT_FOUND) | 404 | `invalid_request` | The sandbox claim link is invalid or has expired |
| [`SANDBOX_FIXED_SECRET`](/docs/errors/SANDBOX_FIXED_SECRET) | 400 | `invalid_request` | The sandbox secret cannot be minted, rotated, or revoked |
| [`SECRET_ALREADY_EXISTS`](/docs/errors/SECRET_ALREADY_EXISTS) | 409 | `invalid_request` | An active secret already exists for this environment |
| [`INVALID_ENVIRONMENT`](/docs/errors/INVALID_ENVIRONMENT) | 400 | `invalid_request` | The environment value is not recognized |
| [`SECRET_NOT_FOUND`](/docs/errors/SECRET_NOT_FOUND) | 404 | `invalid_request` | No secret exists for this client and environment |
| [`SECRET_EXPIRED`](/docs/errors/SECRET_EXPIRED) | 401 | `auth` | The client secret is past its expiry |
| [`SECRET_ROTATION_FAILED`](/docs/errors/SECRET_ROTATION_FAILED) | 500 | `api_error` | The secret rotation could not be completed |
| [`SECRET_REVOCATION_FAILED`](/docs/errors/SECRET_REVOCATION_FAILED) | 500 | `api_error` | The secret revocation could not be completed |
| [`SECRET_GENERATION_FAILED`](/docs/errors/SECRET_GENERATION_FAILED) | 500 | `api_error` | A new secret value could not be generated or stored |
| [`CLIENT_SECRET_ROTATION_CONFLICT`](/docs/errors/CLIENT_SECRET_ROTATION_CONFLICT) | 409 | `invalid_request` | A concurrent rotate or revoke won the race |
| [`CLIENT_SECRET_REVOKE_CONFLICT`](/docs/errors/CLIENT_SECRET_REVOKE_CONFLICT) | 409 | `invalid_request` | The secret you asked to revoke was already replaced |
| [`CLIENT_SECRET_CLEANUP_EXHAUSTED`](/docs/errors/CLIENT_SECRET_CLEANUP_EXHAUSTED) | 500 | `api_error` | Too many stranded credentials to retire automatically |
| [`WEBAUTHN_CHALLENGE_FAILED`](/docs/errors/WEBAUTHN_CHALLENGE_FAILED) | 500 | `api_error` | The passkey challenge could not be issued |
| [`WEBAUTHN_VERIFICATION_FAILED`](/docs/errors/WEBAUTHN_VERIFICATION_FAILED) | 400 | `invalid_request` | The passkey ceremony response did not verify |
| [`WEBAUTHN_CREDENTIAL_NOT_FOUND`](/docs/errors/WEBAUTHN_CREDENTIAL_NOT_FOUND) | 404 | `invalid_request` | No registered passkey matches that credential id |
| [`VERIFICATION_REQUEST_FAILED`](/docs/errors/VERIFICATION_REQUEST_FAILED) | 500 | `api_error` | The business-verification request could not be recorded |
| [`LIVE_ACCESS_REQUEST_FAILED`](/docs/errors/LIVE_ACCESS_REQUEST_FAILED) | 500 | `api_error` | The live-access request could not be recorded |
| [`SENDER_NOT_VERIFIED`](/docs/errors/SENDER_NOT_VERIFIED) | 500 | `api_error` | The platform sender domain is not verified with the mail provider |
| [`CONFIG_MISSING`](/docs/errors/CONFIG_MISSING) | 500 | `api_error` | Email delivery is not configured in this environment |
| [`CODE_MISMATCH`](/docs/errors/CODE_MISMATCH) | 400 | `invalid_request` | The emailed code did not match |
| [`CODE_EXPIRED`](/docs/errors/CODE_EXPIRED) | 400 | `invalid_request` | The emailed code is past its expiry window |
| [`NO_ACTIVE_CODE`](/docs/errors/NO_ACTIVE_CODE) | 400 | `invalid_request` | No code has been requested for this user |
| [`LOCKED`](/docs/errors/LOCKED) | 429 | `rate_limit` | Verification is locked after too many wrong attempts |
| [`COOLDOWN`](/docs/errors/COOLDOWN) | 429 | `rate_limit` | A code was sent recently — resend is on cooldown |
| [`CODE_ROTATED`](/docs/errors/CODE_ROTATED) | 409 | `invalid_request` | A newer code was issued while this attempt was in flight |
| [`INVALID_CODE_FORMAT`](/docs/errors/INVALID_CODE_FORMAT) | 400 | `invalid_request` | The verification code must be exactly 6 digits |
| [`LOOKUP_FAILED`](/docs/errors/LOOKUP_FAILED) | 500 | `api_error` | The stored verification code could not be read |
| [`INVALID_PASSWORD`](/docs/errors/INVALID_PASSWORD) | 400 | `invalid_request` | The new password was rejected by the password policy |
| [`CLIENT_CREATION_NOT_AVAILABLE`](/docs/errors/CLIENT_CREATION_NOT_AVAILABLE) | 501 | `api_error` | Additional client creation is not available |
| [`COOKIE_MUTATION_POLICY_REJECTED`](/docs/errors/COOKIE_MUTATION_POLICY_REJECTED) | 403 | `auth` | The browser mutation failed the cookie-session policy |
| [`TERMS_ACCEPTANCE_REQUIRED`](/docs/errors/TERMS_ACCEPTANCE_REQUIRED) | 400 | `invalid_request` | Signup requires explicit acceptance of the current legal documents |
| [`LEGAL_VERSION_OUTDATED`](/docs/errors/LEGAL_VERSION_OUTDATED) | 400 | `invalid_request` | The accepted legal document versions are no longer current |
| [`LEGAL_ACCEPTANCE_INVALID`](/docs/errors/LEGAL_ACCEPTANCE_INVALID) | 400 | `invalid_request` | The legal acceptance evidence is malformed |

### UNAUTHORIZED

- **Status:** 401
- **Type:** `auth`
- **Summary:** Authentication failed or is missing
- **Cause:** No credentials were supplied, or the supplied credentials are invalid.
- **Fix:** Send Authorization: Bearer <api_key>. Get a sandbox key from the developer keys page.

### FORBIDDEN

- **Status:** 403
- **Type:** `auth`
- **Summary:** Authenticated, but not authorized for this resource
- **Cause:** Your credentials are valid but the resource belongs to a different account, or your key lacks the required scope.
- **Fix:** Confirm the resource belongs to your account and that your key has the necessary access.

### CLIENT_ID_MISMATCH

- **Status:** 403
- **Type:** `auth`
- **Summary:** The supplied client_id does not match your session
- **Cause:** You passed an explicit client_id that differs from your authenticated client account.
- **Fix:** Drop the explicit client_id; the gateway derives it from your credentials/session.

### NEEDS_RESIGNIN

- **Status:** 401
- **Type:** `auth`
- **Summary:** No authenticated client context was resolved
- **Cause:** The portal session could not be resolved (expired or missing).
- **Fix:** Sign in again to refresh your session, then retry.

### SIGNIN_CREDENTIALS_REJECTED

- **Status:** 401
- **Type:** `auth`
- **Summary:** Sign-in was refused
- **Cause:** The email/password pair presented to the portal sign-in endpoint was not accepted. The response deliberately does not say which half was wrong: this endpoint is unauthenticated, so distinguishing "no such account" from "wrong password" would let anyone use it to discover which addresses are registered.
- **Fix:** Re-enter the email and password. If the account exists but has never confirmed its signup code you will get USER_NOT_CONFIRMED instead; if the password is genuinely lost, start the reset flow.

### AUTHENTICATION_INCOMPLETE

- **Status:** 401
- **Type:** `auth`
- **Summary:** Sign-in stopped short of issuing tokens
- **Cause:** The identity provider answered with an additional challenge instead of an access token, and this endpoint cannot carry that challenge.
- **Fix:** Complete sign-in through the portal, which handles the challenge, then reuse the tokens it issues.

### USER_NOT_CONFIRMED

- **Status:** 401
- **Type:** `auth`
- **Summary:** The account was never confirmed after signup
- **Cause:** Signup created the identity but the emailed confirmation code was never submitted, so the account cannot authenticate yet.
- **Fix:** Submit the confirmation code from the signup email — or request a fresh one — and then sign in again.

### INVALID_REFRESH_TOKEN

- **Status:** 401
- **Type:** `auth`
- **Summary:** The refresh token was rejected
- **Cause:** The token expired, was revoked by a sign-out, or was minted against a different user pool. Retrying with the same value cannot succeed.
- **Fix:** Send the user through sign-in again to mint a fresh token pair, and drop the stored refresh token.

### INVALID_CONFIRMATION_CODE

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The signup confirmation code did not match
- **Cause:** The code is wrong or expired. Each new confirmation email supersedes the previous code, so an older one no longer verifies.
- **Fix:** Use the code from the most recent confirmation email, or request a new one if none is current.

### PASSWORD_RESET_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The password reset could not be applied
- **Cause:** Either the reset code is wrong or expired, or the new password fails the pool policy. Nothing was changed.
- **Fix:** Resubmit with the code from the latest reset email and a policy-compliant password.

### FORGOT_PASSWORD_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The forgot-password flow could not be started
- **Cause:** A server-side fault, not a bad request. The code stays deliberately opaque so it cannot be used to probe which addresses are registered.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id from the response.

### SIGNUP_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The account could not be created
- **Cause:** Signup was refused. The specific reason is withheld on purpose — distinguishing the possible causes on an unauthenticated endpoint would leak which addresses are registered.
- **Fix:** Retry with an address you control and a longer, mixed-case password containing a digit and a symbol.

### WRONG_PASSWORD

- **Status:** 401
- **Type:** `auth`
- **Summary:** The current password on a change-password call is wrong
- **Cause:** Change-password re-authenticates with the existing password before applying the new one, and that check failed.
- **Fix:** Re-prompt for the current password, or run the forgot-password flow instead. Repeated attempts trip the rate limiter.

### WEAK_PASSWORD

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The proposed password fails the password policy
- **Cause:** The new password does not satisfy the user-pool complexity policy, so it was not stored.
- **Fix:** Send at least 8 characters with upper case, lower case, a digit, and a symbol.

### USER_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No user with that id under this client
- **Cause:** The user id does not resolve, or it belongs to another client — cross-client lookups collapse to not-found rather than leaking existence.
- **Fix:** Verify the user id came from your own list call, and that the account was not deleted.

### CLIENT_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No client account with that client_id
- **Cause:** The supplied client_id does not resolve to a client account.
- **Fix:** Use the client_id shown in the portal, and omit it entirely on session-authenticated calls, where it is derived from your session.

### EMAIL_ALREADY_VERIFIED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The address is already verified
- **Cause:** A send or confirm was issued for an address that has already completed verification, so there is nothing left to do.
- **Fix:** Treat this as success and continue; re-read the user to confirm the verified flag.

### VERIFICATION_COOLDOWN

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** A verification code was requested too soon after the last one
- **Cause:** Code sends are rate-limited per address to keep the mailbox from being flooded.
- **Fix:** Wait out the cooldown window. The previously sent code is still valid, so prefer using it over re-sending.

### VERIFICATION_CODE_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The email verification code did not match
- **Cause:** The submitted code differs from the one on file for this address.
- **Fix:** Use the code from the most recent verification email. A bounded number of wrong attempts locks the code out.

### VERIFICATION_CODE_EXPIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The email verification code has expired
- **Cause:** The code is past its expiry window, so it no longer verifies the address.
- **Fix:** Request a fresh code and submit that one; resubmitting the expired value cannot succeed.

### VERIFICATION_SEND_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The verification email could not be sent
- **Cause:** The message was never accepted by the mail provider. No code was stored, so nothing is pending for the user to enter.
- **Fix:** Retry the send. If it persists, contact support with the request_id.

### EMAIL_NOT_VERIFIED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** Minting a secret requires a verified email
- **Cause:** The account owner has not confirmed their email address, which secret issuance requires.
- **Fix:** Verify the address with the emailed code, then reissue the secret call.

### OPERATOR_VERIFICATION_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** Production secrets require completed business verification
- **Cause:** The operator behind this account has not finished business verification, which gates production credentials.
- **Fix:** Complete verification in the portal, then retry. Sandbox secrets remain available in the meantime.

### SANDBOX_CLAIM_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The sandbox claim link is invalid or has expired
- **Cause:** The claim token is unknown, the link signature is invalid or past its 7-day window, the sandbox was already claimed, or it expired unclaimed. All of these answer identically by design — there is no oracle distinguishing them.
- **Fix:** If the link is simply stale, use the claim page to request a fresh signed link. Otherwise create a new sandbox with POST /v1/sandboxes.

### SANDBOX_FIXED_SECRET

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The sandbox secret cannot be minted, rotated, or revoked
- **Cause:** Each account has one fixed sandbox secret for its lifetime, so lifecycle calls do not apply.
- **Fix:** Read the sandbox secret from the portal, or target the production environment for a rotatable one.

### SECRET_ALREADY_EXISTS

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** An active secret already exists for this environment
- **Cause:** A client holds at most one active secret per environment, and plaintext is shown only once at creation.
- **Fix:** Rotate the existing secret rather than creating a second one; rotation returns a fresh plaintext value.

### INVALID_ENVIRONMENT

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The environment value is not recognized
- **Cause:** The environment field carried something other than the two accepted values.
- **Fix:** Send exactly `sandbox` or `production` — the field is snake_case and case-sensitive.

### SECRET_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No secret exists for this client and environment
- **Cause:** The secret was never minted for that environment, or it has since been revoked.
- **Fix:** Confirm the environment you targeted, then mint a new secret.

### SECRET_EXPIRED

- **Status:** 401
- **Type:** `auth`
- **Summary:** The client secret is past its expiry
- **Cause:** The secret aged out and no longer authenticates, so retrying with it cannot succeed.
- **Fix:** Rotate the secret in the portal and redeploy your integration with the new value.

### SECRET_ROTATION_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The secret rotation could not be completed
- **Cause:** A server-side fault interrupted the rotation. The previous secret is still the active one, so your integration keeps working.
- **Fix:** Retry the rotation. If it persists, contact support with the request_id.

### SECRET_REVOCATION_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The secret revocation could not be completed
- **Cause:** The revoke did not land, so the secret you intended to kill may still authenticate.
- **Fix:** Retry the revoke. If it keeps failing, rotate the secret instead and contact support with the request_id.

### SECRET_GENERATION_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** A new secret value could not be generated or stored
- **Cause:** The mint failed before anything was issued, so no credential was created or leaked.
- **Fix:** Retry the request. If it persists, contact support with the request_id.

### CLIENT_SECRET_ROTATION_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** A concurrent rotate or revoke won the race
- **Cause:** Another writer changed the secret first, so this rotation was not applied and no plaintext was issued.
- **Fix:** Re-read the current secret state, and rotate again only if you still need to.

### CLIENT_SECRET_REVOKE_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The secret you asked to revoke was already replaced
- **Cause:** A concurrent rotation swapped in a new secret, so the revoke targeted a value that is no longer live.
- **Fix:** Re-read the current secret and reissue the revoke against it.

### CLIENT_SECRET_CLEANUP_EXHAUSTED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** Too many stranded credentials to retire automatically
- **Cause:** More active credentials exist for this client and environment than the bounded cleanup can retire, so the request stopped rather than looping.
- **Fix:** Retrying will not clear it. Contact support with the request_id so an operator can sweep the account.

### WEBAUTHN_CHALLENGE_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The passkey challenge could not be issued
- **Cause:** The challenge could not be generated or stored, so the WebAuthn ceremony never started.
- **Fix:** Request a fresh challenge. If it persists, contact support with the request_id.

### WEBAUTHN_VERIFICATION_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The passkey ceremony response did not verify
- **Cause:** Commonly a stale or already-consumed challenge, or an origin/RP ID that differs from the one the challenge was issued for.
- **Fix:** Start a fresh challenge from the same origin the passkey was registered against, and retry the ceremony.

### WEBAUTHN_CREDENTIAL_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No registered passkey matches that credential id
- **Cause:** The credential was never registered against this account, or it has since been removed.
- **Fix:** Register a passkey for this account, or sign in with another available factor.

### VERIFICATION_REQUEST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The business-verification request could not be recorded
- **Cause:** The submission was not persisted, so no verification review was opened.
- **Fix:** Resubmit the request. If it persists, contact support with the request_id.

### LIVE_ACCESS_REQUEST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The live-access request could not be recorded
- **Cause:** The submission was not persisted, so the account stays sandbox-only.
- **Fix:** Resubmit the request. If it persists, contact support with the request_id.

### SENDER_NOT_VERIFIED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The platform sender domain is not verified with the mail provider
- **Cause:** The provider refused the send because of our configuration, not anything in your request.
- **Fix:** Retrying will not clear it. Contact support with the request_id.

### CONFIG_MISSING

- **Status:** 500
- **Type:** `api_error`
- **Summary:** Email delivery is not configured in this environment
- **Cause:** The mail provider API key or from-address is absent, so no message can be sent at all.
- **Fix:** This is a platform misconfiguration — contact support with the request_id.

### CODE_MISMATCH

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The emailed code did not match
- **Cause:** The digits submitted differ from the stored code for this user.
- **Fix:** Re-enter the digits from the most recent email. A bounded number of wrong attempts locks verification for 15 minutes.

### CODE_EXPIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The emailed code is past its expiry window
- **Cause:** Codes are short-lived, and this one aged out before it was submitted.
- **Fix:** Request a fresh code and submit that one; the expired value cannot succeed.

### NO_ACTIVE_CODE

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** No code has been requested for this user
- **Cause:** A verification attempt arrived with nothing pending to check it against.
- **Fix:** Issue the code-request call first, then submit the code from the email it triggers.

### LOCKED

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** Verification is locked after too many wrong attempts
- **Cause:** The wrong-attempt budget for this code was exhausted, so verification is paused for 15 minutes.
- **Fix:** Wait out the lockout window, then request a fresh code rather than resubmitting the old one.

### COOLDOWN

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** A code was sent recently — resend is on cooldown
- **Cause:** The resend endpoint enforces a cooldown after each issued verification code.
- **Fix:** Back off for the cooldown_seconds returned on the response, then request another code.

### CODE_ROTATED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** A newer code was issued while this attempt was in flight
- **Cause:** The stored verification code rotated between the email send and this submission, so the submitted digits can no longer match.
- **Fix:** Use the code from the most recent email.

### INVALID_CODE_FORMAT

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The verification code must be exactly 6 digits
- **Cause:** The submitted value was not a 6-digit string.
- **Fix:** Use only the digits from the email, with no spaces or other characters.

### LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The stored verification code could not be read
- **Cause:** A server-side read of the pending verification code failed.
- **Fix:** Retry; if it persists, request a fresh code and contact support with the request_id.

### INVALID_PASSWORD

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The new password was rejected by the password policy
- **Cause:** The submitted password does not meet the minimum complexity policy.
- **Fix:** Choose a longer password mixing upper/lower case, digits, and symbols, then retry.

### CLIENT_CREATION_NOT_AVAILABLE

- **Status:** 501
- **Type:** `api_error`
- **Summary:** Additional client creation is not available
- **Cause:** Signup creates the primary client; this compatibility route cannot create another one.
- **Fix:** If an existing account has no client, contact support with the request_id. Retrying this route cannot help.

### COOKIE_MUTATION_POLICY_REJECTED

- **Status:** 403
- **Type:** `auth`
- **Summary:** The browser mutation failed the cookie-session policy
- **Cause:** The request used a portal cookie without the matching canonical Origin, same-site fetch metadata, and double-submit CSRF token.
- **Fix:** Retry from the canonical portal with a fresh session. Never replay or forward portal cookies across origins.

### TERMS_ACCEPTANCE_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** Signup requires explicit acceptance of the current legal documents
- **Cause:** The signup request did not explicitly accept the current Terms of Service and Privacy Policy.
- **Fix:** Review the current Terms of Service and Privacy Policy, then explicitly accept both before retrying signup.

### LEGAL_VERSION_OUTDATED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The accepted legal document versions are no longer current
- **Cause:** The signup accepted legal document versions that have since been superseded.
- **Fix:** Refresh the signup contract, review the current legal documents, and retry with their current version identifiers.

### LEGAL_ACCEPTANCE_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The legal acceptance evidence is malformed
- **Cause:** The acceptance timestamp or source did not validate (for example, a non-ISO-8601 timestamp or an unsupported surface).
- **Fix:** Submit a fresh ISO-8601 acceptance timestamp from a supported BillerAPI signup surface.

## Bills & statements

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`BILL_NOT_FOUND`](/docs/errors/BILL_NOT_FOUND) | 404 | `invalid_request` | No bill exists with that id |
| [`ACCOUNT_LINK_NOT_FOUND`](/docs/errors/ACCOUNT_LINK_NOT_FOUND) | 404 | `invalid_request` | The account link does not exist |
| [`STATEMENT_NOT_EXTRACTED`](/docs/errors/STATEMENT_NOT_EXTRACTED) | 425 | `invalid_request` | The statement has not been extracted yet |
| [`EXTRACTION_FAILED`](/docs/errors/EXTRACTION_FAILED) | 503 | `upstream` | Statement extraction failed upstream |
| [`EXTRACTION_UNSUPPORTED_FOR_BILLER`](/docs/errors/EXTRACTION_UNSUPPORTED_FOR_BILLER) | 422 | `invalid_request` | This biller does not support statement extraction |
| [`STATEMENT_TOO_LARGE`](/docs/errors/STATEMENT_TOO_LARGE) | 413 | `upstream` | The statement exceeds the size limit |

### BILL_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No bill exists with that id
- **Cause:** No bill matches the supplied bill_id under your account (unknown or cross-tenant, collapsed to 404).
- **Fix:** Verify the bill_id from a recent list call.

### ACCOUNT_LINK_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The account link does not exist
- **Cause:** No account link matches the supplied id, or it belongs to a different client (enumerate-safe 404).
- **Fix:** Verify the account-link id and that it has not been disconnected.

### STATEMENT_NOT_EXTRACTED

- **Status:** 425
- **Type:** `invalid_request`
- **Summary:** The statement has not been extracted yet
- **Cause:** You requested statement data before extraction completed for this bill.
- **Fix:** Call POST /v1/bills/:bill_id/statement/refresh, then retry once the statement is extracted.

### EXTRACTION_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** Statement extraction failed upstream
- **Cause:** The extraction pipeline errored while processing this statement.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### EXTRACTION_UNSUPPORTED_FOR_BILLER

- **Status:** 422
- **Type:** `invalid_request`
- **Summary:** This biller does not support statement extraction
- **Cause:** The biller lacks the capability required for statement extraction.
- **Fix:** Check the biller capability flags before requesting extraction.

### STATEMENT_TOO_LARGE

- **Status:** 413
- **Type:** `upstream`
- **Summary:** The statement exceeds the size limit
- **Cause:** The source statement is larger than BillerAPI can process for this request.
- **Fix:** Request a narrower range, or contact support.

## Links & Connect

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`LINK_NOT_FOUND`](/docs/errors/LINK_NOT_FOUND) | 404 | `invalid_request` | No link exists with that id |
| [`LINK_TOKEN_NOT_FOUND`](/docs/errors/LINK_TOKEN_NOT_FOUND) | 404 | `invalid_request` | The link token is unknown or expired |
| [`BACKGROUND_NOT_ELIGIBLE`](/docs/errors/BACKGROUND_NOT_ELIGIBLE) | 409 | `invalid_request` | The link token cannot be backgrounded now |
| [`UPDATE_BILLER_MISMATCH`](/docs/errors/UPDATE_BILLER_MISMATCH) | 409 | `invalid_request` | The update token targets a different biller |
| [`INVALID_UPDATE_REASON`](/docs/errors/INVALID_UPDATE_REASON) | 400 | `invalid_request` | The update reason is not a recognized value |
| [`LINK_UPDATE_FORBIDDEN`](/docs/errors/LINK_UPDATE_FORBIDDEN) | 403 | `auth` | The update token belongs to a different client |
| [`REDIRECT_URI_NOT_REGISTERED`](/docs/errors/REDIRECT_URI_NOT_REGISTERED) | 400 | `invalid_request` | The redirect_uri is not in your registered allowlist |
| [`LINK_TOKEN_LOOKUP_FAILED`](/docs/errors/LINK_TOKEN_LOOKUP_FAILED) | 503 | `upstream` | The link token could not be read |
| [`LINK_TOKEN_EXPIRED`](/docs/errors/LINK_TOKEN_EXPIRED) | 410 | `invalid_request` | The link token expired before the flow completed |
| [`LINK_TOKEN_NOT_USABLE`](/docs/errors/LINK_TOKEN_NOT_USABLE) | 409 | `invalid_request` | The link token cannot accept this step |
| [`LINK_ALREADY_EXISTS`](/docs/errors/LINK_ALREADY_EXISTS) | 409 | `invalid_request` | An active link already exists for this account |
| [`REQUEST_TO_LINK_NOT_FOUND`](/docs/errors/REQUEST_TO_LINK_NOT_FOUND) | 404 | `invalid_request` | No request-to-link exists with that id |
| [`CLIENT_ID_REQUIRED`](/docs/errors/CLIENT_ID_REQUIRED) | 400 | `invalid_request` | This read refuses to run unscoped |
| [`INVALID_PUBLIC_TOKEN`](/docs/errors/INVALID_PUBLIC_TOKEN) | 400 | `invalid_request` | The public token is invalid or already exchanged |
| [`INVALID_CREDENTIALS`](/docs/errors/INVALID_CREDENTIALS) | 400 | `invalid_request` | The biller rejected the credentials |
| [`BILLER_ACCOUNT_LOCKED`](/docs/errors/BILLER_ACCOUNT_LOCKED) | 409 | `invalid_request` | The biller locked the account |
| [`MFA_NO_CHALLENGE`](/docs/errors/MFA_NO_CHALLENGE) | 409 | `invalid_request` | No MFA challenge is outstanding |
| [`MFA_ATTEMPTS_EXHAUSTED`](/docs/errors/MFA_ATTEMPTS_EXHAUSTED) | 409 | `invalid_request` | Every MFA attempt was used |
| [`MFA_RESEND_LIMIT_REACHED`](/docs/errors/MFA_RESEND_LIMIT_REACHED) | 429 | `rate_limit` | The MFA resend limit was reached |
| [`EMPTY_ACCOUNT_SELECTION`](/docs/errors/EMPTY_ACCOUNT_SELECTION) | 400 | `invalid_request` | No accounts were selected |
| [`NO_DISCOVERED_ACCOUNTS`](/docs/errors/NO_DISCOVERED_ACCOUNTS) | 409 | `invalid_request` | Nothing was discovered at the biller |
| [`CREDENTIAL_NOT_FOUND`](/docs/errors/CREDENTIAL_NOT_FOUND) | 404 | `invalid_request` | No vaulted credential with that id |
| [`CREDENTIAL_INVALID`](/docs/errors/CREDENTIAL_INVALID) | 400 | `invalid_request` | The credential failed validation |
| [`CREDENTIAL_OWNER_MISMATCH`](/docs/errors/CREDENTIAL_OWNER_MISMATCH) | 403 | `auth` | The credential belongs to another owner |
| [`CREDENTIAL_REVOKED`](/docs/errors/CREDENTIAL_REVOKED) | 409 | `invalid_request` | The credential is revoked |
| [`CREDENTIAL_TYPE_REQUIRED`](/docs/errors/CREDENTIAL_TYPE_REQUIRED) | 400 | `invalid_request` | credential_type is missing |
| [`CREDENTIAL_NOT_ROTATABLE`](/docs/errors/CREDENTIAL_NOT_ROTATABLE) | 409 | `invalid_request` | The credential cannot be rotated now |
| [`CREDENTIAL_ENCRYPTION_FAILED`](/docs/errors/CREDENTIAL_ENCRYPTION_FAILED) | 500 | `api_error` | The credential could not be encrypted or decrypted |
| [`CREDENTIAL_PERSIST_FAILED`](/docs/errors/CREDENTIAL_PERSIST_FAILED) | 500 | `api_error` | The credential could not be stored |
| [`CREDENTIAL_LOOKUP_FAILED`](/docs/errors/CREDENTIAL_LOOKUP_FAILED) | 500 | `api_error` | The credential store could not be read |
| [`SCRAPING_SESSION_KEY_REQUIRED`](/docs/errors/SCRAPING_SESSION_KEY_REQUIRED) | 400 | `invalid_request` | The session cache key is incomplete |
| [`SCRAPING_SESSION_LOOKUP_FAILED`](/docs/errors/SCRAPING_SESSION_LOOKUP_FAILED) | 500 | `api_error` | The session cache could not be read |
| [`SCRAPING_SESSION_SAVE_FAILED`](/docs/errors/SCRAPING_SESSION_SAVE_FAILED) | 500 | `api_error` | The session cache could not be written |
| [`SCRAPING_SESSION_INVALIDATE_FAILED`](/docs/errors/SCRAPING_SESSION_INVALIDATE_FAILED) | 500 | `api_error` | The cached session could not be invalidated |
| [`SCRAPING_SESSION_INVALID`](/docs/errors/SCRAPING_SESSION_INVALID) | 400 | `invalid_request` | The session snapshot failed validation |
| [`MFA_SUBMISSION_INVALID`](/docs/errors/MFA_SUBMISSION_INVALID) | 400 | `invalid_request` | The MFA submission failed validation |
| [`MFA_SUBMISSION_LOOKUP_FAILED`](/docs/errors/MFA_SUBMISSION_LOOKUP_FAILED) | 500 | `api_error` | The MFA submission store could not be read |
| [`MFA_SUBMISSION_PERSIST_FAILED`](/docs/errors/MFA_SUBMISSION_PERSIST_FAILED) | 500 | `api_error` | The MFA submission could not be recorded |
| [`MFA_CONTINUATION_UNAVAILABLE`](/docs/errors/MFA_CONTINUATION_UNAVAILABLE) | 409 | `invalid_request` | No live MFA continuation is awaiting this code |
| [`MFA_CONTINUATION_STALE`](/docs/errors/MFA_CONTINUATION_STALE) | 409 | `invalid_request` | The continuation_id is out of date |
| [`PENDING_MFA_INVALID`](/docs/errors/PENDING_MFA_INVALID) | 400 | `invalid_request` | The pending-MFA snapshot failed validation |
| [`PENDING_MFA_LOOKUP_FAILED`](/docs/errors/PENDING_MFA_LOOKUP_FAILED) | 500 | `api_error` | The pending-MFA store could not be read |
| [`PENDING_MFA_PERSIST_FAILED`](/docs/errors/PENDING_MFA_PERSIST_FAILED) | 500 | `api_error` | The pending-MFA snapshot could not be written |
| [`PENDING_MFA_RESEND_FAILED`](/docs/errors/PENDING_MFA_RESEND_FAILED) | 500 | `api_error` | The OTP resend could not be recorded |

### LINK_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No link exists with that id
- **Cause:** No link matches the supplied id under your account (unknown, disconnected, or cross-tenant).
- **Fix:** Verify the link id and that it has not been disconnected.

### LINK_TOKEN_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The link token is unknown or expired
- **Cause:** The link-token id/value does not resolve — it never existed, expired, or you passed a value where an id was expected (or vice-versa).
- **Fix:** Mint a fresh link token and retry the hosted-connect flow.

### BACKGROUND_NOT_ELIGIBLE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The link token cannot be backgrounded now
- **Cause:** The token is in a non-backgroundable state (still recording a login script, already completed, or a lost conditional write).
- **Fix:** Complete or restart the connect flow before backgrounding.

### UPDATE_BILLER_MISMATCH

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The update token targets a different biller
- **Cause:** An update-mode link token points at a different biller than the link it is updating.
- **Fix:** Mint an update-mode token for the correct biller.

### INVALID_UPDATE_REASON

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The update reason is not a recognized value
- **Cause:** The supplied update reason is not one of the accepted enum values.
- **Fix:** Use one of the documented update reasons. See the link-update API reference.

### LINK_UPDATE_FORBIDDEN

- **Status:** 403
- **Type:** `auth`
- **Summary:** The update token belongs to a different client
- **Cause:** An update-mode token minted by another client was used (enumerate-safe 403).
- **Fix:** Use an update-mode token minted by your own client.

### REDIRECT_URI_NOT_REGISTERED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The redirect_uri is not in your registered allowlist
- **Cause:** A hosted link token was minted with a redirect_uri that is not in the authenticated client’s registered redirect_uris. The hosted connect page top-navigates to redirect_uri, so an unregistered value is refused at mint (open-redirect guard).
- **Fix:** Register the URI via PUT /v1/iam/clients/:id { redirect_uris }, then retry.

### LINK_TOKEN_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The link token could not be read
- **Cause:** A storage fault stopped us LOOKING for the token — it is not evidence the token is absent. Retryable by design so a transient fault never reports an in-flight connect session as permanently gone.
- **Fix:** Retry the status poll with backoff. Do not tear down the connect session.

### LINK_TOKEN_EXPIRED

- **Status:** 410
- **Type:** `invalid_request`
- **Summary:** The link token expired before the flow completed
- **Cause:** Link tokens are short-lived; this one passed its expires_at.
- **Fix:** Mint a new link token and restart the connect flow.

### LINK_TOKEN_NOT_USABLE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The link token cannot accept this step
- **Cause:** The token is completed, failed, or awaiting a different step than the one you submitted.
- **Fix:** Read the token status and follow next_action.

### LINK_ALREADY_EXISTS

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** An active link already exists for this account
- **Cause:** This user already has an active link to the same biller account.
- **Fix:** Reuse the existing link, or disconnect it before creating a new one.

### REQUEST_TO_LINK_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No request-to-link exists with that id
- **Cause:** The id is unknown, or belongs to another client — cross-tenant lookups collapse to not-found rather than leaking existence.
- **Fix:** Verify the id came from your own list call.

### CLIENT_ID_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** This read refuses to run unscoped
- **Cause:** A tenant-scoped list/read was called without a client_id, which would otherwise return other clients’ rows.
- **Fix:** Supply client_id; the gateway derives it from your authenticated session.

### INVALID_PUBLIC_TOKEN

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The public token is invalid or already exchanged
- **Cause:** Public tokens are single-use and client-scoped. The value is malformed, already exchanged, or belongs to another client.
- **Fix:** Re-run the connect flow to mint a fresh public token, then exchange it once.

### INVALID_CREDENTIALS

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The biller rejected the credentials
- **Cause:** The username/password pair failed at the biller’s own login.
- **Fix:** Prompt the user to re-enter their credentials; the same values will fail again.

### BILLER_ACCOUNT_LOCKED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The biller locked the account
- **Cause:** The biller locked the end-user’s account after too many sign-in attempts. The stored credential is correct and was not revoked — the lock is a biller-side account state, not a wrong password.
- **Fix:** The user must clear the lock at the biller (reset their password, or contact biller support), then reconnect. Retrying with the same credentials will not clear the lock.

### MFA_NO_CHALLENGE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** No MFA challenge is outstanding
- **Cause:** An MFA code was submitted for a flow that is not awaiting one.
- **Fix:** Read the token status and follow next_action.

### MFA_ATTEMPTS_EXHAUSTED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Every MFA attempt was used
- **Cause:** The user exhausted the per-flow MFA attempt budget.
- **Fix:** Mint a new link token and start the connect flow over.

### MFA_RESEND_LIMIT_REACHED

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** The MFA resend limit was reached
- **Cause:** Codes can only be re-sent a bounded number of times per connect flow.
- **Fix:** Use the last code sent, or restart the flow with a new link token.

### EMPTY_ACCOUNT_SELECTION

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** No accounts were selected
- **Cause:** Account selection was submitted with an empty id list.
- **Fix:** Send at least one id, exactly as it appeared in discovered_accounts.

### NO_DISCOVERED_ACCOUNTS

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Nothing was discovered at the biller
- **Cause:** The flow reached account selection with an empty discovery result.
- **Fix:** Restart the connect flow. If it repeats for one biller, report it.

### CREDENTIAL_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No vaulted credential with that id
- **Cause:** The credential_id does not resolve — it was never vaulted, or it belongs to another owner triple.
- **Fix:** Verify the id, or re-run the connect flow to vault a fresh credential.

### CREDENTIAL_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The credential failed validation
- **Cause:** Most often a missing owner triple (client_id, client_user_id, biller_id) or an empty payload.
- **Fix:** Supply the owner triple and retry.

### CREDENTIAL_OWNER_MISMATCH

- **Status:** 403
- **Type:** `auth`
- **Summary:** The credential belongs to another owner
- **Cause:** The credential exists but its (client_id, client_user_id, biller_id) triple differs. No plaintext is released.
- **Fix:** No plaintext is released. Read the credential with the owner triple it was created under.

### CREDENTIAL_REVOKED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The credential is revoked
- **Cause:** A revoked credential can never be decrypted again — the decision is permanent by design.
- **Fix:** Retrying will not help: re-run the connect flow to vault a replacement credential.

### CREDENTIAL_TYPE_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** credential_type is missing
- **Cause:** The vault boundary is snake_case-only, so a camelCase `credentialType` arrives empty.
- **Fix:** The vault boundary is snake_case-only — a camelCase `credentialType` does not resolve and arrives empty.

### CREDENTIAL_NOT_ROTATABLE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The credential cannot be rotated now
- **Cause:** It is revoked, or its rotation policy is disabled.
- **Fix:** Inspect its status before rotating.

### CREDENTIAL_ENCRYPTION_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The credential could not be encrypted or decrypted
- **Cause:** The KMS envelope operation failed — KMS access, or a malformed stored envelope.
- **Fix:** This is a server-side fault (KMS access or a malformed stored envelope), safe to retry with backoff.

### CREDENTIAL_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The credential could not be stored
- **Cause:** The vault store write failed.
- **Fix:** Retry with backoff; the write is idempotent under the same idempotency key.

### CREDENTIAL_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The credential store could not be read
- **Cause:** An infrastructure fault — NOT a missing row, which is CREDENTIAL_NOT_FOUND.
- **Fix:** This is an infrastructure fault, not a missing row (that is CREDENTIAL_NOT_FOUND). Retry with backoff.

### SCRAPING_SESSION_KEY_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The session cache key is incomplete
- **Cause:** biller_id, client_id, and user_hash are all required; an empty triple would address the shared cache row.
- **Fix:** An empty triple would address the shared cache row, so it is refused rather than defaulted.

### SCRAPING_SESSION_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The session cache could not be read
- **Cause:** A cache-store fault. Callers treat it as a MISS and cold-log instead.
- **Fix:** Callers treat this as a cache MISS and continue with a cold login; no action is required beyond retrying later.

### SCRAPING_SESSION_SAVE_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The session cache could not be written
- **Cause:** The scrape itself is unaffected; the next run simply cold-logs.
- **Fix:** The scrape itself is unaffected — the next run simply cold-logs. Retry with backoff.

### SCRAPING_SESSION_INVALIDATE_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The cached session could not be invalidated
- **Cause:** The delete failed; the row still expires on its own TTL.
- **Fix:** Retry with backoff; a stale row expires on its own TTL in the meantime.

### SCRAPING_SESSION_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The session snapshot failed validation
- **Cause:** The captured storage state is empty or malformed.
- **Fix:** Inspect the storage state and capture a fresh one before caching it.

### MFA_SUBMISSION_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The MFA submission failed validation
- **Cause:** Missing or blank identifiers, or an identity that contradicts a prior submission under the same id.
- **Fix:** Verify the identifiers, re-read the pending MFA challenge, and resubmit.

### MFA_SUBMISSION_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The MFA submission store could not be read
- **Cause:** An infrastructure fault reading the submission row.
- **Fix:** Retry with backoff.

### MFA_SUBMISSION_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The MFA submission could not be recorded
- **Cause:** The write or state transition failed.
- **Fix:** Retry with backoff; submissions are idempotent under the same submission_id.

### MFA_CONTINUATION_UNAVAILABLE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** No live MFA continuation is awaiting this code
- **Cause:** It expired, was already resolved, or names an unknown challenge.
- **Fix:** Re-fetch the pending MFA state before submitting an OTP.

### MFA_CONTINUATION_STALE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The continuation_id is out of date
- **Cause:** A newer MFA challenge superseded the one you submitted against.
- **Fix:** Re-fetch the pending MFA state and submit against the current continuation_id.

### PENDING_MFA_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The pending-MFA snapshot failed validation
- **Cause:** Missing identifiers, no challenges, or a malformed paused-browser snapshot.
- **Fix:** Fix the request and retry.

### PENDING_MFA_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The pending-MFA store could not be read
- **Cause:** An infrastructure fault reading the pending row.
- **Fix:** Retry with backoff.

### PENDING_MFA_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The pending-MFA snapshot could not be written
- **Cause:** The write failed, or a stale snapshot lost the compare-and-set against the live continuation.
- **Fix:** Re-fetch the current state, then retry the write.

### PENDING_MFA_RESEND_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The OTP resend could not be recorded
- **Cause:** The resend budget is only debited on a recorded resend.
- **Fix:** Retry with backoff; the resend budget is only debited on a recorded resend.

## Billers

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`BILLER_NOT_CONNECT_READY`](/docs/errors/BILLER_NOT_CONNECT_READY) | 409 | `invalid_request` | The biller is not ready to connect |
| [`BILLER_NOT_FOUND`](/docs/errors/BILLER_NOT_FOUND) | 404 | `invalid_request` | No biller exists with that id |
| [`BILLER_CORRECTION_NOT_AVAILABLE`](/docs/errors/BILLER_CORRECTION_NOT_AVAILABLE) | 501 | `api_error` | Public biller correction proposals are not available |
| [`BILLER_UNSUPPORTED`](/docs/errors/BILLER_UNSUPPORTED) | 422 | `invalid_request` | This biller cannot be automated |

### BILLER_NOT_CONNECT_READY

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The biller is not ready to connect
- **Cause:** The biller has no usable login script yet, so a connect flow cannot start.
- **Fix:** Wait for biller onboarding to finish, or pick another biller.

### BILLER_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No biller exists with that id
- **Cause:** No catalog biller matches the supplied biller_id.
- **Fix:** Verify the biller_id from a discovery or list call.

### BILLER_CORRECTION_NOT_AVAILABLE

- **Status:** 501
- **Type:** `api_error`
- **Summary:** Public biller correction proposals are not available
- **Cause:** Client credentials identify a tenant but cannot authorize mutation of the shared global biller catalog. A scoped proposal workflow is not implemented yet.
- **Fix:** Do not retry this mutation. Contact support to report a biller classification issue.

### BILLER_UNSUPPORTED

- **Status:** 422
- **Type:** `invalid_request`
- **Summary:** This biller cannot be automated
- **Cause:** The biller uses a sign-in method (e.g. certain SSO/IdP flows) the agent cannot drive; selection is refused pre-credential.
- **Fix:** Choose a different biller or connection method.

## Payments

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`PAYMENT_EXECUTION_NOT_AVAILABLE`](/docs/errors/PAYMENT_EXECUTION_NOT_AVAILABLE) | 501 | `upstream` | Bill payment execution is not available in this environment |

### PAYMENT_EXECUTION_NOT_AVAILABLE

- **Status:** 501
- **Type:** `upstream`
- **Summary:** Bill payment execution is not available in this environment
- **Cause:** Bill payment execution is unavailable in the current environment, so the endpoint returns a terminal 501 response.
- **Fix:** Do not retry or integrate with bill payment execution until availability is announced.

## Billing & invoices

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`INVOICE_NOT_FOUND`](/docs/errors/INVOICE_NOT_FOUND) | 404 | `invalid_request` | The invoice does not exist |
| [`INVOICE_LOOKUP_FAILED`](/docs/errors/INVOICE_LOOKUP_FAILED) | 500 | `api_error` | The invoice could not be read back |
| [`INVOICE_PERSIST_FAILED`](/docs/errors/INVOICE_PERSIST_FAILED) | 500 | `api_error` | The invoice could not be saved |
| [`INVOICE_CREATE_FAILED`](/docs/errors/INVOICE_CREATE_FAILED) | 500 | `api_error` | The invoice could not be constructed |
| [`INVOICE_PERIOD_INVALID`](/docs/errors/INVOICE_PERIOD_INVALID) | 400 | `invalid_request` | The billing period is not one completed UTC month |
| [`INVOICE_PERIOD_CLAIM_FAILED`](/docs/errors/INVOICE_PERIOD_CLAIM_FAILED) | 500 | `api_error` | The billing period could not be claimed |
| [`INVOICE_PERIOD_CLAIM_UNREADABLE`](/docs/errors/INVOICE_PERIOD_CLAIM_UNREADABLE) | 409 | `api_error` | The period claim exists but cannot be read |
| [`INVOICE_PERIOD_CLAIM_INVOICE_MISSING`](/docs/errors/INVOICE_PERIOD_CLAIM_INVOICE_MISSING) | 409 | `api_error` | The period is claimed by a missing invoice |
| [`INVOICE_LINE_ITEM_INVALID`](/docs/errors/INVOICE_LINE_ITEM_INVALID) | 500 | `api_error` | An invoice line item is invalid |
| [`INVOICE_CURRENCY_MISMATCH`](/docs/errors/INVOICE_CURRENCY_MISMATCH) | 500 | `api_error` | Invoice line items disagree on currency |
| [`INVOICE_SUBTOTAL_FAILED`](/docs/errors/INVOICE_SUBTOTAL_FAILED) | 500 | `api_error` | The invoice subtotal could not be computed |
| [`INVOICE_STRIPE_LINK_MISSING`](/docs/errors/INVOICE_STRIPE_LINK_MISSING) | 409 | `api_error` | An issued invoice names no Stripe invoice |
| [`INVOICE_STRIPE_DRAFT_FAILED`](/docs/errors/INVOICE_STRIPE_DRAFT_FAILED) | 503 | `upstream` | Stripe would not create the draft invoice |
| [`INVOICE_STRIPE_LINK_PERSIST_FAILED`](/docs/errors/INVOICE_STRIPE_LINK_PERSIST_FAILED) | 500 | `api_error` | The Stripe draft could not be linked |
| [`INVOICE_STRIPE_FINALIZE_FAILED`](/docs/errors/INVOICE_STRIPE_FINALIZE_FAILED) | 503 | `upstream` | Stripe would not finalize the invoice |
| [`INVOICE_LEDGER_DIVERGED`](/docs/errors/INVOICE_LEDGER_DIVERGED) | 500 | `api_error` | The invoice is billed but not recorded |
| [`INVOICE_EMAIL_PDF_UNAVAILABLE`](/docs/errors/INVOICE_EMAIL_PDF_UNAVAILABLE) | 409 | `invalid_request` | The invoice has no PDF to send |
| [`INVOICE_EMAIL_CLAIM_FAILED`](/docs/errors/INVOICE_EMAIL_CLAIM_FAILED) | 500 | `api_error` | The email delivery claim could not be written |
| [`INVOICE_EMAIL_ALREADY_CLAIMED`](/docs/errors/INVOICE_EMAIL_ALREADY_CLAIMED) | 409 | `invalid_request` | Invoice email delivery is already claimed |
| [`INVOICE_EMAIL_SEND_FAILED`](/docs/errors/INVOICE_EMAIL_SEND_FAILED) | 503 | `upstream` | The invoice email could not be sent |
| [`BILLING_USAGE_READ_FAILED`](/docs/errors/BILLING_USAGE_READ_FAILED) | 503 | `upstream` | Usage could not be read for the period |
| [`BILLING_PRICING_LOOKUP_FAILED`](/docs/errors/BILLING_PRICING_LOOKUP_FAILED) | 500 | `api_error` | Pricing could not be read |
| [`BILLING_PRICING_CONTRACT_MISMATCH`](/docs/errors/BILLING_PRICING_CONTRACT_MISMATCH) | 500 | `api_error` | Effective pricing differs from the published contract |
| [`BILLING_STATUS_UNRESOLVABLE`](/docs/errors/BILLING_STATUS_UNRESOLVABLE) | 500 | `api_error` | The billing status could not be resolved |
| [`BILLING_STATUS_INVALID`](/docs/errors/BILLING_STATUS_INVALID) | 400 | `invalid_request` | The billing status is not a known value |
| [`BILLING_STATUS_PERSIST_FAILED`](/docs/errors/BILLING_STATUS_PERSIST_FAILED) | 500 | `api_error` | The billing status could not be saved |
| [`BILLING_CLIENT_ID_REQUIRED`](/docs/errors/BILLING_CLIENT_ID_REQUIRED) | 400 | `invalid_request` | client_id is missing |
| [`BILLING_SETUP_SESSION_FAILED`](/docs/errors/BILLING_SETUP_SESSION_FAILED) | 503 | `upstream` | The billing setup session could not be created |
| [`INVOICE_ISSUER_NOT_CONFIGURED`](/docs/errors/INVOICE_ISSUER_NOT_CONFIGURED) | 409 | `api_error` | Invoice issuer identity is not configured |
| [`INVOICE_ISSUER_ENTITY_MISMATCH`](/docs/errors/INVOICE_ISSUER_ENTITY_MISMATCH) | 409 | `api_error` | Contracting entity does not match the payment processor identity |
| [`INVOICE_ISSUER_STRIPE_MISMATCH`](/docs/errors/INVOICE_ISSUER_STRIPE_MISMATCH) | 409 | `api_error` | Configured Stripe account does not match the authenticated account |
| [`INVOICE_ISSUER_STRIPE_UNAVAILABLE`](/docs/errors/INVOICE_ISSUER_STRIPE_UNAVAILABLE) | 503 | `api_error` | Stripe issuer metadata could not be verified |
| [`INVOICE_ISSUER_DESCRIPTOR_MISMATCH`](/docs/errors/INVOICE_ISSUER_DESCRIPTOR_MISMATCH) | 409 | `api_error` | Invoice descriptor does not match the payment processor configuration |
| [`INVOICE_ISSUER_TAX_PROFILE_MISMATCH`](/docs/errors/INVOICE_ISSUER_TAX_PROFILE_MISMATCH) | 409 | `api_error` | Approved tax-profile reference does not match processor metadata |
| [`INVOICE_REQUIRED_ACCEPTANCE_MISSING`](/docs/errors/INVOICE_REQUIRED_ACCEPTANCE_MISSING) | 409 | `invalid_request` | Current legal acceptance evidence is missing for commercial billing |

### INVOICE_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The invoice does not exist
- **Cause:** The id names no invoice billing has ever written. A Stripe invoice id only resolves once the invoice has been issued.
- **Fix:** Re-read the invoice id from the invoice list or the webhook payload that announced it.

### INVOICE_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The invoice could not be read back
- **Cause:** The durable invoice store rejected the read. Deliberately NOT collapsed into a 404: 'we could not look' and 'it is not there' are different answers.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### INVOICE_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The invoice could not be saved
- **Cause:** The write to the durable invoice store failed.
- **Fix:** Retry. Invoice generation is guarded by a per-(client, period) claim, so a retry cannot double-bill.

### INVOICE_CREATE_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The invoice could not be constructed
- **Cause:** One of the computed invoice fields failed a domain invariant before anything was persisted.
- **Fix:** Not retryable as-is. Contact support with the request_id; nothing was written and no Stripe invoice exists.

### INVOICE_PERIOD_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The billing period is not one completed UTC month
- **Cause:** Invoices are issued per UTC calendar month, in arrears. A partial month, a multi-month span, or a month still in progress is refused at the claim site.
- **Fix:** Send period_start = 00:00:00.000Z on the first of the month and period_end = 23:59:59.999Z on the last day of the SAME month, for a month that has ended.

### INVOICE_PERIOD_CLAIM_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The billing period could not be claimed
- **Cause:** Invoice generation reserves (client, period) with a conditional put before it talks to Stripe. That write failed.
- **Fix:** Retry. The claim is taken before any billable Stripe object exists, so a failure here cannot have charged anyone.

### INVOICE_PERIOD_CLAIM_UNREADABLE

- **Status:** 409
- **Type:** `api_error`
- **Summary:** The period claim exists but cannot be read
- **Cause:** Another run holds the claim for this (client, period) and the claim row is unreadable, so we cannot tell which invoice it points at.
- **Fix:** Contact support. Retrying cannot resolve it; billing refuses to guess rather than risk a second invoice for the month.

### INVOICE_PERIOD_CLAIM_INVOICE_MISSING

- **Status:** 409
- **Type:** `api_error`
- **Summary:** The period is claimed by a missing invoice
- **Cause:** The claim names an invoice id that cannot be read back, so there is no draft to resume and no issued invoice to replay.
- **Fix:** Contact support. The claim must be resolved by a human before the month can be regenerated.

### INVOICE_LINE_ITEM_INVALID

- **Status:** 500
- **Type:** `api_error`
- **Summary:** An invoice line item is invalid
- **Cause:** A line item's description, quantity, or amount failed a domain invariant. Skipping the line would understate the invoice total against what Stripe actually charges.
- **Fix:** Not retryable as-is. Contact support with the request_id.

### INVOICE_CURRENCY_MISMATCH

- **Status:** 500
- **Type:** `api_error`
- **Summary:** Invoice line items disagree on currency
- **Cause:** Each configured rate carries its own currency, and Stripe finalizes the invoice as USD regardless. A non-USD line would be CHARGED as dollars.
- **Fix:** Not retryable. This is a mis-set pricing configuration; contact support so the rate is corrected before the month is re-run.

### INVOICE_SUBTOTAL_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The invoice subtotal could not be computed
- **Cause:** Adding one line item to the running subtotal failed — in practice always a currency disagreement.
- **Fix:** Not retryable. Contact support with the request_id; no invoice was created.

### INVOICE_STRIPE_LINK_MISSING

- **Status:** 409
- **Type:** `api_error`
- **Summary:** An issued invoice names no Stripe invoice
- **Cause:** An ISSUED or VOID record that carries no stripe_invoice_id cannot be replayed: we cannot tell whether the customer was charged.
- **Fix:** Contact support. Billing refuses to issue a second invoice on a guess.

### INVOICE_STRIPE_DRAFT_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** Stripe would not create the draft invoice
- **Cause:** The upstream Stripe call failed before anything billable existed (a draft with no line items is never charged and never sent).
- **Fix:** Retry with backoff. If Stripe is degraded, the monthly run redrives automatically.

### INVOICE_STRIPE_LINK_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The Stripe draft could not be linked
- **Cause:** The stripe_invoice_id must be durable BEFORE the invoice becomes billable; otherwise a later resume cannot tell it apart from 'nothing happened' and could mint a second invoice.
- **Fix:** Contact support. The orphan draft is visible in the Stripe dashboard, is not finalized, and charges nobody.

### INVOICE_STRIPE_FINALIZE_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** Stripe would not finalize the invoice
- **Cause:** The itemise/finalize call failed. The draft's id is already persisted, so the operation is resumable.
- **Fix:** Retry with backoff. The resume re-reads the linked draft and replays an already-finalized invoice rather than creating another.

### INVOICE_LEDGER_DIVERGED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The invoice is billed but not recorded
- **Cause:** Stripe finalized the invoice and the write that records ISSUED plus the Stripe URLs failed. Both ids are named in the message so the money-side reality is recoverable.
- **Fix:** Escalate to support immediately with the request_id. A blind retry is safe (the finalized invoice is replayed, not re-created) but the divergence still needs a human.

### INVOICE_EMAIL_PDF_UNAVAILABLE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The invoice has no PDF to send
- **Cause:** The invoice email links the Stripe-hosted PDF; an invoice that has not been finalized has none.
- **Fix:** Finalize the invoice first (generation does this), then resend the email.

### INVOICE_EMAIL_CLAIM_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The email delivery claim could not be written
- **Cause:** Delivery is claimed BEFORE the send, because the mail provider offers no idempotency token. The claim write failed, so nothing was sent.
- **Fix:** Retry with backoff.

### INVOICE_EMAIL_ALREADY_CLAIMED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Invoice email delivery is already claimed
- **Cause:** An earlier attempt claimed delivery and then died in an ambiguous state (the send may or may not have reached the customer). Billing stops rather than risk a duplicate customer email.
- **Fix:** Check whether the customer received the invoice, then have support clear the claim.

### INVOICE_EMAIL_SEND_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The invoice email could not be sent
- **Cause:** The upstream mail provider refused or failed the send.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### BILLING_USAGE_READ_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** Usage could not be read for the period
- **Cause:** The metering read failed. There is deliberately no zero-default: a zero that means 'we could not read' is indistinguishable from a genuine zero, and that silently under-bills.
- **Fix:** Retry with backoff. The monthly run redrives automatically; nothing was billed.

### BILLING_PRICING_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** Pricing could not be read
- **Cause:** The effective-dated pricing store rejected the read. Invoices price from the rates effective during the invoiced month, so there is no 'current rates' fallback.
- **Fix:** Retry with backoff.

### BILLING_PRICING_CONTRACT_MISMATCH

- **Status:** 500
- **Type:** `api_error`
- **Summary:** Effective pricing differs from the published contract
- **Cause:** The public pricing page is the billing authority. Any drift between it and the effective-dated configuration must stop billing rather than silently charge a different price.
- **Fix:** Not retryable. Contact support so the configuration is reconciled with the published contract.

### BILLING_STATUS_UNRESOLVABLE

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The billing status could not be resolved
- **Cause:** The billing-status store failed, or holds a value that is not a known status. A missing record is NOT this error — that resolves to the documented BETA default.
- **Fix:** Retry with backoff. If it persists, the stored status is malformed; contact support.

### BILLING_STATUS_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The billing status is not a known value
- **Cause:** A typo'd status persisted to the store is worse than a rejected request: every later invoice run for that client would fail to resolve it.
- **Fix:** Send one of the documented statuses, in any case (it is upper-cased before validation).

### BILLING_STATUS_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The billing status could not be saved
- **Cause:** The write to the billing-status store failed. Reporting success here would tell an operator the client is billable when it is not.
- **Fix:** Retry. The operation is a conditionless put, so repeating it is safe.

### BILLING_CLIENT_ID_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** client_id is missing
- **Cause:** The request carried no client id, or only whitespace.
- **Fix:** Send a non-empty client_id. The boundary is snake_case-only — a camelCase clientId does not resolve and arrives empty.

### BILLING_SETUP_SESSION_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The billing setup session could not be created
- **Cause:** The upstream checkout-session call failed, so there is no URL to send the customer to.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### INVOICE_ISSUER_NOT_CONFIGURED

- **Status:** 409
- **Type:** `api_error`
- **Summary:** Invoice issuer identity is not configured
- **Cause:** The invoice issuer identity is incomplete, so commercial billing is disabled.
- **Fix:** Contact finance to approve the issuer configuration before billing can run.

### INVOICE_ISSUER_ENTITY_MISMATCH

- **Status:** 409
- **Type:** `api_error`
- **Summary:** Contracting entity does not match the payment processor identity
- **Cause:** The configured contracting entity differs from the identity registered with the payment processor.
- **Fix:** Verify the contracting entity matches the payment processor identity before retrying the invoice run.

### INVOICE_ISSUER_STRIPE_MISMATCH

- **Status:** 409
- **Type:** `api_error`
- **Summary:** Configured Stripe account does not match the authenticated account
- **Cause:** The Stripe account in the issuer configuration is not the account the platform authenticated with.
- **Fix:** Verify the configured Stripe account matches the authenticated account before retrying.

### INVOICE_ISSUER_STRIPE_UNAVAILABLE

- **Status:** 503
- **Type:** `api_error`
- **Summary:** Stripe issuer metadata could not be verified
- **Cause:** Stripe did not respond or returned an error while verifying issuer metadata.
- **Fix:** Retry after the provider recovers; the check is safe to repeat.

### INVOICE_ISSUER_DESCRIPTOR_MISMATCH

- **Status:** 409
- **Type:** `api_error`
- **Summary:** Invoice descriptor does not match the payment processor configuration
- **Cause:** The statement descriptor configured for invoices differs from the descriptor registered with the payment processor.
- **Fix:** Verify the invoice descriptor matches the payment processor configuration before retrying.

### INVOICE_ISSUER_TAX_PROFILE_MISMATCH

- **Status:** 409
- **Type:** `api_error`
- **Summary:** Approved tax-profile reference does not match processor metadata
- **Cause:** The approved tax-profile reference in the issuer configuration differs from the payment processor metadata.
- **Fix:** Verify the approved tax-profile reference matches the payment processor metadata before retrying.

### INVOICE_REQUIRED_ACCEPTANCE_MISSING

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Current legal acceptance evidence is missing for commercial billing
- **Cause:** The client has no current legal acceptance evidence on record, which commercial billing requires.
- **Fix:** Provide current legal acceptance evidence for the client before commercial billing can proceed.

## Feedback

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`FEEDBACK_RUN_NOT_FOUND`](/docs/errors/FEEDBACK_RUN_NOT_FOUND) | 404 | `invalid_request` | The referenced resource does not exist |
| [`FEEDBACK_RUN_NOT_OWNED`](/docs/errors/FEEDBACK_RUN_NOT_OWNED) | 403 | `auth` | The referenced resource belongs to another client |
| [`FEEDBACK_RUN_EXPIRED`](/docs/errors/FEEDBACK_RUN_EXPIRED) | 410 | `invalid_request` | The feedback window has closed |
| [`FEEDBACK_INVALID_CATEGORY`](/docs/errors/FEEDBACK_INVALID_CATEGORY) | 422 | `invalid_request` | The category is not valid for this resource type |
| [`FEEDBACK_INVALID_SIGNAL`](/docs/errors/FEEDBACK_INVALID_SIGNAL) | 400 | `invalid_request` | The signal is not valid for the resource_type |
| [`FEEDBACK_ALREADY_SUBMITTED`](/docs/errors/FEEDBACK_ALREADY_SUBMITTED) | 409 | `invalid_request` | Feedback was already submitted for this resource |
| [`FEEDBACK_RATE_LIMITED`](/docs/errors/FEEDBACK_RATE_LIMITED) | 429 | `rate_limit` | Too many feedback submissions |

### FEEDBACK_RUN_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The referenced resource does not exist
- **Cause:** The resource referenced by the feedback submission is unknown or not owned by your client (collapsed to 404).
- **Fix:** Verify the referenced resource id from a recent response.

### FEEDBACK_RUN_NOT_OWNED

- **Status:** 403
- **Type:** `auth`
- **Summary:** The referenced resource belongs to another client
- **Cause:** You submitted feedback for a resource owned by a different client.
- **Fix:** Submit feedback only for your own resources.

### FEEDBACK_RUN_EXPIRED

- **Status:** 410
- **Type:** `invalid_request`
- **Summary:** The feedback window has closed
- **Cause:** Feedback is only accepted for recent runs; this one has aged out.
- **Fix:** Submit feedback promptly after the run completes.

### FEEDBACK_INVALID_CATEGORY

- **Status:** 422
- **Type:** `invalid_request`
- **Summary:** The category is not valid for this resource type
- **Cause:** A structurally-valid but unsupported category value was sent on the scraping-runs surface.
- **Fix:** Use a category allowed for this resource type. See the Feedback API reference.

### FEEDBACK_INVALID_SIGNAL

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The signal is not valid for the resource_type
- **Cause:** The signal is not allowed for the given resource_type, the resource_type is unsupported, or the idempotency_key is missing.
- **Fix:** Use a typed enum keyed on resource_type in your SDK, and always send an idempotency_key.

### FEEDBACK_ALREADY_SUBMITTED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Feedback was already submitted for this resource
- **Cause:** Feedback was recorded under a different idempotency_key.
- **Fix:** Reuse the original idempotency_key to make the submission idempotent.

### FEEDBACK_RATE_LIMITED

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** Too many feedback submissions
- **Cause:** You exceeded the feedback submission rate.
- **Fix:** Back off and retry after the window.

## Messaging

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`MESSAGING_CONSENT_NOT_GRANTED`](/docs/errors/MESSAGING_CONSENT_NOT_GRANTED) | 403 | `invalid_request` | The customer has not granted messaging consent |
| [`MESSAGING_LIVE_ACCESS_REQUIRED`](/docs/errors/MESSAGING_LIVE_ACCESS_REQUIRED) | 403 | `auth` | Messaging requires production (live) access |
| [`MESSAGING_SENDER_NOT_AUTHORIZED`](/docs/errors/MESSAGING_SENDER_NOT_AUTHORIZED) | 403 | `auth` | The sender is not authorized for this account link |
| [`MESSAGING_CONTENT_FLAGGED`](/docs/errors/MESSAGING_CONTENT_FLAGGED) | 422 | `invalid_request` | The message content was flagged |
| [`MESSAGING_RATE_LIMITED`](/docs/errors/MESSAGING_RATE_LIMITED) | 429 | `rate_limit` | Messaging rate limit exceeded |
| [`MESSAGING_SUPPRESSED`](/docs/errors/MESSAGING_SUPPRESSED) | 200 | `invalid_request` | The recipient is suppressed; message not sent |
| [`MESSAGING_AUP_NOT_ACCEPTED`](/docs/errors/MESSAGING_AUP_NOT_ACCEPTED) | 428 | `invalid_request` | The messaging Acceptable Use Policy is not accepted |
| [`MESSAGING_AUP_REACCEPT_REQUIRED`](/docs/errors/MESSAGING_AUP_REACCEPT_REQUIRED) | 412 | `invalid_request` | The messaging AUP must be re-accepted |
| [`MESSAGING_INVALID_PAYLOAD`](/docs/errors/MESSAGING_INVALID_PAYLOAD) | 400 | `invalid_request` | The message payload is malformed |
| [`MESSAGING_INVALID_CATEGORY`](/docs/errors/MESSAGING_INVALID_CATEGORY) | 400 | `invalid_request` | The message category is not recognized |
| [`MESSAGING_PERSIST_FAILED`](/docs/errors/MESSAGING_PERSIST_FAILED) | 503 | `upstream` | A transient error prevented recording the message |
| [`MESSAGING_INTERNAL_ERROR`](/docs/errors/MESSAGING_INTERNAL_ERROR) | 500 | `upstream` | An unexpected messaging error occurred |
| [`MESSAGING_NOT_FOUND`](/docs/errors/MESSAGING_NOT_FOUND) | 404 | `invalid_request` | The referenced message or thread does not exist |

### MESSAGING_CONSENT_NOT_GRANTED

- **Status:** 403
- **Type:** `invalid_request`
- **Summary:** The customer has not granted messaging consent
- **Cause:** A message was attempted before the customer opted in to messaging.
- **Fix:** Obtain messaging consent before sending.

### MESSAGING_LIVE_ACCESS_REQUIRED

- **Status:** 403
- **Type:** `auth`
- **Summary:** Messaging requires production (live) access
- **Cause:** Messaging was attempted in production without completing go-live.
- **Fix:** Complete the go-live checklist before sending in production.

### MESSAGING_SENDER_NOT_AUTHORIZED

- **Status:** 403
- **Type:** `auth`
- **Summary:** The sender is not authorized for this account link
- **Cause:** The authenticated biller does not own the active account link, or a fintech credential attempted to send as a biller.
- **Fix:** Use the biller portal credential associated with an active account link. Fintech API keys cannot send biller messages.

### MESSAGING_CONTENT_FLAGGED

- **Status:** 422
- **Type:** `invalid_request`
- **Summary:** The message content was flagged
- **Cause:** Content moderation flagged the message; matched_rules are in details.
- **Fix:** Review details.matched_rules and revise the content before resending.

### MESSAGING_RATE_LIMITED

- **Status:** 429
- **Type:** `rate_limit`
- **Summary:** Messaging rate limit exceeded
- **Cause:** Too many messages in the current window.
- **Fix:** Back off and retry after the window.

### MESSAGING_SUPPRESSED

- **Status:** 200
- **Type:** `invalid_request`
- **Summary:** The recipient is suppressed; message not sent
- **Cause:** The recipient is on the suppression list. This is a terminal, non-error outcome (200).
- **Fix:** No action needed. Do not retry; the recipient has opted out.

### MESSAGING_AUP_NOT_ACCEPTED

- **Status:** 428
- **Type:** `invalid_request`
- **Summary:** The messaging Acceptable Use Policy is not accepted
- **Cause:** Messaging was attempted before accepting the AUP.
- **Fix:** Accept the messaging Acceptable Use Policy, then retry.

### MESSAGING_AUP_REACCEPT_REQUIRED

- **Status:** 412
- **Type:** `invalid_request`
- **Summary:** The messaging AUP must be re-accepted
- **Cause:** The AUP version changed since you last accepted it (see details.current_version).
- **Fix:** Re-accept the current AUP version, then retry.

### MESSAGING_INVALID_PAYLOAD

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The message payload is malformed
- **Cause:** The message body is missing required fields or is otherwise malformed.
- **Fix:** Check the Messaging API reference for the required fields.

### MESSAGING_INVALID_CATEGORY

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The message category is not recognized
- **Cause:** An unrecognized message category value was sent.
- **Fix:** Use one of the documented category values.

### MESSAGING_PERSIST_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** A transient error prevented recording the message
- **Cause:** A storage-layer error prevented persisting the message.
- **Fix:** Retry with backoff.

### MESSAGING_INTERNAL_ERROR

- **Status:** 500
- **Type:** `upstream`
- **Summary:** An unexpected messaging error occurred
- **Cause:** An unhandled condition inside the messaging service.
- **Fix:** Include the request_id when reporting to support.

### MESSAGING_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The referenced message or thread does not exist
- **Cause:** No message or thread matches the supplied id under your account.
- **Fix:** Verify the message/thread id.

## Email & discovery

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`EMAIL_MESSAGE_NOT_FOUND`](/docs/errors/EMAIL_MESSAGE_NOT_FOUND) | 404 | `invalid_request` | No stored email message with that id |
| [`EMAIL_MESSAGE_LOOKUP_FAILED`](/docs/errors/EMAIL_MESSAGE_LOOKUP_FAILED) | 503 | `upstream` | The message store could not be read |
| [`EMAIL_MESSAGE_PERSIST_FAILED`](/docs/errors/EMAIL_MESSAGE_PERSIST_FAILED) | 500 | `api_error` | The message could not be stored |
| [`EMAIL_CLIENT_ID_REQUIRED`](/docs/errors/EMAIL_CLIENT_ID_REQUIRED) | 400 | `invalid_request` | client_id is required to read a message |
| [`EMAIL_CLASSIFICATION_NOT_FOUND`](/docs/errors/EMAIL_CLASSIFICATION_NOT_FOUND) | 404 | `invalid_request` | No classification for that id or message |
| [`EMAIL_CLASSIFICATION_LOOKUP_FAILED`](/docs/errors/EMAIL_CLASSIFICATION_LOOKUP_FAILED) | 503 | `upstream` | The classification store could not be read |
| [`EMAIL_CLASSIFICATION_PERSIST_FAILED`](/docs/errors/EMAIL_CLASSIFICATION_PERSIST_FAILED) | 500 | `api_error` | The classification could not be stored |
| [`EMAIL_CLASSIFICATION_INVALID`](/docs/errors/EMAIL_CLASSIFICATION_INVALID) | 400 | `invalid_request` | The classification aggregate refused the transition |
| [`EMAIL_CLASSIFICATION_FORBIDDEN`](/docs/errors/EMAIL_CLASSIFICATION_FORBIDDEN) | 403 | `auth` | The message behind this classification belongs to another client |
| [`HUMAN_REVIEW_NOT_FOUND`](/docs/errors/HUMAN_REVIEW_NOT_FOUND) | 404 | `invalid_request` | No human-review task with that id |
| [`HUMAN_REVIEW_LOOKUP_FAILED`](/docs/errors/HUMAN_REVIEW_LOOKUP_FAILED) | 503 | `upstream` | The review store could not be read |
| [`HUMAN_REVIEW_PERSIST_FAILED`](/docs/errors/HUMAN_REVIEW_PERSIST_FAILED) | 500 | `api_error` | The review could not be stored |
| [`HUMAN_REVIEW_CLAIM_CONFLICT`](/docs/errors/HUMAN_REVIEW_CLAIM_CONFLICT) | 409 | `invalid_request` | Another worker already holds this review |
| [`HUMAN_REVIEW_INVALID_STATE`](/docs/errors/HUMAN_REVIEW_INVALID_STATE) | 409 | `invalid_request` | The review is not in a state this operation accepts |
| [`HUMAN_REVIEW_COMPLETION_REJECTED`](/docs/errors/HUMAN_REVIEW_COMPLETION_REJECTED) | 409 | `invalid_request` | The review completion was refused |
| [`GMAIL_OAUTH_STATE_INVALID`](/docs/errors/GMAIL_OAUTH_STATE_INVALID) | 400 | `auth` | The Gmail OAuth state did not verify |
| [`GMAIL_TOKEN_EXCHANGE_FAILED`](/docs/errors/GMAIL_TOKEN_EXCHANGE_FAILED) | 503 | `upstream` | Google refused the authorization-code exchange |
| [`GMAIL_ACCESS_TOKEN_UNAVAILABLE`](/docs/errors/GMAIL_ACCESS_TOKEN_UNAVAILABLE) | 503 | `upstream` | No usable Gmail access token for this connection |
| [`GMAIL_WATCH_NOT_FOUND`](/docs/errors/GMAIL_WATCH_NOT_FOUND) | 404 | `invalid_request` | No Gmail watch registration for that mailbox |
| [`GMAIL_IMPORT_FAILED`](/docs/errors/GMAIL_IMPORT_FAILED) | 503 | `upstream` | Mail could not be imported from Gmail |
| [`GMAIL_DISCONNECT_FAILED`](/docs/errors/GMAIL_DISCONNECT_FAILED) | 500 | `api_error` | The Gmail disconnect did not complete |
| [`GMAIL_MAILBOX_ALREADY_CONNECTED`](/docs/errors/GMAIL_MAILBOX_ALREADY_CONNECTED) | 409 | `invalid_request` | This Gmail mailbox is already connected to another account |

### EMAIL_MESSAGE_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No stored email message with that id
- **Cause:** The message_id does not resolve for the calling client. A message owned by a different client reads as missing rather than forbidden, so an id cannot be probed across tenants.
- **Fix:** Verify the message_id, and that you are calling as the client that owns it.

### EMAIL_MESSAGE_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The message store could not be read
- **Cause:** An infrastructure fault reading stored mail — NOT a missing row, which is EMAIL_MESSAGE_NOT_FOUND.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### EMAIL_MESSAGE_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The message could not be stored
- **Cause:** The message row and its outbox event are written in one transaction; the transaction failed, so nothing was stored and no email.created event shipped.
- **Fix:** Retry the create. Supplying the provider message id de-duplicates a replay.

### EMAIL_CLIENT_ID_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** client_id is required to read a message
- **Cause:** A non-privileged message read arrived with no client scope, which would address every tenant's mail.
- **Fix:** Send client_id on the request.

### EMAIL_CLASSIFICATION_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No classification for that id or message
- **Cause:** Either the classification_id does not resolve, or the message has never been classified.
- **Fix:** Classify the message first, then read the result.

### EMAIL_CLASSIFICATION_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The classification store could not be read
- **Cause:** An infrastructure fault, not a missing row (that is EMAIL_CLASSIFICATION_NOT_FOUND).
- **Fix:** Retry with backoff.

### EMAIL_CLASSIFICATION_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The classification could not be stored
- **Cause:** The classification row and its outbox event are written in one transaction; the transaction failed, so no downstream event shipped.
- **Fix:** Retry — the command is idempotent on the classification id.

### EMAIL_CLASSIFICATION_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The classification aggregate refused the transition
- **Cause:** A malformed decision or confidence, or an attempt to complete a classification that is not in progress.
- **Fix:** Fix the payload, or re-read the classification to see its current state before retrying.

### EMAIL_CLASSIFICATION_FORBIDDEN

- **Status:** 403
- **Type:** `auth`
- **Summary:** The message behind this classification belongs to another client
- **Cause:** The classification resolves, but its message is owned by a different client_id.
- **Fix:** Read it as the owning client. Cross-tenant reads are refused.

### HUMAN_REVIEW_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No human-review task with that id
- **Cause:** The review_id does not resolve. Completed reviews remain readable, so a miss means it never existed.
- **Fix:** Take the id from the review queue listing.

### HUMAN_REVIEW_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The review store could not be read
- **Cause:** An infrastructure fault, not a missing row (that is HUMAN_REVIEW_NOT_FOUND).
- **Fix:** Retry with backoff.

### HUMAN_REVIEW_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The review could not be stored
- **Cause:** The review row and its task-lifecycle event are written in one transaction; the transaction failed, so the queue counters were not moved.
- **Fix:** Retry the operation.

### HUMAN_REVIEW_CLAIM_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Another worker already holds this review
- **Cause:** Claims are exclusive. A second claim, or a release issued by someone other than the holder, is refused rather than clobbering the holder.
- **Fix:** Pick a different review, or wait for the stale-claim reaper to free this one.

### HUMAN_REVIEW_INVALID_STATE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The review is not in a state this operation accepts
- **Cause:** For example claiming a review that is already completed or cancelled.
- **Fix:** Re-read the review to see its current status.

### HUMAN_REVIEW_COMPLETION_REJECTED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The review completion was refused
- **Cause:** The review aggregate or its classification refused the decision — most often because the aggregate is no longer in a completable state.
- **Fix:** Re-read the review and its classification, then resubmit.

### GMAIL_OAUTH_STATE_INVALID

- **Status:** 400
- **Type:** `auth`
- **Summary:** The Gmail OAuth state did not verify
- **Cause:** The signed `state` failed its HMAC check, was past its expiry window, or carried no user claim. An unverified state never yields claims, so a forged user_id cannot drive account attachment.
- **Fix:** Restart the Gmail connect flow from the beginning.

### GMAIL_TOKEN_EXCHANGE_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** Google refused the authorization-code exchange
- **Cause:** The code was rejected or already consumed, or Google returned no access token.
- **Fix:** Restart the connect flow — an authorization code is single-use and cannot be replayed.

### GMAIL_ACCESS_TOKEN_UNAVAILABLE

- **Status:** 503
- **Type:** `upstream`
- **Summary:** No usable Gmail access token for this connection
- **Cause:** The vaulted credential could not be read, or holds no access token, so the mailbox cannot be reached.
- **Fix:** Retry with backoff; if it persists, reconnect the Gmail account.

### GMAIL_WATCH_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No Gmail watch registration for that mailbox
- **Cause:** A push notification arrived for an address with no registered watch, so it cannot be routed to a connection.
- **Fix:** Reconnect the account to re-register the watch.

### GMAIL_IMPORT_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** Mail could not be imported from Gmail
- **Cause:** A transient Google API or storage fault during import.
- **Fix:** Retry with backoff. Already-imported messages de-duplicate, so a retry is safe.

### GMAIL_DISCONNECT_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The Gmail disconnect did not complete
- **Cause:** One of the disconnect steps (watch stop, credential revoke, watch-row delete) failed outright.
- **Fix:** Retry — disconnect is idempotent and every step tolerates repetition.

### GMAIL_MAILBOX_ALREADY_CONNECTED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** This Gmail mailbox is already connected to another account
- **Cause:** The mailbox has an active owner claim held by a different account, so a second connect cannot take ownership of it.
- **Fix:** Disconnect the mailbox from the account that owns it before reconnecting, or connect a different mailbox. Re-authorizing will not resolve this.

## BillEBox

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`ACCOUNT_REQUIRED`](/docs/errors/ACCOUNT_REQUIRED) | 409 | `invalid_request` | No verified account context was supplied |
| [`ACCOUNT_CONTEXT_REQUIRED`](/docs/errors/ACCOUNT_CONTEXT_REQUIRED) | 400 | `invalid_request` | No account context was supplied for this request |
| [`ACCOUNT_ID_MISSING`](/docs/errors/ACCOUNT_ID_MISSING) | 400 | `invalid_request` | The route requires an account id it could not resolve |
| [`NOT_AUTHENTICATED`](/docs/errors/NOT_AUTHENTICATED) | 401 | `auth` | No authenticated user was resolved for the request |
| [`NOT_AN_ACTIVE_MEMBER`](/docs/errors/NOT_AN_ACTIVE_MEMBER) | 403 | `auth` | You are not an active member of this account |
| [`ACCOUNT_INACTIVE`](/docs/errors/ACCOUNT_INACTIVE) | 403 | `auth` | This BillEBox account is inactive |
| [`MEMBERSHIP_LOOKUP_FAILED`](/docs/errors/MEMBERSHIP_LOOKUP_FAILED) | 403 | `api_error` | Account access could not be verified |
| [`INSUFFICIENT_ROLE`](/docs/errors/INSUFFICIENT_ROLE) | 403 | `auth` | Your role is below the role this endpoint requires |
| [`AGENT_ACCOUNT_SCOPE_REQUIRED`](/docs/errors/AGENT_ACCOUNT_SCOPE_REQUIRED) | 403 | `auth` | The internal-agent token carries no account scope |
| [`AGENT_ACCOUNT_SCOPE_MISMATCH`](/docs/errors/AGENT_ACCOUNT_SCOPE_MISMATCH) | 403 | `auth` | The internal-agent token is scoped to a different account |
| [`BILLERAPI_UNAVAILABLE`](/docs/errors/BILLERAPI_UNAVAILABLE) | 502 | `upstream` | BillEBox could not reach BillerAPI |
| [`BILL_CREATE_FAILED`](/docs/errors/BILL_CREATE_FAILED) | 502 | `api_error` | The bill could not be created upstream |
| [`INVALID_BILLERAPI_RESPONSE`](/docs/errors/INVALID_BILLERAPI_RESPONSE) | 502 | `upstream` | BillerAPI returned a body BillEBox could not map |
| [`IDEMPOTENCY_KEY_REQUIRED`](/docs/errors/IDEMPOTENCY_KEY_REQUIRED) | 400 | `invalid_request` | This write requires an Idempotency-Key header |
| [`INVALID_BILL_IMPORT_RESPONSE`](/docs/errors/INVALID_BILL_IMPORT_RESPONSE) | 502 | `upstream` | The bill-import response could not be projected |
| [`BILL_IMPORT_NOT_FOUND`](/docs/errors/BILL_IMPORT_NOT_FOUND) | 404 | `invalid_request` | No bill import exists with that id |
| [`INVALID_SOURCE_DOCUMENT_RESPONSE`](/docs/errors/INVALID_SOURCE_DOCUMENT_RESPONSE) | 502 | `upstream` | The source-document response carried no usable HTTPS URL |
| [`MISSING_FIELDS`](/docs/errors/MISSING_FIELDS) | 400 | `invalid_request` | One or more required fields were absent |
| [`INVALID_ROLE`](/docs/errors/INVALID_ROLE) | 400 | `invalid_request` | The supplied role is not a recognized value |
| [`MEMBERSHIP_ALREADY_EXISTS`](/docs/errors/MEMBERSHIP_ALREADY_EXISTS) | 409 | `invalid_request` | That person already has a membership in this account |
| [`MEMBERSHIP_NOT_FOUND`](/docs/errors/MEMBERSHIP_NOT_FOUND) | 404 | `invalid_request` | No membership exists with that id |
| [`INVITE_FAILED`](/docs/errors/INVITE_FAILED) | 400 | `invalid_request` | The invitation was rejected by a domain rule |
| [`INVITE_EMAIL_FAILED`](/docs/errors/INVITE_EMAIL_FAILED) | 400 | `api_error` | The invitation email could not be sent |
| [`INVITATION_NOT_FOUND`](/docs/errors/INVITATION_NOT_FOUND) | 404 | `invalid_request` | No invitation matches that code |
| [`INVITATION_NOT_PENDING`](/docs/errors/INVITATION_NOT_PENDING) | 409 | `invalid_request` | The invitation is no longer pending |
| [`INVITATION_CODE_MISSING`](/docs/errors/INVITATION_CODE_MISSING) | 409 | `api_error` | The pending invitation carries no usable code |
| [`INVITATION_EXPIRED`](/docs/errors/INVITATION_EXPIRED) | 400 | `invalid_request` | The invitation has expired |
| [`EMAIL_MISMATCH`](/docs/errors/EMAIL_MISMATCH) | 403 | `invalid_request` | The invitation was addressed to a different email |
| [`ACCEPT_FAILED`](/docs/errors/ACCEPT_FAILED) | 400 | `invalid_request` | Accepting the invitation was rejected by a domain rule |
| [`REMOVE_FAILED`](/docs/errors/REMOVE_FAILED) | 400 | `invalid_request` | Removing the member was rejected by a domain rule |
| [`UPDATE_ROLE_FAILED`](/docs/errors/UPDATE_ROLE_FAILED) | 400 | `invalid_request` | The role change was rejected by a domain rule |

### ACCOUNT_REQUIRED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** No verified account context was supplied
- **Cause:** The request reached an account-scoped BillEBox endpoint but no account had been verified for the signed-in user.
- **Fix:** Select an account (which sets the bb_current_account cookie) or send the x-billebox-account-id header, then retry.

### ACCOUNT_CONTEXT_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** No account context was supplied for this request
- **Cause:** The endpoint needs to know which account it is acting on and could not resolve one from the cookie, header, or body.
- **Fix:** Select an account first, or send the x-billebox-account-id header.

### ACCOUNT_ID_MISSING

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The route requires an account id it could not resolve
- **Cause:** The account-member guard found no account id in the path, cookie, or header.
- **Fix:** Supply the account id on the route (or via x-billebox-account-id) and retry.

### NOT_AUTHENTICATED

- **Status:** 401
- **Type:** `auth`
- **Summary:** No authenticated user was resolved for the request
- **Cause:** The request carried no session cookie or Bearer token, or the token could not be verified.
- **Fix:** Sign in and retry; the request must carry the session cookie or a Bearer token.

### NOT_AN_ACTIVE_MEMBER

- **Status:** 403
- **Type:** `auth`
- **Summary:** You are not an active member of this account
- **Cause:** The signed-in user is authenticated but holds no ACTIVE membership in the selected account.
- **Fix:** Switch to an account you are an active member of, then retry.

### ACCOUNT_INACTIVE

- **Status:** 403
- **Type:** `auth`
- **Summary:** This BillEBox account is inactive
- **Cause:** The account owner requested reversible account deletion, so account-scoped reads and writes are quarantined during the grace window.
- **Fix:** Use the account restore flow with the owner credentials before accessing account data.

### MEMBERSHIP_LOOKUP_FAILED

- **Status:** 403
- **Type:** `api_error`
- **Summary:** Account access could not be verified
- **Cause:** The membership read failed, so authorization could not be proven. The guard fails closed, so this surfaces as a 403 rather than a 500.
- **Fix:** Retry the request; this is a transient lookup failure, not a denial.

### INSUFFICIENT_ROLE

- **Status:** 403
- **Type:** `auth`
- **Summary:** Your role is below the role this endpoint requires
- **Cause:** You hold an active membership, but at a role that is not permitted to perform this action.
- **Fix:** Ask an account owner to raise your role, then retry; or have an owner perform the action.

### AGENT_ACCOUNT_SCOPE_REQUIRED

- **Status:** 403
- **Type:** `auth`
- **Summary:** The internal-agent token carries no account scope
- **Cause:** An internal-agent token reached an account-scoped endpoint without an account claim. The endpoint fails closed.
- **Fix:** Mint the agent token with the account scope it needs, then retry.

### AGENT_ACCOUNT_SCOPE_MISMATCH

- **Status:** 403
- **Type:** `auth`
- **Summary:** The internal-agent token is scoped to a different account
- **Cause:** The account claim on the agent token does not match the account addressed by the request.
- **Fix:** Re-issue the agent token for the target account, then retry.

### BILLERAPI_UNAVAILABLE

- **Status:** 502
- **Type:** `upstream`
- **Summary:** BillEBox could not reach BillerAPI
- **Cause:** The BillEBox backend could not complete its upstream call to BillerAPI, or BillerAPI rejected BillEBox’s own client credentials. Your user session is unaffected.
- **Fix:** Retry with backoff. If it persists, contact support and quote the request_id.

### BILL_CREATE_FAILED

- **Status:** 502
- **Type:** `api_error`
- **Summary:** The bill could not be created upstream
- **Cause:** BillerAPI rejected the bill create and returned no usable structured detail for BillEBox to forward.
- **Fix:** Re-check the amount, due date, and payee, then retry.

### INVALID_BILLERAPI_RESPONSE

- **Status:** 502
- **Type:** `upstream`
- **Summary:** BillerAPI returned a body BillEBox could not map
- **Cause:** The upstream response was missing or malformed on a field BillEBox requires to build a bill (id, amount, payee, payment route, status, timestamps).
- **Fix:** Nothing was persisted. Retry, and report the request_id if it repeats.

### IDEMPOTENCY_KEY_REQUIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** This write requires an Idempotency-Key header
- **Cause:** The Idempotency-Key header was absent, empty, or longer than 255 characters.
- **Fix:** Send one unique key per logical attempt and reuse the same key across retries.

### INVALID_BILL_IMPORT_RESPONSE

- **Status:** 502
- **Type:** `upstream`
- **Summary:** The bill-import response could not be projected
- **Cause:** The upstream bill-import body failed BillEBox’s projection mapping, so the cross-device read model was not updated.
- **Fix:** The upstream write may still have succeeded — re-fetch the import before retrying it.

### BILL_IMPORT_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No bill import exists with that id
- **Cause:** No bill import matches the supplied id in this account’s durable projection — unknown, expired, or owned by another account (collapsed to 404 so ids cannot be enumerated).
- **Fix:** List bill imports to verify a current id, then retry.

### INVALID_SOURCE_DOCUMENT_RESPONSE

- **Status:** 502
- **Type:** `upstream`
- **Summary:** The source-document response carried no usable HTTPS URL
- **Cause:** The upstream source-document response was missing a url, or the url was not https. BillEBox refuses to hand a non-HTTPS target to the browser.
- **Fix:** Retry; signed source-document URLs are short-lived and re-minted on every request.

### MISSING_FIELDS

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** One or more required fields were absent
- **Cause:** A required field on the request body was missing or empty.
- **Fix:** Send every required field for this endpoint and retry.

### INVALID_ROLE

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The supplied role is not a recognized value
- **Cause:** The role on the request is not one of the recognized BillEBox membership roles.
- **Fix:** Use one of the documented role values and retry.

### MEMBERSHIP_ALREADY_EXISTS

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** That person already has a membership in this account
- **Cause:** An invitation or membership already exists for the supplied email in this account.
- **Fix:** Verify the address, then update the existing member’s role instead of inviting them again.

### MEMBERSHIP_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No membership exists with that id
- **Cause:** No membership matches the supplied id within this account.
- **Fix:** Re-list the account members to verify a current membership id, then retry.

### INVITE_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The invitation was rejected by a domain rule
- **Cause:** A BillEBox membership rule refused the invite; error_message names the specific rule.
- **Fix:** Correct the invitee details per error_message and retry.

### INVITE_EMAIL_FAILED

- **Status:** 400
- **Type:** `api_error`
- **Summary:** The invitation email could not be sent
- **Cause:** The membership row was written, but the outbound invitation email failed at the email provider.
- **Fix:** Resend the invitation. Resending is safe and does not create a second membership.

### INVITATION_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No invitation matches that code
- **Cause:** The invitation code is unknown. Codes are single-use and rotate whenever an invitation is resent.
- **Fix:** Ask the account owner for a fresh invite link.

### INVITATION_NOT_PENDING

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The invitation is no longer pending
- **Cause:** The invitation has already been accepted, declined, or revoked, so it cannot be acted on again.
- **Fix:** Ask the account owner to send a new invitation.

### INVITATION_CODE_MISSING

- **Status:** 409
- **Type:** `api_error`
- **Summary:** The pending invitation carries no usable code
- **Cause:** The invitation is pending but has no code stored, so no link can be re-sent for it.
- **Fix:** Revoke it, send a fresh invitation, and retry with the new code.

### INVITATION_EXPIRED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The invitation has expired
- **Cause:** The invitation passed its expiry timestamp before it was accepted.
- **Fix:** Ask the account owner to resend it, which mints a new code and expiry.

### EMAIL_MISMATCH

- **Status:** 403
- **Type:** `invalid_request`
- **Summary:** The invitation was addressed to a different email
- **Cause:** The signed-in account’s email does not match the address the invitation was issued to.
- **Fix:** Sign in as the invited address, or ask for an invitation to the address you use.

### ACCEPT_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** Accepting the invitation was rejected by a domain rule
- **Cause:** A BillEBox membership rule refused the accept; error_message names the specific rule.
- **Fix:** Resolve the stated condition and retry, or request a fresh invitation.

### REMOVE_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** Removing the member was rejected by a domain rule
- **Cause:** A BillEBox membership rule refused the removal — most often because the target is the last remaining owner.
- **Fix:** Promote another owner first, then retry.

### UPDATE_ROLE_FAILED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The role change was rejected by a domain rule
- **Cause:** A BillEBox membership rule refused the role change — most often demoting the last remaining owner.
- **Fix:** Promote another owner first, then retry.

## Agent traces & debug

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`TRACE_NOT_FOUND`](/docs/errors/TRACE_NOT_FOUND) | 404 | `invalid_request` | No agent-trace run with that run_id |
| [`TRACE_LOOKUP_FAILED`](/docs/errors/TRACE_LOOKUP_FAILED) | 500 | `api_error` | The trace metadata store could not be read |
| [`TRACE_LIST_FAILED`](/docs/errors/TRACE_LIST_FAILED) | 500 | `api_error` | The trace list or recent-feed query was rejected |
| [`TRACE_PERSIST_FAILED`](/docs/errors/TRACE_PERSIST_FAILED) | 500 | `api_error` | The trace row could not be written |
| [`TRACE_INVALID_STATE`](/docs/errors/TRACE_INVALID_STATE) | 500 | `api_error` | The stored trace row cannot be acted on |
| [`TRACE_ASSET_FORBIDDEN`](/docs/errors/TRACE_ASSET_FORBIDDEN) | 403 | `invalid_request` | The asset key is malformed or outside the run prefix |
| [`TRACE_ASSET_READ_FAILED`](/docs/errors/TRACE_ASSET_READ_FAILED) | 500 | `api_error` | The artifact object could not be read |
| [`TRACE_ARTIFACT_REJECTED`](/docs/errors/TRACE_ARTIFACT_REJECTED) | 400 | `invalid_request` | The artifact is refused by credential containment |
| [`TRACE_ARTIFACT_UPLOAD_FAILED`](/docs/errors/TRACE_ARTIFACT_UPLOAD_FAILED) | 500 | `api_error` | The artifact could not be written to storage |
| [`TRACE_FIXTURE_EXPORT_FAILED`](/docs/errors/TRACE_FIXTURE_EXPORT_FAILED) | 500 | `api_error` | The fixture archive could not be built |
| [`EVENT_ARCHIVE_QUERY_FAILED`](/docs/errors/EVENT_ARCHIVE_QUERY_FAILED) | 500 | `api_error` | The archived-event store could not be queried |
| [`EVENT_ARCHIVE_EVENT_NOT_FOUND`](/docs/errors/EVENT_ARCHIVE_EVENT_NOT_FOUND) | 404 | `invalid_request` | No archived event with that id |
| [`DEBUG_EVENT_QUERY_UNINDEXED`](/docs/errors/DEBUG_EVENT_QUERY_UNINDEXED) | 400 | `invalid_request` | The event query named no indexed predicate |
| [`DEBUG_EVENT_CURSOR_INVALID`](/docs/errors/DEBUG_EVENT_CURSOR_INVALID) | 400 | `invalid_request` | The pagination cursor is not valid for this query |
| [`DEBUG_PARAMETER_NOT_FOUND`](/docs/errors/DEBUG_PARAMETER_NOT_FOUND) | 404 | `invalid_request` | No configuration parameter at that path |
| [`DEBUG_PARAMETER_NAME_INVALID`](/docs/errors/DEBUG_PARAMETER_NAME_INVALID) | 400 | `invalid_request` | The parameter name is outside the writable namespace |
| [`DEBUG_PARAMETER_LOOKUP_FAILED`](/docs/errors/DEBUG_PARAMETER_LOOKUP_FAILED) | 500 | `upstream` | Parameter Store could not be read |
| [`DEBUG_PARAMETER_UPDATE_FAILED`](/docs/errors/DEBUG_PARAMETER_UPDATE_FAILED) | 500 | `upstream` | The parameter write was rejected |
| [`DEBUG_SERVICE_LIST_FAILED`](/docs/errors/DEBUG_SERVICE_LIST_FAILED) | 500 | `upstream` | The per-service parameter rollup could not be built |
| [`DEBUG_ALARM_QUERY_FAILED`](/docs/errors/DEBUG_ALARM_QUERY_FAILED) | 500 | `upstream` | Live alarm state could not be read |

### TRACE_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No agent-trace run with that run_id
- **Cause:** The run_id does not resolve to a stored trace. Traces expire on a per-environment TTL (14 days in dev, 90 in staging and production), so an old run reads as missing rather than empty.
- **Fix:** Verify the run_id against a recent trace listing; if the run is older than its environment TTL, it is gone.

### TRACE_LOOKUP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The trace metadata store could not be read
- **Cause:** An infrastructure fault reading the trace row — never a missing run.
- **Fix:** Retry with backoff.

### TRACE_LIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The trace list or recent-feed query was rejected
- **Cause:** Either the repository refused the filters (a date range wider than the cap, a cursor issued for a different query, a missing partition filter) or the query itself faulted. The repository reason is carried in the message.
- **Fix:** Narrow the date range, drop the cursor and start from the first page, or retry with backoff.

### TRACE_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The trace row could not be written
- **Cause:** The start or finalize write to the trace store failed.
- **Fix:** Retry with backoff — both writes are idempotent on run_id.

### TRACE_INVALID_STATE

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The stored trace row cannot be acted on
- **Cause:** The row is not in a shape the operation accepts — most often a started_at that is not ISO 8601, which makes the partition day unknowable.
- **Fix:** The row needs repair; retrying the same call will not help.

### TRACE_ASSET_FORBIDDEN

- **Status:** 403
- **Type:** `invalid_request`
- **Summary:** The asset key is malformed or outside the run prefix
- **Cause:** The requested key failed shape validation (traversal, control characters, URL-encoded separators) or points outside the run's own storage prefix.
- **Fix:** Use a key exactly as returned in that run's asset manifest — asset keys are not composable by hand.

### TRACE_ASSET_READ_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The artifact object could not be read
- **Cause:** Object storage refused or failed the read for a key the manifest lists.
- **Fix:** Retry with backoff; if it persists, the object may have been reclaimed ahead of its metadata row.

### TRACE_ARTIFACT_REJECTED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The artifact is refused by credential containment
- **Cause:** A login-script recording tried to upload a DOM dump. DOM content from a login page can carry credential field values, so the class is refused outright; the per-turn manifest is the one metadata-only exception.
- **Fix:** Upload screenshots and selector scripts instead; do not upload DOM for login recordings.

### TRACE_ARTIFACT_UPLOAD_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The artifact could not be written to storage
- **Cause:** Object storage refused or failed the write.
- **Fix:** Retry with backoff — re-uploading the same key overwrites rather than duplicating.

### TRACE_FIXTURE_EXPORT_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The fixture archive could not be built
- **Cause:** The run's artifact list could not be enumerated, so there was nothing to bundle.
- **Fix:** Retry with backoff.

### EVENT_ARCHIVE_QUERY_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The archived-event store could not be queried
- **Cause:** The event-archive read faulted or was throttled.
- **Fix:** Retry with backoff; narrowing the date range or filter set reduces the chance of a throttled scan.

### EVENT_ARCHIVE_EVENT_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No archived event with that id
- **Cause:** The id does not resolve. Archived events carry a 30-day TTL, so an older id reads as missing.
- **Fix:** Verify the event id against a recent event query.

### DEBUG_EVENT_QUERY_UNINDEXED

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The event query named no indexed predicate
- **Cause:** The filter set could only be answered by scanning the whole table, so the archive refused it instead of answering from an arbitrary slice of rows.
- **Fix:** Add at least one indexed predicate: a single event type, a single source, or an aggregate, correlation or biller id.

### DEBUG_EVENT_CURSOR_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The pagination cursor is not valid for this query
- **Cause:** The cursor did not decode, or it was issued for a different lane, filter set or principal. Pagination never silently restarts at page one.
- **Fix:** Echo the previous response's cursor back unchanged, and start a new pagination run without a cursor whenever a filter changes.

### DEBUG_PARAMETER_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No configuration parameter at that path
- **Cause:** The fully qualified parameter name does not resolve for this environment.
- **Fix:** List parameters first, then read one by its exact `/billerapi/{service}/{env}/{name}` path.

### DEBUG_PARAMETER_NAME_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The parameter name is outside the writable namespace
- **Cause:** A write was attempted on a path that does not begin with the `/billerapi/` prefix.
- **Fix:** Qualify the name under `/billerapi/` and retry.

### DEBUG_PARAMETER_LOOKUP_FAILED

- **Status:** 500
- **Type:** `upstream`
- **Summary:** Parameter Store could not be read
- **Cause:** The upstream parameter read faulted.
- **Fix:** Retry with backoff — reads are cached for five minutes, so a repeat within that window may still be served.

### DEBUG_PARAMETER_UPDATE_FAILED

- **Status:** 500
- **Type:** `upstream`
- **Summary:** The parameter write was rejected
- **Cause:** The upstream parameter write faulted.
- **Fix:** Retry with backoff — the write is a full-value overwrite, so repeating it is safe.

### DEBUG_SERVICE_LIST_FAILED

- **Status:** 500
- **Type:** `upstream`
- **Summary:** The per-service parameter rollup could not be built
- **Cause:** Parameter Store could not be enumerated, so the service counts could not be derived.
- **Fix:** Retry with backoff.

### DEBUG_ALARM_QUERY_FAILED

- **Status:** 500
- **Type:** `upstream`
- **Summary:** Live alarm state could not be read
- **Cause:** The CloudWatch DescribeAlarms read faulted.
- **Fix:** Retry with backoff; the ops pane degrades to its last successful render in the meantime.

## Agent improvement

| Code | Status | Type | Summary |
| --- | --- | --- | --- |
| [`AGENT_TRACE_NOT_FOUND`](/docs/errors/AGENT_TRACE_NOT_FOUND) | 404 | `invalid_request` | No agent trace for that run |
| [`AGENT_TRACE_INVALID`](/docs/errors/AGENT_TRACE_INVALID) | 400 | `invalid_request` | The agent trace payload is invalid |
| [`AGENT_TRACE_LOOKUP_FAILED`](/docs/errors/AGENT_TRACE_LOOKUP_FAILED) | 503 | `upstream` | The agent trace store could not be read |
| [`AGENT_TRACE_PERSIST_FAILED`](/docs/errors/AGENT_TRACE_PERSIST_FAILED) | 500 | `api_error` | The agent trace could not be written |
| [`AGENT_TRACE_OUTCOME_EMIT_FAILED`](/docs/errors/AGENT_TRACE_OUTCOME_EMIT_FAILED) | 500 | `api_error` | The trace outcome event could not be published |
| [`AGENT_TRACE_EVENT_BUS_UNAVAILABLE`](/docs/errors/AGENT_TRACE_EVENT_BUS_UNAVAILABLE) | 503 | `upstream` | No event bus is wired for trace outcomes |
| [`AGENT_AUDIT_LOG_INVALID`](/docs/errors/AGENT_AUDIT_LOG_INVALID) | 400 | `invalid_request` | The audit log entry is invalid |
| [`AGENT_AUDIT_LOG_PERSIST_FAILED`](/docs/errors/AGENT_AUDIT_LOG_PERSIST_FAILED) | 500 | `api_error` | The audit log entry could not be appended |
| [`AGENT_AUDIT_LOG_LOOKUP_FAILED`](/docs/errors/AGENT_AUDIT_LOG_LOOKUP_FAILED) | 503 | `upstream` | The audit log could not be read |
| [`PROMPT_VERSION_NOT_FOUND`](/docs/errors/PROMPT_VERSION_NOT_FOUND) | 404 | `invalid_request` | No prompt version with that hash |
| [`PROMPT_VERSION_INVALID`](/docs/errors/PROMPT_VERSION_INVALID) | 400 | `invalid_request` | The prompt version payload is invalid |
| [`PROMPT_VERSION_LOOKUP_FAILED`](/docs/errors/PROMPT_VERSION_LOOKUP_FAILED) | 503 | `upstream` | The prompt version store could not be read |
| [`PROMPT_VERSION_PERSIST_FAILED`](/docs/errors/PROMPT_VERSION_PERSIST_FAILED) | 500 | `api_error` | The prompt version could not be written |
| [`PROMPT_POINTER_NOT_FOUND`](/docs/errors/PROMPT_POINTER_NOT_FOUND) | 404 | `invalid_request` | No production prompt pointer for that agent |
| [`PROMPT_POINTER_CAS_MISMATCH`](/docs/errors/PROMPT_POINTER_CAS_MISMATCH) | 409 | `invalid_request` | The production pointer moved under you |
| [`PROMPT_POINTER_INVALID`](/docs/errors/PROMPT_POINTER_INVALID) | 400 | `invalid_request` | The production pointer payload is invalid |
| [`PROMPT_POINTER_FLIP_FAILED`](/docs/errors/PROMPT_POINTER_FLIP_FAILED) | 500 | `api_error` | The production pointer could not be written |
| [`PROMPT_POINTER_LOOKUP_FAILED`](/docs/errors/PROMPT_POINTER_LOOKUP_FAILED) | 503 | `upstream` | The production pointer could not be read |
| [`PROMPT_POINTER_ROLLBACK_NO_PRIOR`](/docs/errors/PROMPT_POINTER_ROLLBACK_NO_PRIOR) | 409 | `invalid_request` | No prior prompt version to roll back to |
| [`PROMPT_POINTER_ROLLBACK_HASH_MISMATCH`](/docs/errors/PROMPT_POINTER_ROLLBACK_HASH_MISMATCH) | 409 | `invalid_request` | The rollback guard hash does not match |
| [`PROMPT_EXPERIMENT_NOT_FOUND`](/docs/errors/PROMPT_EXPERIMENT_NOT_FOUND) | 404 | `invalid_request` | No prompt experiment with that id |
| [`PROMPT_EXPERIMENT_ALREADY_ACTIVE`](/docs/errors/PROMPT_EXPERIMENT_ALREADY_ACTIVE) | 409 | `invalid_request` | An experiment is already running for that agent |
| [`PROMPT_EXPERIMENT_VARIANT_NOT_FOUND`](/docs/errors/PROMPT_EXPERIMENT_VARIANT_NOT_FOUND) | 400 | `invalid_request` | The experiment arm is not a registered prompt version |
| [`PROMPT_EXPERIMENT_NO_CONTROL`](/docs/errors/PROMPT_EXPERIMENT_NO_CONTROL) | 400 | `invalid_request` | The experiment has no control arm |
| [`PROMPT_EXPERIMENT_INVALID`](/docs/errors/PROMPT_EXPERIMENT_INVALID) | 400 | `invalid_request` | The prompt experiment payload is invalid |
| [`PROMPT_EXPERIMENT_LOOKUP_FAILED`](/docs/errors/PROMPT_EXPERIMENT_LOOKUP_FAILED) | 503 | `upstream` | The prompt experiment store could not be read |
| [`PROMPT_EXPERIMENT_PERSIST_FAILED`](/docs/errors/PROMPT_EXPERIMENT_PERSIST_FAILED) | 500 | `api_error` | The prompt experiment could not be written |
| [`PROMPT_EVAL_SCORE_INVALID`](/docs/errors/PROMPT_EVAL_SCORE_INVALID) | 400 | `invalid_request` | The prompt eval score is invalid |
| [`PROMPT_EVAL_SCORE_PERSIST_FAILED`](/docs/errors/PROMPT_EVAL_SCORE_PERSIST_FAILED) | 500 | `api_error` | The prompt eval score could not be written |
| [`BRAIN_ENTRY_NOT_FOUND`](/docs/errors/BRAIN_ENTRY_NOT_FOUND) | 404 | `invalid_request` | No brain entry with that id |
| [`BRAIN_ENTRY_INVALID`](/docs/errors/BRAIN_ENTRY_INVALID) | 400 | `invalid_request` | The brain entry payload is invalid |
| [`BRAIN_ENTRY_INVALID_STATE`](/docs/errors/BRAIN_ENTRY_INVALID_STATE) | 409 | `invalid_request` | The brain entry is in the wrong state for that decision |
| [`BRAIN_ENTRY_LOOKUP_FAILED`](/docs/errors/BRAIN_ENTRY_LOOKUP_FAILED) | 503 | `upstream` | The brain store could not be read |
| [`BRAIN_ENTRY_PERSIST_FAILED`](/docs/errors/BRAIN_ENTRY_PERSIST_FAILED) | 500 | `api_error` | The brain entry could not be written |
| [`BRAIN_EFFECT_LOOKUP_FAILED`](/docs/errors/BRAIN_EFFECT_LOOKUP_FAILED) | 503 | `upstream` | The brain effect snapshots could not be read |
| [`BRAIN_EFFECT_COMPUTE_FAILED`](/docs/errors/BRAIN_EFFECT_COMPUTE_FAILED) | 500 | `api_error` | The brain entry effect could not be computed |
| [`BRAIN_SEARCH_FAILED`](/docs/errors/BRAIN_SEARCH_FAILED) | 503 | `upstream` | The brain search could not be executed |
| [`RECORDING_VERSION_NOT_FOUND`](/docs/errors/RECORDING_VERSION_NOT_FOUND) | 404 | `invalid_request` | No recording version with that hash |
| [`RECORDING_VERSION_INVALID`](/docs/errors/RECORDING_VERSION_INVALID) | 400 | `invalid_request` | The recording version payload is invalid |
| [`RECORDING_VERSION_LOOKUP_FAILED`](/docs/errors/RECORDING_VERSION_LOOKUP_FAILED) | 503 | `upstream` | The recording version store could not be read |
| [`RECORDING_VERSION_PERSIST_FAILED`](/docs/errors/RECORDING_VERSION_PERSIST_FAILED) | 500 | `api_error` | The recording version could not be written |
| [`RECORDING_POINTER_NOT_FOUND`](/docs/errors/RECORDING_POINTER_NOT_FOUND) | 404 | `invalid_request` | No production recording pointer for that biller |
| [`RECORDING_POINTER_LOOKUP_FAILED`](/docs/errors/RECORDING_POINTER_LOOKUP_FAILED) | 503 | `upstream` | The production recording pointer could not be read |
| [`CANARY_ALREADY_RUNNING`](/docs/errors/CANARY_ALREADY_RUNNING) | 409 | `invalid_request` | A canary is already running |
| [`CANARY_NOT_RUNNING`](/docs/errors/CANARY_NOT_RUNNING) | 409 | `invalid_request` | No canary is running |
| [`CANARY_TERMINAL`](/docs/errors/CANARY_TERMINAL) | 409 | `invalid_request` | The canary already reached a terminal state |
| [`CANARY_CANDIDATE_NOT_FOUND`](/docs/errors/CANARY_CANDIDATE_NOT_FOUND) | 404 | `invalid_request` | The canary candidate recording does not exist |
| [`CANARY_NO_BASELINE`](/docs/errors/CANARY_NO_BASELINE) | 409 | `invalid_request` | The canary has no baseline to compare against |
| [`CANARY_INVALID`](/docs/errors/CANARY_INVALID) | 400 | `invalid_request` | The canary payload is invalid |
| [`CANARY_CAS_MISMATCH`](/docs/errors/CANARY_CAS_MISMATCH) | 409 | `invalid_request` | The recording pointer moved during the canary decision |
| [`CANARY_LOOKUP_FAILED`](/docs/errors/CANARY_LOOKUP_FAILED) | 503 | `upstream` | The canary state could not be read |
| [`CANARY_PERSIST_FAILED`](/docs/errors/CANARY_PERSIST_FAILED) | 500 | `api_error` | The canary state could not be written |
| [`CANARY_METRICS_UNAVAILABLE`](/docs/errors/CANARY_METRICS_UNAVAILABLE) | 503 | `upstream` | The canary metrics could not be computed |
| [`AGENT_FREEZE_INVALID`](/docs/errors/AGENT_FREEZE_INVALID) | 400 | `invalid_request` | The agent freeze request is invalid |
| [`AGENT_FREEZE_PERSIST_FAILED`](/docs/errors/AGENT_FREEZE_PERSIST_FAILED) | 500 | `api_error` | The agent freeze state could not be written |
| [`AGENT_FREEZE_LOOKUP_FAILED`](/docs/errors/AGENT_FREEZE_LOOKUP_FAILED) | 503 | `upstream` | The agent freeze state could not be read |
| [`CLIENT_FEEDBACK_NOT_FOUND`](/docs/errors/CLIENT_FEEDBACK_NOT_FOUND) | 404 | `invalid_request` | No client feedback for that run |
| [`CLIENT_FEEDBACK_INVALID`](/docs/errors/CLIENT_FEEDBACK_INVALID) | 400 | `invalid_request` | The client feedback payload is invalid |
| [`CLIENT_FEEDBACK_ALREADY_SUBMITTED`](/docs/errors/CLIENT_FEEDBACK_ALREADY_SUBMITTED) | 409 | `invalid_request` | Feedback was already submitted for that run |
| [`CLIENT_FEEDBACK_IDEMPOTENCY_CONFLICT`](/docs/errors/CLIENT_FEEDBACK_IDEMPOTENCY_CONFLICT) | 409 | `invalid_request` | The idempotency key was reused with different content |
| [`CLIENT_FEEDBACK_LOOKUP_FAILED`](/docs/errors/CLIENT_FEEDBACK_LOOKUP_FAILED) | 503 | `upstream` | The client feedback store could not be read |
| [`CLIENT_FEEDBACK_PERSIST_FAILED`](/docs/errors/CLIENT_FEEDBACK_PERSIST_FAILED) | 500 | `api_error` | The client feedback could not be written |
| [`FEEDBACK_INVITED_MARKER_FAILED`](/docs/errors/FEEDBACK_INVITED_MARKER_FAILED) | 500 | `api_error` | The feedback-invited marker could not be written |
| [`EXECUTION_RUN_NOT_FOUND`](/docs/errors/EXECUTION_RUN_NOT_FOUND) | 404 | `invalid_request` | No execution run with that id |
| [`EXECUTION_RUN_INVALID`](/docs/errors/EXECUTION_RUN_INVALID) | 400 | `invalid_request` | The execution run payload is invalid |
| [`EXECUTION_RUN_INVALID_STATE`](/docs/errors/EXECUTION_RUN_INVALID_STATE) | 409 | `invalid_request` | The execution run is in the wrong state for that transition |
| [`EXECUTION_RUN_LOOKUP_FAILED`](/docs/errors/EXECUTION_RUN_LOOKUP_FAILED) | 503 | `upstream` | The execution run store could not be read |
| [`EXECUTION_RUN_PERSIST_FAILED`](/docs/errors/EXECUTION_RUN_PERSIST_FAILED) | 500 | `api_error` | The execution run could not be written |
| [`AGENT_REPORT_WINDOW_INVALID`](/docs/errors/AGENT_REPORT_WINDOW_INVALID) | 400 | `invalid_request` | The report window is invalid |
| [`AGENT_REPORT_DATA_UNAVAILABLE`](/docs/errors/AGENT_REPORT_DATA_UNAVAILABLE) | 503 | `upstream` | The report data could not be read |

### AGENT_TRACE_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No agent trace for that run
- **Cause:** The (agent_type, run_id) pair does not resolve to a stored trace. Traces are written at run start and expire with the retention window.
- **Fix:** Verify the agent_type and run_id. If the run has not started yet, poll rather than treating this as a permanent failure.

### AGENT_TRACE_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The agent trace payload is invalid
- **Cause:** The AgentTrace aggregate refused the fields: an unsupported outcome, a missing agent_type/run_id, or a malformed usage block.
- **Fix:** Fix the offending field named in error_message and resend. This is deterministic — a retry of the same payload fails identically.

### AGENT_TRACE_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The agent trace store could not be read
- **Cause:** An infrastructure fault reading stored traces — not a missing row, which is AGENT_TRACE_NOT_FOUND.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### AGENT_TRACE_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The agent trace could not be written
- **Cause:** The trace row write failed, so the run has no recorded trace and will not appear in reports.
- **Fix:** Retry the write. The (agent_type, run_id) key makes a replay idempotent.

### AGENT_TRACE_OUTCOME_EMIT_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The trace outcome event could not be published
- **Cause:** The outcome is stored on the trace, but the event that tells downstream consumers about it failed to publish.
- **Fix:** Retry. The backfill is idempotent, so re-running it re-attempts only the emit.

### AGENT_TRACE_EVENT_BUS_UNAVAILABLE

- **Status:** 503
- **Type:** `upstream`
- **Summary:** No event bus is wired for trace outcomes
- **Cause:** The service has no event-bus binding in this environment, so the outcome event had nowhere to go. Normal locally; a misconfiguration anywhere else.
- **Fix:** In dev this is expected. In staging/production, check the service EventBridge configuration.

### AGENT_AUDIT_LOG_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The audit log entry is invalid
- **Cause:** The AuditLogEntry aggregate refused the fields, typically a missing actor_id or an unsupported action.
- **Fix:** Fix the field named in error_message and resend.

### AGENT_AUDIT_LOG_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The audit log entry could not be appended
- **Cause:** The append failed, so the action has no audit record even though it may have taken effect.
- **Fix:** Retry the append. Audit rows are additive, so a duplicate is preferable to a gap.

### AGENT_AUDIT_LOG_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The audit log could not be read
- **Cause:** An infrastructure fault reading the audit log.
- **Fix:** Retry with backoff. If it persists, contact support with the request_id.

### PROMPT_VERSION_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No prompt version with that hash
- **Cause:** The (agent_type, version_hash) pair is not a registered PromptVersion.
- **Fix:** Create the version first, or correct the version_hash. Hashes are content-derived, so a whitespace change yields a different hash.

### PROMPT_VERSION_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The prompt version payload is invalid
- **Cause:** The PromptVersion aggregate refused the input: an unsupported agent_type, or malformed/empty prompt sections.
- **Fix:** Fix the field named in error_message and resend.

### PROMPT_VERSION_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The prompt version store could not be read
- **Cause:** An infrastructure fault reading prompt versions — not a missing row, which is PROMPT_VERSION_NOT_FOUND.
- **Fix:** Retry with backoff.

### PROMPT_VERSION_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The prompt version could not be written
- **Cause:** The write failed, so no version was stored.
- **Fix:** Retry. Identical content produces the identical hash, so replays are idempotent.

### PROMPT_POINTER_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No production prompt pointer for that agent
- **Cause:** The agent_type has never had a prompt version promoted to production.
- **Fix:** Create a prompt version and flip the production pointer to it.

### PROMPT_POINTER_CAS_MISMATCH

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The production pointer moved under you
- **Cause:** The flip carried an expected_current_hash that no longer matches the stored pointer — someone else promoted in between. The flip was refused rather than clobbering their change.
- **Fix:** Re-read the current production pointer, confirm you still want to promote, and retry with the fresh hash.

### PROMPT_POINTER_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The production pointer payload is invalid
- **Cause:** The ProductionPointer aggregate refused the fields, typically a missing updated_by or an unsupported agent_type.
- **Fix:** Fix the field named in error_message and resend.

### PROMPT_POINTER_FLIP_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The production pointer could not be written
- **Cause:** An infrastructure fault writing the pointer — distinct from the CAS conflict, which is PROMPT_POINTER_CAS_MISMATCH. The pointer is unchanged.
- **Fix:** Retry with backoff, then re-read the pointer to confirm which version is live.

### PROMPT_POINTER_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The production pointer could not be read
- **Cause:** An infrastructure fault reading the production pointer.
- **Fix:** Retry with backoff.

### PROMPT_POINTER_ROLLBACK_NO_PRIOR

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** No prior prompt version to roll back to
- **Cause:** Rollback walks to the previous registered version; this agent_type has only ever had one.
- **Fix:** Promote a specific version_hash with an explicit flip rather than a rollback.

### PROMPT_POINTER_ROLLBACK_HASH_MISMATCH

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The rollback guard hash does not match
- **Cause:** The expected_current_hash sent with the rollback is not what is live, so the rollback was refused rather than unwinding someone else’s promotion.
- **Fix:** Re-read the current pointer and retry with its hash.

### PROMPT_EXPERIMENT_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No prompt experiment with that id
- **Cause:** The experiment_id does not resolve.
- **Fix:** Verify the id, or list the agent_type’s experiments to find it.

### PROMPT_EXPERIMENT_ALREADY_ACTIVE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** An experiment is already running for that agent
- **Cause:** Only one experiment may be active per agent_type; concurrent experiments contaminate each other’s readouts.
- **Fix:** Stop the running experiment, then create the new one.

### PROMPT_EXPERIMENT_VARIANT_NOT_FOUND

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The experiment arm is not a registered prompt version
- **Cause:** A variant_hash or control_hash named by the experiment does not resolve to a stored PromptVersion for that agent_type.
- **Fix:** Create the prompt version first, then create the experiment.

### PROMPT_EXPERIMENT_NO_CONTROL

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The experiment has no control arm
- **Cause:** The control arm defaults to the production pointer, and this agent_type has none.
- **Fix:** Pass an explicit control_hash, or promote a production version first.

### PROMPT_EXPERIMENT_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The prompt experiment payload is invalid
- **Cause:** The PromptExperiment aggregate refused the fields: a missing experiment_id/stopped_by, a bad split, or an illegal transition.
- **Fix:** Fix the field named in error_message and resend.

### PROMPT_EXPERIMENT_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The prompt experiment store could not be read
- **Cause:** An infrastructure fault reading experiments — not a missing row, which is PROMPT_EXPERIMENT_NOT_FOUND.
- **Fix:** Retry with backoff.

### PROMPT_EXPERIMENT_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The prompt experiment could not be written
- **Cause:** The write failed, so the experiment state is unchanged.
- **Fix:** Retry with backoff, then re-read the experiment to confirm its state.

### PROMPT_EVAL_SCORE_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The prompt eval score is invalid
- **Cause:** The score payload failed validation: a missing evaluated_by, an out-of-range value, or an unknown metric name.
- **Fix:** Fix the field named in error_message and resend.

### PROMPT_EVAL_SCORE_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The prompt eval score could not be written
- **Cause:** The write failed, so the score is absent from the experiment readout.
- **Fix:** Retry with backoff.

### BRAIN_ENTRY_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No brain entry with that id
- **Cause:** The entry_id does not resolve. Retired and rejected entries remain readable, so this really is an unknown id.
- **Fix:** Verify the entry_id, or list entries for the agent_type and scope.

### BRAIN_ENTRY_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The brain entry payload is invalid
- **Cause:** A required field is missing or empty (note, biller_id, task_kind, actor_id), or the scope/status is unsupported.
- **Fix:** Fix the field named in error_message and resend.

### BRAIN_ENTRY_INVALID_STATE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The brain entry is in the wrong state for that decision
- **Cause:** The lifecycle transition was refused: the entry’s current status does not permit the requested action.
- **Fix:** Re-read the entry, confirm its status, and apply a transition that is legal from there.

### BRAIN_ENTRY_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The brain store could not be read
- **Cause:** An infrastructure fault reading brain entries — not a missing row, which is BRAIN_ENTRY_NOT_FOUND.
- **Fix:** Retry with backoff.

### BRAIN_ENTRY_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The brain entry could not be written
- **Cause:** The write failed, so the entry was neither created nor updated.
- **Fix:** Retry with backoff.

### BRAIN_EFFECT_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The brain effect snapshots could not be read
- **Cause:** An infrastructure fault reading the effect-snapshot rows that back the brain-effect surfaces.
- **Fix:** Retry with backoff.

### BRAIN_EFFECT_COMPUTE_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The brain entry effect could not be computed
- **Cause:** The effect computation over the entry’s traces failed part-way, so no figure is returned rather than a partial one.
- **Fix:** Retry with backoff. If it persists, narrow the window or contact support with the request_id.

### BRAIN_SEARCH_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The brain search could not be executed
- **Cause:** An infrastructure fault running the brain search query.
- **Fix:** Retry with backoff.

### RECORDING_VERSION_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No recording version with that hash
- **Cause:** The recording_hash does not resolve for the biller and agent_type.
- **Fix:** Verify the hash, or list recording versions for the biller.

### RECORDING_VERSION_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The recording version payload is invalid
- **Cause:** The RecordingVersion aggregate refused the fields: a missing biller_id, an unsupported agent_type, or malformed steps.
- **Fix:** Fix the field named in error_message and resend.

### RECORDING_VERSION_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The recording version store could not be read
- **Cause:** An infrastructure fault reading recording versions — not a missing row, which is RECORDING_VERSION_NOT_FOUND.
- **Fix:** Retry with backoff.

### RECORDING_VERSION_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The recording version could not be written
- **Cause:** The write failed, so no recording version was stored.
- **Fix:** Retry with backoff.

### RECORDING_POINTER_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No production recording pointer for that biller
- **Cause:** The (biller_id, agent_type) pair has no recording promoted to production.
- **Fix:** Promote a recording version first.

### RECORDING_POINTER_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The production recording pointer could not be read
- **Cause:** An infrastructure fault reading the recording production pointer.
- **Fix:** Retry with backoff.

### CANARY_ALREADY_RUNNING

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** A canary is already running
- **Cause:** Only one canary may run per (biller_id, agent_type); a second would make both readouts uninterpretable.
- **Fix:** Promote or abort the running canary, then start the new one.

### CANARY_NOT_RUNNING

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** No canary is running
- **Cause:** The decision targets a canary that does not exist for this (biller_id, agent_type).
- **Fix:** Start a canary first, or verify the biller_id and agent_type.

### CANARY_TERMINAL

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The canary already reached a terminal state
- **Cause:** The canary was already promoted or aborted; terminal states are final.
- **Fix:** Start a new canary if you want another evaluation.

### CANARY_CANDIDATE_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** The canary candidate recording does not exist
- **Cause:** The candidate_hash is not a stored recording version.
- **Fix:** Create the recording version first, then start the canary.

### CANARY_NO_BASELINE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The canary has no baseline to compare against
- **Cause:** A canary measures candidate-vs-production; this biller/agent pair has no production recording.
- **Fix:** Promote the initial recording version directly; canary the next one.

### CANARY_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The canary payload is invalid
- **Cause:** The CanaryState aggregate refused the fields: a missing biller_id/candidate_hash, or an unsupported decision.
- **Fix:** Fix the field named in error_message and resend.

### CANARY_CAS_MISMATCH

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The recording pointer moved during the canary decision
- **Cause:** Promoting a canary compare-and-swaps the recording production pointer; it moved in between, so the promotion was refused rather than clobbering it.
- **Fix:** Re-read the recording production pointer and re-issue the decision.

### CANARY_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The canary state could not be read
- **Cause:** An infrastructure fault reading canary state.
- **Fix:** Retry with backoff.

### CANARY_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The canary state could not be written
- **Cause:** The write failed, so the canary state is unchanged.
- **Fix:** Retry with backoff, then re-read the canary.

### CANARY_METRICS_UNAVAILABLE

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The canary metrics could not be computed
- **Cause:** Reading the candidate or baseline traces failed, so the comparison is withheld rather than reported half-populated.
- **Fix:** Retry with backoff.

### AGENT_FREEZE_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The agent freeze request is invalid
- **Cause:** The AgentFreezeState aggregate refused the fields: an unsupported agent_type, or a missing reason/actor.
- **Fix:** Fix the field named in error_message and resend.

### AGENT_FREEZE_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The agent freeze state could not be written
- **Cause:** The write failed, so the agent’s frozen flag is unchanged. A failed freeze means the agent is still running.
- **Fix:** Retry immediately, then read the kill-state list to confirm the flag actually flipped.

### AGENT_FREEZE_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The agent freeze state could not be read
- **Cause:** An infrastructure fault reading the frozen-agent rows.
- **Fix:** Retry with backoff.

### CLIENT_FEEDBACK_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No client feedback for that run
- **Cause:** The (agent_type, run_id) pair has no stored feedback.
- **Fix:** Verify the run_id, or list feedback for the agent_type.

### CLIENT_FEEDBACK_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The client feedback payload is invalid
- **Cause:** The ClientFeedback aggregate refused the fields: an unsupported signal value, or a missing client_id/run_id.
- **Fix:** Fix the field named in error_message and resend.

### CLIENT_FEEDBACK_ALREADY_SUBMITTED

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** Feedback was already submitted for that run
- **Cause:** Client feedback is at-most-once per (agent_type, run_id) so a run’s signal cannot be silently rewritten.
- **Fix:** Read the existing feedback. If it genuinely needs changing, that is a support request, not a resubmission.

### CLIENT_FEEDBACK_IDEMPOTENCY_CONFLICT

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The idempotency key was reused with different content
- **Cause:** The key is bound to the first payload it saw; a different payload under the same key is a client bug, not a retry.
- **Fix:** Retry byte-identically to replay the original result, or mint a fresh idempotency_key for genuinely new feedback.

### CLIENT_FEEDBACK_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The client feedback store could not be read
- **Cause:** An infrastructure fault reading feedback — not a missing row, which is CLIENT_FEEDBACK_NOT_FOUND.
- **Fix:** Retry with backoff.

### CLIENT_FEEDBACK_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The client feedback could not be written
- **Cause:** The write failed, so no feedback was recorded.
- **Fix:** Retry with the same idempotency_key so the retry cannot double-record.

### FEEDBACK_INVITED_MARKER_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The feedback-invited marker could not be written
- **Cause:** The conditional marker write failed. The marker is what makes the invite at-most-once per (biller, recording, user), so a later run may re-invite.
- **Fix:** Retry. The conditional write makes a replay safe — it simply loses the race and reports already_emitted.

### EXECUTION_RUN_NOT_FOUND

- **Status:** 404
- **Type:** `invalid_request`
- **Summary:** No execution run with that id
- **Cause:** The run_id or task_id does not resolve to a stored execution run.
- **Fix:** Verify the id. If the run has not started, poll rather than treating this as terminal.

### EXECUTION_RUN_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The execution run payload is invalid
- **Cause:** A required field is missing, or the script type / error code is unsupported.
- **Fix:** Fix the field named in error_message and resend.

### EXECUTION_RUN_INVALID_STATE

- **Status:** 409
- **Type:** `invalid_request`
- **Summary:** The execution run is in the wrong state for that transition
- **Cause:** The lifecycle transition was refused because the run’s current state does not permit it. Terminal states are final.
- **Fix:** Re-read the run and apply a transition that is legal from its current state.

### EXECUTION_RUN_LOOKUP_FAILED

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The execution run store could not be read
- **Cause:** An infrastructure fault reading execution runs — not a missing row, which is EXECUTION_RUN_NOT_FOUND.
- **Fix:** Retry with backoff.

### EXECUTION_RUN_PERSIST_FAILED

- **Status:** 500
- **Type:** `api_error`
- **Summary:** The execution run could not be written
- **Cause:** The write failed, so the run’s recorded state is unchanged and may now lag reality.
- **Fix:** Retry with backoff, then re-read the run.

### AGENT_REPORT_WINDOW_INVALID

- **Status:** 400
- **Type:** `invalid_request`
- **Summary:** The report window is invalid
- **Cause:** from_date is after to_date, or the requested span exceeds the maximum window (the trend comparison doubles the span actually fetched).
- **Fix:** Send a valid, narrower window.

### AGENT_REPORT_DATA_UNAVAILABLE

- **Status:** 503
- **Type:** `upstream`
- **Summary:** The report data could not be read
- **Cause:** Reading the traces that back the report failed, so the report is withheld rather than returned half-populated.
- **Fix:** Retry with backoff, or request a narrower window.
