Skip to main content

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

EventDescription
message.receivedA new incoming message on the line.
message.sentAn outgoing message was delivered.
reaction.addedSomeone reacted to a message.
reaction.removedSomeone 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.
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).
FieldTypeNotes
idstringmsg_…
line_idstringln_…
line_handlestringThe line’s phone number or Apple ID.
chat_idstringcht_… — the conversation.
guidstringiMessage GUID. Use as reply_to or to react.
senderstringPhone number / Apple ID of who sent it.
textstring | nullnull when the message is attachment-only.
attachmentsAttachment[]Files attached to the message. Voice memos include transcription.
is_from_mebooleantrue for message.sent, false for message.received.
is_audio_messageboolean | nulltrue for native voice memos.
sent_atnumberUnix ms.
synced_atnumberUnix ms when synced to messages.dev.
reply_to_guidstring | nulliMessage 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.
FieldTypeNotes
idstringrxn_…
message_idstringmsg_… — the message being reacted to.
chat_idstringcht_… — the conversation the message lives in.
line_handlestringThe line that received (or sent) the reaction.
typestringlove / like / dislike / laugh / emphasize / question.
senderstringPhone number / Apple ID of who reacted.
is_from_mebooleanTrue if your line was the reactor.
addedbooleantrue for reaction.added, false for reaction.removed.
sent_atnumberUnix ms.
synced_atnumberUnix 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.
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:
import { createClient } from "@messages-dev/sdk";

const client = createClient();

const webhook = await client.createWebhook({
  from: "+15551234567",
  url: "https://your-server.com/webhooks",
  events: ["message.received", "message.sent"],
});
Each webhook is scoped to a single line. Create one per line if you have multiple lines.

Payload format

POST /webhooks HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: a1b2c3d4e5f6...
X-Webhook-Timestamp: 1710000000123
X-Webhook-Delivery-Id: dlv_abc...

{
  "event": "message.received",
  "data": {
    "id": "msg_abc123",
    "line_id": "ln_xyz",
    "line_handle": "+15551234567",
    "chat_id": "cht_def456",
    "sender": "+15559876543",
    "text": "Hey there!",
    "is_from_me": false,
    "is_audio_message": false,
    "attachments": [],
    "sent_at": 1710000000000
  },
  "timestamp": 1710000000123,
  "delivery_id": "dlv_abc..."
}
Each delivery includes three headers in addition to Content-Type:
HeaderDescription
X-Webhook-SignatureHex HMAC-SHA256 over ${timestamp}.${rawBody}
X-Webhook-TimestampUnix ms at delivery time. Reject if more than 5 minutes off (replay protection).
X-Webhook-Delivery-IdUnique dlv_... ID for the delivery. Useful for idempotency on your side.

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:
{
  "event": "message.received",
  "data": {
    "id": "msg_abc123",
    "line_id": "ln_xyz",
    "line_handle": "+15551234567",
    "chat_id": "cht_def456",
    "sender": "+15559876543",
    "text": null,
    "is_from_me": false,
    "is_audio_message": true,
    "attachments": [
      {
        "filename": "Audio Message.caf",
        "mime_type": "audio/x-caf",
        "size": 24576,
        "url": "https://files.messages.dev/...",
        "transcription": "Hey, can you grab milk on the way home?"
      }
    ],
    "sent_at": 1710000000000
  },
  "timestamp": 1710000000123
}
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:
import { verifyWebhook } from "@messages-dev/sdk";

app.post("/webhooks", async (req, res) => {
  const event = await verifyWebhook(
    req.body,
    req.headers["x-webhook-signature"],
    "your_webhook_secret",
  );

  console.log(event.event, event.data);
  res.sendStatus(200);
});
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:
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(rawBody, headers, secret, toleranceMs = 5 * 60_000) {
  const signature = headers["x-webhook-signature"];
  const timestamp = Number(headers["x-webhook-timestamp"]);
  if (!signature || !timestamp) return false;
  if (Math.abs(Date.now() - timestamp) > toleranceMs) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
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:
messages-dev listen --forward-to http://localhost:3000/webhooks
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:
import { buildWebhookDelivery } from "@messages-dev/sdk";

const { body, headers } = await buildWebhookDelivery(
  "message.received",
  {
    id: "msg_test",
    lineId: "ln_test",
    lineHandle: "+15551234567",
    chatId: "cht_test",
    guid: "guid-test",
    sender: "+15559876543",
    text: "hello from a test",
    attachments: [],
    isFromMe: false,
    sentAt: Date.now(),
    syncedAt: Date.now(),
  },
  process.env.WEBHOOK_SECRET!,
);

await fetch("http://localhost:3000/webhooks", {
  method: "POST",
  body,
  headers,
});
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:
await client.deleteWebhook({ id: "wh_abc123" });
When using the REST API directly, the webhook ID is passed in the request body, not in the URL path.