Receiving Webhooks
Subscribe to email and platform events, verify the signature, and handle retries.
A webhook endpoint is an HTTPS URL of yours that we POST to whenever something happens in your project — an email is delivered or bounces, an app deploys, a domain verifies. It is the alternative to polling the API on a timer.
Create one under Webhooks in your project, or with the Webhooks API.
Events
Events are named family.thing_that_happened. Subscribe to as few or as many
as you like.
| Event | When it fires |
|---|---|
email.sent | Accepted by us and handed to the mail servers |
email.delivered | The receiving mail server accepted the message |
email.delivery_delayed | Temporarily undeliverable; still being retried |
email.bounced | Rejected by the receiving server |
email.complained | The recipient marked the message as spam |
email.opened | The recipient opened the message |
email.clicked | The recipient clicked a tracked link |
email.rejected | We refused to send the message |
email.rendering_failed | The template could not be rendered for this recipient |
email.domain_verified | A sending domain finished DNS verification |
email.sending_paused | A send allowance ran out and sending was paused |
Platform
| Event | When it fires |
|---|---|
app.deployed | A deployment finished successfully |
app.failed | A deployment failed to build or start |
domain.verified | A domain passed verification |
domain.expired | A domain registration lapsed |
server.running | A server finished provisioning |
server.error | A server entered an error state |
member.invited | Someone was invited to the project |
member.joined | An invitation was accepted |
The request
Every delivery is a POST with a JSON body in the same envelope:
{
"id": "clw9x8y7z",
"type": "email.bounced",
"createdAt": "2026-07-28T09:11:28.000Z",
"data": {
"messageId": "0100018f…",
"email": "[email protected]",
"from": "[email protected]",
"domain": "yourdomain.com",
"subject": "Your receipt",
"timestamp": "2026-07-28T09:11:27.000Z",
"bounceType": "Permanent",
"bounceSubType": "General",
"complaintFeedbackType": null,
"linkClicked": null,
"remoteMtaIp": "203.0.113.10"
}
}Alongside these headers:
| Header | Contents |
|---|---|
X-Datablock-Signature | t=<unix seconds>,v1=<hex hmac-sha256> |
X-Datablock-Delivery-Id | Unique per delivery — use it to dedupe |
X-Datablock-Event | The event type, for routing without parsing |
Verifying the signature
Verify every request before acting on it. Anyone can POST JSON at your URL; the signature is what proves the request came from us.
The signed value is the timestamp and the raw request body joined by a dot:
"{timestamp}.{body}", HMAC-SHA256'd with your signing key. Comparing against
the raw body matters — re-serializing the parsed JSON can change the bytes and
break the comparison.
import crypto from "node:crypto";
/** Returns whether the request really came from us and is recent. */
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => {
const [k, ...rest] = p.split("=");
return [k, rest.join("=")];
}),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
// Reject anything older than five minutes so a captured request cannot be
// replayed at you later.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}In Express, reach for express.raw({ type: "application/json" }) on the
webhook route so req.body is the untouched bytes.
Responding
Answer 2xx as soon as you have stored the event. Anything else — a 4xx, a
5xx, a timeout — is treated as a failure and retried.
Do the real work afterwards, not before responding. We give up on a request after 10 seconds, so a slow handler turns a successful delivery into a retry even though you received the event.
Retries and duplicates
A failed delivery is retried up to 5 times, with a growing delay: 30 seconds, then 2 minutes, 10 minutes, and 30 minutes. After that the delivery is marked failed and stays in the log.
Because a delivery can be retried after your handler already ran, your
endpoint must be idempotent. X-Datablock-Delivery-Id is stable across the
retries of one delivery — record it and ignore ids you have already processed.
Automatic pausing
If 10 deliveries in a row exhaust every retry, the endpoint is paused and the project's biller is emailed. A single successful delivery at any point resets that count, so an endpoint that is merely flaky is never paused.
Pausing stops delivery attempts — it does not drop events. New events keep queuing while the endpoint is down. When you resume it from the Webhooks page or the API, everything that piled up is delivered.
This is why an endpoint that is down for a deploy needs no action from you: it either recovers before the tenth failure, or it pauses and you resume it once the fix is out.
Rotating the signing key
Rotating generates a new key and invalidates the old one immediately, so update your receiver in the same change. If you cannot deploy both at once, have your receiver accept either key for the duration of the switch.