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

# Errors

> Every error the API can return, what causes it, and what to do next.

Every failure comes back in the same shape, whatever went wrong:

```json theme={null}
{
  "success": false,
  "error": {
    "code": "instance_not_connected",
    "message": "This WhatsApp instance is not connected. Connect it before sending messages."
  },
  "request_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
}
```

<Warning>
  Branch your code on **`error.code`** and the HTTP status. `error.message` is written for a human reading a log and its wording can change without notice.
</Warning>

Some errors add a `details` object with extra machine-readable context — for example `retry_after_seconds` on a rate limit. Where that happens, it is noted below.

Every response, success or failure, carries a `request_id`. Log it. It is the one thing that lets support find your exact request.

## Handle these three first

Almost every integration only really needs to get three cases right:

<CardGroup cols={3}>
  <Card title="409 instance_not_connected" icon="plug-circle-xmark">
    **Stop and alert a human.** Retrying cannot help — somebody has to re-scan the QR. [What to do](/guides/disconnects)
  </Card>

  <Card title="429 rate_limit_exceeded" icon="gauge-high">
    **Wait, then retry.** The `Retry-After` header tells you how many seconds.
  </Card>

  <Card title="422 invalid_recipient" icon="user-xmark">
    **Skip this recipient.** The number is not usable on WhatsApp. Do not retry it.
  </Card>
</CardGroup>

## Full error reference

### Authentication — 401

| Code              | Message                                                  | What happened                               | What to do                                                                                                                                                   |
| ----------------- | -------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `missing_api_key` | Missing API key. Send it in the "apikey" request header. | No `apikey` header reached us.              | Add the header. It is not a Bearer token and not a query parameter.                                                                                          |
| `invalid_api_key` | Invalid or revoked API key.                              | The key is unknown, or it has been revoked. | Check for stray whitespace or a truncated copy-paste, then check the key is still active in the portal. Unknown and revoked give the same answer on purpose. |

<Note>
  If you drive the instance endpoints from a signed-in portal session instead of a key, you may also see `missing_session` and `invalid_session`. Those never appear on the `apikey` path described here.
</Note>

### Billing and access — 402, 403

| Status | Code                            | Message                                                                                                                                                     | What happened                                                                                      | What to do                                                                                                                                      |
| ------ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 402    | `no_active_subscription`        | No active subscription found for this account. Activate a plan to send messages.                                                                            | The account has no usable plan.                                                                    | Activate a plan in the portal, or ask whoever manages your account.                                                                             |
| 402    | `subscription_expired`          | Your subscription has expired. Renew it to continue sending messages.                                                                                       | The plan ran out.                                                                                  | Renew it. Nothing is deleted — your numbers and keys are waiting.                                                                               |
| 403    | `account_suspended`             | This account is suspended. Contact support.                                                                                                                 | The account, or the account of whoever provides your service, is suspended.                        | Get in touch to have it reactivated.                                                                                                            |
| 403    | `access_expired`                | This account's access has ended. Please contact your provider.                                                                                              | The account's validity period has passed.                                                          | Ask your provider to extend it.                                                                                                                 |
| 403    | `trial_quota_exceeded`          | Trial message quota reached (N messages). Upgrade to a paid plan to keep sending.                                                                           | A trial has a total message allowance and it is used up. Every message type counts against it.     | Upgrade. `details.trial_message_limit` carries the allowance.                                                                                   |
| 403    | `instance_limit_reached`        | Instance limit reached: your PLAN plan allows N instances… — or — Instance limit reached: your account allows N numbers. Contact your provider to add more. | You already have as many numbers as your account allows. **One code, two situations** — see below. | Delete a number you no longer use, or ask for more. `details.instance_limit` carries the cap and `details.limit_source` says which one you hit. |
| 403    | `activation_unavailable`        | This number can't be activated right now. Please contact your service provider to continue.                                                                 | Your provider cannot activate another number for you at the moment.                                | Contact your provider. Nothing on your side will change this.                                                                                   |
| 403    | `admin_free_slot_limit_reached` | Free slot limit reached: an admin account allows N free numbers. Delete an unused number to add another.                                                    | Only ever appears on a platform operator's own account.                                            | Delete an unused number.                                                                                                                        |

### The request itself — 400, 404, 413, 422

| Status | Code                 | Message                                                                                           | What happened                                                                                                                                                   | What to do                                                                                                                    |
| ------ | -------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request`    | Varies — it names the exact field, including its position in a list.                              | A field is missing, the wrong type, too long, the body is not valid JSON, or a query parameter could not be read.                                               | Read `error.message`; it says which field. Fix and resend. Do not retry unchanged.                                            |
| 400    | `invalid_media_url`  | Field "url" must be a publicly reachable http(s) URL to the file.                                 | The `url` is malformed, is not `http`/`https`, is over 2,048 characters, contains a username or password, or points at a private, internal or loopback address. | Use a public, direct link to the file. `details.field` names the field — `url`, or `buttons[N].url` for a link button.        |
| 404    | `instance_not_found` | Instance not found. Check the instance\_id and that it belongs to your account.                   | No such instance on your account. An id belonging to someone else gives exactly this answer, so nobody can probe for other accounts' ids.                       | Check the id against `GET /v1/instances`.                                                                                     |
| 404    | `not_found`          | The requested resource does not exist.                                                            | The URL path is wrong.                                                                                                                                          | Check the method and path against the [API reference](/api-reference/introduction).                                           |
| 413    | `payload_too_large`  | Request body is too large.                                                                        | The JSON body exceeded 128 KB.                                                                                                                                  | Send less. Remember media travels by link, not in the body.                                                                   |
| 422    | `invalid_recipient`  | The recipient number is not a valid WhatsApp number.                                              | `to` is not a usable number, or WhatsApp reports it is not reachable.                                                                                           | Include the country code and no leading zero. [Check numbers first](/guides/checking-numbers) to catch these before you send. |
| 422    | `media_fetch_failed` | The media at that URL could not be downloaded. Check the link is public, direct, and still valid. | The link passed our checks, but the file could not actually be fetched — a 404, an expired signed link, a page instead of a file, a DNS or TLS failure.         | Open the link in a private browser window. If it does not download the raw file, neither can we.                              |

### Connection — 409

| Code                       | Message                                                                                                                        | What happened                                                                                                               | What to do                                                                                                                                      |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `instance_not_connected`   | This WhatsApp instance is not connected. Connect it before sending messages.                                                   | That number is not linked to WhatsApp right now.                                                                            | **Do not retry in a loop.** Pause the queue and alert someone to re-scan the QR. Full guide: [If your number disconnects](/guides/disconnects). |
| `number_already_connected` | This WhatsApp number is already connected on another of your instances. Disconnect it there first, or scan a different number. | You scanned the same WhatsApp number onto a second slot of the same account. The session that was already up keeps working. | Use the instance that already has it, or disconnect it there and scan again.                                                                    |

### Rate limit — 429

| Code                  | Message                           | What happened                            | What to do                                             |
| --------------------- | --------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
| `rate_limit_exceeded` | Rate limit exceeded, retry in Ns. | You went over your per-minute allowance. | Wait `Retry-After` seconds, then retry. Details below. |

### Our side — 500, 502, 503

| Status | Code                   | Message                                                                 | What happened                                                                              | What to do                                                                                                     |
| ------ | ---------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| 500    | `internal_error`       | An unexpected error occurred.                                           | Something failed on our side that we did not anticipate.                                   | Retry once after a pause. If it persists, send support the `request_id`.                                       |
| 502    | `upstream_unavailable` | The messaging service is temporarily unavailable. Please retry shortly. | The messaging service did not answer in time, or answered in a way we could not interpret. | Safe to retry with backoff. If only one particular request fails repeatedly, check its payload.                |
| 502    | `provisioning_failed`  | Could not create the instance right now. Please retry shortly.          | A new instance could not be created. Only from `POST /v1/instances`.                       | Retry shortly.                                                                                                 |
| 503    | `qr_unavailable`       | A QR code is not available yet. Wait a moment and retry.                | A pairing QR is not ready yet. Only from `GET /v1/instances/{id}/qr`.                      | Wait — the response carries `Retry-After: 3` — then retry, or poll `GET /v1/instances/{id}/qr-status` instead. |

<Note>
  This page covers every error reachable through the public API described on this site. The portal has some additional administrative error codes that no API key can trigger; they are deliberately not listed here.
</Note>

## Telling the two number limits apart

`instance_limit_reached` covers two different situations, on purpose — to you both mean "you cannot add another number". But the remedies differ, so `details.limit_source` tells you which one you hit:

| `details.limit_source` | What it means                                                    | What fixes it                                                        |
| ---------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- |
| `plan`                 | The cap comes from the plan you are on. `details.plan` names it. | Delete a number you no longer use, or upgrade the plan.              |
| `account`              | The cap is set on your account by whoever provides your service. | Delete a number you no longer use, or ask your provider to raise it. |

```javascript Branch on the field, never on the wording theme={null}
if (error.code === "instance_limit_reached") {
  if (error.details.limit_source === "plan") {
    showUpgradePrompt(error.details.plan, error.details.instance_limit);
  } else {
    showContactProviderPrompt(error.details.instance_limit);
  }
}
```

<Warning>
  Never match on `error.message` to tell them apart. The wording can change; `error.code` and `details.limit_source` are the stable contract.
</Warning>

<Note>
  `details.limit_source` also appears on `admin_free_slot_limit_reached`, where it reads `free_slots`. That code only ever reaches a platform operator's own account, so a customer integration will only see `plan` or `account` — but branch with a default case rather than an `if/else`, and a third value can never surprise you.
</Note>

## Rate limits

Sending is limited **per minute, per API key**.

* One allowance is shared by **every message type and the number check**. It is one budget, not one per endpoint.
* The **instance endpoints** (`/v1/instances/...`) have their **own separate allowance** of **120 requests per minute** — comfortable for polling a QR every couple of seconds — so managing numbers never eats into your sending budget.

### The headers

Any request that reaches a rate limiter carries all four `X-RateLimit-*` headers — on success as well as on a 429. `Retry-After` is the exception and appears only on a 429.

| Header                  | Meaning                                                                  | When            |
| ----------------------- | ------------------------------------------------------------------------ | --------------- |
| `X-RateLimit-Scope`     | **Which allowance** the other three describe: `messages` or `instances`. | Always          |
| `X-RateLimit-Limit`     | Your allowance per minute on that scope.                                 | Always          |
| `X-RateLimit-Remaining` | How many requests you have left in the current window.                   | Always          |
| `X-RateLimit-Reset`     | Seconds until your allowance next goes **up** by one.                    | Always          |
| `Retry-After`           | How many seconds to wait before retrying.                                | Only on a `429` |

<Warning>
  **Always read the scope before caching a limit.** The two budgets are different sizes: managing numbers allows 120 a minute, sending usually far fewer. A client that reads `X-RateLimit-Limit: 120` off a `GET /v1/instances` call and applies it to sending will overrun its real send budget and not understand why.
</Warning>

Read `X-RateLimit-Limit` rather than hardcoding a number — your send allowance comes from your plan and can change without any change to this API.

Requests rejected *before* the limiter — a missing or invalid key, for instance — carry no `X-RateLimit-*` headers at all.

### About `X-RateLimit-Reset`

The window is a **rolling** minute, not a fixed one that empties on the hour. There is no single instant when your count drops to zero, so `X-RateLimit-Reset` reports the useful thing instead: **seconds until the oldest request ages out**, which is when `X-RateLimit-Remaining` next goes up by one.

* If `X-RateLimit-Remaining` is above `0`, **you can send now** — you do not need to wait for the reset.
* If it is `0`, waiting `X-RateLimit-Reset` seconds gets you at least one more request.
* On a `429`, `X-RateLimit-Reset` and `Retry-After` are the same number.

### A 429 body

```json theme={null}
{
  "success": false,
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded, retry in 12s.",
    "details": {
      "retry_after_seconds": 12,
      "limit_per_minute": 20,
      "scope": "messages"
    }
  },
  "request_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
}
```

### Pacing a list

Do not fire your whole list at once and mop up the 429s afterwards. Space the sends out from the start — it is gentler on your allowance and much gentler on your WhatsApp number's standing.

```javascript Node.js — respect the headers theme={null}
async function send(body) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch("https://api.bulkneo.com/v1/messages/text", {
      method: "POST",
      headers: {
        apikey: process.env.BULKNEO_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (res.status === 429) {
      const wait = Number(res.headers.get("retry-after") || 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }

    // Pace BEFORE you hit the wall: when the budget is spent, X-RateLimit-Reset
    // says how long until it is not. This scope is 'messages'; the instance
    // endpoints report their own, much larger budget under 'instances'.
    if (res.headers.get("x-ratelimit-remaining") === "0") {
      const wait = Number(res.headers.get("x-ratelimit-reset") || 1);
      await new Promise((r) => setTimeout(r, wait * 1000));
    }

    const json = await res.json();
    if (!res.ok) {
      // 409 and 4xx validation errors are not worth retrying.
      throw new Error(`${json.error.code}: ${json.error.message}`);
    }
    return json.data;
  }
  throw new Error("rate limited too many times");
}
```

## What to retry, and what not to

| Situation                                        | Retry?                                                                            |
| ------------------------------------------------ | --------------------------------------------------------------------------------- |
| `429 rate_limit_exceeded`                        | Yes — after `Retry-After` seconds.                                                |
| `502 upstream_unavailable`, `500 internal_error` | Yes — once or twice, with backoff.                                                |
| `503 qr_unavailable`                             | Yes — after a few seconds.                                                        |
| `409 instance_not_connected`                     | **No.** It will keep failing until a human re-scans. Queue the message and alert. |
| `422 invalid_recipient`                          | **No.** Drop the recipient.                                                       |
| `400 invalid_request`, `400 invalid_media_url`   | **No.** Fix the request first.                                                    |
| `401`, `402`, `403`                              | **No.** Something about the key or the account has to change.                     |

<Tip>
  Make retries **idempotent on your side**. This API has no idempotency key, so a retry after a network timeout can genuinely send the message twice. Record the `message_id` you get back and skip anything already recorded as sent.
</Tip>
