| title | Webhooks |
|---|---|
| description | Receive real-time push notifications when async message outcomes resolve. |
| icon | webhook |
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.
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 |
- Open Webhooks from the sidebar in your dashboard (
/app/webhooks). - Click Add endpoint and paste your HTTPS URL.
- Copy the signing secret shown after creation — it is displayed only once and cannot be retrieved later.
- Choose which events to subscribe to (or leave all selected to receive everything).
- Click Send test event to confirm your endpoint receives and verifies the payload correctly before going live.
Every event is a POST with Content-Type: application/json. The body follows a
consistent envelope:
{
"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:
{
"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 carries a different data.message shape — a received
message, not a send. It's only emitted for mail that matched an active
inbound address; unmatched or quota-exceeded mail is
recorded but never delivered as a webhook.
{
"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.
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.
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));
}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
}
}
);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.
Return `2xx` as quickly as possible and process the event asynchronously in your backend. Long-running handlers risk timing out and triggering a retry. The message lifecycle and the statuses that trigger webhook events. Provision addresses and receive mail as a `message.received` webhook.