Core concepts

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.

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

json
{
"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:

HeaderMeaning
X-Webhook-IdUnique id for this delivery attempt.
X-Webhook-EventThe event type, e.g. email.opened.
X-Webhook-TimestampUnix seconds when we signed the request.
X-Webhook-SignatureThe 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.

javascript
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);
}

Events

EventFires when…
contact.createdA contact is added (manually or via import).
contact.updatedA contact's name, email, or group membership changes.
contact.subscribedA contact is (re)subscribed.
contact.unsubscribedA contact unsubscribes.
contact.deletedA contact is deleted.
email.sentA message is accepted for sending.
email.failedA message fails to send.
email.deliveredThe receiving server accepted the message.
email.openedThe recipient opened the message.
email.clickedThe recipient clicked a tracked link.
email.bouncedThe message bounced.
email.complainedThe recipient marked it as spam.
email.rejectedSES rejected the message (e.g. a detected virus).
campaign.startedA campaign began sending.
campaign.completedA campaign finished sending to all recipients.
campaign.pausedA 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.