Webhooks
Receive a signed POST on your own server whenever something happens.
Webhooks push events to you in real time. Instead of polling the API, you
register an HTTPS endpoint and we POST a signed JSON payload to it whenever a
subscribed event happens — a contact is created, an email is delivered, opened,
or clicked, a campaign finishes, and more.
Creating an endpoint
Open Webhooks in the dashboard and add an endpoint: a URL, an optional description, and the events you want. You can also manage endpoints over the REST API and via MCP.
When you create an endpoint we generate a signing secret (it starts with
whsec_) and show it once. Use it to verify that each delivery genuinely came
from us. You can re-reveal or rotate it later from the endpoint's actions.
curl https://your-app.com/api/v1/webhooks \
-H "Authorization: Bearer m123_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/mail",
"events": ["contact.created", "email.delivered", "email.opened"]
}'Pass "events": ["*"] to subscribe to everything, including event types added
later.
The payload
Every delivery is a POST with a JSON body of this shape:
{
"id": "665f0c2a9b1e4d3a2c1f0e9d",
"type": "email.delivered",
"created": "2026-06-26T12:34:56.000Z",
"project_id": "665f0c2a9b1e4d3a2c1f0e9d",
"data": {
"email": "jane@example.com",
"message_id": "0100018f...",
"subject": "Welcome aboard",
"campaign_id": "665f..."
}
}Each request also carries these headers:
| Header | Meaning |
|---|---|
X-Webhook-Id | Unique id for this delivery attempt. |
X-Webhook-Event | The event type, e.g. email.opened. |
X-Webhook-Timestamp | Unix seconds when we signed the request. |
X-Webhook-Signature | The signature, formatted t=<timestamp>,v1=<hex hmac>. |
Respond with any 2xx status to acknowledge. Anything else (or a timeout after
10 seconds) is treated as a failure and retried.
Verifying the signature
The signature is an HMAC-SHA256 over the string `${timestamp}.${rawBody}`,
keyed by your endpoint's signing secret. Always compute it from the raw
request body, before any JSON parsing, and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret = your endpoint's whsec_… signing secret
// rawBody = the exact request body string (not re-serialized JSON)
// header = the X-Webhook-Signature header value
function verify(secret, rawBody, header) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const timestamp = parts.t;
const signature = parts.v1;
// Reject stale requests to blunt replay attacks (5-minute tolerance).
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Verify every request before trusting it. An unsigned or mis-signed request is
not from us — return 401 and ignore it.
Events
| Event | Fires when… |
|---|---|
contact.created | A contact is added (manually or via import). |
contact.updated | A contact's name, email, or group membership changes. |
contact.subscribed | A contact is (re)subscribed. |
contact.unsubscribed | A contact unsubscribes. |
contact.deleted | A contact is deleted. |
email.sent | A message is accepted for sending. |
email.failed | A message fails to send. |
email.delivered | The receiving server accepted the message. |
email.opened | The recipient opened the message. |
email.clicked | The recipient clicked a tracked link. |
email.bounced | The message bounced. |
email.complained | The recipient marked it as spam. |
email.rejected | SES rejected the message (e.g. a detected virus). |
campaign.started | A campaign began sending. |
campaign.completed | A campaign finished sending to all recipients. |
campaign.paused | A campaign was auto-paused (e.g. by warmup safety). |
Retries & reliability
Deliveries are queued and sent in the background, so a slow or briefly unreachable receiver never blocks anything on our side. A failed delivery is retried with exponential backoff (after roughly 1m, 5m, 30m, 2h, then 6h). After six failed attempts the delivery is marked failed and dropped.
An endpoint that fails continuously is automatically disabled so we stop hammering a dead receiver — fix it and re-enable it from the dashboard.
Build your receiver to be idempotent: the same event may arrive more than
once (deduplicate on id), and engagement events can arrive out of order.
Testing
Use Send test on any endpoint (or the send_test_webhook MCP tool) to queue
a sample webhook.test event, and watch it land in the endpoint's Deliveries
log along with the response code we got back.