---
url: https://textme-docs.matat.io/guide/errors.md
description: >-
  The error envelope, request status versus delivery status, partial success on
  batch writes, the failure classes, and which calls are safe to retry.
---

# Errors & statuses

TextMe reports failures in the body of an otherwise ordinary `HTTP 200` response. There is one shape for all of them, and one field to branch on.

## The error envelope

```json
{
  "status": 4,
  "message": "Not enough credit"
}
```

| Field | Meaning |
|---|---|
| `status` | `0` on success; any other value is a failure. |
| `message` | Human-readable explanation. For status `2` it names the missing field. |

::: danger Never branch on the HTTP status alone
Authentication failures, validation failures and quota failures all arrive as `HTTP 200`. A client that only checks `response.ok` will treat every one of them as a successful send.
:::

Every code example on this site follows the same three steps: post, decode, then compare `status` to `0` before trusting anything else in the body.

## Two statuses, two vocabularies

The word *status* appears in two unrelated places, and confusing them is the most common integration mistake.

| | Where | Vocabulary |
|---|---|---|
| **Request status** | Top-level `status` on any response | *Did the API accept this call?*, [status codes](../reference/status-codes.md) |
| **Delivery status** | `status` inside each `transactions[]` entry of a delivery report | *What did the handset do with the message?*, [DLR statuses](../reference/dlr-statuses.md) |

A delivery report can carry `status: 0` at the top (the report was produced) while individual transactions carry `102` (delivered) or `1` (failed). They are different scales that happen to share a field name.

## Partial success

Several operations accept a batch and process it row by row. Those return `status: 0` (the operation ran) together with an `errors` list describing the rows that did not make it.

```json
{
  "status": 0,
  "message": "The phone numbers have been added successfully",
  "errors": [
    "The phone 05XXXXXXXX is already on the contact list and therefore not added"
  ]
}
```

The operations that behave this way are [contact-list writes](../endpoints/contact-lists.md) (`newCL`, `removeCL`, `addNumCL`, `rmNumCL`). Treating their `status: 0` as "everything worked" will silently drop recipients. Read `errors` whenever it is present.

## Failure classes

**Credentials and permissions**: `3`, `10`, `11`, `504`, `511`, `998` (as `אין הרשאה` in delivery statuses). The token is wrong, expired, mismatched, or the account is not entitled to the operation. Retrying does not help; see [Authentication](./authentication.md).

**Malformed request**: `1`, `2`, `997`. The document did not parse, a required field is absent, or the root element is not an operation. Deterministic: the same payload will always fail. Fix the payload; the [test endpoint](./request-format.md#testing-without-sending) is the cheap way to iterate.

**Rejected values** (`9`, `714`, `980`, `986`, `989`, `990`, `991`, `992`, `993`, `995`, `996`. A specific field failed validation) a phone that is too short, a message that is too long, an `add_unsubscribe` value that is not `2` or `3`. The `message` says which.

**Account state**: `4` (no credit), `12` (not enough money), `5` (not permitted to send at this hour), `515` (unverified sender). Nothing is wrong with the request; something is wrong with the account. These *can* become successful later, once the account is topped up, the hour changes, or the sender is verified.

**Nothing left to send to**: `8` (every destination is blocklisted), `715` (every destination was filtered by `temp_bl`), `988` (the contact list does not exist). The call was well-formed and simply had no recipients left. Worth logging distinctly: it usually means the audience, not the code, needs attention.

**Server-side**: `6`, `970`, `999`. Process failure. Safe to retry once with backoff; if it persists, contact support.

## Retrying safely

There is no idempotency key. A retried send is a second send, and both will be delivered and billed.

* **Never blind-retry a send.** If `sms` or `bulk` times out at the transport level, you do not know whether the message went out. Resolve it by reading rather than writing: give every destination an `id` (see [delivery reports](../use-cases/delivery-reports.md)) and query the report before resending.
* **Reads are free to retry.** `balance`, `dlr`, `dlrByDate`, `incoming`, `getCL`, `getVerifiedPhones` and the other read operations change nothing.
* **Writes are not idempotent.** Calling `newCL` twice creates two lists; `addNumBL` twice is harmless, but `updateAmountSub` twice moves credit twice.
* **Back off on `6` and `999`.** Retry once after a short delay rather than immediately.

## Worked example

The pattern every example on this site uses, in full:

::: code-group

```js [JavaScript]
const response = await fetch('https://my.textme.co.il/api', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TEXTME_API_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
})

// The transport rarely fails, but when it does you learn nothing about
// whether the message was sent. Do not retry a send here.
if (!response.ok) {
  throw new Error(`TextMe transport error: HTTP ${response.status}`)
}

const result = await response.json()

// This is the check that matters.
if (Number(result.status) !== 0) {
  throw new TextMeError(result.status, result.message)
}

// A batch operation can succeed overall and still drop rows.
for (const problem of result.errors ?? []) {
  console.warn('TextMe partial failure:', problem)
}

return result
```

```python [Python]
import os

import httpx


class TextMeError(RuntimeError):
    def __init__(self, status, message):
        super().__init__(f"TextMe {status}: {message}")
        self.status = int(status)
        self.message = message


def call(payload):
    response = httpx.post(
        "https://my.textme.co.il/api",
        headers={"Authorization": f"Bearer {os.environ['TEXTME_API_TOKEN']}"},
        json=payload,
    )
    # A transport failure leaves a send in an unknown state. Do not retry it.
    response.raise_for_status()

    result = response.json()

    # This is the check that matters.
    if int(result["status"]) != 0:
        raise TextMeError(result["status"], result["message"])

    # A batch operation can succeed overall and still drop rows.
    for problem in result.get("errors", []):
        print("TextMe partial failure:", problem)

    return result
```

```php [PHP]
<?php

function textme(array $payload): array
{
    $client = new \GuzzleHttp\Client([
        'headers' => [
            'Authorization' => 'Bearer '.getenv('TEXTME_API_TOKEN'),
            'Accept' => 'application/json',
        ],
    ]);

    $response = $client->post('https://my.textme.co.il/api', ['json' => $payload]);
    $result = json_decode($response->getBody()->getContents(), true);

    // This is the check that matters.
    if ((int) $result['status'] !== 0) {
        throw new RuntimeException("TextMe {$result['status']}: {$result['message']}");
    }

    // A batch operation can succeed overall and still drop rows.
    foreach ($result['errors'] ?? [] as $problem) {
        error_log("TextMe partial failure: {$problem}");
    }

    return $result;
}
```

:::

## Full tables

* [Status codes](../reference/status-codes.md). Every value the top-level `status` can take.
* [DLR statuses](../reference/dlr-statuses.md). Every value a delivery report transaction can take, with English glosses for the Hebrew messages.
