# Errors

> Speedmap uses conventional HTTP status codes and one JSON error shape with a stable, machine-readable code.

Successful requests return `2xx`. `4xx` means the request cannot succeed as sent; `5xx` means a temporary problem on our side or upstream, and the request is safe to retry.

Every error has the same body. Branch on `error.code`, never on `message`, which is written for people and can change.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error.code` | string | Yes | Stable, machine-readable code from the table below. |
| `error.message` | string | Yes | Human-readable explanation. |
| `error.reference_id` | string | No | Present when a verification was created before the failure. Use it with support. |
| `error.details` | array of objects | No | For validation errors: `{ path, message }` per field. `path` is dotted, e.g. `match.date_of_birth`. |

**400 Bad Request**

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Request validation failed",
    "details": [
      {
        "path": "nin",
        "message": "NIN must be 11 digits"
      },
      {
        "path": "match.date_of_birth",
        "message": "Use YYYY-MM-DD"
      }
    ]
  }
}
```

## Error codes

| Status | Code | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | The body or query failed validation. `details` has one entry per field. | Fix the fields listed in `details`. Do not retry unchanged. |
| 400 | `invalid_idempotency_key` | `Idempotency-Key` is shorter than 8 or longer than 128 characters. | Use a UUID. |
| 400 | `invalid_categories` | Consent `categories` is not a 4-letter Y/N mask, or is `NNNN`. | Send a mask with at least one `Y`, e.g. `YYNN`. |
| 400 | `email_required` | Consent channel `email` was chosen without an `email`. | Add `email`, or use another channel. |
| 400 | `too_many_items` | A bulk job has more than 10,000 items. | Split the job. |
| 400 | `invalid_amount` | A wallet top-up is below ₦1,000 or above ₦10,000,000. | Choose an amount in range. |
| 400 | `phone_required` | A top-up was started for a workspace with no contact phone number. | Add a phone number in the top-up dialog (saved to the workspace profile). |
| 400 | `invalid_token` | An email verification, password reset or invitation link is malformed or was already used. | Request a new link from the dashboard or sign-in page. |
| 400 | `token_expired` | The link has expired (verification 24 h, reset 30 min, invitation 7 days). | Request a new link. |
| 401 | `unauthorized` | The API key is missing, malformed or revoked. | Send `Authorization: Bearer sk_…` with an active key. |
| 401 | `key_expired` | The API key passed its `expires_at`. | Create or rotate a key in the dashboard and deploy it. |
| 401 | `invalid_code` | The two-factor or emailed sign-in code is wrong. `details.attempts_left` counts down; five failures end the attempt. | Enter the current code from your authenticator, or a backup code. |
| 401 | `invalid_otp` | The one-time code on the hosted consent page is wrong. | Re-enter it. Five failures lock the request. |
| 402 | `insufficient_balance` | Live only: the wallet balance is below the price of the check. | Top up the wallet in the dashboard, then retry. |
| 403 | `account_suspended` | The workspace is suspended. | Contact support. |
| 403 | `forbidden` | Your role is not allowed to perform this action. | Ask a workspace owner or admin. |
| 403 | `email_unverified` | A live key or go-live application needs a verified email address. | Open the verification link we emailed, or resend it from the dashboard banner. |
| 403 | `mfa_required` | Two-factor authentication is on for this account and this sign-in has not completed it. | Complete the second step with `POST /auth/mfa/verify`. |
| 403 | `mfa_required_by_org` | The workspace requires two-factor authentication and this member has not enabled it. Dashboard writes are blocked; reads still work. | Enable two-factor authentication under Settings → Security. |
| 403 | `key_scope` | The API key is not scoped to this product or is read-only. | Use a key with the product in its scopes, or create one in the dashboard. |
| 403 | `ip_not_allowed` | The request came from an IP address outside the key’s allowlist. | Call from an allowed address, or edit the key’s allowlist. |
| 403 | `live_access_required` | A live action before go-live was approved. | Complete go-live in the dashboard, or use a sandbox key. |
| 403 | `not_entitled` | The workspace is not enabled for the product or method (e.g. raw NIN lookup). | Use a permitted method, or request access in the dashboard. |
| 403 | `product_not_live` | The product is in preview and answers in sandbox only. | Use a sandbox key until the product is live. |
| 403 | `sandbox_only` | A sandbox endpoint (such as consent approve/deny) was called with a live key. | Use a sandbox key. |
| 404 | `not_found` | No object with that id in this mode. | Check the id and that the key mode (sandbox or live) matches. |
| 404 | `consent_not_found` | The `consent_id` on a check does not exist in this mode. | Create a consent request first. |
| 404 | `unknown_product` | The product does not exist. | Check the endpoint path. |
| 409 | `idempotency_key_reused` | The `Idempotency-Key` was already used with a different body. | Use a new key for a new request. |
| 409 | `consent_not_granted` | Revoke was called on a consent that is not granted. | Only granted consents can be revoked. |
| 409 | `consent_not_pending` | A decision was made on a consent that is no longer pending. | Read the consent to see its status. |
| 409 | `payment_not_pending` | A top-up was checked or simulated after it was already paid, expired or failed. | Read the payment; start a new top-up if needed. |
| 412 | `consent_not_granted` | A check used a consent that is pending, denied, expired or revoked. | Wait for `consent.updated`, or create a new request. |
| 412 | `consent_expired` | The consent grant is older than 24 hours. | Create a new consent request. |
| 412 | `consent_subject_mismatch` | The consent was granted for a different identifier. | Use the consent for this identifier. |
| 422 | `provider_rejected_request` | The upstream registry rejected the request as invalid. | Check the identifier and fields. `reference_id` identifies the attempt. |
| 429 | `rate_limited` | The API key exceeded its per-minute budget. | Wait for `Retry-After` seconds, then retry with back-off. |
| 429 | `too_many_attempts` | Too many attempts on a sign-in or consent flow. | Wait a few minutes. |
| 502 | `consent_hub_error` | NIBSS Consent Hub rejected or failed the consent request. | Retry later; check the identifier. |
| 503 | `provider_unavailable` | The upstream for this product is temporarily switched off. | Retry later. Safe with the same `Idempotency-Key`. |
| 503 | `verification_unavailable` | The check could not be completed (provider outage or a Speedily-side issue). | Retry later with the same or a new `Idempotency-Key`. You are not charged. |
| 503 | `payments_unavailable` | The payment provider (FintavaPay) could not open a one-time account or confirm a transfer. | Retry in a few minutes. Nothing was charged. |

## Retrying safely

- Retry `429`, `502` and `503` with exponential back-off. On `429`, honour `Retry-After`.
- Always send an [`Idempotency-Key`](https://speedmap-delta.vercel.app/docs/idempotency) on `POST` requests and reuse it on retry: a retried check returns the original result and is never billed twice. A key whose first attempt failed with `503` is released, so the retry runs again.
- Do not retry `4xx` responses unchanged (except `429`); they will fail the same way.

## Statuses that are not errors

- `202 Accepted` with `status: "pending"`: the provider has not answered yet. See [pending verifications](https://speedmap-delta.vercel.app/docs/verifications#pending-verifications).
- `200` with `status: "not_found"`: the check ran and no record exists.

---

Source: https://speedmap-delta.vercel.app/docs/errors
