> ## 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.

# Webhooks

> Real-time event notifications via HTTPS

## 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.     |

<Note>
  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.
</Note>

Every event delivers a JSON body with the same envelope (`event`, `data`,
`timestamp`, `delivery_id`) and the same set of headers (see
[Payload format](#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](https://app.messages.dev). Click **Add Webhook**, enter your URL,
select the events you care about, and copy the signing secret.

<Tip>
  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.
</Tip>

You can also create webhooks via the API:

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  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"],
  });
  ```

  ```bash curl theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  curl -X POST "https://api.messages.dev/v1/webhooks" \
    -H "Authorization: Bearer sk_live_..." \
    -H "Content-Type: application/json" \
    -d '{
      "from": "+15551234567",
      "url": "https://your-server.com/webhooks",
      "events": ["message.received", "message.sent"]
    }'
  ```
</CodeGroup>

Each webhook is scoped to a single line. Create one per line if you have
multiple lines.

## Payload format

```http theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
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`:

| 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 same `message.received` event.
The message has `is_audio_message: true` and the audio attachment carries the
auto-generated transcription text from iMessage:

```json theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
{
  "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
}
```

<Note>
  `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`.
</Note>

## 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:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
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:

<CodeGroup>
  ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  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);
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  import hmac
  import hashlib
  import time

  def verify_webhook(raw_body: bytes, headers, secret: str, tolerance_ms: int = 5 * 60_000) -> bool:
      signature = headers.get("x-webhook-signature")
      timestamp = int(headers.get("x-webhook-timestamp", "0"))
      if not signature or not timestamp:
          return False
      if abs(int(time.time() * 1000) - timestamp) > tolerance_ms:
          return False

      signed_payload = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

<Warning>
  Always verify the signature before processing a webhook payload. This
  prevents attackers from sending forged events to your endpoint.
</Warning>

## 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](/cli).
[`listen --forward-to`](/cli#forward-live-events-to-a-local-webhook-handler)
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:

```sh theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
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()`](#verifying-signatures) 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](https://ngrok.com),
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/),
or any other tunnel and [register a real webhook](#creating-a-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:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
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](https://app.messages.dev), or in code:

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  await client.deleteWebhook({ id: "wh_abc123" });
  ```

  ```bash curl theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  curl -X DELETE https://api.messages.dev/v1/webhooks \
    -H "Authorization: Bearer sk_live_..." \
    -H "Content-Type: application/json" \
    -d '{"id": "wh_abc123"}'
  ```
</CodeGroup>

<Note>
  When using the REST API directly, the webhook ID is passed in the **request body**, not in the URL path.
</Note>
