> ## Documentation Index
> Fetch the complete documentation index at: https://docs.senderkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time push notifications when async message outcomes resolve.

SenderKit sends messages asynchronously. When you call `send()`, you get back a
message id and `status: queued` immediately — but the outcomes that matter
(delivery confirmation, bounces, opt-outs) happen seconds or minutes later inside
the provider. Webhooks let SenderKit push those outcomes to your backend the moment
they arrive, rather than making you poll. The same channel also delivers mail
sent to your [inbound addresses](/concepts/inbound).

## Events

Webhooks fire only for asynchronous outcomes you can't predict from the API response.
Internal pipeline states (`queued`, `rendered`) are not emitted.

| Event                | When it fires                                                                                                 |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `message.sent`       | The message was handed off to the email/SMS/push provider                                                     |
| `message.delivered`  | The provider confirmed delivery to the recipient                                                              |
| `message.failed`     | The message bounced, errored, or exhausted retries                                                            |
| `message.suppressed` | The send was skipped before dispatch — the recipient failed validation or was already on the suppression list |
| `message.opted_out`  | The recipient unsubscribed, via a one-click link or a provider event                                          |
| `message.complained` | The provider reported the message as spam                                                                     |
| `message.opened`     | The provider reported the recipient opened the email (first occurrence only)                                  |
| `message.clicked`    | The provider reported a link click in the email (first occurrence only)                                       |
| `message.received`   | Mail arrived at one of your [inbound addresses](/concepts/inbound)                                            |

<Note>
  `message.opened` and `message.clicked` are engagement signals, not lifecycle
  states — they never change a message's `status`. `delivered` remains the
  terminal happy-path status; an open or click can arrive seconds or minutes
  after it. Both fire only once, on the message's first reported open/click.
</Note>

<Note>
  `message.opted_out` and `message.complained` are consent/reputation signals,
  not lifecycle states — like opens and clicks, they don't retroactively change
  a message's `status`. A message that was already `delivered` (or `failed`)
  when the recipient unsubscribes or complains keeps that status; only a send
  skipped **before** dispatch because the recipient had already opted out gets
  the `opted_out` status itself (see [Messages](/concepts/messages#the-message-lifecycle)).
  A spam complaint fires both `message.complained` and `message.opted_out`; a
  plain unsubscribe fires `message.opted_out` alone.
</Note>

<Note>
  `message.suppressed` fires on managed (AWS SES) sending only, when the
  recipient address fails SES validation or is already on the account's
  suppression list — the send is skipped before it ever reaches a provider,
  and the message lands in the terminal `suppressed` status. It's distinct
  from `message.failed`, which reports a real bounce from the receiving mail
  server after an actual delivery attempt. Bring-your-own-provider connections
  never emit `message.suppressed`.
</Note>

## Setting up an endpoint

1. Open **Webhooks** from the sidebar in your dashboard (`/app/webhooks`).
2. Click **Add endpoint** and paste your HTTPS URL.
3. Copy the **signing secret** shown after creation — it is displayed only once
   and cannot be retrieved later.
4. Choose which events to subscribe to (or leave all selected to receive everything).
5. Click **Send test event** to confirm your endpoint receives and verifies the
   payload correctly before going live.

<Note>
  Webhooks deliver to **live mode** endpoints only. In test mode, delivery is
  simulated in-process — no real HTTP requests are made to your endpoint.
</Note>

## Payload

Every event is a `POST` with `Content-Type: application/json`. The body follows a
consistent envelope:

```json theme={null}
{
  "id": "evt_01HZ…",
  "type": "message.delivered",
  "created": "2026-06-01T12:34:56.789Z",
  "livemode": true,
  "data": {
    "message": {
      "id": "msg_01HZ…",
      "status": "delivered",
      "channel": "email",
      "recipient": "user@example.com",
      "provider": "ses",
      "metadata": {},
      "error": null,
      "openedAt": null,
      "clickedAt": null,
      "createdAt": "2026-06-01T12:34:00.000Z"
    }
  }
}
```

`data.message` is a public projection of the message — it omits rendered HTML,
template variables, and internal provider message IDs. `openedAt` / `clickedAt`
are set once, on the message's first reported open/click; a `message.clicked`
event additionally carries the clicked URL as `data.link`:

```json theme={null}
{
  "id": "evt_01HZ…",
  "type": "message.clicked",
  "created": "2026-06-01T12:36:10.000Z",
  "livemode": true,
  "data": {
    "message": {
      "id": "msg_01HZ…",
      "status": "delivered",
      "channel": "email",
      "recipient": "user@example.com",
      "provider": "ses",
      "metadata": {},
      "error": null,
      "openedAt": "2026-06-01T12:35:02.000Z",
      "clickedAt": "2026-06-01T12:36:10.000Z",
      "createdAt": "2026-06-01T12:34:00.000Z"
    },
    "link": "https://acme.com/orders/ord_9"
  }
}
```

### `message.received` payloads

`message.received` carries a different `data.message` shape — a received
message, not a send. It's only emitted for mail that matched an active
[inbound address](/concepts/inbound); unmatched or quota-exceeded mail is
recorded but never delivered as a webhook.

```json theme={null}
{
  "id": "evt_01HZ…",
  "type": "message.received",
  "created": "2026-07-24T12:00:00.000Z",
  "livemode": true,
  "data": {
    "message": {
      "id": "rcv_01HZ…",
      "channel": "email",
      "address": "support@acme.in.senderkit.email",
      "plusTag": null,
      "from": { "email": "customer@example.com", "name": "Jamie Customer" },
      "to": [{ "email": "support@acme.in.senderkit.email", "name": null }],
      "cc": [],
      "envelope": {
        "from": "customer@example.com",
        "to": ["support@acme.in.senderkit.email"]
      },
      "subject": "Question about my order",
      "messageId": "<abc123@mail.example.com>",
      "inReplyTo": null,
      "text": "Hi, I have a question about order #4821…",
      "html": "<p>Hi, I have a question about order #4821…</p>",
      "strippedReply": "Hi, I have a question about order #4821…",
      "truncated": false,
      "headers": { "date": "Fri, 24 Jul 2026 12:00:00 +0000" },
      "attachments": [],
      "verdicts": { "spam": "pass", "virus": "pass", "spf": "pass", "dkim": "pass" },
      "sizeBytes": 4213,
      "rawUrl": "https://api.senderkit.com/v1/inbound/messages/rcv_01HZ…/raw",
      "receivedAt": "2026-07-24T12:00:00.000Z"
    }
  }
}
```

`attachments[].url` and `rawUrl` are authenticated API links, not signed
public URLs — fetch them with an `Authorization: Bearer` header carrying an
API key with the `inbound` [scope](/authentication#scopes).

## Verifying signatures

Every webhook request carries three headers:

| Header                  | Value                                       |
| ----------------------- | ------------------------------------------- |
| `X-SenderKit-Event`     | The event type, e.g. `message.delivered`    |
| `X-SenderKit-Delivery`  | Unique delivery ID, e.g. `whd_01HZ…`        |
| `X-SenderKit-Signature` | HMAC-SHA256 signature for replay protection |

The signature format is:

```
t=<unix-timestamp>,v1=<hmac-hex>
```

To verify it, compute `HMAC-SHA256(key=<signing-secret>, data="<timestamp>.<raw-body>")`
and compare with the `v1` value. Reject the event if the signature doesn't match or
if the timestamp is more than 5 minutes old.

### Verification example (Node.js)

```ts theme={null}
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(
  rawBody: string,
  signature: string,
  secret: string,
  toleranceSec = 300
): boolean {
  const parts = Object.fromEntries(
    signature.split(",").map((p) => p.split("=") as [string, string])
  );
  const timestamp = parts["t"];
  const expected = parts["v1"];
  if (!timestamp || !expected) return false;

  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (age > toleranceSec) return false;

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

  return timingSafeEqual(Buffer.from(digest), Buffer.from(expected));
}
```

<Warning>
  Always use a constant-time comparison (`timingSafeEqual`) to prevent timing
  attacks. Never compare signatures with `===`.
</Warning>

### Express example

```ts theme={null}
import express from "express";
import { verifyWebhook } from "./webhooks"; // your verification helper

const app = express();

app.post(
  "/webhooks/senderkit",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["x-senderkit-signature"] as string;
    const secret = process.env.SENDERKIT_WEBHOOK_SECRET!;

    if (!verifyWebhook(req.body.toString(), sig, secret)) {
      return res.status(400).send("Invalid signature");
    }

    const { type, data } = JSON.parse(req.body.toString());

    // Acknowledge immediately, process asynchronously
    res.sendStatus(200);

    if (type === "message.failed") {
      // e.g. alert on failed delivery
    }
  }
);
```

## Retries and delivery logs

SenderKit retries failed deliveries automatically on any non-`2xx` response or
network error. Each endpoint retries independently — a slow or unavailable endpoint
does not block delivery to your other endpoints.

You can inspect delivery history in the **Webhooks** dashboard. Each endpoint shows
recent attempts, HTTP status codes, response times, and whether retries are pending.

<Tip>
  Return `2xx` as quickly as possible and process the event asynchronously in your
  backend. Long-running handlers risk timing out and triggering a retry.
</Tip>

<CardGroup cols={2}>
  <Card title="Messages" icon="list-check" href="/concepts/messages">
    The message lifecycle and the statuses that trigger webhook events.
  </Card>

  <Card title="Inbound Email" icon="inbox" href="/concepts/inbound">
    Provision addresses and receive mail as a `message.received` webhook.
  </Card>
</CardGroup>
