# Messages

Source: https://joinsimplesms.com/docs/messages
Index: https://joinsimplesms.com/llms.txt

## Send a message

`POST /v1/messages`

```bash
curl -X POST https://api.joinsimplesms.com/v1/messages \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "from": "+15005550100", "to": "+15005550006", "body": "Hello" }'
```

| Parameter | Type | Notes |
| --- | --- | --- |
| `from` | string | A number you own (E.164), or a sender pool id (`pool_...`): SimpleSMS picks a member, the same one per recipient. See [Numbers](/docs/numbers). |
| `to` | string | Destination number (E.164, US/Canada). |
| `body` | string | Up to 1600 characters. |
| `customer_id` | string | Optional. Attribute the message to one of your [customers](/docs/customers). Defaults to the customer `from` is assigned to. |

Returns `201` with a Message object. `status` starts at `sent` (or `queued`)
and progresses via events.

### Scheduling

Add `scheduled_at` (a future ISO timestamp or epoch ms, up to 30 days out) to
queue the send. The response is a `scheduled_message` object; see
[Scheduled](/docs/scheduled).

### Automatic retries

If the carrier has a temporary problem (it is overloaded, throttling, or
unreachable), we do not fail your send. The response is **`202`** with the
message `queued`, and we retry it for you after 30 seconds, 2 minutes and 10
minutes. `attempts` counts the tries so far and `next_attempt_at` says when
the next one runs, and the message's `timeline` records every attempt
(`sent_to_carrier` and `retry_scheduled` with an `attempt` number).
You get `message.sent` when the carrier accepts it, or
`message.failed` if every attempt fails. You pay only for messages the
carrier accepts.

A message is never sent twice. We only retry when we know the carrier did not
take the message. If the carrier stops answering after we handed it the
message, we cannot know, so the send fails with `failure_reason:
"carrier_timeout"` and you decide whether to send again. A permanent rejection
(a bad number, say) fails straight away with `502 carrier_error`, as before.

### Idempotency

Pass an `Idempotency-Key` header to make retries safe: the same key + same
payload replays the original response (with an `Idempotent-Replayed: true`
header); the same key with a different payload returns
`409 idempotency_conflict`. Keys last 24 hours. Only successful responses are
kept: if a request fails, the key is released, so retrying with the same key
runs it again. `POST /v1/verify` and `POST /v1/numbers` accept the header
too. With `scheduled_at`, the schedule time is part of the payload, so a
retried scheduling request never queues a second send.

### With the SDKs

Both SDKs add an idempotency key to every send automatically, so their
built-in retries can never double-send.

```js
import { SimpleSMS } from 'joinsimplesms';
const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);

const message = await sms.messages.send({
  from: '+15005550100',
  to: '+15005550006',
  body: 'Hello',
});
```

```python
from joinsimplesms import SimpleSMS

client = SimpleSMS()  # reads SIMPLESMS_API_KEY

message = client.messages.send(to="+15005550006", text="Hello")
# from_ is optional while your account has one number
```

## Retrieve a message

`GET /v1/messages/{id}`; the id is the `msg_...` from the send response.

## List messages

`GET /v1/messages?limit=25&cursor=...&status=failed&created_after=2026-10-01T00:00:00Z`

Newest first. Responses are `{ "data": [...], "has_more": bool, "next_cursor": "..." }`;
pass `next_cursor` back as `cursor` with the same filters for the next page.

| Filter | Notes |
| --- | --- |
| `status` | `queued`, `sent`, `delivered`, `failed`, or `received`. |
| `direction` | `outbound` or `inbound`. |
| `to` / `from` | Exact number (E.164). |
| `number` | Either side: messages to or from this number. |
| `created_after` | Inclusive. ISO timestamp or epoch ms. |
| `created_before` | Exclusive. ISO timestamp or epoch ms. |
| `customer_id` | One [customer](/docs/customers)'s messages. |

Filters combine. Pages come back full: a page shorter than `limit` with
`has_more: false` is the end. A very sparse filter over a long history can
return a short page with `has_more: true` (one request reads at most 2,000
messages); keep following `next_cursor`. Narrow with a date range to make
those requests fast. The SDKs page for you: `sms.messages.listAll()`
(Node, an async iterator) and `client.messages.list_all()` (Python, a
generator).

## The Message object

```json
{
  "id": "msg_a1B2c3D4e5F6g7H8",
  "object": "message",
  "to": "+15005550007",
  "from": "+15005550100",
  "body": "Your order shipped",
  "direction": "outbound",
  "status": "failed",
  "test": true,
  "created_at": "2026-10-01T17:02:11.204Z",
  "timeline": [
    { "status": "accepted", "at": "2026-10-01T17:02:11.204Z" },
    { "status": "validated", "at": "2026-10-01T17:02:11.231Z" },
    { "status": "queued", "at": "2026-10-01T17:02:11.248Z" },
    { "status": "sent_to_carrier", "at": "2026-10-01T17:02:11.249Z" },
    { "status": "carrier_accepted", "at": "2026-10-01T17:02:11.412Z" },
    { "status": "failed", "at": "2026-10-01T17:02:13.020Z" }
  ],
  "segments": 1,
  "encoding": "gsm7",
  "price": {
    "total": 0,
    "currency": "usd",
    "breakdown": [{ "label": "Sandbox message (test mode is never billed)", "amount": 0 }]
  },
  "destination_carrier": "Sandbox Wireless",
  "failure": {
    "code": "carrier_filtered",
    "title": "Filtered as spam",
    "explanation": "The recipient's mobile carrier filtered this message as spam or unwanted traffic, so it never reached the phone.",
    "action": "Recommended: identify your business in the first words, ...",
    "carrier_code": null
  },
  "failure_reason": "stat:UNDELIV err:000 (sandbox) message filtered as spam by the destination carrier"
}
```

| Field | Notes |
| --- | --- |
| `timeline` | Every step, oldest first: `accepted` (the API got the request; for a scheduled send, when you scheduled it), `validated` (every check passed), `queued`, `sent_to_carrier`, `carrier_accepted`, then `delivered` or `failed` from the delivery receipt. Steps from `sent_to_carrier` on carry the carrier `attempt` they belong to; an attempt that failed temporarily adds `retry_scheduled` and the next attempt follows (see [automatic retries](#automatic-retries)). Inbound messages have one step, `received`. The gaps are real latencies. |
| `segments` | How many parts the body is split into on the handset. |
| `encoding` | `gsm7` (160 characters in one segment, 153 per segment when split) or `ucs2` (70, then 67). One character outside the GSM alphabet (an emoji, a curly quote) switches the whole message to `ucs2`. Some symbols (`€ [ ] { } ~ ^`) count as two characters in `gsm7`. |
| `price` | What this message costs, in USD. Outbound SMS is $0.009 per message whatever its length, with carrier fees included (shown as a 0 line). Messages that cost nothing say why: sandbox, free-tier allowance, failed before the carrier accepted it (including a retried send whose every attempt failed; any spend reserved for it is released), inbound, automatic replies. `null` on messages sent before prices were recorded. |
| `destination_carrier` | The recipient's mobile carrier when a recent [lookup](/docs/lookup) of the number is cached; otherwise `null`. Sending never runs a paid lookup. |
| `failure` | `null` unless `status` is `failed`. A stable `code`, a `title`, a plain `explanation`, a recommended `action`, and the carrier's raw `carrier_code` when it sent one. Codes are listed in [Errors](/docs/errors#delivery-failures). |
| `failure_reason` | The carrier's raw text for a failed send. Kept for compatibility; `failure` is the readable version. |

## Statuses

`queued` → `sent` → `delivered` (or `failed`). Inbound messages have
direction `inbound` and status `received`.

Carriers report delivery for most messages within minutes. Some never report
at all. If a sent message has no delivery report 72 hours after the carrier
accepted it, it gets `receipt_status: "missing"`. Its `status` stays
`sent`: we know the carrier accepted it, and we will not guess whether it
arrived. No event fires. A late report still updates the message and clears
the flag.
