Documentation Index
Fetch the complete documentation index at: https://messages.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
Overview
Webhooks push events to your server as they happen, no polling required. Register a URL, choose which events you care about, and Messages.dev will POST to it in real time.Events
| Event | Description |
|---|---|
message.received | A new incoming message on the line. |
message.sent | An outgoing message was delivered. |
reaction.added | Someone reacted to a message. |
reaction.removed | Someone removed their reaction. |
Inbound
typing.started / typing.stopped and receipt.read are not
delivered today — Apple’s underlying APIs surface those events on the macOS
side but our daemon doesn’t expose them yet. Subscribing to them is currently
rejected. They will return when the underlying detection layer ships.event, data,
timestamp, delivery_id) and the same set of headers (see
Payload format). The shape of data depends on the event:
message.received and message.sent
data matches the Message resource and adds line_handle (your line’s
phone number / Apple ID, included so you can route inbound events without a
side lookup).
| Field | Type | Notes |
|---|---|---|
id | string | msg_… |
line_id | string | ln_… |
line_handle | string | The line’s phone number or Apple ID. |
chat_id | string | cht_… — the conversation. |
guid | string | iMessage GUID. Use as reply_to or to react. |
sender | string | Phone number / Apple ID of who sent it. |
text | string | null | null when the message is attachment-only. |
attachments | Attachment[] | Files attached to the message. Voice memos include transcription. |
is_from_me | boolean | true for message.sent, false for message.received. |
is_audio_message | boolean | null | true for native voice memos. |
sent_at | number | Unix ms. |
synced_at | number | Unix ms when synced to messages.dev. |
reply_to_guid | string | null | iMessage GUID of the parent if this is a reply. |
reaction.added and reaction.removed
data matches the Reaction resource and adds chat_id (so you can route
the event to a thread without an extra getMessage call) and line_handle.
| Field | Type | Notes |
|---|---|---|
id | string | rxn_… |
message_id | string | msg_… — the message being reacted to. |
chat_id | string | cht_… — the conversation the message lives in. |
line_handle | string | The line that received (or sent) the reaction. |
type | string | love / like / dislike / laugh / emphasize / question. |
sender | string | Phone number / Apple ID of who reacted. |
is_from_me | boolean | True if your line was the reactor. |
added | boolean | true for reaction.added, false for reaction.removed. |
sent_at | number | Unix ms. |
synced_at | number | Unix ms. |
Creating a webhook
The easiest way to create a webhook is from the Webhooks page in your dashboard. Click Add Webhook, enter your URL, select the events you care about, and copy the signing secret. You can also create webhooks via the API:Payload format
Content-Type:
| Header | Description |
|---|---|
X-Webhook-Signature | Hex HMAC-SHA256 over ${timestamp}.${rawBody} |
X-Webhook-Timestamp | Unix ms at delivery time. Reject if more than 5 minutes off (replay protection). |
X-Webhook-Delivery-Id | Unique dlv_... ID for the delivery. Useful for idempotency on your side. |
Voice memos
Inbound tap-to-record voice memos arrive on the samemessage.received event.
The message has is_audio_message: true and the audio attachment carries the
auto-generated transcription text from iMessage:
transcription may be null on the first delivery if Apple hasn’t finished
on-device transcription yet (typically <2s after receipt). It is also
null for non-voice-memo audio attachments (e.g. a drag-and-dropped MP3),
which arrive with is_audio_message: false.Verifying signatures
Every webhook delivery is signed with HMAC-SHA256 over the string${timestamp}.${rawBody} using your webhook secret. The SDK handles signature
verification, timing-safe comparison, and replay protection automatically:
verifyWebhook throws a SignatureVerificationError. The default tolerance
window can be overridden via the tolerance option (in milliseconds).
If you’re not using the SDK, verify manually. Build the signed payload as
${timestamp}.${rawBody} — using ${rawBody} alone will always fail. The
timestamp is available both as the X-Webhook-Timestamp header and as the
timestamp field inside the parsed body:
Testing webhooks locally
Use the CLI for live local development
The fastest way to iterate on a webhook handler locally is themessages-dev CLI.
listen --forward-to
subscribes to your account’s event stream and POSTs each real event to a
local URL with the same HMAC headers production deliveries use, so you
don’t need to register a webhook, run ngrok, or expose a public URL while
you build:
verifyWebhook() call, or pin one with
MESSAGES_LISTEN_SECRET=…. Because the deliveries are genuinely signed,
your verification code runs unchanged.
If you’d rather exercise the production delivery path end-to-end, expose
your local server with ngrok,
Cloudflare Tunnel,
or any other tunnel and register a real webhook
pointing at the public URL. Same payload, same headers — only the source
of the deliveries changes.
Synthesize signed deliveries in tests
For unit tests, where you want to drive the handler without a network round trip, the SDK exports two helpers that build real signed deliveries you can POST at your handler — no test-mode bypass needed. YourverifyWebhook() code path runs unchanged because the signature is
genuine.
buildWebhookDelivery(event, data, secret) is the high-level helper:
signWebhook(secret, timestamp, rawBody)
helper which returns the lowercase hex HMAC-SHA256 of ${timestamp}.${rawBody}.
This covers the unit-test inner loop: no isEmulator flag, no skipping
signature verification, just real signed deliveries on demand. For
end-to-end local dev with live events from your account, prefer
messages-dev listen --forward-to above.
Deleting a webhook
You can delete webhooks from the Webhooks page in your dashboard, or in code:When using the REST API directly, the webhook ID is passed in the request body, not in the URL path.