# RebelText API

Send SMS and MMS, track delivery, and get status updates pushed to your server.

**Base URL**

```
https://rebeltext.docoitest.com
```

Every request is authenticated with your API key, and every request and
response body is JSON: send `Content-Type: application/json` whenever a request
has a body.

AI agents and LLM tools can read this guide as
[Markdown](https://rebeltext.docoitest.com/docs?format=markdown), which `/docs` also
returns to any request that sends `Accept: text/markdown`.

---

## Contents

- Guides
  - Get started
    - [Quick start](#quick-start)
    - [Authentication](#authentication)
    - [Responses and errors](#responses-and-errors)
    - [Rate limits](#rate-limits)
    - [Pagination](#pagination)
  - Sending messages
    - [Media (MMS)](#media-mms)
    - [Phone numbers](#phone-numbers)
    - [Pricing and segments](#pricing-and-segments)
    - [Safe retries](#safe-retries)
  - Webhooks
    - [How webhooks work](#how-webhooks-work)
    - [Receiving events](#receiving-events)
    - [Verifying signatures](#verifying-signatures)
    - [Retries and pausing](#retries-and-pausing)
  - AI agents
    - [Use with AI agents (MCP)](#use-with-ai-agents-mcp)
- API reference
  - Messages
    - [Send a message](#send-a-message-sms-or-mms)
    - [Send a batch](#send-a-batch)
    - [Check message status](#check-message-status)
    - [List messages](#list-messages)
    - [Get a batch](#get-a-batch)
  - Received messages
    - [List received messages](#list-received-messages)
    - [Get a received message](#get-a-received-message)
  - Suppressions
    - [List suppressions](#list-suppressions)
    - [Add suppressions](#add-suppressions)
    - [Remove a suppression](#remove-a-suppression)
  - Numbers
    - [List your numbers](#list-your-numbers)
  - Account
    - [Get your account](#get-your-account)
    - [Get usage](#get-usage)
  - Webhooks
    - [Register an endpoint](#register-an-endpoint)
    - [List endpoints](#list-endpoints)
    - [Delete an endpoint](#delete-an-endpoint)
    - [Send a test event](#send-a-test-event)
  - Delivery log
    - [List deliveries](#list-deliveries)
    - [Replay a delivery](#replay-a-delivery)
  - Objects and errors
    - [The message object](#the-message-object)
    - [Message statuses](#message-statuses)
    - [Error codes](#error-codes)

---

## Quick start

1. **Send a message.**

   ```bash
   curl -X POST https://rebeltext.docoitest.com/v1/messages \
     -H "Authorization: Bearer $REBELTEXT_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"to_number": "5125550199", "message": "Your order has shipped."}'
   ```

   The response includes a `reference_id`, such as
   `msg_01HQ3F5X8K2M9N4P6R7S8T9V0W`. That's the message's ID.

2. **Check whether it was delivered.**

   ```bash
   curl https://rebeltext.docoitest.com/v1/messages/msg_01HQ3F5X8K2M9N4P6R7S8T9V0W \
     -H "Authorization: Bearer $REBELTEXT_API_KEY"
   ```

3. **Stop polling.** [Register an endpoint](#register-an-endpoint) and we'll tell your
   server the moment a message's status changes.

---

## Authentication

Send your API key as a bearer token on every request:

```http
Authorization: Bearer rt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

There are two kinds of key:

| Prefix | What it does |
|---|---|
| `rt_live_` | Sends real messages to real phones. |
| `rt_test_` | For building and testing your integration. Messages go through validation, pricing, rate limits and webhooks exactly like live ones, but never reach a phone and always end up `delivered`. Test keys are refused in production. |

Treat your key like a password: keep it out of client-side code and out of
source control.

### You need a subscription to send

A new key works straight away for reading, but sending needs an active plan.
Without one, every endpoint that sends a message, registers a brand or
campaign, or buys a number answers `402`:

```json
{
  "status": false,
  "message": "This account has no active subscription. Choose a plan to start sending.",
  "code": "subscription_required"
}
```

You can still read: `GET /v1/account`, `GET /v1/plans`, `GET /v1/messages` and
your invoices all keep working, so you can see where you stand. Pick a plan in
the console and your key can send on its very next request. There's no waiting
period and nothing to restart.

Don't confuse this with `insufficient_balance`. `subscription_required` means
there's no plan; `insufficient_balance` means there is a plan, but your balance
has run out.

### Authentication errors

| Problem | Status | `code` |
|---|---|---|
| Key missing or not recognised | `401` | `unauthenticated` |
| Key is valid, but the account is suspended or closed | `403` | `account_inactive` |
| Account has no active subscription | `402` | `subscription_required` |
| Test key used against production | `403` | `test_key_in_production` |
| Provisioning a number past the account's number allowance | `403` | `number_limit_reached` |

---

## Responses and errors

Every response has the same envelope, so one piece of code can handle them all.

**Success**

```json
{
  "status": true,
  "message": "Success",
  "data": { }
}
```

**Error**

```json
{
  "status": false,
  "message": "The to_number is not a valid North American number.",
  "code": "invalid_request",
  "errors": {
    "to_number": ["The to_number is not a valid North American number."]
  }
}
```

| Field | Description |
|---|---|
| `status` | `true` on success, `false` on error. |
| `message` | A human-readable summary. On a validation error it repeats the first field error, so it's safe to show to a user. |
| `code` | A stable, machine-readable error code. **Branch on `code`, never on `message`**: we may reword messages, but codes don't change. See [Error codes](#error-codes). |
| `errors` | Only on validation errors (`422`). Maps each field name to a list of what's wrong with it. |

Unknown fields in a request body are rejected rather than silently ignored. A
typo such as `toNumber` gets a `422` that names the field.

### Request IDs

Every response carries an `X-Request-Id` header. You can send your own
`X-Request-Id` and we'll use it instead. Quote it when you contact support and
we can find the exact request. Recent requests are also listed in the console
under **API keys › Request log**.

---

## Rate limits

Your plan sets how many messages a second you can send. Go over it and you get
`429 rate_limited` with a `Retry-After` header: the number of seconds to wait.
A rate-limited request isn't charged. Wait at least that long, then retry.

---

## Pagination

The message lists return one page at a time, newest first. Every page says
where it sits in the list:

| Field | Description |
|---|---|
| `limit` | The page size used: what you asked for, or the endpoint's default. |
| `count` | How many items are on this page. Never more than `limit`, and fewer only on the last page. |
| `has_more` | Whether there's another page. |
| `next_cursor` | Pass it as `cursor` to get the next page. `""` on the last page. |

All four are always present. When `has_more` is `true` the next page is never
empty, so keep requesting until it's `false`.

A message list covers the last 30 days unless you set `since` and `until`
(RFC 3339 times).

---

## Media (MMS)

Add `media` to a [send](#send-a-message-sms-or-mms) and the message becomes an
MMS. `message` is then optional.

- A message carries **one** attachment.
- It must be an image or audio file: JPEG, PNG, GIF, WebP, BMP, TIFF,
  HEIC/HEIF, AVIF, MP3, M4A, AAC, WAV, Ogg, Opus, AMR, FLAC, AIFF or MIDI. SVG
  isn't accepted.
- An MMS costs a flat price per message, however long its text. See
  [Pricing and segments](#pricing-and-segments).
- In the response, `sms_type` is `"mms"` and `media_url` is the attachment's
  URL.

### Attach a file by URL

Send a public `http` or `https` URL that the carrier can fetch. A URL ending in
one of the file extensions above, such as `.png` or `.mp3`, is accepted as is.
Any other URL is fetched briefly when you send, and must turn out to be an
image or audio file. URLs on private or local network addresses are refused.

```bash
curl -X POST https://rebeltext.docoitest.com/v1/messages \
  -H "Authorization: Bearer $REBELTEXT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to_number": "5125550199",
    "message": "Here is your ticket.",
    "media": "https://cdn.example.com/tickets/4471.png"
  }'
```

### Upload a file

Send the request as `multipart/form-data`, with the file in a part named
`media`, up to **5 MiB**. We work out the file type from its contents, not its
name, and host the file for the carrier to fetch.

```bash
curl -X POST https://rebeltext.docoitest.com/v1/messages \
  -H "Authorization: Bearer $REBELTEXT_API_KEY" \
  -F to_number=5125550199 \
  -F message="Here is your ticket." \
  -F media=@ticket.png
```

### When media is refused

A `media` value that isn't a supported file, can't be fetched, is over 5 MiB,
or is sent as both a URL and a file gets `422 invalid_request`, with a `media`
entry in `errors`.

---

## Phone numbers

- A number without a leading `+` gets `country_code` added (default `+1`), so
  `5125550199` and `15125550199` are the same US recipient.
- A number starting with `+` is used as is, and `country_code` is ignored.
- US and Canadian (`+1`) numbers must be valid 10-digit North American numbers.

---

## Pricing and segments

Your account holds a balance in US dollars. You add to it by topping up, and
your plan may add to it each billing period. Every message is paid for from
that balance:

| Message | How it's priced |
|---|---|
| SMS | Your account's price **per segment**. RebelText sets the platform price (for example `$0.0095` a segment), and your account may have a negotiated price of its own. |
| MMS | A flat price **per message**, however long its text. |
| Received SMS | May be charged per segment. Free by default, in which case `cost` on an `sms.inbound` event is `"0.00"`. |

`cost` in a response is what that message costs, and `currency` is always
`"usd"`. A 200-character GSM message, for example, is 2 segments: at `$0.0095`
a segment it has `"segments": 2` and `"cost": "0.019"`, and your `balance` goes
down by that much.

### Amounts are strings

`cost`, `balance` and every other amount are **decimal strings** of US dollars,
with up to 4 decimal places and never fewer than 2: `"0.01"`, `"0.019"`,
`"0.0095"`, `"48.21"`. They're strings so they never pass through floating
point. If you do arithmetic on them, parse them with a decimal type, not a
float.

### How many segments a message takes

| Characters used | One segment holds | Each segment of a longer message holds |
|---|---|---|
| Only standard GSM characters | 160 characters | 153 characters |
| Any other character, including most emoji and accented letters outside the GSM set | 70 characters | 67 characters |

`{ } [ ] ~ ^ \ |` and `€` each count as two GSM characters.

---

## Safe retries

A network error can leave you unsure whether a send went through, and simply
retrying can send the message twice. Send an `Idempotency-Key` header to make
retries safe.

If you retry with the same key, from the same API key, within 24 hours:

- no second message is sent, and you're not charged again
- the response is `202` with the **original** `reference_id` and
  `"duplicate": true`
- `created_at` and `updated_at` are `null`; [read the message](#check-message-status)
  for its current state

```json
{
  "status": true,
  "message": "Message accepted",
  "data": {
    "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "status": "accepted",
    "created_at": null,
    "updated_at": null,
    "balance": "48.21",
    "duplicate": true
  }
}
```

(Shortened: the full message object is returned.)

Pick a key that identifies the send itself, such as your order ID plus the
event (`order-4471-shipped`), not a random value generated for each attempt.

---

## How webhooks work

A webhook sends an HTTP `POST` to your server when something happens to your
messages, so you don't have to poll.

1. [Register an endpoint](#register-an-endpoint) with your server's URL, in the
   console under **Webhooks** or with the API, and save the `secret` it returns.
2. [Receive events](#receiving-events) on that URL and answer each with a `2xx`.
3. [Verify each event's signature](#verifying-signatures) before you trust it.

There are two event types. An endpoint subscribes to one or both, and to
`sms.status` alone if you don't say.

| Event | Sent when |
|---|---|
| `sms.status` | A message you sent changes status: `scheduled`, `sending`, `sent`, `delivered`, `undelivered` or `failed`. |
| `sms.inbound` | Someone sends an SMS to one of your numbers. |

### Receiving events

Each event arrives as a `POST` with a JSON body and these headers:

| Header | Description |
|---|---|
| `X-RebelText-Event` | The event type: `sms.status` or `sms.inbound`. |
| `X-RebelText-Delivery-Id` | A unique ID for this event (`whd_...`). A retried event keeps its ID, so **use it to ignore duplicates**. |
| `X-RebelText-Attempt` | Which attempt this is, starting at `1`. |
| `X-RebelText-Timestamp` | When it was sent, in Unix seconds. |
| `X-RebelText-Signature` | Proves the event came from us. See [Verifying signatures](#verifying-signatures). |

The body is a [message object](#the-message-object), the same one
[`GET /v1/messages/{reference_id}`](#check-message-status) returns, so one
parser handles both.

#### How to respond

- Return any **`2xx`** as soon as the event arrives. Anything else, or no
  answer within **10 seconds**, counts as a failure and is
  [retried](#retries-and-pausing).
- Redirects aren't followed, so a `3xx` is a failure too. Register the final
  URL.
- Return **`410 Gone`** to refuse an event for good. We stop retrying it
  straight away.
- Do slow work, such as database updates or sending emails, **after** you
  respond, for example in a background job.
- **Events can arrive more than once and out of order.** Skip any
  `X-RebelText-Delivery-Id` you've already processed, and order a message's
  events by `updated_at`, not by when they arrived.

#### `sms.status` body

```json
{
  "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
  "sender": "+15125550100",
  "receiver": "+15125550199",
  "sms_type": "sms",
  "message": "Your order #4471 has shipped.",
  "media_url": null,
  "status": "delivered",
  "remarks": "The message was delivered to the recipient.",
  "error_code": null,
  "carrier_error_code": null,
  "carrier_error_message": null,
  "segments": 1,
  "cost": "0.0095",
  "currency": "usd",
  "attempts": 1,
  "metadata": { "order_id": "4471" },
  "api_key_id": "key_01HQ2Z8Y7X6W5V4T3S2R1Q0P9N",
  "scheduled_at": null,
  "sent_at": "2026-09-17T10:04:11.881000Z",
  "received_at": null,
  "delivered_at": "2026-09-17T10:04:14.019000Z",
  "created_at": "2026-09-17T10:04:11.204000Z",
  "updated_at": "2026-09-17T10:04:14.019000Z"
}
```

#### `sms.inbound` body

For a received message, `sender` is the person who texted you, `receiver` is
your number, and `reference_id` starts with `inb_`.

```json
{
  "reference_id": "inb_01HQ5B7C8D9E0F1G2H3J4K5M6N",
  "sender": "+15125550199",
  "receiver": "+15125550100",
  "sms_type": "sms",
  "message": "Thanks! When will it arrive?",
  "media_url": null,
  "status": "received",
  "remarks": "The message was received from the sender.",
  "error_code": null,
  "carrier_error_code": null,
  "carrier_error_message": null,
  "segments": 1,
  "cost": "0.00",
  "currency": "usd",
  "attempts": 0,
  "metadata": {},
  "api_key_id": null,
  "scheduled_at": null,
  "sent_at": null,
  "received_at": "2026-09-17T10:06:40.512000Z",
  "delivered_at": null,
  "created_at": "2026-09-17T10:06:40.730000Z",
  "updated_at": "2026-09-17T10:06:40.730000Z"
}
```

### Verifying signatures

Always check that an event came from RebelText before you act on it.
`X-RebelText-Signature` is built like this:

```
base64( hex( HMAC-SHA256(raw_request_body, webhook_secret) ) )
```

To check it:

1. Compute the HMAC-SHA256 of the **raw request body**, using your endpoint's
   full `secret` (including `whsec_`) as the key.
2. Write the result as a **lowercase hex** string.
3. Base64-encode that hex string.
4. Compare the result with `X-RebelText-Signature` using a **constant-time**
   comparison.
5. Reject the event if `X-RebelText-Timestamp` is more than 5 minutes from your
   current time, in either direction.

> Use the raw body exactly as it arrived. If you parse the JSON and encode it
> again, the bytes change and the signature won't match.

#### Node.js (Express)

```js
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.REBELTEXT_WEBHOOK_SECRET;

app.post('/hooks/rebeltext', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-RebelText-Signature') || '';
  const timestamp = Number(req.get('X-RebelText-Timestamp'));

  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return res.sendStatus(400);
  }

  const hex = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const expected = Buffer.from(Buffer.from(hex).toString('base64'));
  const received = Buffer.from(signature);

  if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
    return res.sendStatus(403);
  }

  const event = JSON.parse(req.body);
  res.sendStatus(200);

  // Process `event` after responding.
});
```

#### PHP

```php
<?php
$rawBody   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_REBELTEXT_SIGNATURE'] ?? '';
$timestamp = (int) ($_SERVER['HTTP_X_REBELTEXT_TIMESTAMP'] ?? 0);
$secret    = getenv('REBELTEXT_WEBHOOK_SECRET');

if (abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$expected = base64_encode(hash_hmac('sha256', $rawBody, $secret));

if (!hash_equals($expected, $signature)) {
    http_response_code(403);
    exit;
}

$event = json_decode($rawBody, true);
http_response_code(200);
```

#### Python

```python
import base64, hashlib, hmac, time

def verify(secret: str, signature: str, timestamp: str, body: bytes) -> bool:
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except (TypeError, ValueError):
        return False

    digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    expected = base64.b64encode(digest.encode()).decode()
    return hmac.compare_digest(expected, signature)
```

### Retries and pausing

**Retries.** When a delivery fails, we try again on this schedule. Each delay
varies by up to ±20%.

| Retry | Delay after the previous attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |

If every retry fails, the delivery is marked `dead`. Once your server is
working again you can [replay it](#replay-a-delivery).

**Pausing.** After 10 failed deliveries in a row, we pause sending to that
endpoint. The first pause lasts 30 seconds; if failures continue, later pauses
last 1, 2, 5 and then 15 minutes. When a pause ends we send one delivery first,
and the rest once it succeeds. While paused, `disabled_until` in
[List endpoints](#list-endpoints) shows when sending resumes. A successful
delivery resets both the failure count and the pause length.

**Pacing.** We send at most 8 deliveries to one endpoint at a time, so a backlog
arrives as a steady stream rather than all at once.

---

## Use with AI agents (MCP)

RebelText speaks the [Model Context Protocol](https://modelcontextprotocol.io),
so an AI agent such as Claude, ChatGPT, Claude Code or Cursor can send
messages and look up their status for you. It runs on an ordinary API key and
behaves exactly like the REST API: the same validation, the same subscription
requirement, the same charges, and every call appears in your API log.

Endpoint: `https://rebeltext.docoitest.com/mcp` (Streamable HTTP).

### Sign in from the app

Most AI apps, such as Claude and ChatGPT, need only the endpoint URL. Add it as
a connector or custom MCP server and the app sends you to the RebelText console
to sign in and approve it. The page names the app and the address it will
return you to. Only allow it if that address belongs to the app you are
connecting.

Approving issues a live API key named after the app, such as
"Claude (connected app)". It counts toward your plan's keys and appears on the
API keys page with the others. To disconnect the app, revoke that key.
Connecting needs a plan and a free key in your allowance.

### Use an API key

Apps that take a header instead can use a key you issue yourself. Claude Code:

```bash
claude mcp add --transport http rebeltext https://rebeltext.docoitest.com/mcp \
  --header "Authorization: Bearer $REBELTEXT_API_KEY"
```

Other clients take the same URL and `Authorization` header in their MCP
server settings.

### Tools

| Tool | Does | REST equivalent |
|---|---|---|
| `send_message` | Sends one SMS or MMS. **Charges your balance.** | `POST /v1/messages` |
| `get_message` | One sent message and its status | `GET /v1/messages/{id}` |
| `list_messages` | Sent messages, with filters | `GET /v1/messages` |
| `get_inbound_message` | One received message | `GET /v1/inbound-messages/{id}` |
| `list_inbound_messages` | Received messages | `GET /v1/inbound-messages` |
| `get_conversation` | Texts to and from one number, oldest first, each marked outbound or inbound | `GET /v1/messages` and `GET /v1/inbound-messages` |
| `get_account` | Account, plan and subscription | `GET /v1/account` |
| `get_usage` | Balance and queue depth | `GET /v1/account/usage` |
| `list_numbers` | Numbers you can send from | `GET /v1/numbers` |
| `list_suppressions` | Numbers you won't message | `GET /v1/suppressions` |
| `add_suppressions` | Stop messaging numbers | `POST /v1/suppressions` |
| `remove_suppression` | Allow a number again | `DELETE /v1/suppressions/{number}` |

`send_message` is marked as destructive, so well-behaved clients ask you before
each send. An agent with your key can still spend your balance, so give it a key
you can revoke, and pass `idempotency_key` so a retried send never goes out
twice ([Safe retries](#safe-retries)).

---

## API reference

Every endpoint, grouped by what it acts on. Each one lists its parameters, with
a request and a response example beside it. Paths are relative to the base URL,
`https://rebeltext.docoitest.com`, and every request needs your
[API key](#authentication).

---

## Messages

Send messages and find out what happened to them. Both endpoints return the
[message object](#the-message-object).

### Send a message (SMS or MMS)

```
POST /v1/messages
```

Queues one SMS for delivery, or one MMS when it carries [media](#media-mms).

A successful call returns **`202 Accepted`**: the message is queued, not yet
delivered. To find out what happened next, [check its status](#check-message-status)
or let a [webhook](#how-webhooks-work) tell you.

#### Headers

| Header | Required | Description |
|---|---|---|
| `Authorization` | yes | `Bearer <your API key>` |
| `Content-Type` | yes | `application/json`, or `multipart/form-data` to [upload a media file](#media-mms). Both take the same field names. |
| `Idempotency-Key` | no | A unique string for this send, such as `order-4471-shipped`. Retry with the same key within 24 hours and no second message is sent. See [Safe retries](#safe-retries). |

#### Body

| Field | Type | Required | Description |
|---|---|---|---|
| `to_number` | string | yes | Who to send to. Any common format works: `5125550199`, `(512) 555-0199`, `+15125550199`. See [Phone numbers](#phone-numbers). |
| `message` | string | yes, unless `media` is sent | The text. Up to 1600 characters and 10 segments. |
| `media` | string or file | no | One image or audio attachment, as a public URL or an uploaded file. Makes the message an **MMS**. See [Media](#media-mms). |
| `from_number` | string | no | One of your numbers to send from. **Leave it out and we pick** the best available number on your account. |
| `country_code` | string | no | Country code for `to_number`, such as `+1`. Defaults to `+1`. Ignored when `to_number` starts with `+`. |
| `metadata` | object | no | Your own string key/value pairs, such as `{"order_id": "4471"}`. Returned when you read the message and in its webhooks. In a `multipart/form-data` request, send it as a JSON-encoded string. |
| `scheduled_at` | string | no | Send later instead of now: an RFC 3339 time such as `2026-09-20T15:00:00Z`, up to 90 days ahead and not in the past. |

#### Example request

```bash
curl -X POST https://rebeltext.docoitest.com/v1/messages \
  -H "Authorization: Bearer $REBELTEXT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4471-shipped" \
  -d '{
    "to_number": "5125550199",
    "from_number": "5125550100",
    "message": "Your order #4471 has shipped.",
    "metadata": { "order_id": "4471" }
  }'
```

#### Example response — `202 Accepted`

```json
{
  "status": true,
  "message": "Message accepted",
  "data": {
    "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "sender": "+15125550100",
    "receiver": "+15125550199",
    "sms_type": "sms",
    "message": "Your order #4471 has shipped.",
    "media_url": null,
    "status": "accepted",
    "remarks": "The message was accepted and is waiting to be sent.",
    "error_code": null,
    "carrier_error_code": null,
    "carrier_error_message": null,
    "segments": 1,
    "cost": "0.0095",
    "currency": "usd",
    "attempts": 0,
    "metadata": { "order_id": "4471" },
    "api_key_id": "key_01HQ2Z8Y7X6W5V4T3S2R1Q0P9N",
    "scheduled_at": null,
    "sent_at": null,
    "received_at": null,
    "delivered_at": null,
    "created_at": "2026-09-17T10:04:11.204000Z",
    "updated_at": "2026-09-17T10:04:11.204000Z",
    "balance": "48.21"
  }
}
```

#### What you get back

`data` is a [message object](#the-message-object) plus `balance`: what your
account has left, in US dollars, after paying for this message.

- **Save `reference_id`.** It's the ID you use to check the message's status
  and to match its webhook events.
- `status` is `accepted`, or `scheduled` if you set a future `scheduled_at`.
- `receiver` is the recipient in E.164 format, such as `+15125550199`.
- `sender` is `""` if you left out `from_number`. The number we chose shows up
  when you [read the message](#check-message-status) after it's sent.

#### Errors

| Problem | Status | `code` |
|---|---|---|
| Invalid phone number or `country_code`; `message` missing or too long; `media` unsupported, unreachable or too big; invalid `scheduled_at` | `422` | `invalid_request` |
| Body isn't valid JSON, or has an unknown field | `400` / `422` | `invalid_request` |
| Recipient has opted out of messages from you | `422` | `recipient_suppressed` |
| Balance too low for this message | `402` | `insufficient_balance` |
| No active subscription | `402` | `subscription_required` |
| Too many requests a second ([Rate limits](#rate-limits)) | `429` | `rate_limited` |
| Request body too large | `413` | `payload_too_large` |

### Check message status

```
GET /v1/messages/{reference_id}
```

Returns the current state of a message you sent.

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/messages/msg_01HQ3F5X8K2M9N4P6R7S8T9V0W \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "sender": "+15125550100",
    "receiver": "+15125550199",
    "sms_type": "sms",
    "message": "Your order #4471 has shipped.",
    "media_url": null,
    "status": "delivered",
    "remarks": "The message was delivered to the recipient.",
    "error_code": null,
    "carrier_error_code": null,
    "carrier_error_message": null,
    "segments": 1,
    "cost": "0.0095",
    "currency": "usd",
    "attempts": 1,
    "metadata": { "order_id": "4471" },
    "api_key_id": "key_01HQ2Z8Y7X6W5V4T3S2R1Q0P9N",
    "scheduled_at": null,
    "sent_at": "2026-09-17T10:04:11.881000Z",
    "received_at": null,
    "delivered_at": "2026-09-17T10:04:14.019000Z",
    "created_at": "2026-09-17T10:04:11.204000Z",
    "updated_at": "2026-09-17T10:04:14.019000Z"
  }
}
```

#### Reading the result

`data` is a [message object](#the-message-object). Its `status` says where the
message is; [Message statuses](#message-statuses) lists every value.

When a message fails, `status` is `failed` or `undelivered`, `error_code` is
set and `remarks` gives the reason. If the carrier supplied its own code and
text, you get them word for word in `carrier_error_code` and
`carrier_error_message`:

```json
{
  "status": "undelivered",
  "remarks": "The carrier accepted the message but could not deliver it. Reason: Blocked by carrier spam filter.",
  "error_code": "40002",
  "carrier_error_code": "40002",
  "carrier_error_message": "Message blocked by carrier spam filter"
}
```

> **Tip:** Don't poll this endpoint in a tight loop. [Webhooks](#how-webhooks-work) tell
> you the moment a status changes.

#### Errors

| Problem | Status | `code` |
|---|---|---|
| No message with this ID on your account | `404` | `not_found` |

### List messages

```
GET /v1/messages
```

Returns the messages you've sent, newest first, a page at a time. See
[Pagination](#pagination).

| Query parameter | Default | Description |
|---|---|---|
| `limit` | `50` | How many messages to return, up to `200`. |
| `cursor` | — | `next_cursor` from the previous page. |
| `since` | 30 days ago | Start of the time range, as an RFC 3339 time. |
| `until` | now | End of the time range, as an RFC 3339 time. |
| `status` | — | Only messages with this [status](#message-statuses). |
| `from` | — | Only messages sent from this number. Any common format works. |
| `to` | — | Only messages sent to this number. Any common format works. |
| `api_key_id` | — | Only messages sent with this API key (`key_...`). |

A `from` or `to` that isn't a phone number gets `422 invalid_request` rather
than an empty list. A message still waiting for us to choose its sender has no
`from` yet, so it only appears in lists that don't filter on `from`.

#### Example request

```bash
curl "https://rebeltext.docoitest.com/v1/messages?to=5125550199&limit=20" \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "messages": [
      {
        "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
        "receiver": "+15125550199",
        "status": "delivered",
        "created_at": "2026-09-17T10:04:11.204000Z"
      }
    ],
    "limit": 20,
    "count": 1,
    "has_more": false,
    "next_cursor": ""
  }
}
```

Each entry is a full [message object](#the-message-object); it's shortened here.

### Send a batch

```
POST /v1/messages/batch
```

Sends a message to up to 1000 recipients in one request. Settings at the top
apply to every message, and each recipient can override `body`, `media` and
`metadata`. A recipient's `metadata` is merged into the batch's, not swapped
for it.

> The field names differ from [Send a message](#send-a-message-sms-or-mms):
> `from`, `body` and `to` here, and `media` is a list.

| Field | Type | Required | Description |
|---|---|---|---|
| `messages` | object[] | yes | 1 to 1000 recipients. Each has `to` (required, any common phone format) and, optionally, its own `body`, `media` and `metadata`. |
| `body` | string | yes, unless every recipient has its own `body` or `media` | The text for every message. |
| `from` | string | no | One of your numbers to send from. Leave it out and we pick. |
| `media` | string[] | no | Attachment URLs for every message, which makes them MMS. The response's `media_url` reports the first. |
| `metadata` | object | no | Your own string key/value pairs, added to every message. |
| `scheduled_at` | string | no | Send later instead of now: an RFC 3339 time. |

Every recipient is accepted or rejected on its own, so one bad number doesn't
hold up the rest. The response is `202` whenever at least one recipient was
accepted, and `422` only when none were.

A recipient that didn't become a message has `reference_id` `null`, a `cost`
of `"0.00"`, and `status` either `rejected` (the reason follows `Reason: ` in
`remarks`, and `error_code` names it) or `error` (a problem on our side;
`error_code` is `internal_error`).

**Batches don't take an `Idempotency-Key`**, so retrying a batch after a
network error can send it twice. Before you retry, [list messages](#list-messages)
to see what went out.

#### Example request

```bash
curl -X POST https://rebeltext.docoitest.com/v1/messages/batch \
  -H "Authorization: Bearer $REBELTEXT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Doors open at 7.",
    "metadata": { "campaign": "launch" },
    "messages": [
      { "to": "+15125550199" },
      { "to": "+15125550142", "body": "Doors open at 7. See you there, Sam." },
      { "to": "not-a-number" }
    ]
  }'
```

#### Example response — `202 Accepted`

```json
{
  "status": true,
  "message": "Batch processed",
  "data": {
    "batch_id": "batch_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "total": 3,
    "accepted": 2,
    "rejected": 1,
    "cost": "0.019",
    "balance": "48.191",
    "currency": "usd",
    "messages": [
      { "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W", "receiver": "+15125550199", "status": "accepted", "error_code": null },
      { "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0X", "receiver": "+15125550142", "status": "accepted", "error_code": null },
      { "reference_id": null, "receiver": "not-a-number", "status": "rejected", "error_code": "invalid_request" }
    ]
  }
}
```

`messages` lists every recipient in request order, each a full
[message object](#the-message-object) (shortened here). `cost` is what the
accepted recipients cost together, and `balance` is what your account has left
after them.

### Get a batch

```
GET /v1/batches/{id}
```

Returns a batch's summary: how many recipients were accepted and rejected, and
what they cost. Each recipient's outcome is in the response to
[Send a batch](#send-a-batch); to see them later, [list messages](#list-messages).

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/batches/batch_01HQ3F5X8K2M9N4P6R7S8T9V0W \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "batch_id": "batch_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "total": 1000,
    "accepted": 994,
    "rejected": 6,
    "cost": "9.443",
    "currency": "usd",
    "created_at": "2026-09-17T10:04:11Z"
  }
}
```

#### Errors

| Problem | Status | `code` |
|---|---|---|
| No batch with this ID on your account | `404` | `not_found` |

---

## Received messages

Messages people send to your numbers, the console's **Received messages**. Each one is stored, charged and sent to any endpoint
subscribed to `sms.inbound` (see [Receiving events](#receiving-events)). These
endpoints read them back.

- **Whose reply it is.** A reply to one of your own numbers is yours. A reply
  to a shared-pool number goes to the account that most recently messaged that
  person from it in the last 30 days.
- **What it costs.** A received SMS is charged per segment at the price for
  received messages, which is free unless your account says otherwise; a received MMS is charged per
  message. The charge is taken when the message arrives, even if it takes your
  balance below zero.
- **Attachments.** A received MMS has its first attachment in `media_url` and
  all of them in `media_urls`. We copy them to our own storage within seconds,
  because the carrier's copies expire after 30 days.
- **Opt-outs.** A reply that's just STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or
  QUIT (in any case) adds the sender to your [suppressions](#suppressions), and
  START removes them again. The reply itself is still stored and delivered.
  HELP isn't answered for you.

### List received messages

```
GET /v1/inbound-messages
```

Returns replies to your numbers, newest first, a page at a time. See
[Pagination](#pagination).

| Query parameter | Default | Description |
|---|---|---|
| `limit` | `50` | How many messages to return, up to `200`. |
| `cursor` | — | `next_cursor` from the previous page. |
| `since` | 30 days ago | Start of the time range, as an RFC 3339 time. |
| `until` | now | End of the time range, as an RFC 3339 time. |
| `from` | — | Only messages from this number. Any common format works. |
| `to` | — | Only messages to this one of your numbers. |

#### Example request

```bash
curl "https://rebeltext.docoitest.com/v1/inbound-messages?limit=50" \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "messages": [
      {
        "reference_id": "inb_01HQ5B7C8D9E0F1G2H3J4K5M6N",
        "sender": "+15125550199",
        "receiver": "+15125550100",
        "message": "Thanks! When will it arrive?",
        "status": "received",
        "received_at": "2026-09-17T10:06:40.512000Z"
      }
    ],
    "limit": 50,
    "count": 1,
    "has_more": false,
    "next_cursor": ""
  }
}
```

Each entry is a full [message object](#the-message-object) (shortened here):
`sender` is the person who texted you and `receiver` is your number.

### Get a received message

```
GET /v1/inbound-messages/{id}
```

Returns one reply, exactly as the `sms.inbound` webhook delivered it.

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/inbound-messages/inb_01HQ5B7C8D9E0F1G2H3J4K5M6N \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "reference_id": "inb_01HQ5B7C8D9E0F1G2H3J4K5M6N",
    "sender": "+15125550199",
    "receiver": "+15125550100",
    "sms_type": "sms",
    "message": "Thanks! When will it arrive?",
    "media_url": null,
    "status": "received",
    "remarks": "The message was received from the sender.",
    "error_code": null,
    "carrier_error_code": null,
    "carrier_error_message": null,
    "segments": 1,
    "cost": "0.00",
    "currency": "usd",
    "attempts": 0,
    "metadata": {},
    "api_key_id": null,
    "scheduled_at": null,
    "sent_at": null,
    "received_at": "2026-09-17T10:06:40.512000Z",
    "delivered_at": null,
    "created_at": "2026-09-17T10:06:40.730000Z",
    "updated_at": "2026-09-17T10:06:40.730000Z"
  }
}
```

#### Errors

| Problem | Status | `code` |
|---|---|---|
| No received message with this ID on your account | `404` | `not_found` |

---

## Suppressions

Numbers you won't message. A send to a suppressed number is refused with
`422 recipient_suppressed` and isn't charged. A recipient who replies STOP is
added automatically (see [Received messages](#received-messages)), and you can
add your own, such as a do-not-call list.

### List suppressions

```
GET /v1/suppressions
```

Returns your suppressed numbers, each with why and when it was added.

| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | How many to return, up to `500`. |

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/suppressions \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "suppressions": [
      { "number": "+15125550199", "reason": "stop_keyword", "created_at": "2026-08-01T09:14:00Z" }
    ]
  }
}
```

### Add suppressions

```
POST /v1/suppressions
```

Stops messages to one number or thousands, straight away.

| Field | Type | Required | Description |
|---|---|---|---|
| `numbers` | string[] | yes | 1 to 10,000 phone numbers, in any common format. |
| `reason` | string | no | `manual` (the default), `dnc_import`, `carrier_block` or `complaint`. `stop_keyword` is refused: only a STOP reply records it. |
| `note` | string | no | Your own note, applied to every number. |

Numbers that can't be read as phone numbers are skipped and listed in
`invalid`; the rest are still added.

#### Example request

```bash
curl -X POST https://rebeltext.docoitest.com/v1/suppressions \
  -H "Authorization: Bearer $REBELTEXT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "numbers": ["5125550142", "555-CALL-NOW"], "reason": "dnc_import" }'
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Suppressions added",
  "data": { "added": 1, "invalid": ["555-CALL-NOW"] }
}
```

### Remove a suppression

```
DELETE /v1/suppressions/{number}
```

Lets you message a number again. The number can be in any common format.

A number that replied STOP can't be removed this way: only their own START
reply lifts it.

#### Example request

```bash
curl -X DELETE https://rebeltext.docoitest.com/v1/suppressions/+15125550142 \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{ "status": true, "message": "Suppression removed" }
```

#### Errors

| Problem | Status | `code` |
|---|---|---|
| The number isn't suppressed | `404` | `not_found` |
| The recipient replied STOP | `409` | `recipient_opted_out` |

---

## Numbers

### List your numbers

```
GET /v1/numbers
```

Returns the numbers you can send from: your own, plus any shared numbers
available to your account.

| Field | Description |
|---|---|
| `number` | The number, in E.164 format. |
| `dedicated` | `true` for a number that's yours alone, `false` for a shared one. |
| `sms`, `mms` | Whether it can send SMS and MMS. |
| `toll_free` | Whether it's a toll-free number. |
| `tps_limit` | The most messages a second it may send. Can be below 1 (some carriers allow only a few a minute). Left out when there's no limit. |
| `daily_limit` | The most messages a day it may send. Left out when there's no limit. |

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/numbers \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "numbers": [
      { "number": "+15125550100", "dedicated": true, "sms": true, "mms": true, "toll_free": false, "tps_limit": 1, "daily_limit": 3000 }
    ]
  }
}
```

---

## Account

### Get your account

```
GET /v1/account
```

Returns your account, its plan, balance and subscription.

| Field | Description |
|---|---|
| `id`, `name`, `status` | Your account. |
| `plan_id` | Your plan. Left out when you're on no plan. |
| `balance`, `currency` | What you have left, as a [decimal string](#amounts-are-strings) of US dollars. |
| `rate_per_second` | How many messages a second you can send. See [Rate limits](#rate-limits). |
| `subscription` | Your active subscription: its plan, status and current billing period, and whether it cancels at the end of it. Left out when there isn't one. |

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/account \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "id": "acct_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "name": "Acme Co",
    "status": "active",
    "plan_id": "growth",
    "balance": "48.21",
    "currency": "usd",
    "rate_per_second": 10,
    "subscription": {
      "id": "sub_01HQ3F5X8K2M9N4P6R7S8T9V0W",
      "plan_id": "growth",
      "status": "active",
      "current_period_start": "2026-09-01T00:00:00Z",
      "current_period_end": "2026-10-01T00:00:00Z",
      "cancel_at_period_end": false,
      "created_at": "2026-05-14T10:04:11Z"
    }
  }
}
```

### Get usage

```
GET /v1/account/usage
```

Your balance, plan, send rate and how busy delivery is, in one call, so a bulk
sender can pace itself.

| Field | Description |
|---|---|
| `balance`, `currency` | What you have left, as a [decimal string](#amounts-are-strings) of US dollars. |
| `plan` | Your plan. Left out when you're on no plan. |
| `rate_per_second` | How many messages a second you can send. |
| `queue_depth` | Messages waiting to be sent across the whole platform, not just yours: a sign of how backed up delivery is. |

There's no API for adding money: top up from the console (**Billing**), in whole dollars.

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/account/usage \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "account_id": "acct_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "balance": "48.21",
    "currency": "usd",
    "plan": "growth",
    "rate_per_second": 10,
    "queue_depth": 1204
  }
}
```

---

## Webhooks

Manage your endpoints: the URLs we send events to. You can do the same in the
console under **Webhooks**. How events are delivered, verified and retried is
covered in [How webhooks work](#how-webhooks-work).

### Register an endpoint

```
POST /v1/webhooks
```

Registers a URL to receive events.

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes | Where we send events. `http` or `https`; use `https` for anything real. |
| `event_types` | string[] | no | Which events to receive: `sms.status`, `sms.inbound`, or both. Defaults to `["sms.status"]`. |
| `description` | string | no | A note for yourself, up to 500 characters. |

> ⚠️ **Save `secret` from the response now: it is shown only once.** You need
> it to [verify signatures](#verifying-signatures). If you lose it, delete the
> endpoint and register a new one.

#### Example request

```bash
curl -X POST https://rebeltext.docoitest.com/v1/webhooks \
  -H "Authorization: Bearer $REBELTEXT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/rebeltext",
    "event_types": ["sms.status", "sms.inbound"],
    "description": "Production order notifications"
  }'
```

#### Example response — `201 Created`

```json
{
  "status": true,
  "message": "Webhook endpoint created",
  "data": {
    "id": "wh_01HQ3F5X8K2M9N4P6R7S8T9V0W",
    "url": "https://example.com/hooks/rebeltext",
    "event_types": ["sms.status", "sms.inbound"],
    "status": "active",
    "description": "Production order notifications",
    "secret": "whsec_3vK9dQ2mR7pL5xW8nT4bY6cF1hJ0sA...",
    "consecutive_failures": 0,
    "created_at": "2026-09-17T10:04:11Z"
  }
}
```

#### Errors

| Problem | Status | `code` |
|---|---|---|
| Missing or invalid `url`, unknown event type, `description` too long | `422` | `invalid_request` |

### List endpoints

```
GET /v1/webhooks
```

Returns all your endpoints and how healthy each one is. `secret` is never
included.

Each endpoint has these health fields as well as the ones it was created with:

| Field | Description |
|---|---|
| `consecutive_failures` | Failed deliveries in a row. Back to `0` after a successful delivery. |
| `disabled_until` | Set while we've paused sending to this endpoint after too many failures. See [Retries and pausing](#retries-and-pausing). Left out when not paused. |
| `last_success_at` | When a delivery last succeeded. Left out if none has. |
| `last_failure_at` | When a delivery last failed. Left out if none has. |

#### Example request

```bash
curl https://rebeltext.docoitest.com/v1/webhooks \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "webhooks": [
      {
        "id": "wh_01HQ3F5X8K2M9N4P6R7S8T9V0W",
        "url": "https://example.com/hooks/rebeltext",
        "event_types": ["sms.status", "sms.inbound"],
        "status": "active",
        "description": "Production order notifications",
        "consecutive_failures": 0,
        "last_success_at": "2026-09-17T11:20:03Z",
        "created_at": "2026-09-17T10:04:11Z"
      }
    ]
  }
}
```

### Delete an endpoint

```
DELETE /v1/webhooks/{id}
```

Stops all events to this endpoint. Returns `404 not_found` if there's no endpoint
with this ID on your account.

#### Example request

```bash
curl -X DELETE https://rebeltext.docoitest.com/v1/webhooks/wh_01HQ3F5X8K2M9N4P6R7S8T9V0W \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{ "status": true, "message": "Webhook endpoint deleted" }
```

### Send a test event

```
POST /v1/webhooks/{id}/test
```

Sends a sample `sms.status` event, with status `delivered`, to this endpoint (the console's **Send test**).
Use it to check that your server receives events and verifies signatures,
without sending a real message.

The test event has `reference_id` `msg_test_00000000000000000000000000` and
`metadata` `{"test": "true"}`, so you can recognise and ignore it.

#### Example request

```bash
curl -X POST https://rebeltext.docoitest.com/v1/webhooks/wh_01HQ3F5X8K2M9N4P6R7S8T9V0W/test \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `202 Accepted`

```json
{
  "status": true,
  "message": "Test webhook queued",
  "data": { "delivery_id": "whd_01HQ4A2B3C4D5E6F7G8H9J0K1M" }
}
```

---

## Delivery log

Every event we send to an endpoint is a delivery. The console shows them under
**Webhooks › Delivery log**. Use these requests to see what
was sent, what your server answered, and to send a delivery again.

### List deliveries

```
GET /v1/webhook-deliveries
```

Shows recent webhook deliveries, newest first, with the payload we sent and the
response your server gave. Use it to debug an endpoint that isn't working.

| Query parameter | Default | Description |
|---|---|---|
| `message_id` | — | Only deliveries for this message (`msg_...`). |
| `limit` | `50` | How many deliveries to return, up to `200`. |

Each delivery has a `status`:

| Delivery `status` | Meaning |
|---|---|
| `pending` | Waiting to be sent. After a failed attempt, `last_error` and `response_code` explain the failure and `next_retry_at` says when it will be retried. |
| `sending` | Being sent now. |
| `delivered` | Your server returned a `2xx` (`delivered_at`). |
| `dead` | All retries failed, or your server returned `410`. Nothing more happens unless you replay it. |

#### Example request

```bash
curl "https://rebeltext.docoitest.com/v1/webhook-deliveries?message_id=msg_01HQ3F5X8K2M9N4P6R7S8T9V0W" \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `200 OK`

```json
{
  "status": true,
  "message": "Success",
  "data": {
    "deliveries": [
      {
        "id": "whd_01HQ4A2B3C4D5E6F7G8H9J0K1M",
        "endpoint_id": "wh_01HQ3F5X8K2M9N4P6R7S8T9V0W",
        "url": "https://example.com/hooks/rebeltext",
        "message_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
        "event_type": "sms.status",
        "status": "dead",
        "attempt": 6,
        "response_code": 502,
        "last_error": "unexpected status 502",
        "payload": { "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W", "status": "delivered" },
        "created_at": "2026-09-17T10:04:14Z"
      }
    ]
  }
}
```

`payload` is shortened here; the full message object is returned.

### Replay a delivery

```
POST /v1/webhook-deliveries/{id}/replay
```

Sends a `dead` or `delivered` delivery again, with a fresh set of retries. It
keeps the same payload and delivery ID, so your duplicate check still works.

Returns `404 not_found` if the delivery doesn't exist, is still `pending` or
`sending`, or belongs to an endpoint that has been deleted.

#### Example request

```bash
curl -X POST https://rebeltext.docoitest.com/v1/webhook-deliveries/whd_01HQ4A2B3C4D5E6F7G8H9J0K1M/replay \
  -H "Authorization: Bearer $REBELTEXT_API_KEY"
```

#### Example response — `202 Accepted`

```json
{ "status": true, "message": "Delivery queued for replay" }
```

---

## Objects and errors

### The message object

Sending a message, reading one, and every webhook event all return this object.
Every field is always present, except `media_urls`: one that doesn't apply is
`null`, or `{}` for `metadata`. All timestamps are UTC, such as `2026-09-17T10:04:14.019000Z`.

| Field | Type | Description |
|---|---|---|
| `reference_id` | string | The message ID: `msg_...` for messages you send, `inb_...` for messages you receive. |
| `sender` | string | The sending number, in E.164 format. `""` until a number is chosen, if you didn't set `from_number`. |
| `receiver` | string | The receiving number, in E.164 format. |
| `sms_type` | string | `sms`, or `mms` for a message with media. |
| `message` | string | The message text. |
| `media_url` | string \| null | The attachment's URL for an MMS; `null` for SMS. |
| `media_urls` | string[] | Only on a received MMS: every attachment's URL. Left out otherwise. |
| `status` | string | Where the message is. See [Message statuses](#message-statuses). |
| `remarks` | string | `status` in plain English. On failure it ends with `Reason: ` and the reason. |
| `error_code` | string \| null | Set when `status` is `failed`, `undelivered` or `rejected`; otherwise `null`. See [Failure codes](#failure-codes). |
| `carrier_error_code` | string \| null | The carrier's own error code, exactly as it sent it, when a failure came from the carrier. `null` when the carrier gave none, including for failures RebelText sets itself. |
| `carrier_error_message` | string \| null | The carrier's own error text, exactly as it sent it, up to 512 characters. `null` when the carrier gave none. |
| `segments` | integer | How many segments the message was split into. |
| `cost` | string | What the message costs, in US dollars, as a decimal string such as `"0.0095"`. See [Pricing and segments](#pricing-and-segments). |
| `currency` | string | The currency of `cost`. Always `usd`. |
| `attempts` | integer | Send attempts so far. |
| `metadata` | object | The `metadata` you sent, or `{}`. |
| `api_key_id` | string \| null | The API key the message was sent with, such as `key_01HQ...`. `null` for messages you receive. |
| `scheduled_at` | string \| null | When the message is scheduled to send, if it is. |
| `sent_at` | string \| null | When the carrier accepted the message. |
| `received_at` | string \| null | When a received message arrived. `null` for messages you send. |
| `delivered_at` | string \| null | When delivery to the phone was confirmed. |
| `created_at` | string \| null | When the message was created. |
| `updated_at` | string \| null | When the message last changed status. |

### Message statuses

A sent message usually moves through these statuses:

```
accepted → queued → sending → sent → delivered
                                   ↘ undelivered
```

| Status | Meaning | Final? | Charged? |
|---|---|---|---|
| `accepted` | We received it and queued it for sending. | | |
| `scheduled` | Waiting for its `scheduled_at` time. | | |
| `queued` | Waiting to be sent. | | |
| `sending` | Being handed to the carrier. | | |
| `sent` | The carrier accepted it but hasn't confirmed delivery yet. | | Yes |
| `delivered` | The carrier confirmed it reached the phone. | ✅ | Yes |
| `undelivered` | The carrier accepted it but couldn't deliver it. | ✅ | Yes |
| `failed` | It couldn't be sent. | ✅ | Only if the carrier accepted it first |
| `rejected` | Refused before sending. | ✅ | No |
| `cancelled` | Cancelled before it was sent. | ✅ | No |
| `received` | A message someone sent you (`sms.inbound`). | ✅ | Yes |

- A message never moves back to an earlier status.
- `sent` usually becomes `delivered` or `undelivered` within seconds, but some
  carriers never confirm delivery, so `sent` may be the last status you see.
- Webhooks are sent for `scheduled`, `sending`, `sent`, `delivered`,
  `undelivered` and `failed`. Not for `accepted`: you already have that from
  the send response.

#### Failure codes

When `status` is `failed`, `error_code` says why. The codes below come from
RebelText. Any other value comes from the carrier, and then
`carrier_error_code` and `carrier_error_message` carry the carrier's own code
and wording, so you can tell a carrier's spam block from a problem on the
route.

| `error_code` | Meaning | How to fix it |
|---|---|---|
| `no_sender_number` | Your account has no number available to send from. | Add a number to your account. |
| `sender_not_sms_capable` | The sending number can't send SMS. | Send from a different number, or leave out `from_number`. |
| `sender_not_found` | `from_number` isn't one of your numbers. | Use one of your account's numbers, or leave out `from_number`. |

### Error codes

| `code` | Status | Meaning |
|---|---|---|
| `invalid_request` | `400`, `422` | The request is malformed or failed validation. For a `422`, `errors` lists the problems by field. |
| `unauthenticated` | `401` | The API key is missing or invalid. |
| `account_inactive` | `403` | Your account is suspended or closed. |
| `test_key_in_production` | `403` | You used an `rt_test_` key against production. |
| `insufficient_balance` | `402` | Your balance is too low to send this message. Top up, or wait for your plan's next renewal. |
| `subscription_required` | `402` | Your account has no active subscription. Choose a plan in the console; sending works again on your next request. |
| `not_found` | `404` | The resource doesn't exist, or doesn't belong to your account. |
| `payload_too_large` | `413` | The request body is too large. |
| `recipient_suppressed` | `422` | The recipient has opted out of messages from you. |
| `recipient_opted_out` | `409` | You tried to remove a suppression the recipient asked for by replying STOP. Only their START reply lifts it. |
| `rate_limited` | `429` | Too many requests. Wait the number of seconds in `Retry-After`, then retry. |
| `internal_error` | `500`, `503` | Something went wrong on our side. Retry with the same `Idempotency-Key`; if it keeps happening, contact support with the `X-Request-Id`. |
