# Messages HTTP API

Source: https://modulify.ai/docs/built-in/messages-http-api

The endpoints your site calls to send a text, read one back, list its history, read its templates and check its texting status, with every error they return.

Your site's texting is a small REST API. Every endpoint is a `POST` with a JSON body and a key header. `/send` sends one text with one of the site's templates, `/text` reads one text back with every delivery update, `/texts` lists the history, `/templates` lists the templates with their fields and review state, `/countries` lists every country Sent texts with its price, and `/status` reports whether texting is on, test mode, the countries and every limit. See [Messages](https://modulify.ai/docs/built-in/messages) for the tab, the templates and the prices.

The helper your site's code imports, `@/lib/modulify/messages`, makes exactly these calls. Keep the key on a server, never in a browser or an app.

## Authentication

Send the key as an `X-Messages-Key` header on every request. The base URL is the value of `MODULIFY_MESSAGES_URL`, such as `https://api.modulify.ai/site-messages/site`, and every path below goes after it.

```bash
curl -X POST "$MODULIFY_MESSAGES_URL/status" \
  -H "X-Messages-Key: $MODULIFY_MESSAGES_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Keys start with `mmk_`. Your site reads its key from `process.env.MODULIFY_MESSAGES_KEY` and the base URL from `process.env.MODULIFY_MESSAGES_URL`, both server side only. They are [managed secrets](https://modulify.ai/docs/data/secrets) that Modulify creates on its own the next time the preview starts or the site is published, or at once when the key is shown or rotated. **API access** on the **Settings** tab of the **Messages** tab shows the two values. A missing, malformed or unknown key returns `401` with "A valid texting key is required!"

A key belongs to exactly one site, and each endpoint only ever sees that site's texts. Rotating the key under **API access** invalidates the old one immediately, and your published site keeps the old key until you publish again. A site that moves to another workspace gets a new key the same way.

## Response envelope

Every response, success or failure, has the same shape:

```json
{
    "success": true,
    "message": "Sent checked this test text. Nothing went out and nothing was charged.",
    "data": { "text": { "id": "66f7c2a14d2b8e0012ab3501", "template": "order_confirmed", "templateLabel": "Order confirmed", "to": "+14155551234", "country": "US", "channel": "sms", "status": "queued", "credits": 0, "segments": 1, "test": true, "error": null, "createdAt": "2026-11-06T14:43:05.163Z" }, "code": null, "duplicate": false },
    "code": 200
}
```

Treat a call as failed unless the HTTP status is ok **and** `success` is `true`. Read the payload from `data`, and when a send is refused, branch on `data.code` rather than on the message.

## Endpoints

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/send` | `{ to, template, variables, channel, idempotencyKey }` | `{ text, code, duplicate }` |
| POST | `/text` | `{ id }` | One text in full |
| POST | `/texts` | `{ limit, cursor, status, phone }` | `{ items, nextCursor }` |
| POST | `/templates` | `{}` | Every template of the site |
| POST | `/countries` | `{ query, selectedOnly }` | `{ countries, selected, max, total }` |
| POST | `/status` | `{}` | `{ enabled, testMode, paused, isPaid, countries, limits, usage }` |

### /send

Sends one text. `to` is one number in international format, such as `+14155551234`. `template` is the key of one of the site's templates, such as `order_confirmed`, and `variables` holds a value for each of its fields, such as `{ "name": "Ana", "order": "#1042", "total": "EUR 48.00" }`. `{{site}}` is filled with the site's name on its own. `channel` is `sms` when left out, `whatsapp` once the site has a WhatsApp account of its own, or `sent` to let Sent choose.

While test mode is on, Sent checks the text but nothing goes out and nothing is charged, and `text.test` is `true`. Real texts need a paid plan and test mode turned off in the **Messages** tab. `text.status` is `queued` once Sent takes the text, and delivery arrives later: read it with `/text`.

Pass an `idempotencyKey` on every send that could be retried, in the body or as an `Idempotency-Key` header. Use letters, digits, dashes and underscores, up to 200 characters: other characters are dropped. Sending again with the same key never sends a second text. It answers the first text with `duplicate: true`.

A refused text answers with `success: false`, the reason as `message` and its code in `data.code`:

| Status | Code | Why |
|---|---|---|
| `400` | `template-unknown` | No template on the site has that key |
| `400` | `variables-invalid` | A field is missing, unknown, longer than 120 characters, not an https link on this site for a `link` field, or not 4 to 10 letters or digits for a `code` field |
| `400` | `phone-invalid` | The number is not in international format |
| `400` | `channel-invalid` | The channel is not one Sent sends on |
| `400` | `country-not-priced` | There is no price for texts to that country yet |
| `402` | `no-credits` | The workspace does not have the credits the text costs |
| `402` | `cap-reached` | The text would pass the workspace [spending cap](https://modulify.ai/docs/plans/spending) |
| `403` | `country-not-allowed` | The site may not text that country. Pick countries on the **Settings** tab |
| `403` | `plan-required` | Real texts need a paid plan |
| `409` | `turned-off` | Texting is turned off for the site |
| `409` | `paused` | Modulify paused texting on the site |
| `409` | `template-not-ready` | The template is not on Sent yet, or Sent has not approved it, which a real text needs |
| `409` | `opted-out` | The number replied STOP or was blocked |
| `409` | `no-credentials` | Texting was never turned on, so the site has no account on Sent yet |
| `429` | `rate-limited` | The site sent 30 texts in the last minute |
| `429` | `number-limit` | That number already got 5 texts from the site today |
| `429` | `daily-limit` | The text would take today's spend past 20 credits |
| `502` | `provider` | Sent refused or failed the text. No credits were taken |
| `503` | `charge-failed` | The credits could not be reserved, so nothing was sent |

A text that fails, is filtered, is blocked or is cancelled gets its credits back.

### /text

Reads one text the site sent, by the `id` `/send` or `/texts` returned. It carries everything `/send` returns, plus `variables`, `body` (the text that went out, with a verification code masked), `messageId`, `templateName`, `reasonCode`, `refunded`, `cancellable`, `unicode`, `contactFee` and `events`, every delivery update as `{ event, status, reason, at }`. An id the site never sent answers `404` with "That text could not be found!"

A text is `pending`, `queued`, `routed`, `scheduled`, `sent`, `delivered`, `read`, `cancelled`, `filtered`, `blocked` or `failed`.

### /texts

Lists the site's texts, newest first, 25 by default. `limit` asks for up to 100. Pass `nextCursor` back as `cursor` until it is `null`. `status` is `all`, `in-progress`, `delivered`, `failed` or `cancelled`, and `phone` keeps the texts to one number.

### /templates

Lists every template of the site, the built-in ones and the site's own, as `{ key, label, text, state, approved, custom, fields }`. `state` is `initial`, `pending`, `approved`, `rejected`, `paused` or `disabled`, and `approved` is `true` when the template can send real texts. Each field is `{ name, kind, example, auto }`: `kind` is `text`, `link` or `code`, and `auto` is `true` for `{{site}}`, which Modulify fills in.

### /countries

Lists every country Sent texts as `{ code, name, credits, selected }`: the ISO code, the English name, the credits one SMS part to it costs and whether the site may text it. The countries the site may text come first, then the rest by name. `query` keeps the countries whose name or code holds every word of it, ignoring case and accents, and `selectedOnly: true` keeps only the ones the site may text, which suits a country picker on a phone field. `selected` is the codes the site may text, `max` how many its plan lets it pick and `total` how many Sent texts.

### /status

Reports `enabled`, `testMode`, `paused` and `isPaid`, the `countries` the site may text, the `limits` (`perMinute`, `perNumberPerDay`, `creditsPerDay`, `countriesMax`, `variableMaxLength`) and the `usage` for `today` and this `month`, each with `texts`, `tests`, `delivered`, `failed` and `credits`, plus `blockedNumbers`.

## Limits

| Limit | Value |
|---|---|
| Texts a minute, per site | 30 on a paid plan |
| Texts to one number a day | 5 on a paid plan |
| Credits of texts a day, per site | 20 on a paid plan |
| Value of one field | 120 characters |
| Texts on one `/texts` page | 100 |
| Idempotency key | 200 characters |
| How long texts stay in the history | 90 days |

## Next

- [Messages](https://modulify.ai/docs/built-in/messages) covers the tab, the templates, test mode and the prices.
- [Secrets](https://modulify.ai/docs/data/secrets) explains the managed `MODULIFY_MESSAGES_URL` and `MODULIFY_MESSAGES_KEY` values.