8 event types live today, 9 more on the roadmap. Every payload is HMAC-SHA256-signed; verifying takes ten lines in any language. Exponential-backoff retry, replay UI for failed deliveries.
reply.receivedA reply lands in the workspace (email or WhatsApp), after classification.Liveemail.sentAn outbound email is accepted by the relay.Liveemail.bouncedAn outbound email fails at the relay (4xx/5xx/timeout).Livedomain.verifiedA sending domain transitions to verified status.Livedomain.failedA previously-verified domain transitions back to pending.Livemailbox.connectedA new inbox is connected (Gmail / Outlook / IMAP).Livewhatsapp.receivedAn inbound WhatsApp message arrives (also fires reply.received).Livetest.pingSent when you click Send test in the dashboard.Liveemail.openedFirst open event on a tracked email (pixel hit).Q3email.clickedFirst click on a tracked link.Q3email.complainedRecipient marked as spam via FBL.Q3contact.createdNew contact added (manual or import).Q3contact.unsubscribedContact opted out via List-Unsubscribe.Q3automation.firedAn automation matched and started a run.Q3automation.completedAn automation finished all its actions.Q3dmarc.advancedDMARC progression bumped a step.Q3workspace.suspendedAdmin suspended this workspace (admin-driven only).Q3One envelope shape across every event type. Headers carry routing metadata; body carries the data.
# What we POST to your endpoint
// X-Email-Digit-Signature: sha256=abc...,t=1748459269
// X-Email-Digit-Event: reply.received
// X-Email-Digit-Event-Id: 00000000-0000-0000-0000-000000000000
// X-Email-Digit-Delivery-Id: 00000000-0000-0000-0000-000000000000
// X-Email-Digit-Attempt: 1
{
"id": "00000000-0000-0000-0000-000000000000",
"type": "reply.received",
"created_at": "2026-05-28T14:21:09Z",
"data": {
"reply_id": "rep_amelia_9k2",
"from_email": "amelia@stripe.com",
"subject": "Re: your demo",
"intent": "interested",
"sentiment": 0.78,
"confidence": 0.94,
"channel": "email"
}
}We sign sha256({timestamp}.body) using your subscription's signing secret. Reject anything older than 5 minutes.
import crypto from "crypto";
import express from "express";
const SECRET = process.env.ED_WEBHOOK_SECRET;
function verify(rawBody, header) {
const [sigPart, tsPart] = header.split(",");
const sig = sigPart.slice("sha256=".length);
const ts = tsPart.slice("t=".length);
// Reject anything older than 5 minutes (replay defense)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET)
.update(`${ts}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
// IMPORTANT: use express.raw — JSON parsers break the MAC
app.post("/webhooks/email-digit",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req.body, req.get("X-Email-Digit-Signature"))) {
return res.status(401).send("invalid");
}
const event = JSON.parse(req.body.toString("utf8"));
// dispatch on event.type
res.status(200).send("ok");
});2xx → done. 4xx (except 429) → permanent failure, no retry. Anything else → retry.
After 25 consecutive failures across all events the subscription auto-pauses so we don't hammer a permanently-broken endpoint. Resume it from the dashboard once you've fixed things. Failed deliveries can be replayed individually from /dashboard/webhooks.
Subscribe an endpoint in the dashboard, click Send test, watch the verification pattern work in your code in two minutes.