Skip to main content

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.

app/hooks/mailroom/route.ts
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 });
}
Pass the raw body

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:

HeaderContents
X-Mailroom-SignatureHMAC-SHA256(secret, "<timestamp>.<raw body>"), hex
X-Mailroom-TimestampUnix 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

EventFires when
contact.subscribedA contact opts in
contact.unsubscribedA contact opts out (link click or API)
email.sentHanded to the delivery provider
email.deliveredProvider confirmed delivery
email.openedRecipient opened it
email.clickedRecipient clicked a tracked link
email.bouncedHard or soft bounce
email.complainedMarked as spam
sequence.completedAn enrollment reached the end of a flow
campaign.completedA 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

webhooks.ts
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 id or the event's own identifiers.
  • Do not assume order. email.opened can arrive before email.delivered.
  • Reject bad signatures with 400 and never retry them yourself — a valid event never fails verification.

Reference