How it works · Webhook-to-Email

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.

TL;DR

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.

<span class="dim">External: POST /hooks/stripe</span> <span class="dim">Body: { type: "invoice.paid", data: {...} }</span> <span class="dim">X-Signature: sha256=a3f5...</span> <span class="hl">Step 1 · Parse</span> express.json + rawBody buffer <span class="hl">Step 2 · Verify</span> hmac(rawBody, SECRET) === sig? <span class="ok">✓ match</span> <span class="hl">Step 3 · Load</span> ./templates/stripe.js <span class="hl">Step 4 · Format</span> format(payload) → { subject, text, html } <span class="hl">Step 5 · Send</span> resend.emails.send(...) <span class="ok">✓ 200 OK</span> <span class="hl">Step 6 · Slack</span> POST { text } → SLACK_WEBHOOK_URL <span class="dim">(fire-and-forget)</span> <span class="hl">Step 7 · Reply</span> 200 { ok: true } <span class="ok">~350ms p50. ~700ms p95. Stateless throughout.</span>
Core data flow

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 })                                │
└────────────────────────────────────────────────────────────┘
Subsystems

Each piece, deep-dived

Body parser with raw buffer capture

Why it exists

HMAC verification needs the exact bytes the sender signed. Express's default JSON parser mutates the body before middleware sees it, breaking signatures.

How it actually works

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

Why it exists

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.

How it actually works

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

Why it exists

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.

How it actually works

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

Why it exists

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.

How it actually works

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

Why it exists

Resend has occasional transient 5xx. One retry catches roughly 95% of them with no risk of unbounded latency.

How it actually works

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

Why it exists

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.

How it actually works

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.

Technology choices

Why this, not that

Node 20 on Alpine

Why we use it

Smallest credible runtime. ~80MB final image, fast cold starts, native crypto in the base layer.

Why not the alternative

Debian Node — 250MB+ for no functional gain. Bun — fewer libraries, more breaking changes for a service that should be boring.

Express 4

Why we use it

Most-read web framework in Node. Vast middleware ecosystem. Performance overhead is irrelevant — Resend is the bottleneck.

Why not the alternative

Fastify / Hono — marginally faster, smaller community, fewer engineers can read the code at a glance.

Resend for delivery

Why we use it

Cleanest transactional email API on the market. Domain verification is one DNS record. Free tier of 3,000/mo covers most personal use.

Why not the alternative

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

Why we use it

Constant-time comparison defeats timing attacks. A bare === comparison leaks signature bytes through wall-clock variance.

Why not the alternative

Bare === — fast, broken. Buffer.compare — not constant-time. Both are subtle CVEs waiting to happen.

Stateless design

Why we use it

No database = no migrations, no connection pool, no schema drift. One container, one job. Scaling is "run more containers".

Why not the alternative

Built-in retry queue — durability is real value, but it changes the operational model entirely. Belongs in a separate layer.

Dynamic import for templates

Why we use it

Drop a JS file in src/templates/, the next request picks it up. No build step, no registration, no manifest.

Why not the alternative

Compile-time imports — adding a template requires a redeploy. Configuration files — duplicates JavaScript with a worse syntax.

Performance & observability

What you can measure

350ms
p50 end-to-end
Bottleneck is Resend, not us
1k/s
throughput per CPU core
Before Resend rate limits hit
80MB
Docker image size
Alpine Node 20 base

Failure modes you should expect

Sender does not include X-Signature
Cause: WEBHOOK_SECRET is set but the sender is not configured to sign
Fix: Configure the sender (Stripe / GitHub / etc.) with the secret, or unset WEBHOOK_SECRET to disable verification
Resend returns 422 invalid email
Cause: NOTIFY_EMAIL or FROM_EMAIL malformed, or sending domain unverified
Fix: Verify the from-domain in Resend's dashboard and use a real recipient email
Resend 5xx for sustained period
Cause: Resend incident
Fix: Two-attempt window means messages during the outage are lost. Add a queue layer if durability matters
Template throws an error
Cause: Bug in custom format(payload) function
Fix: Wrapped in try/catch — falls through to default formatter so the email still goes out
Body too large
Cause: Express default body size limit is 100kb
Fix: Tune express.json({ limit: "1mb" }) for sources that send larger payloads
Slack webhook 429
Cause: Slack incoming webhook rate limited
Fix: Slack POST is fire-and-forget — the email still goes out, only Slack notification is missed
Future direction

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.