Skip to content
sarmakska edited this page Jun 7, 2026 · 5 revisions

webhook-to-email

Small, self-hosted webhook receiver. POST anything, get a clean Markdown email, with durable retries, a dead-letter inbox, per-provider HMAC verification, and optional Slack and Telegram fan-out.

Built by Sarma. MIT licence.


What this is

A small Node.js service that turns webhook traffic into formatted emails and, optionally, Slack and Telegram messages. Drop it next to any service that emits webhooks: Stripe, GitHub, Linear, Cal.com, Typeform, Vercel, Sentry, or your own cron jobs. Point the webhook at this service, it verifies the per-provider signature when a secret is configured, formats a readable Markdown email, and delivers it via Resend behind a retry queue. Anything that cannot be delivered after every retry lands in a durable dead-letter inbox you can browse over HTTP. It keeps no database; the only durable state is the dead-letter file.

Who this is for

  • Developers who want a single notification destination for all their SaaS webhooks rather than one inbox rule per service.
  • Teams who need a readable, formatted email per event instead of raw JSON in a logging tool, plus optional Slack and Telegram copies.
  • Anyone who wants durable delivery (retries with backoff and a dead-letter inbox) on a single container, without standing up Redis or a database.

Architecture in depth

The service is a single Express app split into focused modules. The request lifecycle is deliberately linear:

  1. Raw body capture. The JSON body parser stashes the exact raw bytes on req.rawBody before parsing. HMAC must run against the raw bytes, because re-serialising the parsed object would change whitespace and break the signature.
  2. Per-provider signature check. If WEBHOOK_SECRET is set, the verifier selects the provider profile from the :source segment. GitHub, Cal.com and Linear use hex HMACs in their own headers; Stripe signs <timestamp>.<body> and its profile additionally rejects stale timestamps to defeat replay. Everything else uses a generic sha256=<hex> scheme. Comparison is constant-time.
  3. Formatting. The :source segment selects a template at src/templates/<source>.js. A template returns { subject, markdown }, and the renderer derives a styled HTML body and a plain-text fallback. A template can also return { skip: true } to drop a noisy event without emailing. A template that throws is caught and logged, and the request falls through to the default formatter, which renders the JSON payload as a Markdown code block.
  4. Enqueue and respond. The rendered message is placed on an in-memory retry queue and the endpoint returns 202 immediately. Response latency stays flat regardless of how Resend is behaving.
  5. Delivery with retries. A background worker delivers via Resend with exponential backoff and full jitter, up to RETRY_MAX_ATTEMPTS. On success it fans out to Slack and Telegram if configured. On exhausting every attempt the job is written to the dead-letter inbox rather than dropped.
sequenceDiagram
    participant Src as Webhook source
    participant App as webhook-to-email
    participant Q as Retry queue
    participant R as Resend
    participant DL as Dead-letter inbox
    Src->>App: POST /hooks/:source (raw body)
    App->>App: verify HMAC (per provider)
    App->>App: format to Markdown, render
    App->>Q: enqueue
    App-->>Src: 202 queued
    Q->>R: send email (retry with backoff)
    alt delivered
        R-->>Q: ok
        Q-)Src: fan out Slack and Telegram (best effort)
    else attempts exhausted
        Q->>DL: record failure
    end
Loading

See Architecture for the module map and design rationale.

Real-world examples

These are the bundled templates, taken from the repository.

Stripe payment alerts. Point a Stripe webhook at https://your-host/hooks/stripe and set your Stripe signing secret as WEBHOOK_SECRET. The bundled template turns invoice.paid and customer.subscription.created into clean Markdown emails with the amount, customer and invoice number. Stripe's timestamped signature is verified, including the timestamp tolerance, by the stripe verifier profile.

GitHub push and PR notifications. Add a repository webhook to https://your-host/hooks/github with content type application/json and your secret as WEBHOOK_SECRET. GitHub signs with X-Hub-Signature-256, which the github profile reads automatically. Pushes arrive as a per-commit summary; pull request events arrive with the action, number and title.

Linear issue notifications. Point a Linear webhook at https://your-host/hooks/linear. Linear signs with the Linear-Signature header, read by the linear profile. Issue create and update events arrive with the identifier, title, state and priority.

Cal.com booking forwards. Point a Cal.com BOOKING_CREATED webhook at https://your-host/hooks/cal and the booking lands in your inbox with attendee, title and start time.

Anything else, with zero config. Send any JSON to /hooks/<name> where no template exists and you get a Markdown-rendered email of the payload. Promote it to a real template later.

Troubleshooting

  • The service exits immediately on start. RESEND_API_KEY and NOTIFY_EMAIL are mandatory. The process logs the missing variable and exits with code 1 by design. Check your .env is present and loaded.
  • Every request returns 401 Invalid signature. WEBHOOK_SECRET is set but the signature does not match. Confirm the sender signs the exact raw body, that the secret matches on both ends, and that you are hitting the right source path so the correct provider profile is used. For Stripe, a 401 can also mean a clock skew larger than the timestamp tolerance. To test without signing, unset WEBHOOK_SECRET and restart.
  • A request returns 202 but no email arrives. A 202 means queued, not delivered. Check GET /dead-letter: if the job failed every retry it will be listed there with the error. The most common cause is an invalid RESEND_API_KEY or an unverified FROM_EMAIL.
  • Jobs keep landing in the dead-letter inbox. Read the error field on each entry. It is the message Resend returned. Fix the underlying cause (key, sender domain, rate limit), then replay the failure. With WEBHOOK_REPLAY_TOKEN set you can POST /dead-letter/:id/replay to re-enqueue a single stored failure; see Retry-and-Dead-Letter.
  • Slack or Telegram messages are missing but email works. Fan-out is best-effort and never fails the job. Check the stdout log for a fan-out warning. Confirm the Slack webhook URL is still active, or that both TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set.
  • My template never fires. The filename must match the :source segment exactly, for example /hooks/stripe needs src/templates/stripe.js, and the function must return an object with a subject. Returning null is the documented way to fall through to the default formatter.
  • Large payloads are rejected. The JSON body limit is 1mb. Raise the bodyLimit passed to createApp in src/app.js if a source sends more.

Wiki pages

Repository

github.com/sarmakska/webhook-to-email

Clone this wiki locally