<!-- 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`.
A key beginning `mb_test_` acts on the sandbox and sends nothing to a real phone.

## Contents

- **Identity** — What this key is, and which project it holds.
- **Messages** — Send, read, react, unsend, and see what failed.
- **Conversations** — Threads, and the typing bubble.
- **Lookup** — Whether iMessage has reached a handle.
- **Contacts** — Names for handles, shared by the project.
- **Attachments** — Upload a file, then send it.
- **Numbers** — The lines you hold, and naming them.
- **Delivery** — Webhooks and per-message callbacks.

## 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 organisation above it. `live` is false for an mb_test_ key. 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 leaves the Mac shortly after — see queued 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.

| 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. iMessage has no captions, so text and attachments are sent as separate messages. |
| `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. |

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",
  "status": "sent",
  "queued": false,
  "chat_id": "iMessage;-;+15555550100"
}
```

Errors:

- `400` — No text and no attachments, or neither number_id nor agent_id.
- `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.
- `503` — The Mac holding that number is offline. Retry; nothing was sent.

### 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"
```

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

Example:

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

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.

Example:

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

### 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"
  }'
```

### GET /v1/messages/problems

Messages that failed, or are still waiting for a Mac.

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"
```

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

Example:

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

## 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/threads

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

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.

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,
      "last_replied_by": "Sam Okafor"
    }
  ]
}
```

### 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.
- `503` — The Mac is offline. Nothing was acknowledged.

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

## Lookup

Will a blue message to this handle arrive?

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

What we know about reaching a handle on iMessage.

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 iMessage is proof the handle answers; a send that left the Mac is the same proof from the other side. unknown means we have never tried, and is not a negative answer: routing to SMS on it would be routing on ignorance.

| 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",
  "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.

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.

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

Request body:

```json
{
  "handle": "+15551230002",
  "name": "Head of catering"
}
```

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": "Head of catering"
  }'
```

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

Request body:

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

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"
      }
    ]
  }'
```

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

### POST /v1/attachments

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

The file name and type ride in headers because a multipart parser is a lot of surface for a form with one field.

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.

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

Example:

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

### 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"
  }'
```

## Delivery

Two ways to find out what happened, for two different questions. Both are signed, and both retry.

### 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"
```

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

Register where events should go.

Answers “tell me about everything on this line”: inbound messages, delivery, read receipts, reactions. Each delivery is signed so you can prove it came from us, and retried with backoff. An endpoint that keeps failing is switched off rather than retried forever, and you are told.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `url` | `https url` | yes | Must be https, and must not resolve to a private address. |
| `events` | `string[]` | no | Which to receive. Omit for all of them. |

Request body:

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

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"
    ]
  }'
```

### 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"
```

### 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"
```
