# SMS

> The message resource — every attribute of the message object, then the three operations on it, send, retrieve, and bulk send.

A **message** is one SMS to one recipient. Sending creates it, the carrier's confirmation
completes it, and its `id` reads it back at any point in between — a bulk send is simply many
messages created in one call, each with its own id and its own delivery signal.

One convention to hold onto throughout: **`POST /api/v1/sms` returns the message object
bare**, while every other operation wraps its payload in the `{ status, message, data }`
envelope — see [two response conventions](/api#two-response-conventions).

## The message object

The same object comes back from a send and from a retrieve; only the envelope around it
differs. Fresh from a send it reads `status: enqueued`, `delivered_at: null`; the carrier's
confirmation later fills `delivered_at`.

### Attributes

- **`id`** (`string (uuid)`)
  Message id. Pass it to [`GET /api/v1/sms/{id}`](#retrieve-an-sms).

- **`to`** (`string`)
  Recipient MSISDN in international format.

- **`message`** (`string`)
  The body as sent.

- **`sender_id`** (`string`)
  Display name of the approved sender the recipient saw. Note the name: the field you *send*
  is `from`; the field that comes *back* is `sender_id`.

- **`operator`** (`string`)
  Carrier derived from the recipient MSISDN: `econet`, `netone`, or `telecel`.

- **`charge`** (`string | integer`)
  Amount billed, in ten-thousandths of `charge_currency` (`10000` == 1.00, so `450` == 0.045).
  **Serialised as a string on the single send and retrieve, as an integer on bulk items** —
  same unit, different JSON type. See [billing and segments](/api#billing-and-segments).

- **`charge_currency`** (`string`)
  Currency of `charge`.

- **`operator_response`** (`string`)
  Raw response from the carrier. Empty until dispatched; `DELIVRD` is a typical confirmed
  value.

- **`received_at`** (`string (date-time)`)
  When Sendai accepted the message.

- **`status`** (`string`)
  The send pipeline: `created` → `enqueued` → `processing` → `success`, or `failed`. `success`
  means the carrier accepted or confirmed it — it is **not** the delivery signal; there is no
  separate delivery-status field.

- **`created_at`** (`string (date-time)`)
  When the message row was created.

- **`delivered_at`** (`string (date-time) | null`)
  `null` until the carrier confirms handset delivery, then an RFC 3339 timestamp. **This is
  the only delivery signal.** A message can sit at `status: success` with `delivered_at: null`
  for a while — normal, not a failure.

```json [Message]
{
  "id": "0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10",
  "to": "263771000000",
  "message": "Your code is 4821",
  "sender_id": "Sendai SMS",
  "operator": "econet",
  "charge": "450",
  "charge_currency": "USD",
  "operator_response": "DELIVRD",
  "received_at": "2026-07-06T09:12:03Z",
  "status": "success",
  "created_at": "2026-07-06T09:12:03Z",
  "delivered_at": "2026-07-06T09:12:44Z"
}
```

## Send an SMS

`POST /api/v1/sms`

Sends one message. Sendai validates the sender ID, prices the message per segment, places a
hold on your prepaid balance, and answers `202 Accepted` — accepted, priced, and queued,
**not delivered**. [Retrieve the message](#retrieve-an-sms) or register a
[webhook](/guides/webhooks) for the delivery signal.

### Request body

- **`to`** (`string`, required)
  Recipient MSISDN in international format, e.g. `263771000000`.

- **`from`** (`string`, required)
  An approved sender ID on your account — list yours with
  [Sender IDs](/api/sender-ids). An unapproved value is rejected with
  `400 unknown or unapproved sender id`.

- **`message`** (`string`, required)
  Message body. Billed per GSM-7/UCS-2 segment — see
  [billing and segments](/api#billing-and-segments).

### Response

`202` returns the [message object](#the-message-object) **bare** — this is the one endpoint
with no `{ status, message, data }` envelope, so there is no `data` to unwrap. Two fields to
note:

- The sender comes back as **`sender_id`**, not `from`.
- **`charge` is a string** here (`"450"` == 0.045 `charge_currency`); the
  [bulk operation](#send-bulk-sms) returns the same value as an integer.

`status: enqueued` and `delivered_at: null` are the normal state of a fresh send — delivery
confirmation arrives asynchronously.

### Errors

| Status | When |
| --- | --- |
| `400` | Malformed JSON, or a `from` that is not an approved sender ID. Body carries `error.type`. |
| `401` | Missing, malformed, or revoked API key. Body carries `error.code`. |

```bash [cURL]
curl -X POST https://api.sendai.co.zw/api/v1/sms \
  -H "Authorization: Bearer $SENDAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "263771000000",
    "from": "Sendai SMS",
    "message": "Your code is 4821"
  }'
```

```js [Node.js]
const res = await fetch('https://api.sendai.co.zw/api/v1/sms', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.SENDAI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: '263771000000',
    from: 'Sendai SMS',
    message: 'Your code is 4821',
  }),
})

const sms = await res.json() // bare message object — no data wrapper
```

```python [Python]
import os
import requests

res = requests.post(
    "https://api.sendai.co.zw/api/v1/sms",
    headers={"Authorization": f"Bearer {os.environ['SENDAI_API_KEY']}"},
    json={
        "to": "263771000000",
        "from": "Sendai SMS",
        "message": "Your code is 4821",
    },
)

sms = res.json()  # bare message object — no data wrapper
```

```json [202 Accepted]
{
  "id": "0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10",
  "to": "263771000000",
  "message": "Your code is 4821",
  "sender_id": "Sendai SMS",
  "operator": "econet",
  "charge": "450",
  "charge_currency": "USD",
  "operator_response": "",
  "received_at": "2026-07-06T09:12:03Z",
  "status": "enqueued",
  "created_at": "2026-07-06T09:12:03Z",
  "delivered_at": null
}
```

## Retrieve an SMS

`GET /api/v1/sms/{id}`

Reads back one message by the `id` from the send response. Unlike the send operation, this
response **is** enveloped — the message sits under `data`.

### Path parameters

- **`id`** (`string (uuid)`, required)
  The message id from the [send](#send-an-sms) or [bulk send](#send-bulk-sms) response.

### Response

Two fields track the journey, and they are two separate facts:

- **`data.status`** — the send pipeline: `created` → `enqueued` → `processing` → `success`,
  or `failed`.
- **`data.delivered_at`** — `null` until the carrier confirms delivery, then a timestamp.
  **This is the only delivery signal.**

A message can sit at `status: success` with `delivered_at: null` for a while — the pipeline
finished but the carrier has not confirmed yet. That is normal, not a failure. Carrier
confirmation takes seconds to hours, so poll on an interval, or let a
[webhook](/guides/webhooks) call you instead.

`operator_response` is the raw carrier response — empty until dispatched.

### Errors

| Status | When |
| --- | --- |
| `400` | `id` is not a valid UUID. Body carries `error.type`. |
| `401` | Missing, malformed, or revoked API key. Body carries `error.code`. |
| `404` | No such message on your account — unknown id, another account's message, or not an SMS. |

```bash [cURL]
curl https://api.sendai.co.zw/api/v1/sms/0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10 \
  -H "Authorization: Bearer $SENDAI_API_KEY"
```

```js [Node.js]
const id = '0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10'

const res = await fetch(`https://api.sendai.co.zw/api/v1/sms/${id}`, {
  headers: { 'Authorization': `Bearer ${process.env.SENDAI_API_KEY}` },
})

const { data: sms } = await res.json() // enveloped — the message is under data
```

```python [Python]
import os
import requests

sms_id = "0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10"

res = requests.get(
    f"https://api.sendai.co.zw/api/v1/sms/{sms_id}",
    headers={"Authorization": f"Bearer {os.environ['SENDAI_API_KEY']}"},
)

sms = res.json()["data"]  # enveloped — the message is under data
```

```json [200 OK]
{
  "status": "success",
  "message": "request successful",
  "data": {
    "id": "0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10",
    "to": "263771000000",
    "message": "Your code is 4821",
    "sender_id": "Sendai SMS",
    "operator": "econet",
    "charge": "450",
    "charge_currency": "USD",
    "operator_response": "DELIVRD",
    "received_at": "2026-07-06T09:12:03Z",
    "status": "success",
    "created_at": "2026-07-06T09:12:03Z",
    "delivered_at": "2026-07-06T09:12:44Z"
  }
}
```

## Send bulk SMS

`POST /api/v1/sms/bulk`

Sends the same body to every recipient in `to`, priced as one send. The whole send is
accepted or rejected together — there is no partial success. Returns one entry per recipient,
**in request order**; each `items[].id` behaves exactly like a single-send id, so you track
each recipient with [Retrieve an SMS](#retrieve-an-sms).

### Request body

- **`to`** (`array of strings`, required)
  Recipient MSISDNs in international format. At least one. A repeated MSISDN is not collapsed —
  the recipient gets two messages and you are billed for both.

- **`from`** (`string`, required)
  An approved sender ID on your account — list yours with [Sender IDs](/api/sender-ids).

- **`message`** (`string`, required)
  Body sent to every recipient. Billed per segment, per recipient.

- **`webhook_url`** (`string (url)`)
  Optional callback applied to every message in the send. Must be a valid URL. See
  [Webhooks](/guides/webhooks) for the account-level alternative.

### Response

This response **is** enveloped. `data.charge` is the total held for the send and
`data.items[].charge` what each recipient cost — operators price independently, so entries
within one send can differ.

**`charge` is an integer here**, unlike the string the
[single send](#send-an-sms) returns. Both are in ten-thousandths of the currency
(`450` == 0.045); only the JSON type differs. Note the total's currency arrives as
`currency`, not `charge_currency`.

### Errors

| Status | When |
| --- | --- |
| `400` | Validation failed — `to` empty, `message` or `from` blank, or `webhook_url` not a URL. Offending fields are listed in `error.validation_errors`. |
| `401` | Missing, malformed, or revoked API key. |
| `422` | `insufficient balance` — your prepaid balance does not cover the send. Nothing is sent. Check [Balances](/api/balances) first. |

```bash [cURL]
curl -X POST https://api.sendai.co.zw/api/v1/sms/bulk \
  -H "Authorization: Bearer $SENDAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["263771000000", "263772000001"],
    "from": "Sendai SMS",
    "message": "Polls close at 18:00. Thanks for taking part!",
    "webhook_url": "https://example.com/webhooks/dlr"
  }'
```

```js [Node.js]
const res = await fetch('https://api.sendai.co.zw/api/v1/sms/bulk', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.SENDAI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: ['263771000000', '263772000001'],
    from: 'Sendai SMS',
    message: 'Polls close at 18:00. Thanks for taking part!',
    webhook_url: 'https://example.com/webhooks/dlr',
  }),
})

const { data } = await res.json() // enveloped — items under data.items
```

```python [Python]
import os
import requests

res = requests.post(
    "https://api.sendai.co.zw/api/v1/sms/bulk",
    headers={"Authorization": f"Bearer {os.environ['SENDAI_API_KEY']}"},
    json={
        "to": ["263771000000", "263772000001"],
        "from": "Sendai SMS",
        "message": "Polls close at 18:00. Thanks for taking part!",
        "webhook_url": "https://example.com/webhooks/dlr",
    },
)

data = res.json()["data"]  # enveloped — items under data["items"]
```

```json [202 Accepted]
{
  "status": "success",
  "message": "bulk sms enqueued",
  "data": {
    "total": 2,
    "charge": 900,
    "currency": "USD",
    "items": [
      {
        "id": "0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10",
        "to": "263771000000",
        "status": "enqueued",
        "operator": "econet",
        "charge": 450,
        "charge_currency": "USD"
      },
      {
        "id": "7e5f2a8b-4c10-4a1b-9c3d-0f9c1b3a6d2e",
        "to": "263772000001",
        "status": "enqueued",
        "operator": "netone",
        "charge": 450,
        "charge_currency": "USD"
      }
    ]
  }
}
```
