email·digit
Docs · Webhooks

Events you can trust.

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.

Event catalogue

Eight live events.

Payload

What we POST to your endpoint.

One envelope shape across every event type. Headers carry routing metadata; body carries the data.

POST /your-endpoint
# 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"
  }
}
Verifying signatures

Ten lines, any language.

We sign sha256({timestamp}.body) using your subscription's signing secret. Reject anything older than 5 minutes.

verify(body, header)
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");
  });
Retry policy

Exponential backoff, then dead.

2xx → done. 4xx (except 429) → permanent failure, no retry. Anything else → retry.

attempt 1 → +1m
attempt 2 → +5m
attempt 3 → +30m
attempt 4 → +2h
attempt 5 → +12h
attempt 6 → dead (terminal)

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.

Docs · Webhooks

Sign. Send. Retry.
All the way down.

Subscribe an endpoint in the dashboard, click Send test, watch the verification pattern work in your code in two minutes.