# Errors

> Every MobileValidate API error code with its HTTP status, whether to retry, what causes it and how to fix it, plus the error response format.

Canonical: https://mobilevalidate.com/docs/errors · Last updated: 2026-09-25

Machine-readable: [Error catalog (JSON)](https://mobilevalidate.com/errors.json)

When a whole request can't be processed, the API returns a non-2xx status and one JSON error object. Each error has a stable `code` to branch on, a `retryable` flag, and a `request_id` to quote to support. Problems with single rows, such as invalid, duplicate, suppressed, unknown or unsupported numbers, are reported inside `results` instead. They never fail the request and are never billed.

## What does an error response look like?

```json
{
  "error": {
    "code": "service_disabled",
    "message": "The check 'signal.registered' is available in bulk jobs only (POST /v1/jobs).",
    "status": 403,
    "retryable": false,
    "param": "checks[0]",
    "doc_url": "https://mobilevalidate.com/docs/errors#service_disabled",
    "request_id": "req_0VWF4BN8PIsgkUh3EvUj",
    "suggestion": "Create a bulk job (POST /v1/jobs) for this check, or remove it from checks."
  }
}
```

- `code` is stable. Branch on it, not on `message`, whose wording may change.
- `param` points to the field at fault (`checks[1]`, `numbers`, `emails[3]`, `max_cost.amount`), or is `null`. Your values are never echoed back.
- `retryable` says whether sending the same request again later can succeed.
- `request_id` also comes back as the `X-Request-Id` header on every response.
- `suggestion` (optional) is a plain-English hint on how to fix the request, for example a missing country code or a likely typo in an e-mail domain. It never repeats the value you sent. Show it to developers; don't parse it.

## Which codes can the API return?

| Code | HTTP | Retryable |
|---|---|---|
| `invalid_request` | 400 | no |
| `too_many_numbers` | 400 | no |
| `test_number_only` | 400 | no |
| `invalid_cursor` | 400 | no |
| `unauthorized` | 401 | no |
| `insufficient_balance` | 402 | no |
| `cost_limit_exceeded` | 402 | no |
| `insufficient_scope` | 403 | no |
| `service_disabled` | 403 | no |
| `sandbox_magic_only` | 403 | no |
| `suspected_enumeration` | 403 | no |
| `not_found` | 404 | no |
| `idempotency_key_reused` | 409 | no |
| `idempotency_request_in_progress` | 409 | yes |
| `test_key_exists` | 409 | no |
| `payload_too_large` | 413 | no |
| `rate_limited` | 429 | yes |
| `daily_cap_reached` | 429 | yes (after the reset) |
| `spend_cap_reached` | 429 | no |
| `internal_error` | 500 | yes |
| `temporarily_unavailable` | 503 | yes |

## What does each code mean?

### `invalid_request`
**400, not retryable.** The body is malformed JSON, breaks the schema (missing or unknown field, wrong type), or uses an unknown check code. It is also returned when e-mails are sent without an e-mail check or numbers without a phone check (`param: "checks"`), for more than 20 checks, and when identifiers × applicable checks exceed 2,000 per lookup or 100,000 per job. **Fix:** read `param` and `message`, and correct that field. `GET /v1/services` lists valid codes.

### `too_many_numbers`
**400, not retryable.** More than 100 numbers + e-mails on `POST /v1/lookup`, more than 50,000 on `POST /v1/jobs` (or in an uploaded CSV, `param: "file"`), or more than 10 in a job or estimate with the public sandbox key. **Fix:** split the list, or use a [bulk job](/docs/bulk-jobs) for anything over 100.

### `test_number_only`
**400, not retryable.** A live key was used with a test number ("Test numbers can only be used with test keys.") or a `test.mobilevalidate.com` address ("Test e-mail addresses can only be used with test keys."). **Fix:** use your test key for [test data](/docs/test-mode) and real identifiers with live keys.

### `invalid_cursor`
**400, not retryable.** The `after` cursor for job results is malformed. **Fix:** use `next_cursor` from the previous page exactly as returned, or start again without `after`.

### `unauthorized`
**401, not retryable.** The key is missing, malformed, unknown, revoked or expired, or was used from an IP address outside its allowlist. All cases look the same on purpose. **Fix:** check the `Authorization: Bearer` header, the key prefix and your outgoing IP address, including IPv6 (see [authentication](/docs/authentication)).

### `insufficient_balance`
**402, not retryable.** Your balance can't cover the maximum possible cost of the request, which is reserved before checking. **Fix:** top up, or send fewer identifiers or checks. Cached and inconclusive answers are released afterwards, so the final charge is often lower than the reservation.

### `cost_limit_exceeded`
**402, not retryable.** The maximum possible cost is higher than the `max_cost` you sent. **Fix:** raise `max_cost` or shrink the request. `POST /v1/jobs/estimate` shows the maximum cost for free.

### `insufficient_scope`
**403, not retryable.** The key doesn't have the scope this endpoint needs, for example `webhooks:manage` or `account:read`. **Fix:** use a key with that scope, or ask for one.

### `service_disabled`
**403, not retryable.** One of the checks is switched off, isn't enabled for your account, or is bulk only and was sent to `POST /v1/lookup` ("… is available in bulk jobs only (POST /v1/jobs)"). `param` names the check (`checks[i]`); it is `null` when real-time checks are not enabled for the whole account. **Fix:** run bulk-only services in a job, or remove the check. Services marked coming soon, such as `number.hlr`, give this answer until they launch.

### `sandbox_magic_only`
**403, not retryable.** The public sandbox key (the key in the docs examples) accepts only the documented [test numbers and e-mail addresses](/docs/test-values). `param` names the first other identifier (`numbers[2]`, `emails[0]`); an invalid number counts as another identifier. The sandbox's other limits have their own codes: jobs over 10 rows return `too_many_numbers`, and webhooks return `invalid_request`. **Fix:** use a test value, or get a personal test key to test your own data. The `suggestion` field links both.

### `suspected_enumeration`
**403, not retryable.** The request looks like a scan: 20 or more consecutive numbers (`param: "numbers"`), 20 or more addresses on one domain differing only by digits or separators, or (live keys) 50 or more such addresses from your account in one UTC day (`param: "emails"`). **Fix:** only check identifiers you actually hold, such as customers, sign-ups and leads. See [rate limits and abuse](/docs/rate-limits-and-abuse).

### `not_found`
**404, not retryable.** The id doesn't exist, belongs to another account, or its results were purged ("Results for this job were purged."). For privacy, we don't say which. An unknown path also returns `not_found`, before the key is checked; the `suggestion` names the closest endpoint. **Fix:** check the id and the path, and fetch results within your retention period.

### `idempotency_key_reused`
**409, not retryable.** The `Idempotency-Key` was already used within 24 hours with a different request body. **Fix:** use a new key for each distinct request, and reuse a key only for exact retries.

### `idempotency_request_in_progress`
**409, retryable.** The first request with this `Idempotency-Key` is still running. **Fix:** wait briefly and retry with the same key and body. You'll get the stored response.

### `test_key_exists`
**409, not retryable.** `POST /v1/test-keys` (the "get a test key" form): no more test keys can be issued for this e-mail address right now (at most 3 in 30 days). Keys already issued are never shown again. **Fix:** use a key you received earlier, or request access and we'll set you up.

### `payload_too_large`
**413, not retryable.** The body is larger than 1 MB (4 MB for `POST /v1/jobs` and `/v1/jobs/estimate`). **Fix:** split the list into several jobs.

### `rate_limited`
**429, retryable.** More than 10 requests per second (burst 20) on this key, or, for the public sandbox key, more than 30 requests per minute or 1,000 per day from your IP address. **Fix:** wait for the `Retry-After` seconds, then retry with backoff. Batch numbers into one request instead of sending one request per number.

### `daily_cap_reached`
**429, retryable after the reset.** Your account's daily cap on identifiers is used up. Caps reset at 00:00 UTC, and `Retry-After` counts down to it. **Fix:** retry after the reset shown in `GET /v1/limits`, or ask for a higher cap.

### `spend_cap_reached`
**429, not retryable.** The key's daily spend cap (agent keys) has been reached. It resets at 00:00 UTC. **Fix:** wait for the reset, raise the cap or use another key. Retrying right away won't help.

### `internal_error`
**500, retryable.** Something failed on our side. **Fix:** retry with backoff, using the same `Idempotency-Key` so the retry is safe. If it keeps happening, send us the `request_id`.

### `temporarily_unavailable`
**503, retryable.** The service is overloaded or every route for a check is down. **Fix:** retry after `Retry-After`. A request that fails this way has not been charged.

## Are there codes outside the HTTP API?

- **MCP server:** `confirmation_required` means a tool call would spend more than the confirmation threshold (default USD 1.00) or covers more than 100 numbers. The agent should show the amount to the user and call again with `confirm_max_cost`. See [MCP](/docs/mcp).
- **SDK:** client-side codes `missing_api_key`, `connection_error`, `timeout`, `invalid_response` and `invalid_argument` never come from the API itself. See [SDK](/docs/sdk).

## Frequently asked questions

### Is an unknown result an error?

No. Per-row problems (invalid, duplicate, suppressed, unknown, unsupported country) never fail the request and are never billed. Errors are only for problems with the request as a whole.

### Which errors should my client retry?

Only those with retryable: true: rate_limited, daily_cap_reached (after the reset), idempotency_request_in_progress, internal_error and temporarily_unavailable. Honour Retry-After and use backoff with jitter.
