Open source · MIT · HMAC · retries · dead-letter inbox

Every webhook, one clean email.

A small, self-hosted webhook receiver. POST anything from Stripe, GitHub, Linear, Cal.com or anywhere else and get a clean Markdown email. Per-provider HMAC verification, exponential-backoff retries, a durable dead-letter inbox, optional Slack and Telegram fan-out. One container, no database.

202
enqueue response
5
retries by default
~80MB
Docker image
4
provider profiles
MIT
license

Why this exists

Every modern SaaS tool emits webhooks. Stripe, GitHub, Linear, Cal.com, Sentry, Vercel, Typeform, your own cron jobs. Each one wants its own destination, and each one signs requests in a slightly different way. The default response is to wire each one into Zapier or n8n, pay per task, and end up with one workflow per event type spread across two tools.

Half the time what you actually want is a single, readable, formatted email per event plus an optional Slack and Telegram copy. With per-provider signature verification so you know the request is real. With retries that survive transient Resend hiccups. With a dead-letter inbox so a delivery failure never silently swallows a webhook.

Webhook-to-Email is exactly that. A single Node container, no database, around two hundred lines of business code, four bundled provider profiles, and a templating contract that takes about three lines of JavaScript to extend. Deploy it once, point every webhook source at it, and stop reasoning about webhook delivery as a problem.

The design choices are deliberate: stateless apart from the dead-letter file, in-memory retry queue, best-effort fan-out, raw-body capture for honest HMAC, constant-time signature comparison. Simple to operate, simple to extend, simple to put behind a reverse proxy with TLS.

Why this matters

A dropped webhook is worse than a noisy one.

Workflow tools and SaaS gateways will happily lose a delivery to a transient Resend hiccup and never tell you. Webhook-to-Email refuses that path: every job lives in a retry queue with exponential backoff, every exhausted job lands in a JSONL dead-letter inbox you can browse over HTTP, every signature is verified against the raw bytes with a constant-time comparison and a Stripe replay window. Simple to run, hard to silently lose.

Built-in features

Everything below ships in the repository. One env var (RESEND_API_KEY) and one recipient (NOTIFY_EMAIL) and the service is live.

Per-source endpoints

POST /hooks/stripe, /hooks/github, /hooks/linear, /hooks/cal, /hooks/anything. The :source segment selects a template and a signature profile. Anything without a template falls through to the default JSON formatter.

Per-provider HMAC verification

Set WEBHOOK_SECRET and every request must carry a valid signature. The verifier knows Stripe's timestamped header with replay protection, GitHub's X-Hub-Signature-256, Cal.com, Linear-Signature, and falls back to a generic sha256=<hex> for everything else. Constant-time comparison.

Retry queue with backoff

Delivery is decoupled from the request. The endpoint returns 202 immediately and a background worker delivers with configurable attempts, exponential backoff, and full jitter. Response latency stays flat regardless of how Resend is behaving.

Durable dead-letter inbox

A job that exhausts every retry is written to a JSON Lines file and kept in a bounded in-memory ring. Browse recent failures at GET /dead-letter. Undelivered jobs are flushed to the inbox on graceful shutdown.

Per-source Markdown templates

Drop a JS file in src/templates/. Export format(payload) returning { subject, markdown } or null to fall through. The renderer produces a styled HTML email with an inline-CSS body and a clean plain-text fallback.

Resend for delivery

Resend is the cleanest transactional email API on the market: verified domains, deliverability reporting, generous free tier. One env var (RESEND_API_KEY) and you are sending. Single retry on transient 5xx, all wrapped in the retry queue.

Slack and Telegram fan-out

Set SLACK_WEBHOOK_URL for Slack Block Kit messages and TELEGRAM_BOT_TOKEN with TELEGRAM_CHAT_ID for Telegram. Both fan-outs are best-effort and never fail email delivery.

Raw body for HMAC integrity

The JSON body parser stashes the exact raw bytes on req.rawBody before parsing. HMAC verification runs against the raw bytes because re-serialising would change whitespace and break Stripe-style signatures.

Health and queue visibility

GET / reports queue depth and dead-letter count. GET /health is for liveness. GET /dead-letter lists recent failures with the original payload and the error. Enough operational signal to run it confidently on a single box.

Single-container deploy

Multi-stage Alpine Dockerfile, docker-compose.yml with a health check and a persistent volume for the dead-letter inbox. ~80MB image. Runs unchanged on Fly.io, Render, Railway, or any container host.

Bundled templates as worked examples

Stripe (invoice.paid, customer.subscription.created), GitHub (push, pull_request), Linear (issue create / update), Cal.com (booking created). Each is short and readable and doubles as a template-writing reference.

Tiny resource footprint

Single-process Node, ~50MB RSS at idle, ~80MB under sustained load. Comfortable on the smallest VPS tier. No database, no Redis, no external dependencies beyond Resend.

Stateless apart from dead-letter

No database, no migrations, no connection pool. The only durable state is the dead-letter JSONL file. If you need stronger delivery guarantees, put a real broker (Redis, NATS, SQS) in front of this service.

Examples and templates in repo

examples/ ships working curl invocations and template files. Copy a template, change the subject and the markdown shape, restart, and you have a new provider integrated.

Architecture at a glance

Deliberately linear. Verify, format, enqueue, respond 202, deliver in the background with retries, fan out on success, dead-letter on failure.

Request lifecycle

From a Stripe POST to an email in your inbox, with a Slack copy on the side. The endpoint returns 202 immediately, delivery happens out of band.

rendering
Request lifecycle: verify, format, enqueue, respond, deliver with retries, fan out, dead-letter on exhaustion.

Per-provider HMAC selection

The :source segment selects which provider profile verifies the signature. Stripe additionally rejects stale timestamps to defeat replay.

rendering
HMAC verification: raw-body capture, per-provider profiles, constant-time comparison, Stripe replay protection.

Retry queue and dead-letter

Exponential backoff with full jitter up to RETRY_MAX_ATTEMPTS. Exhausted jobs are written to JSONL and tracked in an in-memory ring browsable over HTTP.

rendering
Retry queue plus dead-letter inbox: backoff with jitter, JSONL persistence, HTTP browse.

Quick start

Five commands to running. WEBHOOK_SECRET, Slack, and Telegram are all optional and can be added later.

# 1. Clone
git clone https://github.com/sarmakska/webhook-to-email.git
cd webhook-to-email

# 2. Install
npm install

# 3. Configure
cp .env.example .env
# then edit .env and set at minimum:
#   RESEND_API_KEY=re_...
#   NOTIFY_EMAIL=you@yourdomain.com
# optional:
#   WEBHOOK_SECRET=...              (enables HMAC verification)
#   SLACK_WEBHOOK_URL=...           (enables Slack fan-out)
#   TELEGRAM_BOT_TOKEN=...
#   TELEGRAM_CHAT_ID=...

# 4. Run
npm start

# 5. Test from another terminal
curl -X POST http://localhost:3000/hooks/test \
  -H "Content-Type: application/json" \
  -d '{"hello":"world","user":{"name":"Sarma"}}'
# → 202 {"ok":true,"queued":true}
# → an email titled "Webhook: test" lands in your inbox

Full walkthrough including Stripe and GitHub setup: Quick-Start wiki page.

Writing a template

Templates are small functions that return Markdown. The renderer produces the styled HTML body and plain-text fallback for you. Return null to fall through to the default JSON formatter.

// src/templates/stripe.js
// Selected by /hooks/stripe; returns { subject, markdown } or null.

module.exports = function format(payload) {
  if (payload.type === 'invoice.paid') {
    const inv = payload.data.object
    const amount = (inv.amount_paid / 100).toFixed(2)
    const currency = (inv.currency || 'gbp').toUpperCase()
    return {
      subject: `Invoice paid: ${amount} ${currency}`,
      markdown: [
        '# Invoice paid',
        '',
        `**Amount:** ${amount} ${currency}`,
        `**Customer:** ${inv.customer_email}`,
        `**Invoice:** ${inv.number}`,
        '',
        `[Open in Stripe](${inv.hosted_invoice_url})`,
      ].join('\n'),
    }
  }

  if (payload.type === 'customer.subscription.created') {
    const sub = payload.data.object
    return {
      subject: `New subscription: ${sub.id}`,
      markdown: [
        '# New subscription',
        '',
        `**Customer:** ${sub.customer}`,
        `**Status:** ${sub.status}`,
      ].join('\n'),
    }
  }

  // Return null to fall through to the default JSON formatter.
  return null
}

Per-provider HMAC

The verifier knows each provider’s signing scheme by name. Stripe uses a timestamped header and rejects stale signatures; everything else uses a hex HMAC in a provider-specific header.

// src/verify.js (sketch of the per-provider HMAC selector)
const crypto = require('crypto')

const PROFILES = {
  stripe: {
    header: 'stripe-signature',
    // Stripe signs "<timestamp>.<body>" and rejects stale timestamps
    verify(raw, header, secret) {
      const parts = Object.fromEntries(
        header.split(',').map(p => p.split('=')),
      )
      const ts = Number(parts.t)
      if (Math.abs(Date.now() / 1000 - ts) > 300) return false // 5 min skew
      const expected = crypto
        .createHmac('sha256', secret)
        .update(`${parts.t}.${raw}`)
        .digest('hex')
      return crypto.timingSafeEqual(
        Buffer.from(parts.v1, 'hex'),
        Buffer.from(expected, 'hex'),
      )
    },
  },
  github: { header: 'x-hub-signature-256', verify: verifyShaHex },
  linear: { header: 'linear-signature',    verify: verifyShaHex },
  cal:    { header: 'x-cal-signature-256', verify: verifyShaHex },
  // default: generic sha256=<hex>
}

function verifyShaHex(raw, header, secret) {
  const sig = header.startsWith('sha256=') ? header.slice(7) : header
  const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex'),
  )
}

Use cases

What people actually run this for.

Single notification destination

Stop juggling twelve different webhook inboxes from twelve different SaaS tools. Route everything through this and into one email, one Slack channel, and one Telegram chat.

Stripe payment alerts

Point a Stripe webhook at /hooks/stripe with your signing secret as WEBHOOK_SECRET. The bundled template turns invoice.paid and subscription.created into Markdown emails with amount, customer, and invoice number. Stripe's timestamped signature is verified including the replay window.

GitHub push and PR notifications

Add a repo webhook to /hooks/github with content type application/json. GitHub signs with X-Hub-Signature-256, read automatically. Pushes arrive as a per-commit summary; PR events with action, number, and title.

Linear issue alerts

Point a Linear webhook at /hooks/linear. Linear signs with Linear-Signature, verified by the linear profile. Issue create and update events arrive with identifier, title, state, and priority.

Cal.com booking forwards

Forward every Cal.com booking to your personal inbox immediately. Attendee details, title, and start time, on the device you actually check.

Internal cron audit trail

Your nightly Postgres backup posts success or failure to /hooks/cron. You get an email if anything goes wrong, no email if everything works.

Tech stack

Node.js 20Express 4ResendMarkdown rendering (no deps)DockerAlpine Linuxdocker-composeSlack Block KitTelegram Bot APIFly.io / Render / Railway

Webhook-to-Email vs alternatives

Zapier and n8n are workflow tools. Svix is a managed webhook gateway. Webhook-to-Email is a small, self-hosted purpose-built service. Rows reflect the public capabilities of each.

CapabilityWebhook-to-EmailZapiern8nSvix
Webhook ingestPer-source endpointsGeneric webhook stepWebhook nodeManaged ingest
HMAC verificationPer-provider profiles + Stripe replayManualManual code nodeBuilt in (paid)
Email deliveryResend (built in)Email stepEmail nodeBYO destination
Retry queueIn-process, exponential backoffManagedManualManaged
Dead-letter inboxJSONL file + HTTP browseHosted historyExecutions logHosted
Slack fan-outBuilt inExtra step (cost)Slack nodeBYO
Telegram fan-outBuilt inExtra step (cost)Telegram nodeBYO
Self-host~80MB Docker, single boxSaaS onlyYesCloud-first
Database requiredNoneHostedYes (Postgres / SQLite)Hosted
LicenseMITCommercialFair-codeCommercial

An honest limitations list

Where the simplicity costs you something, and what to put in front when those costs matter.

In-memory retry queue

A hard crash can drop a job that is mid-retry. Undelivered jobs are flushed to the dead-letter file on graceful shutdown only. If you need stricter guarantees, put Redis Streams, NATS JetStream, or SQS in front.

Single recipient list per service

Comma-separated recipients are supported but rule-based routing is not. If you need "send invoice.paid to finance, send subscription.created to growth", run two instances or extend the template to call your own router.

No cross-instance coordination

You can run several instances behind a load balancer for capacity, but each has its own queue and its own dead-letter file. By design, not a bug. If you need shared state, put a shared broker in front.

Throughput ceiling around 1k req/s

A single Node process handles roughly a thousand requests per second on one CPU core before Resend rate limits or process saturation become the bottleneck. Plenty for SaaS webhook traffic, not enough for sustained heavy load.

Default JSON body limit is 1mb

Raise the bodyLimit passed to createApp in src/app.js if a source sends more. Large payloads also slow HMAC verification since it runs over the raw bytes.

No built-in TLS

Run behind a reverse proxy with TLS (Caddy, Nginx, Traefik, or your platform's ingress). The service itself speaks plain HTTP.

Frequently asked

How does signature verification work?+

When WEBHOOK_SECRET is set, every request must carry a valid signature. The verifier looks at the :source segment and selects a per-provider profile: Stripe (timestamped, replay-protected), GitHub (X-Hub-Signature-256), Linear (Linear-Signature), Cal.com, and a generic sha256=<hex> fallback for everything else. Comparison is constant-time. Raw bytes are stashed before JSON parsing because re-serialising would change whitespace and invalidate signatures.

What happens when a delivery fails?+

The worker retries with exponential backoff and full jitter up to RETRY_MAX_ATTEMPTS (default 5). When attempts are exhausted the job is written to the dead-letter JSONL file and added to the in-memory ring you can browse at GET /dead-letter. The error message and the original payload are preserved so you can fix the cause and replay with a short script.

What if the service crashes mid-retry?+

The retry queue is in-memory. On graceful shutdown undelivered jobs are flushed to the dead-letter inbox, but a hard crash can drop a job that is mid-retry. If that matters, put a real broker (Redis Streams, NATS JetStream, SQS) in front of this service. The README is honest about this trade-off; the design choice was simplicity over recovery from hard crashes.

Why no database?+

Statelessness is the design. No migrations, no connection pool, no failures from a DB outage. The only durable state is the dead-letter JSONL file, which is enough for the operational signal you need. Anything more is a queue layer's job, not this service's.

How do I add a new source?+

Drop a JS file in src/templates/ named after the source segment (so /hooks/stripe needs src/templates/stripe.js). Export format(payload) returning { subject, markdown } or null to fall through to the default JSON formatter. POST to /hooks/<source> and the template fires. The renderer derives the HTML body and plain-text fallback from your Markdown automatically.

How fast is it?+

A single Node process handles roughly a thousand requests per second on one CPU core before Resend rate limits become the bottleneck. RSS is ~50MB at idle, ~80MB under load. For typical webhook traffic from SaaS tools this is several orders of magnitude more headroom than you need.

Can I run several instances?+

Yes for horizontal capacity, but each instance has its own in-memory retry queue and its own dead-letter file. There is no cross-instance coordination by design. If you need delivery guarantees across instances, put a shared broker in front. For typical SaaS webhook volumes one container is enough.

Does the Slack and Telegram fan-out block email delivery?+

No. Both fan-outs are best-effort and run after the email send. A failure on either is logged and ignored. The contract is "email is the audit trail, Slack and Telegram are the immediate signal".

Open source · MIT

Use it. Fork it. Ship it.

MIT licensed. Pull requests welcome, especially new provider profiles (Sentry, Vercel, Typeform, PostHog) and new template examples.

Ready to deploy?

Clone the repo, fill in RESEND_API_KEY and NOTIFY_EMAIL, docker compose up. Point every webhook source you care about at /hooks/<source>.

All open-source projects