<!-- https://missblue.dev/docs/messages -->

# Messages

Sending is the whole point. A send is accepted here and goes out from your number shortly after — or, if the number is temporarily unavailable, as soon as it is back. See queued and notice below, and Delivery for how you find out what happened to it.

Base URL: `https://api.missblue.dev`

Every request carries `Authorization: Bearer $MISS_BLUE_KEY`.

### POST /v1/messages

Send an iMessage.

Text, files, or a reply quoting an earlier message. Sending to an existing group is the same call with the group's chat_id as the recipient. A send the number cannot take right now is still accepted, not refused: 201 with status pending, queued: true, and a notice — { "code": "number_unavailable", "message": "This number is temporarily unavailable. Your message is queued and will be sent automatically as soon as it's back." }. It goes out by itself when the number is back, so do not send it again; until then POST /v1/messages/{id}/cancel calls it off. A notice code of send_retrying means the send was turned down for now and will be tried again automatically. A notice code of daily_new_contact_limit means the number has messaged as many new people as it may today: the first message waits and goes out by itself at send_after, as room opens — do not send it again. Only a first message that could not go within five days is refused, with 429 and a Retry-After of when a try would be taken. GET /v1/projects/{id}/sending-stats shows each number's allowance: new_contacts_used, new_contacts_limit, new_contacts_waiting, and next_new_contact_at, when the next new person can be messaged if not now; messages_used and messages_limit, every message the number sent in the last 24 hours against its daily total (150 unless raised); and warmup, its week and limit while it climbs back after sending as texts. A paced send — or an automated one past the daily total — is queued with no notice: the message's send_after says when; one past the daily total that could not go within five days is refused with 429 daily_message_limit and a Retry-After. Pass manual: true for one message a person asked to send now: like a send typed in the inbox, it goes past the daily total and still counts toward it; opt-outs, blocks and the new-people limit still apply. Not for bulk. See /docs/rate-limits. notice.message is written to be shown to your own users as it is; switch on notice.code. link_code and url are the conversation's short link, the same one GET /v1/threads and its webhooks give it; they are absent while a first message to somebody new is still waiting for its number, and message.sent carries them once it has gone.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | Which of your numbers to send from. GET /v1/numbers lists them. |
| `recipient` | `string` | yes | A phone number, an Apple ID email, or a chat_id to send into an existing conversation. |
| `text` | `string` | no | Optional only if you are sending attachments — a message must carry one or the other. |
| `attachment_ids` | `uuid[]` | no | From POST /v1/attachments. This call accepts text or attachments, not both; send a caption separately. |
| `native_voice_note` | `boolean` | no | Audio-only sends default to a native voice note. Set false to send an ordinary file. |
| `reply_to_message_id` | `uuid` | no | Our id for the message being quoted, not Apple's. |
| `status_callback` | `https url` | no | Where to POST this one message's outcome. See Delivery. |
| `allow_duplicate` | `boolean` | no | Send anyway, though the same text went to the same person moments ago. |
| `manual` | `boolean` | no | One message a person asked to send now: not held by the number's daily total, as a send from the inbox is not. It still counts toward it; opt-outs, blocks and the new-people limit still apply. Not for bulk. |

Request body:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "recipient": "+15555550100",
  "text": "Your table is confirmed for 7pm."
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "recipient": "+15555550100",
    "text": "Your table is confirmed for 7pm."
  }'
```

Response:

```json
{
  "id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
  "request_id": "d0cc8f6e-8d57-4bd2-bb27-a3c86ca810e8",
  "agent_id": "3e814ce3-d92b-4d93-9baf-33459eacb264",
  "status": "sent",
  "external_message_id": "p:0/4AEE9B2A-7E3C-4E7C-88EA-5F071974624B",
  "queued": false,
  "chat_id": "iMessage;-;+15555550100",
  "link_code": "k7Qm3xT9pa",
  "url": "https://missblue.dev/c/k7Qm3xT9pa"
}
```

Errors:

- `400` — No text and no attachments, or neither number_id nor agent_id.
- `400` — invalid_recipient: a phone number nobody can have — a +1 number without ten digits, an area code or exchange starting with 0 or 1 or an N11 code, a fictional 555-0100–0199, or not 8 to 15 digits after the +. Nothing is written or sent. error.reason is invalid.
- `422` — recipient_undeliverable: the number cannot get texts — a landline, not in service, or not a valid number, as its carrier, Twilio or the check before a first text on a Twilio line said. error.reason is landline, not_in_service, invalid or other.
- `404` — A number_id your key does not hold. Not 403 — whether it exists is not your business.
- `409` — The same text went to the same person moments ago. Pass allow_duplicate to mean it.
- `409` — A native voice note while the number is temporarily unavailable (agent_offline). Voice notes are sent live and never queued; ordinary sends are queued instead — see queued and notice.

### POST /v1/messages

Send an uploaded audio file as a native voice note.

Upload one audio/* file first. An attachment-only audio send defaults to Apple's native voice-note bubble for every number-scoped customer call. Pass native_voice_note: false only when you deliberately want a normal file. If native delivery is unavailable before sending, Miss Blue automatically sends the original audio as a regular playable attachment. An uncertain delivery is kept pending to avoid duplicate messages.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | The dedicated number sending the voice note. |
| `recipient` | `string` | yes | Phone number, Apple ID email, or existing chat id. |
| `attachment_ids` | `uuid[1]` | yes | Exactly one uploaded audio attachment. |
| `native_voice_note` | `boolean` | no | Defaults to true for this request shape; false sends a generic file. |

Request body:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "recipient": "+15555550100",
  "attachment_ids": [
    "4c9103dd-ae62-4888-9a17-c762efe8ddcb"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "recipient": "+15555550100",
    "attachment_ids": [
      "4c9103dd-ae62-4888-9a17-c762efe8ddcb"
    ]
  }'
```

Response:

```json
{
  "id": "f30fd81d-79a4-4795-9794-b58a49cbb825",
  "request_id": "2ebf14c9-2952-4931-adc1-2e80852ce239",
  "agent_id": "3e814ce3-d92b-4d93-9baf-33459eacb264",
  "status": "sent",
  "external_message_id": "p:0/2A3E52BC-6922-4608-94E9-F120285AE812",
  "chat_id": "iMessage;-;+15555550100",
  "queued": false
}
```

Errors:

- `400` — More than one attachment, a non-audio attachment, text, or a quoted reply.
- `409` — The number is temporarily unavailable (agent_offline). Native voice notes are sent live, not queued for later; retry shortly.

### POST /v1/messages

Send two through twenty images as one native carousel.

Upload every image first and preserve the order in attachment_ids. All files must be images. A native carousel cannot carry a caption or quote another message; send text separately if you need it.

Request body:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "recipient": "+15555550100",
  "attachment_ids": [
    "88de9745-30e0-4b93-bfcb-dbb7f3325dde",
    "45793a35-0a1c-431d-8806-38f71ce658ff",
    "fe74c17c-f3cd-4547-a32b-cf271845d95f"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "recipient": "+15555550100",
    "attachment_ids": [
      "88de9745-30e0-4b93-bfcb-dbb7f3325dde",
      "45793a35-0a1c-431d-8806-38f71ce658ff",
      "fe74c17c-f3cd-4547-a32b-cf271845d95f"
    ]
  }'
```

Response:

```json
{
  "id": "9396bdad-9e14-4caa-8f01-20188aedbb23",
  "request_id": "3cd8cab6-1e17-4169-866d-eac13765ef58",
  "agent_id": "3e814ce3-d92b-4d93-9baf-33459eacb264",
  "status": "sent",
  "external_message_id": "p:0/F4AF7E62-FC00-4805-A9A9-86557DB11137",
  "chat_id": "iMessage;-;+15555550100",
  "queued": false
}
```

Errors:

- `400` — More than twenty files, a non-image file, text, or a quoted reply.

### GET /v1/messages

List messages across your project's numbers, newest first.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/messages \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 1,
  "data": [{
    "id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
    "chat_id": "iMessage;-;+15555550100",
    "direction": "outbound",
    "recipient": "+15555550100",
    "text": "Your table is confirmed for 7pm.",
    "status": "delivered",
    "service": "imessage",
    "message_at": "2026-08-28T10:04:00Z",
    "created_at": "2026-08-28T10:04:00Z",
    "updated_at": "2026-08-28T10:04:02Z",
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d"
  }]
}
```

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

One message, including its current delivery state.

Polling this works, but a webhook or a status_callback tells you sooner and costs you nothing. `agent_version` and `macos_version` say which Mac build handled it, stamped when the message was written rather than looked up now — a Mac that is updated must not rewrite what it was running last week, which is exactly the week you are trying to explain. `read` means the person has seen it: an iMessage read receipt reads the conversation up to that message, so earlier delivered iMessages to the same person turn `read` with it, as they do on the sender's iPhone — Apple itself stamps only some of them. A failed message carries `reason` (landline, not_in_service, unreachable, invalid, opted_out, blocked, spam_filtered or other) and, when a carrier gave one, `error_code` (Twilio's 30006, say), as message.failed webhooks do.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/messages/0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
  "chat_id": "iMessage;-;+15555550100",
  "direction": "outbound",
  "recipient": "+15555550100",
  "text": "Your table is confirmed for 7pm.",
  "status": "delivered",
  "service": "imessage",
  "message_at": "2026-08-28T10:04:00Z",
  "created_at": "2026-08-28T10:04:00Z",
  "updated_at": "2026-08-28T10:04:02Z",
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d"
}
```

Errors:

- `404` — No such message, or one on a number your key does not hold.

### GET /v1/messages/thread/{chat_id}

Every message in one conversation, oldest first.

Ordered by the platform's own sort key, so a message the Mac picked up late still lands where it belongs rather than at the end. message_at is the original Messages.app time; created_at is the later control-plane ingestion time. link_code and url are the conversation's short link, the same one GET /v1/threads gives it, made from the messages you are shown; absent when you are shown none.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/messages/thread/iMessage%3B-%3B%2B15555550100 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 14,
  "link_code": "k7Qm3xT9pa",
  "url": "https://missblue.dev/c/k7Qm3xT9pa",
  "data": [{
    "id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
    "chat_id": "iMessage;-;+15555550100",
    "direction": "inbound",
    "sender_handle": "+15555550100",
    "sender_name": "Ari Chen",
    "text": "Perfect, see you then",
    "status": "received",
    "service": "imessage",
    "message_at": "2026-08-28T10:04:00Z",
    "created_at": "2026-08-28T10:04:00Z",
    "updated_at": "2026-08-28T10:04:00Z"
  }]
}
```

### POST /v1/messages/{id}/react

Add or remove a tapback.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `reaction_key` | `string` | yes | heart, like, dislike, laugh, emphasize, or question. |
| `remove` | `boolean` | no | Take it back rather than add it. |

Request body:

```json
{
  "reaction_key": "heart"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages/0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77/react \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reaction_key": "heart"
  }'
```

Errors:

- `409` — The number is temporarily unavailable (agent_offline). Nothing was changed; retry shortly.

### GET /v1/messages/problems

Messages that failed, or are still queued.

The queue and the failures in one list, because from a caller's side they are the same question: what have I sent that has not arrived. Worth polling on a schedule even if you take webhooks — a webhook that never fired is exactly the case a webhook cannot tell you about.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `only` | `string` | no | queued or failed. Omit for both. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/messages/problems \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 1,
  "data": [{
    "id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
    "status": "failed",
    "recipient": "+15555550100",
    "error": "This message could not be sent. Try again or contact support.",
    "message_at": "2026-08-28T10:04:00Z",
    "created_at": "2026-08-28T10:04:00Z",
    "updated_at": "2026-08-28T10:04:30Z"
  }]
}
```

### POST /v1/messages/{id}/cancel

Cancel a message that is still queued.

For a send that has not gone yet — held while its number is temporarily unavailable, paced, or waiting to be tried again. Only an outbound message that is pending, queued, and has no external_message_id can be cancelled. It ends failed, with the error "Cancelled before it was sent.", and the usual message.failed webhook and status_callback fire: an integration learns this outcome the way it learns every other. Once a message has been handed over for sending it may already be on somebody's phone, so it can no longer be cancelled.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages/0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77/cancel \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
  "chat_id": "iMessage;-;+15555550100",
  "direction": "outbound",
  "recipient": "+15555550100",
  "text": "Your table is confirmed for 7pm.",
  "status": "failed",
  "error": "Cancelled before it was sent.",
  "service": "imessage",
  "message_at": "2026-08-28T10:04:00Z",
  "created_at": "2026-08-28T10:04:00Z",
  "updated_at": "2026-08-28T10:06:12Z",
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d"
}
```

Errors:

- `404` — No such message on a number your key holds.
- `409` — It is already on its way and can't be cancelled — or it was never queued: sent, failed, or inbound.

### POST /v1/messages/{id}/unsend

Unsend a message.

Apple's two-minute window and its own rules apply. We do not re-check them against our clock — the Mac reports the platform's answer, so a refusal here is Apple's refusal, not ours. The recipient is left with "You unsent a message" where the bubble was; unsending is not the same as them never having seen it. Afterwards the message reads is_deleted: true with no text, and the Mac writes a separate action row carrying the notice.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages/0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77/unsend \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Errors:

- `409` — The number is temporarily unavailable (agent_offline). Nothing was changed; retry shortly.

### POST /v1/messages/{id}/edit

Change what a message already sent says.

202, not 204, and the difference matters: Messages.app accepts an edit and reports nothing. The change arrives back through ordinary sync moments later, exactly as one typed on the Mac would, so "accepted" is the honest answer and the transcript is what confirms it. Poll GET /v1/messages/{id} and watch for edited_at — until it is set, the edit has not landed. Apple allows this for roughly fifteen minutes after sending and shows the recipient that the message was edited; there is no silent correction.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `text` | `string` | yes | The replacement. An empty or whitespace-only edit is a 400 rather than an unsend — the two are different enough that guessing which was meant would be the wrong kind of helpful. |

Request body:

```json
{
  "text": "Tuesday at 10, not Thursday."
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/messages/0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77/edit \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Tuesday at 10, not Thursday."
  }'
```

Errors:

- `400` — The text is empty, or the Mac refused the edit — most often the window has closed.
- `404` — No such message on a number your key holds.
- `409` — The number that sent it is temporarily unavailable (agent_offline). Nothing was changed; retry shortly.
