How Webhook-to-Email works
Six steps in 200 lines. Verify, format, send, fan out, retry once, return 200. Stateless, predictable, and small enough to read in one sitting.
A POST in,
an email out.
That is it.
External service POSTs to /hooks/<source>. Service verifies the HMAC signature, loads a template if there is one, formats the payload, sends via Resend, optionally fans out to Slack, returns 200.
No database. No queue. No ORM. No GraphQL. No microservices. One Express app, one Resend SDK call per request, one Slack POST when configured. Roughly 200 lines.
The bottleneck is always Resend. The service handles 1,000 req/sec on one CPU core. Resend’s rate limit is lower, so practical capacity is whatever your Resend plan allows.
The request loop
┌────────────────── /hooks/:source ──────────────────────────┐
│ External service │
│ │ POST body + X-Signature │
│ ▼ │
│ Express │
│ │ express.json({ verify: bufferRawBody }) │
│ │ │
│ ▼ verifySignature(rawBody, secret, sigHeader) │
│ hmac → timingSafeEqual │
│ on mismatch → 401 │
│ │
│ ▼ format(source, payload) │
│ try import templates/<source>.js │
│ else default JSON pretty-print │
│ │
│ ▼ sendWithRetry({ subject, text, html }) │
│ resend.emails.send │
│ on 5xx → sleep 500ms → retry once │
│ │
│ ▼ maybeSlack({ subject, text }) (non-blocking) │
│ │
│ ▼ res.json({ ok: true }) │
└────────────────────────────────────────────────────────────┘Each piece, deep-dived
Body parser with raw buffer capture
HMAC verification needs the exact bytes the sender signed. Express's default JSON parser mutates the body before middleware sees it, breaking signatures.
express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }) buffers the raw body during parsing. The parsed JSON is on req.body, the raw bytes are on req.rawBody. HMAC verification uses rawBody; everything else uses body.
HMAC SHA-256 verification
A public webhook endpoint without verification is a free vehicle for spam, exploit attempts, and replay. HMAC ensures only senders that know the shared secret can deliver to your service.
Read the signature from X-Signature, X-Hub-Signature-256, or Stripe-Signature, strip any sha256= prefix. Compute HMAC SHA-256 of the raw body with the WEBHOOK_SECRET. Compare in constant time using crypto.timingSafeEqual to defeat timing attacks. Reject with 401 on mismatch.
Template loader
Webhook payloads vary wildly between services. Stripe's invoice.paid is nothing like GitHub's push. Templates let each source produce a useful email without a config-driven format DSL.
Try to dynamic-import src/templates/<source>.js. If it exists and exports a default format(payload) function, call it. The function returns { subject, text, html } — or null to fall through to the default formatter. Cache the imported module across requests.
Default JSON formatter
A brand-new webhook source should produce a useful email immediately, even before you write a template. Pretty-printed JSON is good enough for "what happened?" inspection.
Subject is "Webhook · <source>". Body is JSON.stringify(payload, null, 2). HTML body wraps the JSON in a <pre> block with HTML escaping to prevent XSS in email clients that render HTML.
Resend with single retry
Resend has occasional transient 5xx. One retry catches roughly 95% of them with no risk of unbounded latency.
Try once. On error with statusCode >= 500, sleep 500ms and try once more. On second failure, throw. Non-5xx errors (401 invalid key, 422 invalid payload) are not retried — those are programmer errors, not transient.
Slack fan-out
Email gives you the audit trail. Slack gives you the immediate signal. Sending to both lets you triage in the moment and look up details later.
If SLACK_WEBHOOK_URL is set, POST { text: subject + "\n" + text } to it. Fire-and-forget — do not await before responding to the sender. The webhook caller wants its 200 OK fast; Slack delays should not block that.
Why this, not that
Node 20 on Alpine
Smallest credible runtime. ~80MB final image, fast cold starts, native crypto in the base layer.
Debian Node — 250MB+ for no functional gain. Bun — fewer libraries, more breaking changes for a service that should be boring.
Express 4
Most-read web framework in Node. Vast middleware ecosystem. Performance overhead is irrelevant — Resend is the bottleneck.
Fastify / Hono — marginally faster, smaller community, fewer engineers can read the code at a glance.
Resend for delivery
Cleanest transactional email API on the market. Domain verification is one DNS record. Free tier of 3,000/mo covers most personal use.
SendGrid — heavier API, opaque pricing, more deliverability quirks. SES — needs domain verification, IAM, sandbox approval. Worth it at scale, overkill for a webhook hub.
crypto.timingSafeEqual
Constant-time comparison defeats timing attacks. A bare === comparison leaks signature bytes through wall-clock variance.
Bare === — fast, broken. Buffer.compare — not constant-time. Both are subtle CVEs waiting to happen.
Stateless design
No database = no migrations, no connection pool, no schema drift. One container, one job. Scaling is "run more containers".
Built-in retry queue — durability is real value, but it changes the operational model entirely. Belongs in a separate layer.
Dynamic import for templates
Drop a JS file in src/templates/, the next request picks it up. No build step, no registration, no manifest.
Compile-time imports — adding a template requires a redeploy. Configuration files — duplicates JavaScript with a worse syntax.
What you can measure
Failure modes you should expect
What’s next
SQS / Redis queue option
Optional opt-in queue layer for durability. 200-immediate, deliver in background. Replay on failure.
Webhook replay endpoint
Store the last N events in Redis or Postgres. /admin/replay/:id re-runs the formatter and send. Useful for missed deliveries.
Multi-tenant routing
Route different sources to different recipient emails. Per-source FROM addresses. Per-tenant secrets.
Built-in rate limiting
express-rate-limit with sane defaults. Per-IP and per-source caps. Stops automated abuse without an external WAF.
Discord and Telegram fan-out
Same pattern as Slack — a webhook URL plus a formatter. Two more env vars, two more output channels.
Structured logs
Pino with one-line JSON output. Trace IDs from inbound headers. Drop-in for any log aggregator.
Ready to run it?
Clone the repo. Set RESEND_API_KEY and NOTIFY_EMAIL. docker compose up. Five minutes from zero to your first email.