Skip to main content

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

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.
Every event delivers a JSON body with the same envelope (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).

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.

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.
The dashboard is the recommended way to manage webhooks. It shows the signing secret once on creation and makes it easy to enable, disable, or delete webhooks without writing code.
You can also create webhooks via the API:
Each webhook is scoped to a single line. Create one per line if you have multiple lines.

Payload format

Each delivery includes three headers in addition to Content-Type:

Voice memos

Inbound tap-to-record voice memos arrive on the same message.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:
If the signature is invalid (or the timestamp is more than 5 minutes off), 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:
Always verify the signature before processing a webhook payload. This prevents attackers from sending forged events to your endpoint.

Testing webhooks locally

Use the CLI for live local development

The fastest way to iterate on a webhook handler locally is the messages-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:
The CLI prints a per-session HMAC secret on first run. Use it as the secret in your 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. Your verifyWebhook() code path runs unchanged because the signature is genuine. buildWebhookDelivery(event, data, secret) is the high-level helper:
For more control — building a full body yourself, signing on a different language, etc. — use the lower-level 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.