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

# Miss Blue API reference

The complete HTTP API for sending and receiving iMessage from a dedicated business number.

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

Authentication is a bearer token on every request: `Authorization: Bearer $MISS_BLUE_KEY`.
Use `mb_sandbox_` keys for isolated fixtures, simulated message outcomes and signed test webhooks. See /docs/sandbox. Legacy `mb_test_` keys still reach real traffic. Regular phone calling and billing require a signed-in person.

## Contents

- **Sandbox API** — Virtual numbers, simulated outcomes and isolated webhook events.
- **Identity** — What this key is, and which project it holds.
- **Messages** — Send, read, react, unsend, and see what failed.
- **Conversations** — Threads, history sync, read receipts, and typing indicators.
- **Group conversations** — Create groups, pull metadata, and manage names, photos, and people.
- **Location sharing** — Request Find My sharing, pull its state, or watch live fixes.
- **Calls** — FaceTime signaling, shared phone and FaceTime history, recordings.
- **Lookup** — Which services have reached a handle.
- **Contacts** — Names for handles, shared by the project.
- **Attachments** — Upload a file, then send it.
- **Numbers** — Pull lines, label them, forward calls, manage sender identity, and check recipients.
- **Campaigns** — Announcements, scheduled messages, lists, tags, and automations created entirely through the API, including multi-step sequences with waits, reply conditions and messages personalized from each contact's name and custom fields.
- **Delivery** — Webhooks and per-message callbacks.

## Sandbox API

Use an mb_sandbox_ key on the normal API origin. Other supported endpoints operate on isolated fixtures. On POST /v1/messages, X-Miss-Blue-Sandbox-Scenario chooses sent, delivered, read, reply, failed, offline, rate_limited or timeout. See /docs/sandbox for limits and unsupported operations.

### GET /v1/sandbox

Inspect this project's sandbox.

Returns the virtual number, retained fixtures, endpoints without secrets, delivery attempts, expiry and limits. Requires a sandbox key.

Example:

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

Errors:

- `403` — A live or legacy test key was supplied.

### DELETE /v1/sandbox

Reset fixtures and sandbox webhook endpoints.

The virtual number ID and API keys stay valid. Returns the reset sandbox state.

Example:

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

### GET /v1/sandbox/events

List retained simulated events.

Example:

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

Response:

```json
{"data":[],"total":0}
```

### POST /v1/sandbox/events

Emit a documented webhook type with test data.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `type` | `string` | yes | One of the documented webhook event types. |
| `data` | `object` | yes | Fixture payload, sent as given. Use the event's live data shape. It is marked sandbox and sent only to sandbox endpoints. |

Request body:

```json
{
  "type": "call.ended",
  "data": {
    "project_id": "b3c2a1d0-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "number": {
      "id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
      "handle": "+12025550100"
    },
    "resource": {
      "call_id": "fixture-call",
      "direction": "outbound",
      "outcome": "answered",
      "talk_seconds": 42
    }
  }
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/sandbox/events \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "call.ended",
    "data": {
      "project_id": "b3c2a1d0-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "number": {
        "id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
        "handle": "+12025550100"
      },
      "resource": {
        "call_id": "fixture-call",
        "direction": "outbound",
        "outcome": "answered",
        "talk_seconds": 42
      }
    }
  }'
```

### POST /v1/sandbox/messages/{id}/simulate

Advance a simulated outbound message.

Returns the message. Completed receipts cannot move backwards; each reply adds an inbound fixture.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `scenario` | `string` | yes | sent, delivered, read, reply, failed, offline or timeout. |
| `reply_text` | `string` | no | Optional text for the reply scenario, up to 4096 bytes. |

Request body:

```json
{
  "scenario": "reply",
  "reply_text": "Thanks, I received it."
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/sandbox/messages/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/simulate \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "scenario": "reply",
    "reply_text": "Thanks, I received it."
  }'
```

Errors:

- `409` — The message already passed the requested status or the fixture limit was reached.

## Identity

One call, and the first one to make. The webhook routes take a project id in the path, and nothing else returns one — so without this a key could act on its project without being able to name it.

### GET /v1/me

What this credential is.

For a key: its id, its project, and the organization above it. `live` is false for a sandbox or legacy test key. `sandbox: true` identifies an isolated mb_sandbox_ key; legacy mb_test_ keys still access real traffic. There is no person in the answer, because there is no person behind a key.

Example:

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

Response:

```json
{
  "kind": "key",
  "key_id": "2aea3032-1631-419a-ba45-05cb965d157c",
  "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
  "project_name": "Client Support",
  "workspace_id": "3480fdd8-2ef3-4edd-9949-bf78b4cd3e35",
  "workspace_name": "Acme Agency",
  "live": true
}
```

## 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.

### 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.

## Conversations

A conversation is what a person sees: one thread with one handle, or a group. Distinct from the message list, which is a log.

### GET /v1/conversations/sync

Read conversation sync availability and the latest result.

Limited to Macs behind numbers this credential can access. The cooldown is shared per Mac, not per caller. Optional last_started_at, last_completed_at, last_count, and last_error describe the latest observed work.

Example:

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

Response:

```json
{ "limit_per_agent": 100, "cooldown_seconds": 300, "connected_agents": 1, "eligible_agents": 1, "syncing_agents": 0 }
```

### POST /v1/conversations/sync

Queue a fresh conversation history sync from eligible Macs.

Requests up to 100 recent conversations per eligible Mac. A 202 means queued, not complete; poll GET /v1/conversations/sync for progress. Offline Macs, active syncs, and Macs in the five-minute cooldown may be skipped.

Example:

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

Response:

```json
{ "queued_agents": 1, "skipped_agents": 0 }
```

### GET /v1/threads

Every conversation on your project's numbers, most recent first.

contact_is_typing is transient presence reported by the Mac and expires automatically. unread is always 0 for a key. A key is not a person and has no place in a conversation it has read up to, so reporting every inbound message ever would have read as a backlog nobody has. number_id and search are applied in the query, before the page is cut — filtering the returned page client-side would search the newest few hundred conversations and report that as the whole answer. link_code and url are each conversation's short link (url is https://missblue.dev/c/ and the code). It opens only for somebody who may read the conversation, so it is safe to pass on.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `sort` | `string` | no | newest (default), oldest, or unreplied: conversations whose last message came in, newest first, then the rest. A blocked contact's conversation sorts with the rest, since nobody can answer it. |
| `number_id` | `uuid` | no | One of your lines, to see only what arrived on it. Use GET /v1/threads/lines for the ids and what each is carrying. |
| `search` | `string` | no | A handle, part of one, or a saved contact's name. Matches both, so somebody found by name does not have to be found again by number. |
| `unreplied` | `boolean` | no | true: only conversations waiting on a reply (their latest message came in, from somebody not blocked), by latest activity alone. Exactly the ones sort=unreplied puts first. |
| `since` | `string` | no | RFC 3339. Only conversations whose latest activity is at or after it. |
| `before` | `string` | no | With unreplied=true: the previous page's next_before, which is opaque, to read on from right after its last conversation; or an RFC 3339 time. Nothing is missed or repeated, however many conversations share an instant. |
| `include` | `string` | no | pending: each conversation also has pending (what they said since our last message, oldest first, at most 50), pending_truncated, and last_reply (our last message, or null). |

Example:

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

Response:

```json
{
  "total": 2,
  "data": [
    {
      "chat_id": "iMessage;-;+15555550100",
      "handle": "+15555550100",
      "is_group": false,
      "last_text": "Perfect, see you then",
      "last_at": "2026-08-23T09:12:04Z",
      "last_direction": "inbound",
      "message_count": 14,
      "unread": 0,
      "contact_is_typing": true,
      "last_replied_by": "Sam Okafor",
      "link_code": "k7Qm3xT9pa",
      "url": "https://missblue.dev/c/k7Qm3xT9pa"
    }
  ]
}
```

### PUT /v1/threads/{chat_id}/pin

Pin or unpin a conversation for the signed-in member.

Member sessions only. Pins stay ahead of unpinned conversations under every sort and do not affect other members. Current number and organization access is required; a pin never grants access.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `agent_id` | `string` | yes | The agent_id returned by the conversation list. |
| `pinned` | `boolean` | yes | true to pin; false to unpin. |

Request body:

```json
{
  "agent_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e",
  "pinned": true
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/threads/iMessage%3B-%3B%2B15555550100/pin \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e",
    "pinned": true
  }'
```

### GET /v1/threads/lines

Each of your numbers, and what it is carrying right now.

The counts behind a by-number filter, asked as its own question rather than derived from the conversation list. The list is a page: counting what came back would report the lines present in the newest few hundred conversations and call it the total. unread is 0 for a key, for the same reason it is 0 in the conversation list.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `project_id` | `uuid` | no | Narrow to one project. Omit for every project this credential reaches. |

Example:

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

Response:

```json
{
  "total": 2,
  "data": [
    { "number_id": "9c1e4f0a-3d7b-4a21-9f38-7c2d5e6b8a14", "unread": 0, "conversations": 214 },
    { "number_id": "b47d2a90-5e13-4c8f-a6b2-19d3f7c04e85", "unread": 0, "conversations": 37 }
  ]
}
```

### GET /v1/threads/unread

Unread totals across everything this credential can see.

Every visible unread message, not the unread on one page of conversations. notification_unread counts only what a person has asked to be notified about, so a badge and an inbox count can differ honestly. Both are 0 for an API key, which has read nothing and has no place to have read up to.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `project_id` | `uuid` | no | Narrow to one project. Omit for every project this credential reaches. |

Example:

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

Response:

```json
{
  "unread": 0,
  "latest_unread_at": null,
  "notification_unread": 0,
  "latest_notification_at": null
}
```

### POST /v1/read-receipts

Tell the customer their message was seen.

The "Read" that appears under their message on their phone. Distinct from POST /v1/threads/{chat_id}/read, which records where a person has read to so their own unread count is right and which nobody outside this system sees. Nothing sends this automatically: it is a claim that a human looked, and a receipt that fired because a dashboard was open is a lie told at scale.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `chat_id` | `string` | yes | The conversation to acknowledge. |
| `read` | `boolean` | no | Defaults to true. False marks it unread again. |

Request body:

```json
{
  "chat_id": "iMessage;-;+15555550100",
  "read": true
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/read-receipts \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chat_id": "iMessage;-;+15555550100",
    "read": true
  }'
```

Errors:

- `404` — No such conversation on a number your key holds.
- `409` — The number is temporarily unavailable (agent_offline). Nothing was acknowledged; retry shortly.

### POST /v1/typing

Show or hide the typing bubble in a conversation.

Do not block a reply on this. A bubble that arrives after the message it was meant to precede is worse than no bubble — send it, ignore the outcome, and reply. There is no guaranteed stop either: Messages.app clears it on its own after a few seconds of silence, which is the behaviour to rely on.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `chat_id` | `string` | yes | The conversation. Not a bare handle — guessing which of several threads was meant would show a stranger that somebody is typing. |
| `active` | `boolean` | no | true to start, false to stop. |

Request body:

```json
{
  "chat_id": "iMessage;-;+15555550100",
  "active": true
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/typing \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chat_id": "iMessage;-;+15555550100",
    "active": true
  }'
```

Errors:

- `404` — No such conversation on a number your key holds.
- `409` — The number is temporarily unavailable (agent_offline). Retry shortly, or skip it — a late typing bubble is worse than none.

### POST /v1/typing-indicators

Show a bounded typing bubble to a contact.

Use this form when your integration knows the contact rather than the chat_id. The one-to-one iMessage conversation must already exist. Start defaults to 60 seconds and is always cleared by five minutes; another start extends the active timer, while stop is a safe no-op when nothing is active.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number` | `E.164 string` | yes | The contact who sees the bubble. |
| `number_id` | `uuid` | no | Which of your numbers sends it. Preferred over a mutable handle. |
| `from_number` | `E.164 string` | no | Handle alternative to number_id. Omit both only when your key holds one number. |
| `state` | `start | stop` | no | Defaults to start. |
| `max_duration_ms` | `integer` | no | 1–300000. Defaults to 60000. |

Request body:

```json
{
  "number": "+15555550100",
  "from_number": "+16465550142",
  "state": "start",
  "max_duration_ms": 60000
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/typing-indicators \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number": "+15555550100",
    "from_number": "+16465550142",
    "state": "start",
    "max_duration_ms": 60000
  }'
```

Response:

```json
{
  "status": "ACTIVE",
  "number": "+15555550100",
  "from_number": "+16465550142",
  "chat_id": "iMessage;-;+15555550100",
  "max_duration_ms": 60000
}
```

Errors:

- `400` — The number or duration is invalid, the source is ambiguous, or no prior iMessage conversation exists.
- `404` — The source number is not held by this credential.
- `409` — The source number is temporarily unavailable (agent_offline). Retry shortly.

## Group conversations

Native iMessage groups use the same number ownership boundary as one-to-one sends. Every mutation returns a refreshed group, so an integration can store exactly what Messages accepted instead of guessing.

### POST /v1/groups

Create a native iMessage group and send its first message.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | A number held by this project. |
| `participants` | `string[]` | yes | 2–32 different phone numbers or Apple ID emails. |
| `initial_message` | `string` | yes | 1–20,000 characters. Group creation and the first message are one operation. |

Request body:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "participants": [
    "+15555550100",
    "+15555550101"
  ],
  "initial_message": "Welcome to the project group."
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/groups \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "participants": [
      "+15555550100",
      "+15555550101"
    ],
    "initial_message": "Welcome to the project group."
  }'
```

Response:

```json
{
  "chat_id": "iMessage;+;chat123456789",
  "participants": [
    { "id": "p1", "handle": "+15555550100", "name": "Ari" },
    { "id": "p2", "handle": "+15555550101", "name": "Sam" },
    { "id": "self", "handle": "+16465550142", "is_self": true }
  ]
}
```

Errors:

- `400` — Fewer than two different participants, too many participants, or an empty first message.
- `404` — The number is outside this project.

### GET /v1/groups/{chat_id}

Pull the group's current name, photo URL, and participants.

Example:

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

Response:

```json
{
  "chat_id": "iMessage;+;chat123456789",
  "title": "Install team",
  "photo_url": "https://api.missblue.dev/v1/attachments/…/view",
  "participants": [
    { "id": "p1", "handle": "+15555550100", "name": "Ari" },
    { "id": "self", "handle": "+16465550142", "is_self": true }
  ]
}
```

Errors:

- `404` — No such group on a number held by this project.

### PATCH /v1/groups/{chat_id}

Rename a group and/or replace its photo.

Upload the JPEG or PNG first. To remove the current photo, omit photo_attachment_id and send remove_photo: true. The complete refreshed group is returned.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `title` | `string` | no | At most 256 characters. |
| `photo_attachment_id` | `uuid` | no | Uploaded JPEG or PNG. |
| `remove_photo` | `boolean` | no | Cannot be true while replacing the photo. |

Request body:

```json
{
  "title": "Install team",
  "photo_attachment_id": "88de9745-30e0-4b93-bfcb-dbb7f3325dde"
}
```

Example:

```bash
curl -X PATCH https://api.missblue.dev/v1/groups/iMessage%3B%2B%3Bchat123456789 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "chat_id": "iMessage;+;chat123456789",
  "title": "Install team",
  "photo_url": "https://api.missblue.dev/v1/attachments/…/view",
  "participants": [
    { "id": "p1", "handle": "+15555550100", "name": "Ari" },
    { "id": "self", "handle": "+16465550142", "is_self": true }
  ]
}
```

### POST /v1/groups/{chat_id}/participants

Add or remove one group participant.

Set add to false to remove. The response is the complete refreshed group.

Request body:

```json
{
  "participant": "+15555550102",
  "add": true
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/groups/iMessage%3B%2B%3Bchat123456789/participants \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "participant": "+15555550102",
    "add": true
  }'
```

Response:

```json
{
  "chat_id": "iMessage;+;chat123456789",
  "title": "Install team",
  "participants": [
    { "id": "p1", "handle": "+15555550100", "name": "Ari" },
    { "id": "p3", "handle": "+15555550102", "name": "Jo" },
    { "id": "self", "handle": "+16465550142", "is_self": true }
  ]
}
```

## Location sharing

A location request belongs to an existing iMessage conversation. The recipient chooses whether and how long to share; the API reports that lifecycle without turning an unanswered request into a location.

### POST /v1/locations/{chat_id}/{recipient}

Send a native Find My location request.

Example:

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

Response:

```json
{ "requested": true }
```

Errors:

- `404` — The conversation is not on a number held by this project.
- `503` — The number could not send the request right now.

### GET /v1/locations/{chat_id}/{recipient}

Pull the sharing lifecycle and newest location fix.

state is not_shared, request_pending, declined, stopped, expired, shared_no_fix_yet, or shared_with_fix. Coordinates are absent until a fix exists; ended_at is present for an ended lifecycle.

Example:

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

Response:

```json
{
  "handle": "+15555550100",
  "state": "shared_with_fix",
  "latitude": 40.7411,
  "longitude": -73.9897,
  "horizontal_accuracy": 12.4,
  "observed_at": "2026-08-28T10:04:00Z",
  "short_address": "Flatiron District, New York",
  "expires_at": "2026-08-28T11:04:00Z",
  "requested_at": "2026-08-28T10:00:00Z",
  "started_at": "2026-08-28T10:03:12Z"
}
```

### GET /v1/locations/{chat_id}/{recipient}/watch

Watch changing location state as Server-Sent Events.

A location event is sent only when the JSON snapshot changes. The stream sends a keepalive every 15 seconds. An error event reports a temporary read failure without silently ending the stream.

Example:

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

Response:

```json
event: location
data: {"handle":"+15555550100","state":"shared_with_fix","latitude":40.7411,"longitude":-73.9897}
```

## Calls

Read FaceTime Audio and regular phone call history, control FaceTime sessions, and retrieve recordings. Calls are separate from message transcripts. A FaceTime dial request alone does not carry audio: connect a browser or custom WebRTC client too. Regular phone dialing is available to signed-in console users, not API keys; use /docs/calling for the product workflow.

### GET /v1/calls

Every call on the lines you hold, newest first.

Takes an optional project_id to narrow to one project's numbers. Returns your workspace's calls; calls made by another workspace on a shared number remain separate. transport is direct or livekit for FaceTime Audio, telnyx for regular phone calls, or null before the path is known. direction is inbound or outbound. outcome is dialing, answered, no_answer, declined, or failed. declined records an incoming FaceTime ring explicitly declined through the app. For outgoing FaceTime calls, no_answer does not distinguish a decline from a ring-out. History and recording completion are separate.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `project_id` | `uuid` | no | Optionally narrow the history to one project's numbers. |

Example:

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

Response:

```json
{
  "data": [
    {
      "id": "cf33aa62-1c89-40b0-9887-137108be4b48",
      "number_handle": "+19292727048",
      "handle": "+15555550100",
      "started_by": "Ada Lovelace",
      "transport": "direct",
      "direction": "outbound",
      "outcome": "answered",
      "started_at": "2026-09-06T17:19:37Z",
      "answered_at": "2026-09-06T17:19:38Z",
      "ended_at": "2026-09-06T17:20:18Z",
      "ring_seconds": 1,
      "talk_seconds": 40,
      "recording_status": "ready",
      "recording_bytes": 669427
    }
  ],
  "total": 1
}
```

### GET /v1/numbers/{number_id}/calls

The same history, for one line.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/5d2c1f1e-6f4a-4d43-9d8e-1a2b3c4d5e6f/calls \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

### POST /v1/numbers/{number_id}/rtc/calls

Dial a FaceTime Audio call from a number.

The handle must be an E.164 number or Apple ID email. This rings and tracks the call but does not connect your microphone or speaker. Establish the audio leg with the RTC signaling routes, then use the recording upload routes if the number has recording enabled. Poll or delete the session to follow or end it.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `string` | yes | An E.164 phone number or Apple ID email reachable through FaceTime. |

Request body:

```json
{
  "handle": "+15555550100"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/5d2c1f1e-6f4a-4d43-9d8e-1a2b3c4d5e6f/rtc/calls \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "+15555550100"
  }'
```

Response:

```json
{ "session_id": "271b92eb-0a59-4491-bec3-314c7db48400", "state": "dialing" }
```

Errors:

- `404` — The number is not held by this caller.
- `503` — The number could not place the call right now.

### GET /v1/numbers/{id}/rtc/config

Get the ICE configuration for FaceTime browser audio.

Pass ice_servers to your WebRTC client. TURN and LiveKit fallback availability depend on this deployment. Keep returned credentials in memory for the session; do not publish or log them.

Example:

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

Response:

```json
{ "ice_servers": [{ "urls": ["stun:stun.example.com:3478"] }], "turn_available": false, "livekit_fallback_available": false }
```

### POST /v1/numbers/{id}/rtc/offer

Negotiate the audio connection to the number.

Send a real SDP offer produced by your WebRTC client after gathering ICE candidates. The response is the remote answer to set on that connection. The abbreviated SDP below illustrates the shape, not a usable offer. Signaling does not itself dial a recipient.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `type` | `string` | yes | Must be offer. |
| `sdp` | `string` | yes | The complete local SDP with gathered ICE candidates. |

Request body:

```json
{
  "type": "offer",
  "sdp": "v=0\r\n..."
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/YOUR_ID/rtc/offer \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "offer",
    "sdp": "v=0\r\n..."
  }'
```

Response:

```json
{ "type": "answer", "sdp": "v=0\r\n..." }
```

Errors:

- `503` — Call audio for this number is unavailable right now.

### POST /v1/numbers/{id}/rtc/livekit

Start the configured LiveKit audio fallback.

Use when the direct connection cannot be established and config reports livekit_fallback_available. The response contains temporary room access for this session, not a reusable account token.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/YOUR_ID/rtc/livekit \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "url": "wss://relay.example.com", "token": "<temporary-room-token>" }
```

Errors:

- `503` — LiveKit fallback is not configured, or the number cannot join right now.

### GET /v1/numbers/{id}/rtc/calls/{session_id}

Read the FaceTime session state.

state is dialing, active, or ended. Use call history for the durable outcome and durations.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/YOUR_ID/rtc/calls/YOUR_SESSION_ID \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "session_id": "271b92eb-0a59-4491-bec3-314c7db48400", "state": "active" }
```

### DELETE /v1/numbers/{id}/rtc/calls/{session_id}

End a FaceTime Audio call.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/numbers/YOUR_ID/rtc/calls/YOUR_SESSION_ID \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "session_id": "271b92eb-0a59-4491-bec3-314c7db48400", "state": "ended" }
```

### POST /v1/numbers/{id}/rtc/calls/{session_id}/transport

Report which audio path carried the FaceTime call.

Accepts direct or livekit. Report the path that actually connected so call history can identify it.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `transport` | `string` | yes | direct or livekit, according to the connected media path. |

Request body:

```json
{
  "transport": "direct"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/YOUR_ID/rtc/calls/YOUR_SESSION_ID/transport \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "transport": "direct"
  }'
```

Errors:

- `400` — transport is neither direct nor livekit.

### PUT /v1/numbers/{number_id}/call-recording

Turn call recording on or off for one line.

acknowledged is required to enable and refused without it. Recording law is decided by where the people on the call are — some places need every party's consent — and Miss Blue provides the feature without deciding whether a given call may lawfully be recorded. Who switched it on, and when, is stored with the flag. Turning it off asks nothing, and does not delete what was already captured.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `enabled` | `boolean` | yes | Whether this number should record future calls. |
| `acknowledged` | `boolean` | no | Must be true when enabling recording; optional when disabling. |

Request body:

```json
{
  "enabled": true,
  "acknowledged": true
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/numbers/5d2c1f1e-6f4a-4d43-9d8e-1a2b3c4d5e6f/call-recording \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "acknowledged": true
  }'
```

Response:

```json
{
  "enabled": true,
  "enabled_at": "2026-09-06T16:54:38Z",
  "enabled_by": "Ada Lovelace"
}
```

Errors:

- `400` — Enabling without acknowledging responsibility for consent law.

### POST /v1/numbers/{id}/rtc/calls/{session_id}/recording

Begin uploading a FaceTime call recording.

Recording must be enabled for the number and the call must belong to that line. Capture both the local microphone and remote audio in one WebM stream before sending parts. This endpoint does not record audio by itself.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/YOUR_ID/rtc/calls/YOUR_SESSION_ID/recording \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "recording_id": "f4075a5e-b819-4bcf-90c5-e0c43bc4c611", "call_id": "271b92eb-0a59-4491-bec3-314c7db48400", "status": "recording", "mime_type": "audio/webm", "byte_size": null, "parts": 0, "expires_at": null }
```

Errors:

- `409` — Call recording is not enabled for this number.

### PUT /v1/numbers/{id}/recordings/{recording_id}/parts/{part}

Upload one WebM recording part.

Part numbers start at 1 and may be at most 3000. Each part must be nonempty and at most 1 MiB. Upload consecutive parts from the same WebM stream in order; the first must contain the WebM header. Retry a part with the same bytes and part number before completion.

Headers beyond `Authorization` and `Content-Type`:

- `Content-Type`: audio/webm

Request body: the raw bytes of the file (`part-1.webm`), not JSON.

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/numbers/YOUR_ID/recordings/f4075a5e-b819-4bcf-90c5-e0c43bc4c611/parts/1 \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: audio/webm" \
  --data-binary @part-1.webm
```

Errors:

- `400` — Part number or byte size is outside the accepted bounds.
- `409` — Recording is disabled or the recording is already finished.

### POST /v1/numbers/{id}/recordings/{recording_id}/complete

Finish and publish the uploaded FaceTime recording.

Call after the final part has uploaded. Parts are joined in order and call.recording.ready is emitted once the audio is durable. A missing part can leave only the playable prefix, so confirm every upload before completing. Repeating completion for a ready recording returns that recording. expires_at is null when no retention deadline was configured.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/YOUR_ID/recordings/YOUR_RECORDING_ID/complete \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "recording_id": "f4075a5e-b819-4bcf-90c5-e0c43bc4c611", "call_id": "271b92eb-0a59-4491-bec3-314c7db48400", "status": "ready", "mime_type": "audio/webm", "byte_size": 669427, "parts": 8, "expires_at": null }
```

Errors:

- `400` — No audio was captured.

### GET /v1/numbers/{number_id}/calls/{call_id}/recording

A short-lived signed URL for the audio.

Fetch it when somebody presses play rather than with the page: it is a link to a recorded conversation. Check recording_status on the call first — an answered call has audio only if the line was recording at the time.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/5d2c1f1e-6f4a-4d43-9d8e-1a2b3c4d5e6f/calls/cf33aa62-1c89-40b0-9887-137108be4b48/recording \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "url": "https://…" }
```

Errors:

- `404` — No recording, or it belongs to another organization.
- `409` — The recording failed, was deleted, or is not finished.

## Lookup

What will a message to this handle go out as?

### GET /v1/lookup?handle={handle}

Which services have demonstrably reached a handle.

This is evidence, not a query to Apple. We cannot ask — so instead this reads what has already happened on your own numbers. An inbound message is proof that service answers; a send that left the Mac is the same proof from the other side. Messages sends over three — iMessage, SMS, and RCS where the carrier and the recipient both support it — so `service` is the best one proven and `services` lists them all, best first. `unknown` means we have never tried, and is not a negative answer.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `string` | yes | A phone number or Apple ID email. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/lookup?handle=YOUR_HANDLE \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "handle": "+15555550100",
  "service": "imessage",
  "services": ["imessage", "sms"],
  "attempts": 3,
  "detail": "iMessage has worked with this handle before."
}
```

Errors:

- `400` — No handle, an empty one, or one over 256 characters.

## Contacts

The address book belongs to the project, not to one person. Three people answer the same number; when one works out who a handle is, the other two should not have to work it out again — and the customer should not get "Hi, who is this?" from the second person they speak to.

### GET /v1/contacts

Every name this project has given a handle.

`reach` says whether iMessage is known to work with each one, from your own traffic. See Lookup for what the three states mean.

Example:

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

Response:

```json
{
  "total": 1,
  "data": [
    {
      "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
      "handle": "+15551230002",
      "name": "Head of catering",
      "reach": "reachable",
      "created_at": "2026-08-23T09:12:04Z",
      "updated_at": "2026-08-23T09:12:04Z"
    }
  ]
}
```

### PUT /v1/contacts

Name a handle, or rename one already named, and queue it for Apple Contacts.

PUT rather than POST: naming a handle is idempotent, and naming one that already has a name is a rename rather than a conflict. A caller should not have to know which it is doing. The save answers at once and queues the push to Apple Contacts on the book's numbers that have texted them or heard from them; a number that has not gets their card, with this name, when it first texts them, and that first message waits for it so it carries the number's Name & Photo. `sync.status` is when_texted when no number has texted them yet. `sync` in the answer says what was queued; to see it land, read `syncs[].status` on GET /v1/contacts/{id} (queued, then synced or failed) or listen for the contact.synced webhook, sent as each number takes it. This changed: a save used to wait for each number and answer with counts of synced and failed.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `string` | yes | Phone number or Apple ID email. |
| `name` | `string` | yes | Clearing a name is a delete; there is a route for that. |
| `sync` | `boolean` | no | Default true. false keeps the name in Miss Blue only. If one of the book's own numbers texts them, that number gets a card with their number and no name (syncs[].nameless), so its Name & Photo (shared with Contacts only) reaches them; no contact.synced is sent for it. A contact already on one of the book's numbers with its name goes, with its current name, to any other of them that texts it. |
| `sync_number_id` | `uuid` | no | Push only to this number. One of this book's own numbers, not a shared line. Any other of the book's numbers that texts them later gets their card too. |
| `custom_data` | `object` | no | Fields to set, merged into the ones the contact has: a field given a value is set, one given null, "" or only spaces is removed, one not named is kept. Absent changes none. Numbers and true/false are stored as text; a list or an object is 400. A project's first save of somebody its organization has starts with the organization's fields. At most 50 per contact after the merge; names 1 to 80 characters, without }} or %} or both kinds of quote; values up to 2,000. Messages read them as {{ contact.custom.<name> }}, as they are when each message sends. |

Request body:

```json
{
  "handle": "+15551230002",
  "name": "Ari Chen",
  "custom_data": {
    "product": "Blue hoodie",
    "amount": "$48",
    "checkout_link": "https://shop.example/c/91"
  }
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "+15551230002",
    "name": "Ari Chen",
    "custom_data": {
      "product": "Blue hoodie",
      "amount": "$48",
      "checkout_link": "https://shop.example/c/91"
    }
  }'
```

Response:

```json
{
  "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
  "handle": "+15551230002",
  "name": "Ari Chen",
  "photo_attachment_id": null,
  "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
  "custom_data": { "product": "Blue hoodie", "amount": "$48", "checkout_link": "https://shop.example/c/91" },
  "reach": "unknown",
  "created_at": "2026-08-23T09:12:04Z",
  "updated_at": "2026-08-28T10:04:00Z",
  "sync": {
    "status": "queued",
    "numbers": [
      { "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5", "number_handle": "+16465550142" }
    ],
    "check": "GET /v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 shows syncs[].status for each number: queued, then synced or failed. A contact.synced webhook is sent as each number takes it."
  }
}
```

Errors:

- `400` — sync_number_id is not one of this book's numbers, or is given with sync: false; or custom_data would take the contact past 50 fields, or has a name or value outside the limits. Nothing is saved, not even the name.

### GET /v1/contacts/{id}

Pull everything known about one project contact.

Includes its project and organization context, custom data, and where it stands in Apple Contacts on each number: `syncs[].status` is queued, synced or failed, or when_texted for a number that will get the card when it first texts them, with the last successful sync and the last error. Queued waits for a number that is away however long it takes, and is retried a few times over about an hour and a half if the number refuses it, with last_error saying why, before it is failed. `syncs[].nameless` is true for a card with the number alone and no name: put there because that number texted them, so its Name & Photo (shared with Contacts only) reaches them, while the name stays in Miss Blue. No contact.synced is sent for one.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "contact": {
    "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
    "handle": "+15551230002",
    "name": "Ari Chen",
    "photo_attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e",
    "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
    "custom_data": { "crm_id": "lead_2048", "tier": "gold" },
    "reach": "reachable",
    "created_at": "2026-08-23T09:12:04Z",
    "updated_at": "2026-08-28T10:04:00Z"
  },
  "project_name": "Client Support",
  "workspace_id": "3480fdd8-2ef3-4edd-9949-bf78b4cd3e35",
  "workspace_name": "Acme Agency",
  "syncs": [{
    "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5",
    "number_handle": "+16465550142",
    "number_label": "Main support",
    "status": "synced",
    "last_attempted_at": "2026-08-28T10:03:00Z",
    "last_synced_at": "2026-08-28T10:03:02Z",
    "last_error": null,
    "nameless": false
  }]
}
```

### PUT /v1/contacts/{id}/block

Block or unblock a contact across its organization.

Requires a member session covering the whole organization and project_id or workspace_id for the contact's address book. Outgoing messages fail with contact_blocked (403); unsent queued messages are cancelled. New incoming messages and notifications are suppressed in Miss Blue. Existing history remains. Unblocking does not replay discarded messages. Calls and delivery to the underlying Apple account are unaffected. Deleting the contact does not remove the block.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `project_id` | `string` | no | Query parameter. Project address book; use workspace_id instead for an organization contact. |
| `workspace_id` | `string` | no | Query parameter. Organization address book; mutually exclusive with project_id. |
| `blocked` | `boolean` | yes | true to block; false to unblock. |

Request body:

```json
{
  "blocked": true
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7/block \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "blocked": true
  }'
```

### PUT /v1/contacts/{id}

Update a contact's name and custom data, and queue it for Apple Contacts.

custom_data, when given, is the complete replacement map; absent keeps the fields the contact has (a rename without it used to clear them). To set a few fields and keep the rest, save by handle with PUT /v1/contacts. It supports up to 50 fields; keys are at most 80 characters and values at most 2,000. Messages read them as {{ contact.custom.<name> }}. Like a save, it answers at once and queues the push to Apple Contacts on the book's numbers: `contact.sync` says what was queued, and `syncs` shows each number as queued until it takes it. The detailed contact and sync state are returned.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | yes | The contact's name. |
| `custom_data` | `object` | no | Replaces what the contact had. Absent or null keeps it. |
| `sync` | `boolean` | no | Default true. false saves the change in Miss Blue only. |

Request body:

```json
{
  "name": "Ari Chen",
  "custom_data": {
    "crm_id": "lead_2048",
    "tier": "gold"
  }
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ari Chen",
    "custom_data": {
      "crm_id": "lead_2048",
      "tier": "gold"
    }
  }'
```

Response:

```json
{
  "contact": {
    "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
    "handle": "+15551230002",
    "name": "Ari Chen",
    "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
    "custom_data": { "crm_id": "lead_2048", "tier": "gold" },
    "reach": "reachable",
    "sync": {
      "status": "queued",
      "numbers": [
        { "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5", "number_handle": "+16465550142" }
      ],
      "check": "GET /v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 shows syncs[].status for each number: queued, then synced or failed. A contact.synced webhook is sent as each number takes it."
    }
  },
  "project_name": "Client Support",
  "workspace_id": "3480fdd8-2ef3-4edd-9949-bf78b4cd3e35",
  "workspace_name": "Acme Agency",
  "syncs": [{
    "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5",
    "number_handle": "+16465550142",
    "number_label": "Main support",
    "status": "queued",
    "last_attempted_at": "2026-08-28T10:03:00Z",
    "last_synced_at": "2026-08-28T10:03:02Z",
    "last_error": null,
    "nameless": false
  }]
}
```

### POST /v1/contacts/{id}/sync

Save this contact to the Mac and its iCloud contact account.

The way to push a contact again, to one number, when a save's queued push did not take. Unlike a save, it waits for that number's answer. Name the project number whose Mac should receive it. The server resolves the Mac from that authorised number; an agent id is never accepted. The first sync may wait for the Mac operator to approve Contacts access. A project contact that lands also sends the contact.synced webhook.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | A number in this project. |

Request body:

```json
{
  "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7/sync \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5"
  }'
```

Response:

```json
{
  "request_id": "bb56a0f4-4f40-4e85-9cd6-c3268f51dc85",
  "contact_id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
  "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5",
  "synced": true
}
```

Errors:

- `404` — The contact or number is outside this project.
- `409` — This number is temporarily unavailable. The push stays queued and goes when the number is back.
- `400` — The number is a shared line, which never holds one organization's contacts.
- `400` — The Mac refused or could not save the contact.

### PUT /v1/contacts/{id}/photo

Use an uploaded JPEG or PNG as this contact's address-book photo.

Upload the image through Attachments first. This updates the contact photo in the Mac/iCloud address book when synced; Apple's account-wide outbound Messages name and photo sharing remains a separate Messages setting.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `attachment_id` | `uuid` | yes | An uploaded JPEG or PNG no larger than 10 MiB. |
| `sync` | `boolean` | no | Default true: queues the photo for Apple Contacts on the book's numbers, and the answer's `sync` says where. false keeps it in Miss Blue only. |

Request body:

```json
{
  "attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e"
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7/photo \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e"
  }'
```

Response:

```json
{
  "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
  "handle": "+15551230002",
  "name": "Ari Chen",
  "photo_attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e",
  "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
  "custom_data": {},
  "reach": "unknown",
  "created_at": "2026-08-23T09:12:04Z",
  "updated_at": "2026-08-28T10:04:00Z",
  "sync": {
    "status": "queued",
    "numbers": [
      { "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5", "number_handle": "+16465550142" }
    ],
    "check": "GET /v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 shows syncs[].status for each number: queued, then synced or failed. A contact.synced webhook is sent as each number takes it."
  }
}
```

### POST /v1/contacts/import

Name many handles at once.

Saves what is usable and tells you precisely which rows were not, rather than refusing the whole file over one bad line — a five-hundred-row import that fails on row four hundred means somebody edits and retries, several times.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `contacts` | `array` | yes | Objects with `handle` and `name`, and optionally `custom_data`, merged the same way as PUT /v1/contacts. A row whose fields break a limit is skipped with the reason, fields and name together. |

Request body:

```json
{
  "contacts": [
    {
      "handle": "+15551230002",
      "name": "Head of catering",
      "custom_data": {
        "plan": "Gold"
      }
    }
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/contacts/import \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {
        "handle": "+15551230002",
        "name": "Head of catering",
        "custom_data": {
          "plan": "Gold"
        }
      }
    ]
  }'
```

Response:

```json
{ "saved": 1, "skipped": [] }
```

### DELETE /v1/contacts/{id}

Remove a name from the project's book.

Anyone on the project may remove any name, not only whoever added it. A shared book that only its author can tidy is a book nobody tidies.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Errors:

- `404` — No such contact in this project's book.

## Attachments

Upload first, then send the id. The upload is a raw body, not a multipart form.

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

Download a file from a message your project can read.

Accepts the same project bearer key as the message API. The attachment must appear in this message's attachments array. Returns the original bytes and Content-Type with private, no-store caching. A missing file, unreadable message, or unrelated attachment returns 404. Received files can remain unavailable if the file did not finish uploading; reconcile before a bounded retry. On a Twilio line, each photo, video, sound or file on an incoming MMS is kept the same way and downloads with this route.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/messages/0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77/attachments/7deff905-9038-4e4e-b47c-580a1ab52220 --output attachment.bin \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
Original attachment bytes
```

Errors:

- `404` — Message or attachment unavailable to this project, or attachment does not belong to the message.

### POST /v1/attachments

Upload a file and get an id to send it with.

The file name and type ride in headers. GIFs, common video and voice-recording formats, PDFs, archives, Office and iWork files are accepted up to 100 MiB. Send one attachment id per message call.

Headers beyond `Authorization` and `Content-Type`:

- `Content-Type`: image/png
- `X-Miss-Blue-File-Name`: receipt.png

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `X-Miss-Blue-File-Name` | `header` | yes | The name the recipient sees. |
| `Content-Type` | `header` | yes | Must be a type we allow. |

Request body: the raw bytes of the file (`receipt.png`), not JSON.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/attachments \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: image/png" \
  -H "X-Miss-Blue-File-Name: receipt.png" \
  --data-binary @receipt.png
```

Response:

```json
{
  "id": "7deff905-9038-4e4e-b47c-580a1ab52220",
  "file_name": "receipt.png",
  "mime_type": "image/png",
  "size_bytes": 20481,
  "sha256": "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77"
}
```

Errors:

- `400` — An empty file, a missing name, or a type we do not allow.
- `413` — Over the size limit.

## Numbers

The lines your project holds. The Mac behind each line remains our implementation detail.

### GET /v1/numbers

Your project's numbers, with the name each has been given.

The Mac behind a number is never named. Which machine holds a line is our problem, and telling you would make it yours. availability says whether a number can send right now: { "status": "available" }, or { "status": "unavailable", "message": … } with a sentence written to be shown to your own users as it is. It is unavailable exactly when a send made now would be queued rather than sent — sends are still accepted, and go out when it is back.

Example:

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

Response:

```json
{
  "total": 1,
  "data": [{
    "id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "handle": "+16465550142",
    "label": "Main support",
    "service": "imessage",
    "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
    "disabled": false,
    "simulated": false,
    "availability": { "status": "available" },
    "shared": false,
    "created_at": "2026-08-23T09:12:04Z"
  }]
}
```

### GET /v1/numbers/{id}

Pull one number and its project and organization context.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "number": {
    "id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "handle": "+16465550142",
    "label": "Main support",
    "service": "imessage",
    "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
    "disabled": false,
    "simulated": false,
    "availability": { "status": "available" },
    "shared": false,
    "created_at": "2026-08-23T09:12:04Z"
  },
  "project_name": "Client Support",
  "workspace_id": "3480fdd8-2ef3-4edd-9949-bf78b4cd3e35",
  "workspace_name": "Acme Agency"
}
```

### POST /v1/numbers/{id}/label

Give a number a name.

So a thread says it arrived on Main support rather than +1 646 555 0142. Coming back to a conversation days later, the digits do not tell you which line you used.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `label` | `string` | no | Empty or absent clears it back to the bare handle. |

Request body:

```json
{
  "label": "Main support"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/label \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Main support"
  }'
```

Response:

```json
{
  "id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "handle": "+16465550142",
  "label": "Main support",
  "service": "imessage",
  "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
  "disabled": false,
  "simulated": false,
  "availability": { "status": "available" },
  "shared": false,
  "created_at": "2026-08-23T09:12:04Z"
}
```

### GET /v1/numbers/{selector}/call-forwarding

Pull the currently confirmed call-forwarding destination and latest request.

forwarding_number changes only after staff confirms the carrier update. A pending destination appears under latest_request without pretending it is already active.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/call-forwarding \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "number": "+16465550142",
  "forwarding_number": "+14155550100",
  "eligible": true,
  "eligibility_reason": null,
  "monthly_limit": 2,
  "requests_used_this_month": 1,
  "requests_remaining_this_month": 1,
  "latest_request": {
    "id": "927c4759-fb1a-43c4-9a39-acde90645fe0",
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "forward_to": "+14155550100",
    "status": "completed",
    "rejection_reason": null,
    "created_at": "2026-09-03T16:00:00Z",
    "updated_at": "2026-09-03T16:30:00Z",
    "completed_at": "2026-09-03T16:30:00Z"
  }
}
```

Errors:

- `400` — The path is neither a UUID nor a canonical E.164 number.
- `404` — The selected number is outside this project.

### POST /v1/numbers/{selector}/call-forwarding

Request a new call-forwarding destination.

The source in the path and forward_to must be canonical E.164 phone numbers. Staff complete the carrier change within 2–3 business days. Each number may request two changes per calendar month and have one open request at a time.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `forward_to` | `E.164 string` | yes | The destination: +, a non-zero country code, and 7–15 digits total. |

Request body:

```json
{
  "forward_to": "+14155550100"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/call-forwarding \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "forward_to": "+14155550100"
  }'
```

Response:

```json
{
  "id": "927c4759-fb1a-43c4-9a39-acde90645fe0",
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "forward_to": "+14155550100",
  "status": "pending",
  "rejection_reason": null,
  "created_at": "2026-09-03T16:00:00Z",
  "updated_at": "2026-09-03T16:00:00Z",
  "completed_at": null
}
```

Errors:

- `400` — The source or destination is not canonical E.164, or both are the same.
- `404` — The selected number is outside this project.
- `409` — This number already has a pending or processing request.
- `429` — This number already requested two changes in the current calendar month.

### GET /v1/numbers/{id}/name-photo

Pull this number's current Messages Name & Photo Sharing profile.

This is the Apple Account-wide outbound identity recipients may be offered, not a local Contacts card. photo_base64 is a bounded preview and has_photo remains authoritative. sharing_enabled says whether it is offered at all (to the number's contacts); Miss Blue keeps it on. Once a name and photo are saved here, saved is what was saved and in_sync whether the number still holds exactly that (photo_sha256 is the fingerprint of the exact image it holds). If it was changed outside Miss Blue, it is put back from what was saved and restored_at says when. The project's people get an email about it, at most one a week per number, and none for a change put back within an hour of a save. A new number starts with a name and photo Miss Blue chose (saved.is_default), and saved.verified_at says when a test message last proved the saved name and photo reach people.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/name-photo \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "first_name": "Blue",
  "last_name": "Lover",
  "has_photo": true,
  "photo_mime_type": "image/jpeg",
  "photo_base64": "/9j/4AAQSkZJRg…",
  "photo_sha256": "53d37af3e156b59a0c7ed129912a7ff8c4b29de3acd0bca2b702b030d70bcd53",
  "sharing_enabled": true,
  "saved": {
    "first_name": "Blue",
    "last_name": "Lover",
    "has_photo": true,
    "photo_attachment_id": "88de9745-30e0-4b93-bfcb-dbb7f3325dde",
    "saved_at": "2026-10-01T17:10:55Z",
    "is_default": false
  },
  "in_sync": true
}
```

Errors:

- `403` — The number is shared or its Apple Account has more than one number.
- `404` — The number is outside this project.

### PUT /v1/numbers/{id}/name-photo

Update and verify the number's Messages name and photo.

Upload a JPEG or PNG first. To remove the photo, omit photo_attachment_id and set remove_photo to true. Success is returned only after an independent Messages readback matches the requested name and image. Saving also turns sharing on, for the number's contacts.

Request body:

```json
{
  "first_name": "Blue",
  "last_name": "Lover",
  "photo_attachment_id": "88de9745-30e0-4b93-bfcb-dbb7f3325dde",
  "remove_photo": false
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/name-photo \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Blue",
    "last_name": "Lover",
    "photo_attachment_id": "88de9745-30e0-4b93-bfcb-dbb7f3325dde",
    "remove_photo": false
  }'
```

Response:

```json
{
  "first_name": "Blue",
  "last_name": "Lover",
  "has_photo": true,
  "photo_mime_type": "image/jpeg",
  "photo_base64": "/9j/4AAQSkZJRg…",
  "photo_sha256": "53d37af3e156b59a0c7ed129912a7ff8c4b29de3acd0bca2b702b030d70bcd53",
  "sharing_enabled": true,
  "saved": {
    "first_name": "Blue",
    "last_name": "Lover",
    "has_photo": true,
    "photo_attachment_id": "88de9745-30e0-4b93-bfcb-dbb7f3325dde",
    "saved_at": "2026-10-01T17:10:55Z"
  },
  "in_sync": true
}
```

### GET /v1/numbers/{id}/service-status?handle={handle}

Pull live iMessage and FaceTime registration for a recipient.

Each service is registered, not_registered, or unknown. Unknown is an inconclusive Apple lookup and is never turned into a negative answer.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `string` | yes | The E.164 phone number or Apple ID email to check. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/service-status?handle=%2B15163128403 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "handle": "+15163128403",
  "imessage": "registered",
  "facetime": "registered",
  "checked_at": "2026-08-28T10:04:00Z"
}
```

### POST /v1/numbers/{id}/service-status

Run the same live service lookup with a JSON request.

Request body:

```json
{
  "handle": "+15163128403"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/numbers/8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d/service-status \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "+15163128403"
  }'
```

Response:

```json
{
  "handle": "+15163128403",
  "imessage": "registered",
  "facetime": "registered",
  "checked_at": "2026-08-28T10:04:00Z"
}
```

## Campaigns

Everything the console's campaign tools do, with a key or a signed-in session. A key reaches its own project and gets 404 for every other. The same rules apply as in the console: opted-out and blocked people are left out before anybody sees a count, first contacts are paced per number, and a project's first announcement and first automation that writes to people first wait for a person at Miss Blue to read them. Announcement, scheduled and automation bodies are Liquid, filled in for each person when that message sends: {{ contact.first_name | default: "there" }}, and the contact's own fields as {{ contact.custom.<name> }}. The variables and preview routes at the end list what a project can use and show what a body will say.

### GET /v1/projects/{id}/announcements

Announcements in this project, newest first, with how each is going.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/announcements \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [{
    "id": "5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90",
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "title": "Spring hours",
    "body": "We open at nine from Monday.",
    "status": "sending",
    "send_at": null,
    "time_zone": null,
    "reply_window_hours": 72,
    "review_note": null,
    "started_at": "2026-09-26T14:00:00Z",
    "finished_at": null,
    "created_at": "2026-09-26T13:58:00Z",
    "stats": { "total": 120, "sent": 64, "skipped": 2, "invalid": 0, "failed": 0, "pending": 54, "replied": 9, "unsubscribed": 1 }
  }]
}
```

### POST /v1/projects/{id}/announcements

Draft an announcement. Nothing is sent.

Recipients are the handles you pass, everybody on each list, and everybody carrying each tag, once each. Lists and tags are read now: somebody added later is not included. Opted-out and blocked people are marked skipped before the response, so the counts are what sending would do. At most 5,000 people.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | One of this project's numbers. |
| `title` | `string` | yes | Internal. Recipients never see it. |
| `body` | `string` | no | The message, in Liquid, filled in for each person as it sends: {{ contact.first_name \| default: "there" }}, {{ contact.custom.<name> }}. See /docs/templates. Required unless `variants`; with them, version A's text. |
| `variants` | `object[]` | no | An A/B/C test: 2 or 3 of `{letter, body, weight}`, letters A to C with an A, weights whole percents adding up to 100 (leave them all out for an even split). Each person is given one when the draft is made, cut by the weights exactly. |
| `test_first` | `object` | no | With `variants`: `{percent, wait_hours}`. Send the versions to `percent` of the list first (5 to 50), wait `wait_hours` (1 to 72) from when the last of their messages left the number, then send the rest the version with the best reply rate, leaving out one that looks to cause more opt-outs. The pick waits (another `wait_hours` at a time, up to 72 hours, saying so in `pick_note`) until at least half of those whose message went have been reached. |
| `recipients` | `string[]` | no | Phone numbers or emails. |
| `list_ids` | `uuid[]` | no | Everybody on these lists, as they stand now. |
| `tags` | `string[]` | no | Everybody carrying these tags, as they stand now. |
| `send_at` | `RFC 3339` | no | When to start. Absent: when it is sent. |
| `time_zone` | `string` | no | The zone send_at was chosen in. Shown in the console. |
| `quiet_hours` | `boolean` | no | On unless false: each person is reached only 8 AM to 9 PM in their own time (tighter where their state requires it), and held until their morning otherwise. |
| `reply_window_hours` | `integer` | no | 1 to 720. How long a reply counts as a reply to this. 72 by default. |

Request body:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "title": "Spring hours",
  "body": "We open at nine from Monday.",
  "recipients": [
    "+15551230002"
  ],
  "list_ids": [
    "c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68"
  ],
  "tags": [
    "vip"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/announcements \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "title": "Spring hours",
    "body": "We open at nine from Monday.",
    "recipients": [
      "+15551230002"
    ],
    "list_ids": [
      "c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68"
    ],
    "tags": [
      "vip"
    ]
  }'
```

Response:

```json
{
  "id": "5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90",
  "status": "draft",
  "stats": { "total": 120, "sent": 0, "skipped": 2, "invalid": 0, "failed": 0, "pending": 118, "replied": 0, "unsubscribed": 0 }
}
```

Errors:

- `400` — No body, nobody to send to, more than 5,000 people, a reply window outside 1 to 720 hours, versions that are not 2 or 3 with an A, weights that do not add up to 100, a version that will not parse, or test first without versions.
- `404` — The number or a list is not this project's.

### GET /v1/projects/{id}/announcements/{announcement_id}/split

Preview what sending it would do, before anybody does it.

Show this to whoever approves the send. `established` people have written to the number and go out at normal speed. `first_contact` people have not written to it (whether or not it messaged them before), so they keep the number's pace, about seven an hour; those it never messaged also count against its new people a day. `first_contact_days` estimates them at `first_contact_per_day` a day. `skipped` were left out: opted out, blocked, or not a phone number or email. `needs_review` is true for a project's first announcement. With versions, `versions` is how many get each one; with a test first those are the test slice, and the row with `variant: null` is the rest, who get the best version after the wait.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/announcements/5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90/split \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 120,
  "established": 70,
  "first_contact": 48,
  "skipped": 2,
  "first_contact_days": 1,
  "first_contact_per_day": 50,
  "needs_review": false,
  "versions": [
    { "variant": "A", "people": 8, "in_test": 8 },
    { "variant": "B", "people": 8, "in_test": 8 },
    { "variant": "C", "people": 8, "in_test": 8 },
    { "variant": null, "people": 94, "in_test": 0 }
  ]
}
```

### POST /v1/projects/{id}/announcements/{announcement_id}/send

Send a draft.

A project's first announcement becomes `awaiting_review` until a person at Miss Blue reads it. After that, one with a `send_at` becomes `scheduled` and one without starts `sending`. Preview it with the split first: this cannot be undone for the people it has already reached.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/announcements/5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90/send \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "id": "5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90", "status": "sending" }
```

Errors:

- `409` — It has already been sent or cancelled.

### POST /v1/projects/{id}/announcements/{announcement_id}/cancel

Cancel a draft, a scheduled announcement, or the rest of one that is sending.

What has already gone stays gone. Cancelling stops the people not yet reached.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/announcements/5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90/cancel \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "cancelled": true }
```

Errors:

- `409` — It has already finished.

### POST /v1/projects/{id}/announcements/{announcement_id}/variants/{letter}/use

Use this one: everybody an A/B/C announcement has not reached yet gets this version.

During a test first's wait it is the pick, made by hand instead of by the numbers. Once only. The announcement comes back with `picked_variant` and `pick_note`, and `test`: the versions side by side (the test slice only, with a test first) with a verdict, in the shape of an automation step's `test`.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/announcements/5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90/variants/B/use \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "id": "5f0c1a2e-8a4b-4c1d-9e7f-2b6d3c8a1e90", "status": "sending", "picked_variant": "B", "pick_note": "B was chosen by hand." }
```

Errors:

- `400` — It has one text.
- `404` — It has no such version, or is not this project's.
- `409` — A version was already picked, or it has finished.

### GET /v1/projects/{id}/scheduled

Messages scheduled for later, and what became of them.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/scheduled \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [{
    "id": "9b2e4c61-3f8a-4d7e-b1c0-6a5d8e2f7c13",
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "recipient": "+15551230002",
    "body": "See you at nine",
    "send_at": "2026-10-02T14:00:00Z",
    "time_zone": "America/Chicago",
    "status": "pending",
    "error": null,
    "sent_message_id": null,
    "created_at": "2026-09-26T13:58:00Z"
  }]
}
```

### POST /v1/projects/{id}/scheduled

Schedule one message for later.

`send_at` is absolute. Choose it in the recipient's local time and pass their offset. When it goes, it goes through the same path as a send now: opt-outs, blocks and first-contact pacing apply at that moment.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | One of this project's numbers. |
| `recipient` | `string` | yes | Phone number or email. |
| `body` | `string` | yes | Optional only with attachment_ids. Liquid, filled in from the recipient's contact when it sends, not when it is scheduled. |
| `send_at` | `RFC 3339` | yes | At least 30 seconds and at most 365 days ahead. |
| `time_zone` | `string` | no | The zone it was chosen in, for display. |
| `quiet_hours` | `boolean` | no | On unless false: a send_at in the recipient's night (8 AM to 9 PM their time, tighter where their state requires it) goes at their morning. goes_at in the response says when. |
| `attachment_ids` | `uuid[]` | no | Uploaded files to send with it. |

Request body:

```json
{
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "recipient": "+15551230002",
  "body": "See you at nine",
  "send_at": "2026-10-02T09:00:00-05:00",
  "time_zone": "America/Chicago"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/scheduled \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "recipient": "+15551230002",
    "body": "See you at nine",
    "send_at": "2026-10-02T09:00:00-05:00",
    "time_zone": "America/Chicago"
  }'
```

Response:

```json
{ "id": "9b2e4c61-3f8a-4d7e-b1c0-6a5d8e2f7c13", "status": "pending", "send_at": "2026-10-02T14:00:00Z" }
```

Errors:

- `400` — A time that has passed, or more than a year ahead.

### POST /v1/projects/{id}/scheduled/{scheduled_id}

Change the text or the time of a message that has not gone.

Request body:

```json
{
  "body": "See you at ten",
  "send_at": "2026-10-02T10:00:00-05:00"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/scheduled/9b2e4c61-3f8a-4d7e-b1c0-6a5d8e2f7c13 \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "See you at ten",
    "send_at": "2026-10-02T10:00:00-05:00"
  }'
```

Response:

```json
{ "id": "9b2e4c61-3f8a-4d7e-b1c0-6a5d8e2f7c13", "status": "pending", "send_at": "2026-10-02T15:00:00Z" }
```

Errors:

- `409` — It has already been sent or cancelled.

### DELETE /v1/projects/{id}/scheduled/{scheduled_id}

Cancel a scheduled message.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/scheduled/9b2e4c61-3f8a-4d7e-b1c0-6a5d8e2f7c13 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "cancelled": true }
```

Errors:

- `409` — It has already been sent or cancelled.

### GET /v1/projects/{id}/automations

Automations in this project: what starts each, its steps, and whether it is on.

`results` is the last 30 days: the same totals as the automation's stats.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [{
    "id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21",
    "name": "Welcome",
    "enabled": true,
    "trigger_kind": "tag_added",
    "trigger_keywords": [],
    "trigger_list_id": null,
    "trigger_tag": "new-customer",
    "number_id": null,
    "allow_repeat": false,
    "stop_on_reply": true,
    "send_hours": { "start": "08:00", "end": "18:00", "time_zone": "America/New_York", "days": ["mon", "tue", "wed", "thu", "fri"] },
    "number_note": null,
    "review_status": "approved",
    "review_note": null,
    "reaches_strangers": true,
    "steps": [
      { "id": "…", "position": 10, "kind": "send", "body": "Welcome aboard.", "wait_seconds": null, "condition_kind": null, "next_step_id": null },
      { "id": "…", "position": 20, "kind": "wait", "body": null, "wait_seconds": 172800, "condition_kind": null, "next_step_id": null },
      { "id": "…", "position": 30, "kind": "condition", "body": null, "wait_seconds": null, "condition_kind": "not_replied_since_last", "next_step_id": null },
      { "id": "…", "position": 40, "kind": "send", "body": "Anything we can help with?", "wait_seconds": null, "condition_kind": null, "next_step_id": null }
    ],
    "running": 12,
    "completed": 30,
    "created_at": "2026-09-20T10:00:00Z",
    "results": { "days": 30, "sent": 118, "delivered": 104, "read": 61, "replied": 19, "opted_out": 2, "failed": 3, "cancelled": 6, "pending": 0, "reached": 105, "reply_rate": 0.181, "opt_out_rate": 0.019 }
  }]
}
```

### POST /v1/projects/{id}/automations

Create an automation, from a one-message reply to a multi-step sequence. It starts switched off.

The list and the number it names must be this project's. Triggers: `keyword`, `first_message`, `conversation_opened`, `added_to_list` and `tag_added`. Steps run in order: `send` (body), `wait` (wait_seconds, up to 31 days), `condition` (carries on only when it holds, and otherwise ends the run), and `stop`. A send body is Liquid, filled in for each person when that step sends, from their contact as it is then: {{ contact.first_name | default: "there" }}, {{ contact.custom.checkout_link }}. Update a field before a follow-up and the follow-up says the new value. `not_replied` and `replied` count anything since the run started; `not_replied_since_last` and `replied_since_last` count only what came after its latest message. A list or tag automation with no `number_id` sends from Auto: each person gets the best of the project's numbers when their first message goes. If a pinned number leaves the project, the automation moves to another of its numbers, or is switched off when there is none, and `number_note` says which. Only point one at people who asked to hear from you.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | yes |  |
| `trigger_kind` | `string` | yes | What starts it. |
| `trigger_keywords` | `string[]` | no | For `keyword`. Matched as whole messages, case-insensitively. |
| `trigger_list_id` | `uuid` | no | For `added_to_list`. |
| `trigger_tag` | `string` | no | For `tag_added`. |
| `number_id` | `uuid` | no | The number it listens on or sends from. Absent: every number for a keyword or message trigger, Auto for a list or tag. |
| `allow_repeat` | `boolean` | no | Let one person go through it more than once. |
| `stop_on_reply` | `boolean` | no | End a person's run when they reply. On when absent. A condition that waits for a reply never holds while it is on. |
| `skip_if_wrote_within_days` | `integer | null` | no | For a list or tag automation: don't start it for anybody who wrote to the project in the last 1 to 365 days. Off when absent or null. Stop on reply only hears replies to the automation's own messages; this hears the conversation before it. |
| `exit_tags` | `string[]` | no | Up to 20 tags, normalised as tags are. It doesn't start for anybody carrying one, and a run ends (`stopped`, reason `they were tagged “paid”`) the moment one is added, calling off what it had waiting. Applies to every trigger. |
| `send_hours` | `object` | no | Only send during these hours: `start` and `end` as `HH:MM` local time, `time_zone` (IANA, like `America/New_York`), `days` (`mon` to `sun`, every day when absent). `end` must be after `start`; no overnight hours yet. What starts it still starts it at any time; a message due outside the hours waits until they open. Used instead of quiet hours. Absent or null: quiet hours, unless quiet_hours is false (then any time). |
| `quiet_hours` | `boolean` | no | Applies when there are no send_hours; with send_hours, those are used instead. On unless false: each message waits for 8 AM to 9 PM in its recipient's own time (tighter where their state requires it). A reply to somebody who wrote in the last hour still goes. Absent on an update keeps what it has. |
| `steps` | `array` | yes | Up to 40. A `send` step can be an A/B/C test: `variants`, 2 or 3 of `{letter, body, weight}` (letters A to C with an A, weights adding up to 100, or none for an even split) instead of `body`. A person is given a version at the first step with versions, by its weights, and keeps it for every later one (A where a step has none): a later step follows that split, and may only have that step's letters. `fresh_results: true` starts a step's comparison from now. |

Request body:

```json
{
  "name": "Cart reminder",
  "trigger_kind": "tag_added",
  "trigger_tag": "cart-abandoned",
  "stop_on_reply": true,
  "send_hours": {
    "start": "08:00",
    "end": "18:00",
    "time_zone": "America/New_York",
    "days": [
      "mon",
      "tue",
      "wed",
      "thu",
      "fri"
    ]
  },
  "steps": [
    {
      "kind": "send",
      "body": "Hi {{ contact.first_name | default: \"there\" }}, your {{ contact.custom.product }} ({{ contact.custom.amount }}) is still waiting: {{ contact.custom.checkout_link }}"
    },
    {
      "kind": "wait",
      "wait_seconds": 86400
    },
    {
      "kind": "condition",
      "condition_kind": "not_replied_since_last"
    },
    {
      "kind": "send",
      "body": "Still thinking it over, {{ contact.first_name | default: \"there\" }}? Your cart is saved: {{ contact.custom.checkout_link }}"
    }
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Cart reminder",
    "trigger_kind": "tag_added",
    "trigger_tag": "cart-abandoned",
    "stop_on_reply": true,
    "send_hours": {
      "start": "08:00",
      "end": "18:00",
      "time_zone": "America/New_York",
      "days": [
        "mon",
        "tue",
        "wed",
        "thu",
        "fri"
      ]
    },
    "steps": [
      {
        "kind": "send",
        "body": "Hi {{ contact.first_name | default: \"there\" }}, your {{ contact.custom.product }} ({{ contact.custom.amount }}) is still waiting: {{ contact.custom.checkout_link }}"
      },
      {
        "kind": "wait",
        "wait_seconds": 86400
      },
      {
        "kind": "condition",
        "condition_kind": "not_replied_since_last"
      },
      {
        "kind": "send",
        "body": "Still thinking it over, {{ contact.first_name | default: \"there\" }}? Your cart is saved: {{ contact.custom.checkout_link }}"
      }
    ]
  }'
```

Response:

```json
{ "id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "enabled": false, "review_status": "not_needed" }
```

Errors:

- `400` — A missing trigger detail, an empty send, a wait over a month, an unknown condition, or sending hours that end before they start, have no days, or name an unknown zone.
- `404` — The list or the number is not this project's.

### POST /v1/projects/{id}/automations/{automation_id}

Replace an automation's settings and steps.

Takes the same body as creating one, with the same checks. `stop_on_reply`, `send_hours`, `skip_if_wrote_within_days` and `exit_tags` absent keep what it has, and `send_hours: null` and `skip_if_wrote_within_days: null` clear them; `number_id` absent means Auto (or every number), not "unchanged". Saving clears `number_note`. People partway through keep their step number (their position in `steps`, counted from the top): the step now at that number is what they do next, so a step added or removed above them changes it, and one whose number no longer exists is finished, `done`, once its last message has gone. A message already on its way is not recalled. Steps sent unchanged are left as they are. Changing or clearing `send_hours` looks again at everybody waiting for the old hours. Editing a switched-on automation into a list or tag automation gets the same review as switching one on. A send step that leaves `variants` out keeps the versions of the tested step with the same text, wherever it has moved; changing a tested step's text without `variants` is 409, and `variants: []` goes back to one text. A text already waiting to go keeps the words it was written in, unless `reword_queued: true`: then each queued text of a step whose words changed that would now say something else is written again in the new words within a few minutes, behind what its number already has waiting, and the answer's `queued_reworded` says how many. The old text is called off, so it fires `message.failed` ("Cancelled before it was sent.") and its replacement `message.sent` later. Only while the automation keeps its shape (no step added, removed or moved, and no message taking another step's words): otherwise people's step numbers point at different messages, and nothing is rewritten. A text already handed to a Mac (back in the queue for a retry), held for review, or whose person has moved on to a later step keeps its words. Every change to the steps is kept in the automation's history (`GET …/changes`).

Request body:

```json
{
  "name": "Welcome",
  "trigger_kind": "tag_added",
  "trigger_tag": "new-customer",
  "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
  "stop_on_reply": false,
  "steps": [
    {
      "kind": "send",
      "body": "Welcome aboard!"
    }
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21 \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome",
    "trigger_kind": "tag_added",
    "trigger_tag": "new-customer",
    "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d",
    "stop_on_reply": false,
    "steps": [
      {
        "kind": "send",
        "body": "Welcome aboard!"
      }
    ]
  }'
```

Response:

```json
{ "id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "enabled": true }
```

### DELETE /v1/projects/{id}/automations/{automation_id}

Delete an automation, and end everybody partway through it.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "deleted": true }
```

### GET /v1/projects/{id}/automations/{automation_id}/effect

Preview what switching it on would do.

How many people are already on the list or carry the tag, how many of them are first contacts and how long those take, and whether it will wait for review. `lines` is how many numbers it sends from: one when pinned, every usable one on Auto, 0 when none can take it. `first_contact_per_day` is the pace across all of them: with sending hours, what fits in them at the first-contact spacing. `first_contact_days` counts calendar days.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/effect \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "already_there": 400, "first_contact": 380, "first_contact_days": 4, "first_contact_per_day": 100, "lines": 2, "needs_review": true }
```

### POST /v1/projects/{id}/automations/{automation_id}/enabled

Switch an automation on or off.

Off ends every run in progress. On, a list or tag automation from a project that has never had one approved is held as `awaiting_review` until a person at Miss Blue reads it. `include_existing` also starts it for everybody already on the list or carrying the tag, which messages them: preview with the effect first.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `enabled` | `boolean` | yes |  |
| `include_existing` | `boolean` | no | Start it for the people already there too. Off by default. |

Request body:

```json
{
  "enabled": true,
  "include_existing": false
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/enabled \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "include_existing": false
  }'
```

Response:

```json
{ "id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "enabled": true, "review_status": "awaiting_review" }
```

Errors:

- `400` — It has no steps.

### GET /v1/projects/{id}/automations/{automation_id}/runs

Who is in an automation, how far each person got, and when they hear next.

Newest first, a page at a time; `order=next` puts the running people whose next message is soonest first. `total` is how many there are in all, so you know when you have them. `waiting_for` says what a person's next message waits for: `quiet_hours`, `sending_hours`, `paced`, `daily_limit`, `line_unavailable`, `retry`, `waiting`, `wait_step`, or null when it is going now; `next_message_at` is when. `time_zone` and `state` come from their number, to show it in their time.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `status` | `query` | no | `running`, `done`, `stopped` or `failed`. |
| `order` | `query` | no | `next` (soonest next message first) or `newest` (the default). |
| `limit` | `query` | no | 1 to 500, 100 by default. |
| `offset` | `query` | no | How many to skip, for the next page. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/runs \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 240,
  "data": [{
    "run_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
    "handle": "+15551230002",
    "status": "running",
    "reason": null,
    "step_position": 20,
    "due_at": "2026-09-28T14:00:00Z",
    "deferred_until": "2026-09-28T14:00:00Z",
    "waiting_for": "quiet_hours",
    "next_message_at": "2026-09-28T14:00:00Z",
    "time_zone": "America/Denver",
    "state": "CO",
    "started_at": "2026-09-26T14:00:00Z",
    "finished_at": null
  }]
}
```

### GET /v1/projects/{id}/automations/{automation_id}/stats

How an automation is doing, step by step: sent, delivered, read, replied and opted out.

Counts the messages it sent in the range, by step (numbered as the editor numbers them), and how the people who started in the range ended. `sent`, `delivered` and `read` count messages. `read` is a floor: iMessage has no separate open, a read receipt is the open, and only people with read receipts on send one, so it has no rate. `replied` and `opted_out` count people, once each however many texts they sent. A reply counts for a step when that step's message was the latest thing sent to them in the conversation before they wrote (by their phone's time, with a minute's allowance), within 7 days; after a message from your team or an announcement it counts for neither. `opted_out` counts replies that opted them out, when the project acts on STOP. An automation older than the record of which step sent each message is counted from `recorded_since`, runs included, and `from_recorded_since` says the range was moved. `reached` (delivered, or sent as a text message, which reports no delivery, or answered) is the denominator of `reply_rate` and `opt_out_rate`. `variants` are one row per version of an A/B/C test, with the same counts. `test`, on a step with versions, puts them side by side with each one's text and weight and a verdict: `not_enough` until every version has reached 30 people, then the reply rate (two-proportion z-test at 95%) and, separately, the opt-out rate (Fisher's exact test at 95%, corrected for the number of versions), in one sentence (`summary`), and `best` when one is clearly ahead on replies without clearly more opt-outs. A later step has `follows_step`: it follows that step's split. `periods`, on a step whose words changed in the range, splits its counts at each change, oldest first: `from` the change (`null` before the first), `to` the next (`null` for now), what the step said then (`text`, and `versions` with their weights) and the same counts, by version too. A message is placed by when it was written, which is when its words were decided. Empty for a step whose words have not changed since changes were kept.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `from` | `query` | no | RFC 3339, or a date for midnight UTC. Absent: since it began. |
| `to` | `query` | no | Up to, not including. Absent: now. |
| `variant` | `query` | no | Only this version of each step's text, like `A`. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/stats \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "automation_id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21",
  "from": "2026-08-28T00:00:00Z",
  "to": "2026-09-27T00:00:00Z",
  "recorded_since": null,
  "from_recorded_since": false,
  "variant": null,
  "reply_window_days": 7,
  "totals": { "sent": 118, "delivered": 104, "read": 61, "replied": 19, "opted_out": 2, "failed": 3, "cancelled": 6, "pending": 0, "reached": 105, "reply_rate": 0.181, "opt_out_rate": 0.019 },
  "steps": [{
    "step": 1, "text": "Hi {{first_name}}, still looking?", "in_flow": true,
    "sent": 40, "delivered": 37, "read": 22, "replied": 11, "opted_out": 0, "failed": 1, "cancelled": 0, "pending": 0,
    "reached": 37, "reply_rate": 0.2973, "opt_out_rate": 0.0, "variants": [],
    "test": null
  }, {
    "step": 4, "text": "Thanks for coming by today!", "in_flow": true,
    "sent": 112, "delivered": 108, "read": 60, "replied": 22, "opted_out": 2, "failed": 0, "cancelled": 0, "pending": 0,
    "reached": 108, "reply_rate": 0.2037, "opt_out_rate": 0.0185,
    "variants": [{ "variant": "A", "sent": 60, "replied": 7 }, { "variant": "B", "sent": 52, "replied": 15 }],
    "test": {
      "since": null,
      "follows_step": null,
      "versions": [
        { "variant": "A", "text": "Thanks for coming by today!", "weight": 50, "sent": 60, "delivered": 58, "read": 31, "replied": 7, "reply_rate": 0.1207, "opted_out": 1, "opt_out_rate": 0.0172, "reached": 58, "pending": 0 },
        { "variant": "B", "text": "Want a second look at the unit?", "weight": 50, "sent": 52, "delivered": 50, "read": 29, "replied": 15, "reply_rate": 0.3, "opted_out": 1, "opt_out_rate": 0.02, "reached": 50, "pending": 0 }
      ],
      "verdict": {
        "status": "ready",
        "summary": "B gets more replies (30% vs 12%), and opt-outs are about the same.",
        "minimum_per_version": 30, "more_per_version": null,
        "replies": { "leader": "B", "leader_rate": 0.3, "against": "A", "against_rate": 0.1207, "z": 2.31, "p": null, "clear": true },
        "opt_outs": { "leader": "B", "leader_rate": 0.02, "against": "A", "against_rate": 0.0172, "z": 0.11, "p": 1.0, "clear": false },
        "best": "B"
      }
    }
  }],
  "variants": [],
  "runs": {
    "started": 40, "in_progress": 9, "finished": 31,
    "outcomes": { "completed": 12, "replied": 15, "stopped": 2, "number_left": 0, "gave_up": 0, "failed": 2, "unknown": 0 }
  }
}
```

Errors:

- `400` — An unreadable `from` or `to`, `from` not before `to`, or a variant that is not one to eight letters or digits.
- `404` — The automation is not this project's.

### GET /v1/projects/{id}/automations/{automation_id}/changes

An automation's history: every change to what its steps say, newest first.

One entry per change to the steps' kinds, words, waits, conditions or versions, and per Start fresh results: `created`, `edited` (a save that changed them; one that changed only the name or the hours is not kept), `version_chosen` (Use this one) or `weights_changed`. Each says when, who (`changed_by.kind`: `member`, `staff` for Miss Blue support acting for a member, `api_key`, or `unknown`), and every step that differs, `before` and `after` (`null` for a step added or removed). `changed_steps` are the steps whose words changed: where the results' `periods` split. New weights, waits and conditions change no words. Kept from 2026-10-15.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `limit` | `query` | no | 1 to 100, 50 by default. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/changes \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [{
    "id": "5b0f2c1e-7a3d-4e9b-9c2f-0d8e6a4b1c37",
    "kind": "edited",
    "changed_at": "2026-10-15T16:37:24Z",
    "changed_by": { "kind": "member", "name": "Dana" },
    "steps": [{
      "step": 1,
      "before": { "step": 1, "kind": "send", "body": "Saw you started your assessment.", "wait_seconds": null, "condition_kind": null, "variants": [] },
      "after": { "step": 1, "kind": "send", "body": "Your plan options are ready whenever you are.", "wait_seconds": null, "condition_kind": null, "variants": [] },
      "words_changed": true
    }],
    "changed_steps": [1],
    "fresh_results_steps": [],
    "queued_reworded": 12
  }],
  "total": 1
}
```

Errors:

- `400` — A `limit` that is not 1 to 100.
- `404` — The automation is not this project's.

### GET /v1/projects/{id}/automations/{automation_id}/replies

Which texts got the replies: every reply, the message it answered, and a link to the conversation.

Newest first, a page at a time. `total` and `people` count every page; pass `next_cursor` as `cursor` for the next, and it is null on the last. `opted_out` marks a reply that opted them out; `said_stop` one that said STOP to a project that handles opt-outs itself, which is not counted as an opt-out.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `step` | `query` | no | Only replies to this step's messages: 1 to 40. |
| `from` | `query` | no | Only replies to messages sent from this time, as for stats. |
| `to` | `query` | no | And before this one. |
| `variant` | `query` | no | Only replies to this version of the text. |
| `cursor` | `query` | no | `next_cursor` from the page before. |
| `limit` | `query` | no | 1 to 100, 25 by default. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/replies \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [{
    "id": "4b1e2c3d-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
    "step": 4,
    "variant": null,
    "handle": "+15551230002",
    "contact_name": "Dana Whitfield",
    "text": "Sorry, just saw this. Is Saturday open?",
    "received_at": "2026-09-26T15:02:11Z",
    "opted_out": false,
    "said_stop": false,
    "answered": { "message_id": "9a0c1b2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "text": "Last one from us, Dana.", "sent_at": "2026-09-26T14:00:03Z" },
    "conversation": { "number_id": "8f14e45f-ceea-467a-9a1b-1b1e5f9e2c3d", "chat_id": "iMessage;-;+15551230002", "link_code": "k7Qm3xT9pa", "link": "/c/k7Qm3xT9pa" }
  }],
  "total": 23,
  "people": 19,
  "next_cursor": "1790000000123456_4b1e2c3d-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "variants": []
}
```

Errors:

- `400` — A step that is not 1 to 40, a limit out of range, a cursor this route did not write, or an unreadable date.
- `404` — The automation is not this project's.

### POST /v1/projects/{id}/automations/{automation_id}/runs/stop

Take one person out of one automation. Everybody else carries on.

Request body:

```json
{
  "handle": "+15551230002"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/runs/stop \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "+15551230002"
  }'
```

Response:

```json
{ "stopped": 1 }
```

### POST /v1/projects/{id}/automations/{automation_id}/steps/{step}/variants/{letter}/use

Use this one: a step's version for everybody who reaches it from now on.

Sets that version's weight to 100 and the others' to 0. Everybody who reaches the step from now on gets it, including people given another version earlier (their own version still decides the other steps). Messages already sent stay as they were, and every version's text and results stay. With `?reword_queued=true`, texts already waiting to go in another version are written again in this one (`queued_reworded` in the answer). `step` is the step's number from 1, as the results number it, or its id. Answers with the automation.

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/steps/4/variants/B/use \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "steps": [{ "kind": "send", "body": "Thanks for coming by today!", "variants": [{ "letter": "A", "body": "Thanks for coming by today!", "weight": 0 }, { "letter": "B", "body": "Want a second look at the unit?", "weight": 100 }] }] }
```

Errors:

- `400` — The step is not a message, or has one text.
- `404` — No such step or version, or the automation is not this project's.

### PUT /v1/projects/{id}/automations/{automation_id}/steps/{step}/weights

Change the weights of the first step with versions, for people given one from now on.

Every version of the step, in letter order or by letter, adding up to 100. Anybody already given a version keeps it. Only the first step with versions has weights: a later step follows its split, and is 400. Answers with the automation.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `weights` | `integer[] | object` | yes | `[70, 30]`, or `{"A": 70, "B": 30}`. |

Request body:

```json
{
  "weights": [
    70,
    30
  ]
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automations/e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21/steps/4/weights \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "weights": [
      70,
      30
    ]
  }'
```

Response:

```json
{ "id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "steps": [{ "kind": "send", "variants": [{ "letter": "A", "weight": 70 }, { "letter": "B", "weight": 30 }] }] }
```

Errors:

- `400` — Weights that do not add up to 100, miss a version, or name one the step does not have; a step that is not a message with versions; a later step, which follows the first step's split.
- `404` — No such step, or the automation is not this project's.

### GET /v1/projects/{id}/automation-runs

Which automations are messaging one person right now.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `query` | yes | Phone number or email. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automation-runs \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 1,
  "data": [{ "run_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "automation_id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "automation_name": "Welcome", "due_at": "2026-09-28T14:00:00Z" }]
}
```

### GET /v1/projects/{id}/automation-status

One person and your automations: what they are in, what they were in and why it ended, and what would start for them now.

`free` is whether nothing project-wide keeps an automation from starting for them: with one automation at a time on, that they are in none. `automations` lists every automation with `would_start` and, when it would not, the `reason`: switched off, waiting for review, in it now, been through it once, an exit tag, partway through another (one automation at a time), or wrote in recently. `ended` is the latest 20, newest first, with the outcome and reason `automation.run.finished` sent. Somebody an automation left out was never started, so there is no run and no webhook for them; this is where it says why.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `query` | yes | Phone number or email. |

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/automation-status \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "handle": "+15555550100",
  "one_automation_at_a_time": true,
  "free": false,
  "running": [{ "run_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "automation_id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "automation_name": "Cart reminder", "due_at": "2026-09-28T14:00:00Z", "waiting_for": "wait", "next_message_at": "2026-09-28T14:00:00Z" }],
  "ended": [{ "run_id": "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f", "automation_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "automation_name": "Welcome", "outcome": "stopped", "reason": "they were tagged “paid”", "started_at": "2026-09-20T15:00:00Z", "finished_at": "2026-09-21T09:30:00Z" }],
  "automations": [
    { "automation_id": "e7d1c3a9-2b4f-4e6a-8c0d-1f3a5b7c9e21", "automation_name": "Cart reminder", "trigger_kind": "tag_added", "would_start": false, "reason": "they are in it now" },
    { "automation_id": "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", "automation_name": "Checkout follow-up", "trigger_kind": "tag_added", "would_start": false, "reason": "they are partway through “Cart reminder”" }
  ]
}
```

Errors:

- `400` — A handle that is not a phone number or an email address.

### GET /v1/projects/{id}/lists

Lists in this project, and how many people are on each.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [{ "id": "c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68", "name": "Spring customers", "description": null, "members": 118, "created_at": "2026-09-01T10:00:00Z" }]
}
```

### POST /v1/projects/{id}/lists

Make a list.

Request body:

```json
{
  "name": "Spring customers",
  "description": "Bought in March or April"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Spring customers",
    "description": "Bought in March or April"
  }'
```

Response:

```json
{ "id": "c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68", "name": "Spring customers", "description": "Bought in March or April", "members": 0, "created_at": "2026-09-01T10:00:00Z" }
```

Errors:

- `409` — This project already has a list with that name.

### PATCH /v1/projects/{id}/lists/{list_id}

Rename a list or change its description.

Request body:

```json
{
  "name": "Spring 2026 customers"
}
```

Example:

```bash
curl -X PATCH https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists/c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "saved": true }
```

### DELETE /v1/projects/{id}/lists/{list_id}

Delete a list. The people on it are not deleted.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists/c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "deleted": true }
```

### GET /v1/projects/{id}/lists/{list_id}/members

Who is on a list, newest first. Up to 500.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists/c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68/members \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "data": [{ "handle": "+15551230002", "source": "api", "added_at": "2026-09-26T13:50:00Z" }] }
```

### POST /v1/projects/{id}/lists/{list_id}/members

Add people to a list.

If an automation starts on this list, each person who was not already on it starts that automation, which messages them. `started` says how many. Up to 5,000 per call.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handles` | `string[]` | yes | Phone numbers or emails. |
| `source` | `string` | no | `manual`, `import` or `api`. `api` by default. |

Request body:

```json
{
  "handles": [
    "+15551230002",
    "+15551230003"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists/c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68/members \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handles": [
      "+15551230002",
      "+15551230003"
    ]
  }'
```

Response:

```json
{ "added": 2, "already": 0, "started": 0 }
```

### POST /v1/projects/{id}/lists/{list_id}/remove

Take somebody off a list.

An automation they are already in carries on. To stop messaging somebody, record an opt-out or stop their run.

Request body:

```json
{
  "handle": "+15551230002"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/lists/c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68/remove \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "+15551230002"
  }'
```

Response:

```json
{ "removed": true }
```

### GET /v1/projects/{id}/tags

Tags in this project, and how many people carry each.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/tags \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{ "data": [{ "tag": "vip", "people": 14 }] }
```

### POST /v1/projects/{id}/tags

Tag people.

Tags are stored lowercase with single spaces, so `VIP ` and `vip` are one tag. If an automation starts on this tag, each newly tagged person starts it, which messages them, unless the automation leaves them out (an exit tag, writing in recently, or one automation at a time). An automation that lists this tag as an exit tag ends for each newly tagged person first. `started` counts the runs started.

Request body:

```json
{
  "tag": "vip",
  "handles": [
    "+15551230002"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/tags \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tag": "vip",
    "handles": [
      "+15551230002"
    ]
  }'
```

Response:

```json
{ "added": 1, "already": 0, "started": 0 }
```

### POST /v1/projects/{id}/tags/remove

Take a tag off somebody.

Request body:

```json
{
  "tag": "vip",
  "handle": "+15551230002"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/tags/remove \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tag": "vip",
    "handle": "+15551230002"
  }'
```

Response:

```json
{ "removed": true }
```

### GET /v1/projects/{id}/audience/{handle}

The lists one person is on and the tags they carry.

`tag_details` says when each tag was added and how: `manual`, `import`, `api` or `automation`. Tagging somebody again keeps the time it was added; taking a tag off and adding it back starts a new one.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/audience/%2B15551230002 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "lists": [{ "id": "c2a7e1d4-6b3f-4a8e-9d1c-5e7f0a2b4c68", "name": "Spring customers" }],
  "tags": ["vip"],
  "tag_details": [{ "tag": "vip", "added_at": "2026-10-14T19:05:12.481234Z", "source": "api" }]
}
```

### GET /v1/projects/{id}/templates/variables

The variables a message body can use in this project, built-in and custom.

The built-in ones (`kind: built_in`), then every custom field name this project's contacts carry (`kind: custom`), each once, sorted, up to 200. `name` is what to write between the braces: a field whose name is not a plain word is written with brackets, like contact.custom["Checkout link"]. A field is listed because some contact has it; the preview says how many of the people you are sending to have it empty.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/templates/variables \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "data": [
    { "name": "contact.name", "about": "Their name from your contacts", "kind": "built_in" },
    { "name": "contact.first_name", "about": "The first word of their name", "kind": "built_in" },
    { "name": "contact.last_name", "about": "The rest of their name, after the first word", "kind": "built_in" },
    { "name": "contact.phone_number", "about": "The number this is going to", "kind": "built_in" },
    { "name": "system.24hr", "about": "The time it sends, like 14:30", "kind": "built_in" },
    { "name": "contact.custom[\"Checkout link\"]", "about": "A custom field on their contact", "kind": "custom", "field": "Checkout link" },
    { "name": "contact.custom.product", "about": "A custom field on their contact", "kind": "custom", "field": "product" }
  ]
}
```

### POST /v1/projects/{id}/templates/preview

Show what a body will say for one person, and who it would leave a gap for.

The same renderer the sender uses. `text` is the body filled in for `handle` (or the first of `handles`). `missing` counts, for each variable, how many of `handles` have nothing for it, up to 500 people. A body that will not parse comes back with `text: null` and one line in `error`.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `body` | `string` | yes | Liquid. |
| `handle` | `string` | no | Render it as this person would read it. |
| `handles` | `string[]` | no | Count empty variables across these people. |

Request body:

```json
{
  "body": "Hi {{ contact.first_name | default: \"there\" }}, your {{ contact.custom.product | default: \"order\" }} is ready.",
  "handles": [
    "+15551230002",
    "+15551230003"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/templates/preview \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Hi {{ contact.first_name | default: \"there\" }}, your {{ contact.custom.product | default: \"order\" }} is ready.",
    "handles": [
      "+15551230002",
      "+15551230003"
    ]
  }'
```

Response:

```json
{
  "text": "Hi Ari, your Blue hoodie is ready.",
  "error": null,
  "variables": ["contact.custom.product", "contact.first_name"],
  "unresolved": [],
  "missing": [{ "name": "contact.custom.product", "people": 1 }],
  "counted": 2
}
```

## Delivery

Project webhooks are signed and retried. A per-message status callback is retried too, but is not signed; pull the message by id when you need authoritative state.

### GET /v1/projects/{id}/webhooks

List the endpoints this project sends events to.

Your key may manage its own project's endpoints and no others. Another project's id returns 404 rather than 403, because whether it exists is not something a stranger should learn.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/webhooks \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 1,
  "data": [{
    "id": "b71c0d92-5a83-4e1f-9d70-6c2e8a4b3f15",
    "project_id": "3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60",
    "url": "https://example.com/hooks/miss-blue",
    "events": ["message.received", "group.updated", "contact.synced"],
    "enabled": true,
    "disabled_reason": null,
    "failure_streak": 0,
    "created_at": "2026-08-28T10:04:00Z"
  }]
}
```

### POST /v1/projects/{id}/webhooks

Register where events should go.

Answers “tell me about everything on this line”: messages, typing, group changes, location requests, number profile changes, contacts, syncs, provisioning, calls, and campaigns — automation runs starting and finishing (automation.run.finished carries an outcome: completed, replied, stopped, number_left, gave_up or failed), announcements from review to finish with their counts, and scheduled messages sent, failed or cancelled. Every message event carries data.message.source: null for inbound, otherwise { type: automation | announcement | scheduled | api | console | mac } with the automation_id, run_id and step, the announcement_id, or the scheduled_id it came from. Every message event also carries data.conversation: { url, link_code, chat_id }, the conversation's short link (url is https://missblue.dev/c/<link_code>), so a handler can link to it without another call. The link opens only for somebody who may read the conversation. Message events put their fields under data.message on every line, Twilio included; group, location, number profile, contact sync and call events put their object under data.resource, beside data.number; contact.updated, number.provisioned, typing.indicator and campaign events put their fields directly under data. The call.started, answered, ended and failed events are sent for FaceTime Audio calls only for now; a phone call sends call.recording.ready when it was recorded. Omit events to receive all supported events, including ones added later; naming events delivers only those. The signing secret is returned only once. Each delivery is signed and retried with backoff; an endpoint that keeps failing is switched off and the reason is exposed.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `url` | `https url` | yes | Must be https, and must not resolve to a private address. |
| `events` | `string[]` | no | message.received, message.sent, message.delivered, message.failed, typing.indicator, group.created, group.updated, location.requested, number.profile.updated, contact.updated, contact.synced, number.provisioned, call.started, call.answered, call.ended, call.failed, call.recording.ready, automation.run.started, automation.run.finished, automation.review, announcement.awaiting_review, announcement.approved, announcement.rejected, announcement.started, announcement.finished, announcement.cancelled, scheduled.sent, scheduled.failed, or scheduled.cancelled. Omit for all. |

Request body:

```json
{
  "url": "https://example.com/hooks/miss-blue",
  "events": [
    "message.received",
    "message.delivered",
    "group.updated",
    "contact.synced"
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/webhooks \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/miss-blue",
    "events": [
      "message.received",
      "message.delivered",
      "group.updated",
      "contact.synced"
    ]
  }'
```

Response:

```json
{
  "id": "b71c0d92-5a83-4e1f-9d70-6c2e8a4b3f15",
  "project_id": "3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60",
  "url": "https://example.com/hooks/miss-blue",
  "events": ["message.received", "message.delivered", "group.updated", "contact.synced"],
  "enabled": true,
  "disabled_reason": null,
  "failure_streak": 0,
  "created_at": "2026-08-28T10:04:00Z",
  "secret": "whsec_…"
}
```

### POST /v1/projects/{id}/settings/one-automation-at-a-time

Keep anybody partway through one automation out of the others until it finishes.

On, a list or tag automation doesn't start for somebody who is in another of this project's: the one they are in finishes, and the other doesn't start for them then or later. An automation somebody starts by texting you (a keyword, a first message, any message) still answers them. Applies from the next start; nobody already in two is taken out of either. Off for every project until changed. Project admins and a key belonging to this project may change it; the project's detail says what it is.

Request body:

```json
{
  "one_automation_at_a_time": true
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/settings/one-automation-at-a-time \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "one_automation_at_a_time": true
  }'
```

Response:

```json
{
  "project_id": "3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60",
  "one_automation_at_a_time": true
}
```

### POST /v1/projects/{id}/settings/auto-typing-indicator

Automatically start typing after an inbound iMessage.

When enabled, each inbound one-to-one iMessage immediately starts a bounded native typing indicator while your responder prepares a reply. SMS, RCS, group chats, action messages, and hidden messages never trigger it. Project admins and a key belonging to this project may change the setting.

Request body:

```json
{
  "auto_typing_indicator": true
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/settings/auto-typing-indicator \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "auto_typing_indicator": true
  }'
```

Response:

```json
{
  "project_id": "3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60",
  "auto_typing_indicator": true
}
```

### POST /v1/projects/{id}/webhooks/{endpoint_id}/enabled

Switch an endpoint on or off without deleting it.

An endpoint that keeps failing is switched off by us for the same reason you would switch one off during a deploy: to stop hammering something that is not listening. Turning it back on is this call.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `enabled` | `boolean` | yes |  |

Request body:

```json
{
  "enabled": true
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/webhooks/b71c0d92-5a83-4e1f-9d70-6c2e8a4b3f15/enabled \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true
  }'
```

### GET /v1/projects/{id}/webhooks/{endpoint_id}/deliveries

What we tried to send you, and what came back.

Every attempt, its response status, and how many retries it took. This is the first place to look when your endpoint is up but you are not seeing events — it distinguishes “we never sent it” from “we sent it and your server said 500”.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/webhooks/b71c0d92-5a83-4e1f-9d70-6c2e8a4b3f15/deliveries \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 1,
  "data": [{
    "id": "d91d6b51-8639-43ad-8909-fd0439ecb80e",
    "event": "group.updated",
    "status": "delivered",
    "attempts": 1,
    "response_status": 204,
    "response_body": "",
    "created_at": "2026-08-28T10:04:00Z",
    "delivered_at": "2026-08-28T10:04:01Z"
  }]
}
```

### DELETE /v1/projects/{id}/webhooks/{endpoint_id}

Stop sending to an endpoint.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/projects/3c9a1f77-2d64-4b0e-8a5c-7e1d4b2f9a60/webhooks/b71c0d92-5a83-4e1f-9d70-6c2e8a4b3f15 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```
