# Messages

> Every field of POST /v1/messages, GET /v1/messages, GET /v1/messages/{id}, POST /v1/media and GET /v1/media/{id}.

<!-- Generated by apps/docs/scripts/gen-reference.mjs from apps/docs/public/openapi/v1.json. Do not edit: change the API's schemas (then `pnpm --filter @seasalt/api openapi:emit`) or the notes in apps/docs/src/reference-notes/, then run `pnpm --filter @seasalt/docs gen:reference`. -->

Sending one SMS, MMS or WhatsApp template, uploading its picture, reading it back, and pictures people text you. How-to: [Send a text](/platform-api/sms/), [Send a WhatsApp template](/platform-api/whatsapp/).

Generated from [`/openapi/v1.json`](/openapi/v1.json); interactive version: [API reference](/openapi/v1/).

## Endpoints

| method | path | what it does | success |
|---|---|---|---|
| [`GET`](#get-v1messages) | `/v1/messages` | List messages you sent, newest first | `200` |
| [`POST`](#post-v1messages) | `/v1/messages` | Send one SMS or WhatsApp message | `202` |
| [`GET`](#get-v1messagesid) | `/v1/messages/{id}` | Read a message's status | `200` |
| [`POST`](#post-v1media) | `/v1/media` | Upload a picture or video to send as MMS | `201` |
| [`GET`](#get-v1mediaid) | `/v1/media/{id}` | Get a download URL for a file someone sent in an MMS | `200` |

## `GET /v1/messages`

List messages you sent, newest first.

The SMS and WhatsApp template sends of the workspace (this API's, the dashboard's and campaigns'), newest first by `created_at` to the microsecond, ties by `id`. Not the person's messages and not inbox replies: those are in `GET /v1/conversations/{id}/messages`. Without `channel`, both channels in one list, each item with its `channel`; with it, that channel only. `to`, `from`, `campaign`, `status` and the `created_*` times narrow it. Each item is the object `GET /v1/messages/{id}` answers. A cursor belongs to the list that answered it: one from `channel=sms` is refused without `channel`, and the reverse (`invalid_cursor`, with a hint). A cursor from the SMS list before 2026-10-16 (when it was ordered by hand-over time) is refused once with `invalid_cursor`: start again without `after`.

**Parameters**

<div class="ref-table">

| name | in | type | description |
|---|---|---|---|
| `limit` | query | integer<br>1–200 | Page size, 1 to 200 (default 50). |
| `after` | query | string | The previous page's `next_cursor`. |
| `channel` | query | string<br>`sms`, `whatsapp` | Only this channel's sends. Leave it out to list both channels together. |
| `to` | query | string | Only messages to this recipient, E.164. |
| `campaign` | query | string | Only this campaign's messages: its `ca-…` id or UUID. On WhatsApp, the template sends the campaign made (also listed per person by `GET /v1/campaigns/{id}/recipients`). |
| `from` | query | string | Only messages sent from this number: its E.164, `pn-…` id or the channel's `ch-…` id, as `GET /v1/senders` lists them (on WhatsApp, the WhatsApp channel of that number). Without `channel`, a number that is only an SMS sender or only a WhatsApp number lists that channel's sends. |
| `status` | query | string<br>`queued`, `sending`, `sent`, `delivered`, `read`, `undelivered`, `failed`, `skipped`, `unconfirmed` | Only messages with this `status` (SMS has no `read`; WhatsApp has no `undelivered`). Without `channel`, `read` lists WhatsApp only and `undelivered` SMS only. |
| `created_after` | query | string | Only messages created at or after this, exact to the microsecond, as an ISO 8601 time (`2026-10-16T09:00:00Z`). Not a sync feed: `created_at` is stamped as a message is accepted, before it is committed, so a later page can show an older `created_at`; overlap your window or use the webhooks. |
| `created_before` | query | string | Only messages created before this, as an ISO 8601 time (`2026-10-16T09:00:00Z`). |

</div>

**Answers** `200` with [MessageList](#messagelist). Errors use the [error envelope](/platform-api/reference/#v1error); common codes: [SMS](/platform-api/errors/#sms-messages), [uploads](/platform-api/errors/#uploads), [WhatsApp](/platform-api/errors/#whatsapp-messages), [lists](/platform-api/errors/#lists), [every request](/platform-api/errors/#every-request).

## `POST /v1/messages`

Send one SMS or WhatsApp message.

Sends one text (`channel: sms`) or one approved WhatsApp template (`channel: whatsapp`). Answers `202` with the message: it is on its way, not yet delivered. Follow it with `GET /v1/messages/{id}` or the `message.status_updated` webhook. Opt-outs, sender readiness, credits and sending limits are all checked before anything is sent.

**Parameters**

<div class="ref-table">

| name | in | type | description |
|---|---|---|---|
| `idempotency-key` | header | string<br>≤ 255 chars | Send one with every message. A retry with the same key never sends a second message: it answers the first one as it is now. Rules: [Idempotency](/platform-api/retries/#idempotency). |

</div>

**Request body:** [MessageCreate](#messagecreate).

**Answers** `202` with [Message](#message). Errors use the [error envelope](/platform-api/reference/#v1error); common codes: [SMS](/platform-api/errors/#sms-messages), [uploads](/platform-api/errors/#uploads), [WhatsApp](/platform-api/errors/#whatsapp-messages), [lists](/platform-api/errors/#lists), [every request](/platform-api/errors/#every-request).

## `GET /v1/messages/{id}`

Read a message's status.

**Parameters**

<div class="ref-table">

| name | in | type | description |
|---|---|---|---|
| `id` | path | string, required | The message `id` from the send. |

</div>

**Answers** `200` with [Message](#message). Errors use the [error envelope](/platform-api/reference/#v1error); common codes: [SMS](/platform-api/errors/#sms-messages), [uploads](/platform-api/errors/#uploads), [WhatsApp](/platform-api/errors/#whatsapp-messages), [lists](/platform-api/errors/#lists), [every request](/platform-api/errors/#every-request).

## `POST /v1/media`

Upload a picture or video to send as MMS.

Upload one file as a `multipart/form-data` part named `file`, then pass the answer's `media` as `media` on `POST /v1/messages`, as `content.media` on `POST /v1/campaigns` or `PATCH /v1/campaigns/{id}`, or as `media` on `POST /v1/conversations/{id}/messages`. Accepted: a JPEG, PNG or GIF image up to 5 MB (sent without its metadata, such as GPS), or an MP4, MOV or WebM video up to 50 MB, which is sent as an MP4 of at most 500 KB (`transcoded: true`). The bytes decide the type; the declared content type and file name extension are ignored. An upload that no message, campaign or template uses is deleted after 7 days (`expires_at`). There is no `Idempotency-Key`: a retried upload stores a second copy, which the same rule deletes if it goes unused.

**Request body** (`multipart/form-data`)

<div class="ref-table">

| field | type | description |
|---|---|---|
| `file` | string (binary), required | The picture or video. Any other form field is `400 unknown_field`. |

</div>

Example: see [Send a text](/platform-api/sms/).

**Answers** `201` with [UploadedMedia](#uploadedmedia). Errors use the [error envelope](/platform-api/reference/#v1error); common codes: [SMS](/platform-api/errors/#sms-messages), [uploads](/platform-api/errors/#uploads), [WhatsApp](/platform-api/errors/#whatsapp-messages), [lists](/platform-api/errors/#lists), [every request](/platform-api/errors/#every-request).

## `GET /v1/media/{id}`

Get a download URL for a file someone sent in an MMS.

Each `message.received` event lists the photos and files the person sent in `media[]`, and each item's `url` is this endpoint. We fetch every file from the carrier when the message arrives and keep our own copy, so the link keeps working after the carrier deletes theirs. The answer carries a short-lived download URL (see `expires_in`): fetch it when you need the bytes, rather than storing it. Scoped to the workspace that owns the API key; a file in another workspace returns 404, not 403. A file we could not fetch from the carrier is not listed in the event and has no media id.

**Parameters**

<div class="ref-table">

| name | in | type | description |
|---|---|---|---|
| `id` | path | string, required | The `att-…` media id from `message.received.media[].url`. |

</div>

**Answers** `200` with the object below. Errors use the [error envelope](/platform-api/reference/#v1error); common codes: [SMS](/platform-api/errors/#sms-messages), [uploads](/platform-api/errors/#uploads), [WhatsApp](/platform-api/errors/#whatsapp-messages), [lists](/platform-api/errors/#lists), [every request](/platform-api/errors/#every-request).

<div class="ref-table">

| field | type | description |
|---|---|---|
| `object` | string, required<br>always `media` |   |
| `id` | string, required |   |
| `message_id` | string, required |   |
| `conversation_id` | string, required |   |
| `content_type` | string, required |   |
| `size` | integer, required | Bytes. |
| `url` | string, required | Presigned HTTPS URL. Treat as a secret — it grants read access until it expires. |
| `expires_in` | integer, required | Seconds until `url` stops working. |

</div>

Example: see [Inbound pictures](/platform-api/reference/events/#inbound-pictures-mms).

## Objects

### Message

<div class="ref-table">

| field | type | description |
|---|---|---|
| `id` | string, required |   |
| `object` | string, required<br>always `message` |   |
| `channel` | string, required<br>`sms`, `whatsapp` |   |
| `from` | string or null, required | The sending number in E.164. `null` when it could not be read: a WhatsApp number whose channel has no display number yet, or a sender id that no longer resolves. |
| `to` | string, required |   |
| `status` | string, required<br>`queued`, `sending`, `sent`, `delivered`, `read`, `undelivered`, `failed`, `skipped`, `unconfirmed` | `queued`, `sending`, `sent` (the carrier or Meta has it), `delivered`, `read` (WhatsApp), `undelivered`, `failed`, `skipped` (opted out just before sending) or `unconfirmed` (we could not confirm it went; do not resend). |
| `conversation_id` | string or null, required |   |
| `campaign_id` | string or null, required |   |
| `segments` | integer or null, required | SMS only. |
| `billed_credits` | number or null, required | Credits this message cost. SMS: `null` until it is billed. WhatsApp: always `null` (not reported per message). |
| `error` | object or null, required |   |
| `error.code` | string or null, required | The carrier's, Meta's or the call's own code, when there is one. |
| `error.category` | string, required<br>`opted_out`, `carrier_filtered`, `rate_limited`, `invalid_number`, `unreachable`, `account`, `unconfirmed`, `blocked_by_route`, `not_sent`, `other` | Closed set: `opted_out`, `carrier_filtered`, `rate_limited`, `invalid_number`, `unreachable`, `account`, `unconfirmed`, `blocked_by_route`, `not_sent`, `other`. |
| `error.message` | string, required |   |
| `error.action` | string or null, required | What to do about it. |
| `template` | object or null, required | WhatsApp only. |
| `template.id` | string, required |   |
| `template.name` | string, required |   |
| `template.language` | string, required |   |
| `text` | string or null, required | What was sent: an SMS's text (for a long text sent in parts, this part's), or a WhatsApp template's text as the person got it. Null when there is none, or once the platform's retention cleared a text's body. |
| `media` | array of objects, required | The files sent with it (an MMS), as a conversation message lists them; `[]` for none. |
| `media[].id` | string or null, required | The file's `att-…` id. |
| `media[].url` | string or null, required | `GET` it with your key for a download link (`/v1/media/{id}`). Null for a file kept only as a link. |
| `media[].content_type` | string or null, required |   |
| `created_at` | string or null, required |   |
| `sent_at` | string or null, required |   |
| `delivered_at` | string or null, required |   |
| `read_at` | string or null, required |   |
| `updated_at` | string or null, required | When the status last changed. SMS only: always `null` on WhatsApp (follow `sent_at`, `delivered_at` and `read_at`). |
| `metadata` | map of string to string or null, required | The `metadata` you sent, as you sent it; `null` when you sent none (or `{}`). |

</div>

Example: see [Send a text](/platform-api/sms/).

### MessageCreate

One message. `channel` decides the rest of the body. More channels may be added later; ignore values you don't know.

Fields for every `channel`:

<div class="ref-table">

| field | type | description |
|---|---|---|
| `from` | string, required<br>1–64 chars | The number to send from, as `GET /v1/senders` lists it: its E.164 (`+16285550100`), its `pn-…` id or UUID, or the `ch-…` id of one of its channels. Every channel takes the same forms. The shared trial number sends no texts or campaigns (`422 trial_sender_not_allowed`). |
| `to` | string, required<br>1–32 chars | The recipient, in E.164: `+`, the country code and the number, no spaces or punctuation. |
| `metadata` | map of string to string<br>≤ 20 keys; keys 1–40 chars; values ≤ 500 chars | Your own key/value pairs, stored and returned as sent. String values only; at most 20 keys, each key 1–40 characters, each value at most 500 characters (`400 invalid_request`, `param` `metadata` or `metadata.<key>`). `{}` is the same as leaving it out. |

</div>

<a id="smsmessagecreate"></a>

**When `channel` is `sms`** (SmsMessageCreate):

<div class="ref-table">

| field | type | description |
|---|---|---|
| `channel` | string, required<br>always `sms` |   |
| `text` | string<br>≤ 1,600 chars | The text, up to 1,600 characters. Optional when `media` is set. |
| `media` | string<br>≤ 512 chars | A picture or video: the `media` that `POST /v1/media` answers. MMS goes to +1 numbers only. |
| `conversation` | string<br>`auto`, `none` | `auto` (default) adds the message to the recipient's conversation, so a reply lands next to it and your AI agent has the context. `none` records only the send. |

</div>

Example (`channel: sms`):

```json
{
  "channel": "sms",
  "from": "+16285550100",
  "to": "+14155550100",
  "text": "Your table is ready",
  "conversation": "auto",
  "metadata": {
    "order_id": "1042",
    "source": "checkout"
  }
}
```

<a id="whatsappmessagecreate"></a>

**When `channel` is `whatsapp`** (WhatsappMessageCreate):

<div class="ref-table">

| field | type | description |
|---|---|---|
| `channel` | string, required<br>always `whatsapp` |   |
| `template` | object, required |   |
| `template.id` | string<br>1–64 chars | The template's `wt-…` id or UUID. Or name it with `name` + `language`. |
| `template.name` | string<br>1–512 chars | The template's name, as approved by Meta. |
| `template.language` | string<br>2–15 chars | With `name`: the language code, e.g. `en_US`. |
| `template.variables` | map of string to string or array of string<br>values 1–1,024 chars; ≤ 30 items; each 1–1,024 chars | The body variables. Named templates: an object (`{ "first_name": "Mei" }`). Positional templates (`{{1}}`): an array in order, or an object keyed `"1"`, `"2"`, …. |
| `template.header` | object |   |
| `template.header.type` | string, required<br>`text`, `image`, `video`, `document` |   |
| `template.header.text` | string<br>1–1,024 chars | `type: text`: the header variable's value. |
| `template.header.url` | string (uri)<br>≤ 2,048 chars | `image` / `video` / `document`: an https URL Meta can fetch. |
| `template.header.filename` | string<br>1–240 chars | `document`: the file name shown to the recipient. |
| `template.buttons` | array of objects<br>≤ 10 items |   |
| `template.buttons[].index` | integer, required<br>0–9 | The button's position in the template, from 0. |
| `template.buttons[].url_suffix` | string<br>1–1,024 chars | Dynamic URL button: the value appended to the URL. |
| `template.buttons[].coupon_code` | string<br>1–15 chars | Copy-code button: the code. |
| `template.buttons[].payload` | string<br>1–256 chars | Quick-reply button: returned to you when the person taps it. |
| `template.components` | array of map of string to any<br>≤ 12 items | Meta's raw `components` array, for shapes `variables`, `header` and `buttons` cannot express. It is still checked against the approved template (`422 template_components_mismatch`). Send this OR `variables`/`header`/`buttons`, not both. |

</div>

Example (`channel: whatsapp`):

```json
{
  "channel": "whatsapp",
  "from": "+16285550100",
  "to": "+14155550100",
  "template": {
    "name": "order_ready",
    "language": "en_US",
    "variables": {
      "first_name": "Mei"
    }
  }
}
```

### MessageList

<div class="ref-table">

| field | type | description |
|---|---|---|
| `object` | string, required<br>always `list` |   |
| `data` | array of [Message](#message), required |   |
| `has_more` | boolean, required |   |
| `next_cursor` | string or null, required | Pass as `after` for the next page; `null` on the last. |

</div>

Example: see [Send a text](/platform-api/sms/).

### UploadedMedia

<div class="ref-table">

| field | type | description |
|---|---|---|
| `object` | string, required<br>always `uploaded_media` |   |
| `media` | string, required | Pass this exact string as `media` on `POST /v1/messages`, `content.media` on campaigns, or `media` on a conversation reply. |
| `content_type` | string, required | The stored file's type: `image/jpeg`, `image/png`, `image/gif` or `video/mp4` (every video is stored as an MP4). |
| `size` | integer, required | Bytes stored (after preparing). |
| `transcoded` | boolean, required | `true` for a video, which is re-encoded as an MP4 of at most 500 KB. |
| `preview_url` | string or null, required | A short-lived link to view the stored file. Treat as a secret. `null` when the link could not be made (the upload still succeeded). |
| `preview_expires_in` | integer or null, required | Seconds until `preview_url` stops working; `null` with a `null` `preview_url`. |
| `expires_at` | string (date-time), required | When the file is deleted if nothing uses it (7 days). A message, campaign or template that uses it keeps it. |

</div>

Example: see [Send a text](/platform-api/sms/).