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

Getting started

# Receiving messages

Receive replies and delivery outcomes through webhooks, per-message callbacks, and the API. Read receipts are available through message polling.

## Webhooks — “tell me about everything on this line”

The one to start with. Register an endpoint and we send every event on the project’s numbers. Subscribe to message.received, message.sent, message.delivered, and message.failed for a messaging integration.

```bash
curl -X POST https://api.missblue.dev/v1/projects/$PROJECT/webhooks \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/missblue"}'
```

Must be `https`, and must not resolve to a private address — an endpoint pointing at your own network would make us a way to reach it.

### What an inbound message looks like

```json
{
  "id": "a18d48e2-72b3-4a55-8544-5cbb860c17c5",
  "type": "message.received",
  "created": 1789128000,
  "data": {
    "project_id": "b3c2…",
    "number": { "id": "8f14…", "handle": "+16465550142" },
    "message": {
      "id": "platform-message-id",
      "api_id": "0b5ed7e8-f7a1-4f6e-9c2a-2a1f0d9e8b77",
      "external_message_id": "platform-message-id",
      "thread_id": "imsg##thread:opaque-thread-id",
      "from": "opaque-participant-id",
      "sender_handle": "+15555550100",
      "sender_name": "Ari",
      "text": "Can I move it to 8?",
      "timestamp": 1789128000000,
      "is_inbound": true,
      "is_action": false,
      "is_hidden": false,
      "is_deleted": false,
      "service": "imessage",
      "attachments": [],
      "source": null
    },
    "conversation": {
      "url": "https://missblue.dev/c/k7Qm3xT9pa",
      "link_code": "k7Qm3xT9pa",
      "chat_id": "imsg##thread:opaque-thread-id"
    }
  }
}
```

Incoming `id` and `from` are platform identifiers. Use `api_id` with GET /v1/messages/{id} and `sender_handle` to match a contact. Enrichment fields can be null. Message fields are under `data.message`, not data.resource, on every line. Each file in `attachments` has an `id` and `type`, and `fileName`, `fileSize` and `mimeType` when they are known; download it with GET /v1/messages/{api\_id}/attachments/{id}.

On a Twilio line, `id` is the Twilio Message SID, `from` and `sender_handle` are the sender’s phone number, `service` is `sms`, and the message adds `to` (your line) and `provider: "twilio"`. The photos, videos and files of an MMS are in `attachments`, like an iMessage’s. Links arrive inside `text` as written.

### A link back to the conversation

Every message event (`message.received`, `sent`, `delivered`, `failed`, and the status callback) carries `data.conversation`: its `url` in Miss Blue, the short `link_code` that `GET /v1/threads` gives the same conversation, and its `chat_id`. Post the `url` to Slack or a ticket as it is, with no call back to the API. The link opens only for somebody who may read the conversation; anybody else gets a not found, so it is safe to share. `conversation` is `null` for a message with no conversation yet, such as a first message to somebody new that was cancelled before it went.

```text
// message.received → Slack, with a link back to the conversation
const { data } = JSON.parse(rawBody); // after verifying the signature
const who = data.message.sender_name ?? data.message.sender_handle;
const said = `${who} replied: "${data.message.text ?? "(a file)"}"`;
const link = data.conversation?.url;
await fetch(process.env.SLACK_WEBHOOK_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    text: link ? `${said} <${link}|Open the conversation>` : said,
  }),
});
```

### Where a message came from

Every message event carries `data.message.source`, so an integration can tell a campaign’s messages from its own. `null` means somebody wrote to you. Otherwise its `type` is `automation` (with `automation_id`, `automation_name`, `run_id` and the 1-based `step`), `announcement` (`announcement_id`, `title`), `scheduled` (`scheduled_id`), `api` (a key), `console` (a person in the app), or `mac` (typed on the Mac itself).

```json
"source": {
  "type": "automation",
  "automation_id": "5f0c…",
  "automation_name": "Welcome",
  "run_id": "91d2…",
  "step": 2
}
```

## Campaign events

Automations, announcements and scheduled messages also say when they start, finish and are reviewed. Fields are directly under `data`, with `project_id`. An endpoint that subscribed to named events hears only those; one that subscribed to everything hears these too. Deliveries are sent concurrently, so two close together can arrive the other way round, and `created` is whole seconds: order by the timestamps in `data` — a run’s `started_at` and `finished_at`.

| Event | When |
| --- | --- |
| automation.run.started | Somebody started on an automation: a keyword, their first message, a list they joined or a tag.trigger names the keyword, list or tag. number is null for a flow sending from Auto until its first message picks a line. |
| automation.run.finished | Somebody's run of an automation ended, however it ended.outcome is completed, replied, stopped, number\_left, gave\_up or failed. Once per run. |
| automation.review | A flow that messages people first is waiting for review, or was approved or rejected.Only a project's first flow that reaches people who never wrote to you is reviewed. |
| announcement.awaiting\_review | Your first announcement is waiting for a person at Miss Blue to read it. |
| announcement.approved | It was approved, and is sending now or at its scheduled time. |
| announcement.rejected | It was rejected. review\_note says why. |
| announcement.started | Its first message was handed out.Not sent for a list where nobody could be messaged; announcement.finished still is. |
| announcement.finished | Everybody on the list has been messaged or skipped.The counts at that moment. Replies inside the reply window keep arriving afterwards. |
| announcement.cancelled | It was cancelled, by you or because its number left the project. |
| scheduled.sent | A scheduled message went at its time.message\_id is the same message your message.sent names; its source points back here. |
| scheduled.failed | A scheduled message could not go when its time came. |
| scheduled.cancelled | A scheduled message was cancelled before it went, by you or because its number left the project. |

### A run of an automation ending

```json
{
  "id": "3b8e…",
  "type": "automation.run.finished",
  "created": 1790541312,
  "data": {
    "automation_id": "5f0c…",
    "automation_name": "Welcome",
    "run_id": "91d2…",
    "handle": "+15555550100",
    "number": { "id": "8f14…", "handle": "+16465550142" },
    "outcome": "replied",
    "reason": "they replied",
    "steps_sent": 1,
    "started_at": "2026-09-27T20:14:02.113Z",
    "finished_at": "2026-09-27T20:15:12.940Z",
    "project_id": "b3c2…"
  }
}
```

`outcome` is `completed`, `replied`, `stopped` (taken off the flow, the flow switched off or deleted), `number_left`, `gave_up` (stuck a week) or `failed` (a message could not be sent or was not delivered). `automation.run.started` carries the same ids and a `trigger`, such as `{ "type": "keyword", "keyword": "hours" }`. Each is sent once per run.

### An announcement finishing

```json
{
  "type": "announcement.finished",
  "data": {
    "announcement_id": "c4e1…",
    "title": "Spring hours",
    "status": "sent",
    "number": { "id": "8f14…", "handle": "+16465550142" },
    "send_at": null,
    "started_at": "2026-09-27T20:20:01.402Z",
    "first_sent_at": "2026-09-27T20:20:21.955Z",
    "finished_at": "2026-09-27T20:21:02.310Z",
    "review_note": null,
    "stats": {
      "total": 3, "sent": 2, "delivered": 1, "failed": 0, "skipped": 1,
      "invalid": 0, "pending": 0, "replied": 0, "unsubscribed": 0
    },
    "project_id": "b3c2…"
  }
}
```

Every announcement event has this shape; `announcement.cancelled` adds a `reason`. `scheduled.sent` carries `scheduled_id`, `message_id`, `to`, `number` and `send_at`; `scheduled.failed` and `scheduled.cancelled` a `reason` in place of the message.

### Proving it came from us

Every delivery is signed with the secret returned when you registered the endpoint. Verify it before trusting the body — a public URL that accepts anything posted to it cannot establish who sent the event. Verify the hex HMAC-SHA256 of timestamp + "." + the raw body using the returned secret, compare in constant time, and reject timestamps outside your tolerance. Read timestamp and signature from Miss-Blue-Signature: t=…,v1=….

### Retries, and being switched off

A failed delivery is retried with backoff. An endpoint that keeps failing is switched off rather than retried forever, and you are told — a queue draining into a server that stopped listening a week ago helps nobody. Turn it back on with `POST /v1/projects/{id}/webhooks/{endpoint_id}/enabled`.

When your endpoint is up but you are not seeing events, read the delivery log first. It distinguishes “we never sent it” from “we sent it and your server said 500”, which are different problems with different owners.

```bash
curl https://api.missblue.dev/v1/projects/$PROJECT/webhooks/$ENDPOINT/deliveries \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

## status\_callback — “tell me about this one message”

For a script that sends one message and wants its outcome, without standing up a project-wide endpoint and filtering it. Pass a URL when you send; we POST that message’s terminal status there once the Mac reports it. This per-message callback is unsigned; verify its claim by fetching the message with your key. Project webhooks are the signed option.

```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.",
    "status_callback": "https://example.com/hooks/one-message"
  }'
```

## Polling — the one you still want

Take webhooks, and still poll this on a schedule:

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

It returns what failed and what is still waiting for a Mac. A webhook that never fired is exactly the case a webhook cannot tell you about, and a message stuck in a queue produces no event at all — so the only way to notice is to ask.

[Next Messages reference Every field on every message endpoint.](https://missblue.dev/docs/messages)
