Webhooks
Mailroom POSTs signed JSON to endpoints you register. This is how you learn about bounces, complaints, unsubscribes, and engagement without polling the send log.
Verify every delivery
constructEvent checks the signature in constant time, rejects stale timestamps,
and returns a typed event. It uses Web Crypto, so it runs in Node, edge runtimes,
and workers alike.
import { constructEvent, WebhookVerificationError } from "@mailroom/sdk";
// A framework-agnostic handler (Next.js route handler, Hono, Bun.serve, workers).
// The signature covers the RAW body — read it as text and never re-serialize.
export async function POST(req: Request): Promise<Response> {
const body = await req.text();
try {
const event = await constructEvent(body, {
signature: req.headers.get("x-mailroom-signature") ?? "",
timestamp: req.headers.get("x-mailroom-timestamp") ?? "",
secret: process.env.MAILROOM_WEBHOOK_SECRET!,
});
switch (event.type) {
case "email.bounced":
case "email.complained":
// Stop mailing this address in your own system too.
break;
case "contact.unsubscribed":
break;
default:
break;
}
} catch (err) {
if (err instanceof WebhookVerificationError) {
// Bad signature or a stale timestamp: reject, do not retry-loop.
return new Response("invalid signature", { status: 400 });
}
throw err;
}
// Acknowledge fast. Mailroom retries non-2xx with backoff, up to 6 attempts.
return new Response(null, { status: 204 });
}
Verify against the exact bytes you received. JSON.parse then JSON.stringify
produces a different string and the signature will not match. In frameworks that
auto-parse bodies, disable that for this route.
Two headers carry the proof:
| Header | Contents |
|---|---|
X-Mailroom-Signature | HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex |
X-Mailroom-Timestamp | Unix seconds when the delivery was signed |
constructEvent rejects timestamps more than 300 seconds old — pass
toleranceSec to widen it, or 0 to disable the check (not recommended: the
timestamp is what stops an old capture being replayed).
verifyMailroomSignature(secret, timestamp, body, signature) is available if you
want the boolean and your own parsing.
Event types
| Event | Fires when |
|---|---|
contact.subscribed | A contact opts in |
contact.unsubscribed | A contact opts out (link click or API) |
email.sent | Handed to the delivery provider |
email.delivered | Provider confirmed delivery |
email.opened | Recipient opened it |
email.clicked | Recipient clicked a tracked link |
email.bounced | Hard or soft bounce |
email.complained | Marked as spam |
sequence.completed | An enrollment reached the end of a flow |
campaign.completed | A campaign finished sending |
The body is always { type, data }. Subscribe to a subset with events, or omit
it to receive everything.
webhooks.ping() sends a webhook.ping event — deliberately not a
subscribable type, so a ping never lands in your production handler by accident.
Handle unknown types by ignoring them.
Managing endpoints
import { mailroom } from "./client";
// `secret` comes back exactly once — persist it before you drop the response.
const { endpoint, secret } = await mailroom.webhooks.create({
url: "https://app.example.com/hooks/mailroom",
events: ["email.bounced", "email.complained", "contact.unsubscribed"], // omit for all
});
console.log(`store this: ${secret}`);
// Send yourself a signed `webhook.ping` and see what your receiver answered.
const ping = await mailroom.webhooks.ping(endpoint.id);
console.log(ping.delivered, ping.responseStatus);
// Delivery log: status, response code, and when the next retry is due.
for (const d of await mailroom.webhooks.deliveries(endpoint.id, { limit: 25 })) {
console.log(d.event_type, d.status, d.attempts, d.response_status);
}
// Rotating invalidates the old secret immediately — deploy the new one first.
const rotated = await mailroom.webhooks.rotate(endpoint.id);
console.log(rotated.secret);
await mailroom.webhooks.update(endpoint.id, { enabled: false });
The signing secret is returned once, at create or rotate. There is no way to
read it back — store it immediately. Rotation invalidates the old secret at once,
so deploy the new one before you rotate.
Endpoint URLs are validated against SSRF: the address is checked at connect time (DNS-rebinding safe) and redirects are never followed. Private, loopback, and link-local targets are refused — use a public HTTPS URL, even in development (a tunnel works).
Delivery, retries, and your handler
Deliveries are queued, then drained by a dispatcher roughly every minute. A
non-2xx response is retried with exponential backoff up to 6 attempts
(2, 4, 8 … minutes, capped at 60), after which the delivery is marked failed and
visible in webhooks.deliveries(id).
Practical consequences for your handler:
- Acknowledge fast. Return 2xx as soon as you have persisted the event; do the work afterwards. A slow handler becomes a retry storm.
- Be idempotent. At-least-once delivery means the same event can arrive twice;
key on the delivery
idor the event's own identifiers. - Do not assume order.
email.openedcan arrive beforeemail.delivered. - Reject bad signatures with 400 and never retry them yourself — a valid event never fails verification.