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.
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
{
"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.
// 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).
"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
{
"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
{
"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.
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.
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:
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.
NextMessages referenceEvery field on every message endpoint.