> ## Documentation Index
> Fetch the complete documentation index at: https://docs.isnap.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and rate limits

> Describes the error format of the iSnap API, the errors every operation shares, what to retry, and the request-rate and message-operations limits with their headers.

Every refused or failed request returns the same JSON shape, with a code you can branch on. This page describes that shape, the errors that every operation can return, the retry rule of each, and the limits on the number of requests.

## Error format

```bash theme={null}
curl -sS "https://api.isnap.ai/v1/lines?status=reserved"
```

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "status": 400,
    "code": "validation_failed",
    "message": "Invalid request data",
    "retry_after": null,
    "retry_at": null,
    "issues": [
      {
        "path": ["status", 0],
        "message": "Invalid input: expected \"available\"",
        "code": "invalid_value"
      }
    ]
  },
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "request_id": "req_4Tn7Bw9Rz3Lc6Yp1Hd8Xk2mQv"
}
```

The six keys of `error` are always present. The last three are `null` when they do not apply.

| Field | Meaning |
| - | - |
| `error.status` | The HTTP status of the response. |
| `error.code` | The stable value to branch on. |
| `error.message` | Text for a person. It can change. Log it for your own diagnosis, do not show it to your users, and never branch on it. |
| `error.retry_after` | A number of seconds, on the `429` responses that know a delay. The `Retry-After` header carries the same value. It is `null` on every other error, a `503` included. |
| `error.retry_at` | An ISO 8601 timestamp, on `429 rebind_cap_reached` only. |
| `error.issues` | An array of `{ path, message, code }` on `400 validation_failed`, and on the refusals whose reference page says so. `path` is an array of field names and indexes. |
| `trace_id`, `request_id` | The ids of the request, also in the `X-Trace-Id` and `X-Request-Id` headers. |

On `400 validation_failed`, the `code` of an issue names the check that failed, for example `invalid_type`, `invalid_value`, `invalid_format`, `invalid_union`, `too_small`, `too_big`, `unrecognized_keys` or `custom`. The list is open: read `path` and `message`. Two refusals add to their issue: `limit_out_of_bounds` carries `min` and `max`, and `webhook_url_not_allowed` carries the reason in the `code` of the issue. On `POST /v1/lines/assign`, `POST /v1/lines/shared` and `POST /v1/macos-sessions/rent`, an `Idempotency-Key` longer than 255 characters returns an issue on `idempotency-key` with the code `too_big` and `max`.

`GET /v1/health` does not use this format on `503`: it returns the health object itself. See [`GET /v1/health`](/api-reference/service/get-health).

## Errors

These errors are not specific to one operation.

| Status | `error.code` | Meaning | Retry? |
| - | - | - | - |
| `400` | `validation_failed` | The query, the path, a header or the body does not match what the operation takes. `error.issues` lists every problem of the first invalid part, in the order query, path, headers, body. | No. Fix the request. |
| `401` | `unauthorized` | iSnap found no valid credential. | No. See [Troubleshooting](/authentication#troubleshooting). |
| `403` | `forbidden` | A write was sent without an `Authorization` header that carries a key, or a line-scoped key acted outside its lines. | No. See [Refusals](/authentication#refusals). |
| `404` | `not_found` | The path or the method is not defined, or the resource does not exist for your account. | No. |
| `429` | `rate_limit_exceeded` | Too many requests from your network address. | Yes, after `Retry-After` seconds. |
| `500` | `internal_error` | An unexpected error. The response gives no detail. A body that is not valid JSON also returns it. | Yes, up to 2 times, with exponential backoff. Retry a write only on an operation that honors `Idempotency-Key`, with the key of the first attempt. |

The retry rules, in two sentences:

* Retry a `429` after `Retry-After` seconds. Retry a `500` or a `503` up to 2 times, with exponential backoff. Do not retry any other `4xx`: fix the request.
* Retry a write only on an operation that honors `Idempotency-Key`, with the key of the first attempt.

One `4xx` changed nothing and can be sent again once: `409 purchase_in_progress` from [`POST /v1/lines/{id}/release`](/api-reference/lines/release-line).

[Idempotency](/idempotency#retrying-safely) tells you what to do with a write whose response you did not receive. The [helpers](/authentication#helpers) apply these rules.

When a `500` persists, report it. Write to [support@isnap.ai](mailto:support@isnap.ai) with the `trace_id` and `request_id` of the response.

### Service unavailable

A `503` in the error format carries one of six codes, and its `error.retry_after` is `null`.

| `error.code` | Operations | Meaning | Retry? |
| - | - | - | - |
| `idempotency_in_flight` | `POST /v1/lines/assign`, `POST /v1/lines/shared`, `POST /v1/macos-sessions/rent` | The first request with this idempotency key is still running. | Yes, with the same key. |
| `quota_state_unavailable` | `POST /v1/messages` and `POST /v1/chats/{chat_id}/share-contact-card`, on a shared line | iSnap could not read the state of the shared line. | Yes. |
| `line_pool_exhausted` | The same two operations on a shared line, and `POST /v1/trial/init` | No number can serve a new contact of the shared line, or no line can carry a new trial. | Yes, up to 2 times. Then report it. |
| `pairing_code_unavailable` | `POST /v1/trial/init` | iSnap could not issue a pairing code. | Yes, up to 2 times. |
| `replay_enqueue_failed` | `POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay` | iSnap could not queue the replay. The new delivery exists, with the status `failed`. | Yes: call the operation again. |
| `payment_provider_unavailable` | `POST /v1/pre-orders/{id}/confirm`, `POST /v1/pre-orders/{id}/cancel` | iSnap could not reach its payment provider. Nothing changed. | Yes, up to 2 times. |

None of these refusals sends a message, delivers an event, starts a trial or changes a pre-order: retry it up to 2 times, even on `POST /v1/trial/init`, the replay operation and the two pre-order operations, which ignore `Idempotency-Key`. A refused replay leaves a delivery record with the status `failed`, and each retry adds one.

### Codes of each domain

Each operation adds its own codes. The reference page of an operation lists them, and its guide explains them.

| Domain | Codes | Guide |
| - | - | - |
| Sending | `400 bad_request`, `400 line_not_active`, `403 line_suspended`, `403 outbound_first_forbidden`, `422 reply_to_cross_line`, `409 too_late_to_cancel` | [Sending messages](/guides/sending-messages) |
| Sending limits | `429 minute_limit_exceeded`, `429 hourly_limit_exceeded`, `429 total_message_quota_exceeded`, `429 new_contact_quota_exceeded`, `429 known_contact_quota_exceeded`, `429 queue_full` | [Sending limits](/guides/sending-limits) |
| Reactions, typing indicators, read receipts, contact cards | `422 sms_reactions_not_supported`, `422 multi_attachment_reaction`, `422 parent_not_yet_acked`, `422 reaction_recipient_unsupported`, `422 sms_typing_not_supported`, `422 sms_read_receipt_not_supported`, `422 unsupported_handle`, `502 avatar_fetch_failed` | [Chat features](/guides/conversations) |
| Attachments | `413 attachment_exceeds_storage_limit`, `410 attachment_expired` | [Attachments](/guides/attachments) |
| Recipient lookup | `429 lookup_rate_limit_exceeded` | [Recipient lookup](/guides/lookup) |
| Getting a line | `402 checkout_required`, `402 payment_required`, `409 conflict`, `409 no_matching_line`, `409 line_not_reserved`, `409 purchase_payment_in_progress`, `409 purchase_in_progress`, and the `line_` codes that say why an available line cannot be taken now | [Getting a line](/guides/getting-a-line) |
| Managing a line | The codes of the metadata, configuration, health, terminate, cancel, variant, test line and transfer operations | [Managing lines](/guides/managing-lines) |
| Pre-orders | `400 tier_not_pre_orderable`, `400 android_rental_not_purchasable`, `400 billing_anchor_too_far`, `pre_order_not_draft`, `pre_order_already_confirmed`, `409 pre_order_already_paid`, `409 payment_setup_failed`, `503 payment_provider_unavailable` | [Pre-orders](/guides/pre-orders) |
| BYOD | `402 payment_required`, `404 no_slot_available` | [BYOD lines](/guides/byod) |
| Hosted macOS sessions | `403 not_a_wholesale_partner`, `409 no_macos_session_in_stock`, `404 macos_session_not_found`, `409 macos_session_cancellation_already_due` | [Hosted macOS sessions](/guides/hosted-macos-sessions) |
| Trials | `409 trial_already_active`, `404 trial_not_found`, `410 pairing_expired`, `402 trial_needs_inbound`, `403 trial_scope_violation`, `422 trial_attachments_unsupported`, `403 rebind_not_allowed`, `429 rebind_cap_reached`, `422 admin_reason_forbidden` | [Trials](/guides/trials) |
| Webhooks | `400 webhook_url_not_allowed`, `400 invalid_cursor`, `422 delivery_not_terminal` | [Webhooks overview](/webhooks/overview), [Delivery guarantees](/webhooks/delivery), [Events feed](/webhooks/pulling-events) |
| Idempotency | `409 idempotency_key_collision`, `409 idempotency_conflict`, `409 idempotency_terminal_state` | [Idempotency](/idempotency#operations-that-honor-the-key) |

Three rules for these codes:

* Read a code together with the operation that returned it. The same code can come with another status on another operation: `line_not_active` is a `400` on a send with an API key, a `403` on a send with a trial key, and a `409` on a variant change, and `pre_order_not_draft` is a `400` or a `409`.
* Do not treat a list of codes as closed. The `line_` codes that refuse a reservation are an open set: treat any `409` of a reservation as "choose another line".
* For a code your client does not know, apply the retry rules by status.

```bash curl theme={null}
curl -sS https://api.isnap.ai/v1/messages/msg_3Lc6Yp1Hd8Xk2mQ4vTn7Bw9Rz \
  -H "Authorization: Bearer $ISNAP_API_KEY"
```

```typescript TypeScript theme={null}
import { APIError } from '@isnap/sdk'
import { client } from './isnap.js'

try {
  const message = await client.messages.get('msg_3Lc6Yp1Hd8Xk2mQ4vTn7Bw9Rz')
  console.log(message.status)
} catch (err) {
  // An error that is not an APIError means that no response arrived.
  if (!(err instanceof APIError)) throw err
  switch (err.code) {
    case 'not_found':
      console.log('No such message for this key, or iSnap deleted it.')
      break
    case 'validation_failed':
      console.error(err.issues)
      break
    default:
      console.error(err.status, err.code, `trace_id=${err.traceId}`, `request_id=${err.requestId}`)
      throw err
  }
}
```

```python Python theme={null}
from isnap import ISnapError, isnap_request

try:
    message = isnap_request("GET", "/v1/messages/msg_3Lc6Yp1Hd8Xk2mQ4vTn7Bw9Rz")
    print(message["status"])
except ISnapError as err:
    if err.code == "not_found":
        print("No such message for this key, or iSnap deleted it.")
    elif err.code == "validation_failed":
        print(err.issues)
    else:
        print(err.status, err.code, f"trace_id={err.trace_id}", f"request_id={err.request_id}")
        raise
```

## Rate limits

| Limit | Counts | Number |
| - | - | - |
| Request rate | Every request, on every operation | 100 requests per rolling 60 seconds, per network address |
| Message operations | `POST /v1/messages` and `GET /v1/messages`, together | 60 requests per rolling 60 seconds, per network address, in addition to the request-rate limit |

Both limits are counted per network address, before the key is read. Neither is tied to an API key or to an account:

* All the keys and all the accounts that call from one network address share one budget.
* A request with a wrong key, a request without a key and a request to an operation that needs no credential all count. A `POST /v1/messages` refused with `403 forbidden` because its `Authorization` header carries no API key does not count against the message-operations limit: see [How a request is checked](/authentication#how-a-request-is-checked).
* A request refused with `429` counts too. A caller that keeps sending stays refused: stop for 60 seconds.

The paths under `/v1/messages/{id}` are outside the message-operations limit.

### Headers

Every response carries three headers.

| Header | Meaning |
| - | - |
| `X-RateLimit-Limit` | The limit: `100`, or `60` on `POST /v1/messages` and `GET /v1/messages`. |
| `X-RateLimit-Remaining` | The requests left in the current window. |
| `X-RateLimit-Reset` | A Unix time in seconds, always 60 seconds after the response. The window is rolling: this is not the time at which the count returns to zero. |

```bash theme={null}
curl -sS -D - -o /dev/null https://api.isnap.ai/v1/version
```

Among the headers of the response:

```text theme={null}
x-ratelimit-limit: 100
x-ratelimit-remaining: 99
x-ratelimit-reset: 1791469437
x-request-id: req_4Tn7Bw9Rz3Lc6Yp1Hd8Xk2mQv
x-trace-id: 0af7651916cd43dd8448eb211c80319c
```

### Over the limit

Over the limit, the API returns `429` with `error.code` `rate_limit_exceeded`, `error.retry_after` `60` and the header `Retry-After: 60`.

```json theme={null}
{
  "success": false,
  "data": null,
  "error": {
    "status": 429,
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded",
    "retry_after": 60,
    "retry_at": null,
    "issues": null
  },
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "request_id": "req_4Tn7Bw9Rz3Lc6Yp1Hd8Xk2mQv"
}
```

To stay under the limit:

* Receive events by webhook instead of reading the same resource in a loop: see [Webhooks overview](/webhooks/overview).
* Walk lists with `limit=100`: see [Pagination](/pagination).
* When several of your systems call from one address, give them one shared budget.

## Other `429` codes

Four other limits return `429`, each with its own code. They are not the request-rate limit, and the `X-RateLimit-` headers do not describe them. Each carries `error.retry_after` and `Retry-After`. The delay can be long: read it before you wait.

| `error.code` | Operations | What is limited | Guide |
| - | - | - | - |
| `minute_limit_exceeded`, `hourly_limit_exceeded`, `total_message_quota_exceeded`, `new_contact_quota_exceeded`, `known_contact_quota_exceeded` | `POST /v1/messages`, `POST /v1/chats/{chat_id}/share-contact-card` | One of the five sending limits of the line, on a line set to refuse a message over a limit. By default, iSnap holds such a message and returns `202`. These five responses also carry the `X-iSnap-Quota-Remaining` and `X-iSnap-Quota-Resets-At` headers. | [The five limits](/guides/sending-limits#the-five-limits) |
| `queue_full` | The same two operations | The number of held messages a line can have. | [Held messages](/guides/sending-limits#held-messages) |
| `lookup_rate_limit_exceeded` | `POST /v1/lookup` | The number of lookups, per account and per line. | [Recipient lookup](/guides/lookup) |
| `rebind_cap_reached` | `POST /v1/trial/{trial_id}/revoke` | The number of revokes with `reason: rebind`. This code also carries `error.retry_at`. | [Trials](/guides/trials) |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.