Webhook to Email

May 31, 2026 ยท View on GitHub

POST any webhook, get a clean formatted email, with durable retries and optional Slack and Telegram fan-out.

License: MIT Top language Last commit

A small, self-hosted Node.js service that turns webhook traffic from Stripe, GitHub, Cal.com, Linear or anything else into readable, Markdown-rendered emails. It verifies each request with per-provider HMAC signatures, formats the payload with a per-source template, and delivers through Resend behind a retry queue with exponential backoff. Anything that cannot be delivered after every retry lands in a durable dead-letter inbox you can browse over HTTP, so a webhook is never silently lost.

What is in the box

  • Single endpoint. POST /hooks/:source accepts any JSON body. GET / reports queue depth and dead-letter count, GET /health is for liveness, and GET /dead-letter lists recent delivery failures.
  • Per-provider HMAC verification. Set WEBHOOK_SECRET and every request must carry a valid signature. The verifier knows the signing scheme for GitHub, Cal.com, Linear and Stripe, including Stripe's timestamped header with replay protection, and falls back to a generic sha256=<hex> scheme for everything else.
  • Retry queue with exponential backoff. Delivery is decoupled from the request. The endpoint returns 202 immediately and a background worker delivers with configurable attempts and exponential backoff with jitter.
  • Dead-letter inbox. A job that exhausts every retry is written to a JSON Lines file and kept in a bounded in-memory ring, browsable at GET /dead-letter. Undelivered jobs are flushed to the inbox on shutdown.
  • Rich Markdown email rendering. Templates return Markdown and the built-in renderer produces a styled, inline-CSS HTML body plus a clean plain-text fallback. No Markdown dependency, and all payload values are HTML-escaped.
  • Slack and Telegram fan-out. Set SLACK_WEBHOOK_URL for Slack Block Kit messages and TELEGRAM_BOT_TOKEN plus TELEGRAM_CHAT_ID for Telegram. Both are best-effort and never block or fail email delivery.
  • Bundled templates. Stripe, GitHub, Cal.com and Linear formatters are ready to use and double as worked examples.
  • Container-ready. Multi-stage Alpine Dockerfile and a docker-compose file with a health check and a persistent volume for the dead-letter inbox.

Quickstart

git clone https://github.com/sarmakska/webhook-to-email.git
cd webhook-to-email
npm install
cp .env.example .env   # fill in RESEND_API_KEY and NOTIFY_EMAIL
npm start

Then send a test webhook from another terminal:

curl -X POST http://localhost:3000/hooks/test \
  -H "Content-Type: application/json" \
  -d '{"hello":"world","user":{"name":"Sarma"}}'

You get a 202 {"ok":true,"queued":true} straight away, the worker delivers the email, and an email titled "Webhook: test" lands in your inbox.

Architecture

flowchart LR
    S[Webhook source<br/>Stripe, GitHub, Cal.com, Linear...] -->|POST /hooks/:source| V[Verify HMAC<br/>per provider]
    V -->|signature ok| F[Format via template<br/>to Markdown]
    F --> Q[Retry queue<br/>exponential backoff]
    Q -->|deliver| R[Resend email API]
    Q -.->|fan-out| SL[Slack]
    Q -.->|fan-out| TG[Telegram]
    Q -->|attempts exhausted| DL[Dead-letter inbox<br/>GET /dead-letter]
    R --> I[Your inbox]

A request comes in, the signature is checked when a secret is configured, the payload is run through a matching template into Markdown, and the rendered message is enqueued. The endpoint returns 202 immediately. A background worker delivers via Resend with exponential-backoff retries, fans out to Slack and Telegram on success, and writes any permanently failed job to the dead-letter inbox. State lives in a single in-memory queue plus the dead-letter file, which keeps the service to one container with no external database.

Configuration

Env varRequiredDefaultPurpose
RESEND_API_KEYyesnoneAPI key from resend.com
NOTIFY_EMAILyesnoneRecipient. Comma-separate for several
FROM_EMAILnowebhooks@onresend.devUse a verified domain in production
WEBHOOK_SECRETnononeIf set, requests must carry a valid HMAC signature
SLACK_WEBHOOK_URLnononeIf set, events fan out to Slack
TELEGRAM_BOT_TOKENnononeTelegram bot token (needs TELEGRAM_CHAT_ID too)
TELEGRAM_CHAT_IDnononeTelegram chat to post to
DEAD_LETTER_FILEno./data/dead-letter.jsonlPath for the dead-letter JSONL file
RETRY_MAX_ATTEMPTSno5Delivery attempts before dead-lettering
RETRY_BASE_DELAY_MSno500Base backoff delay
RETRY_MAX_DELAY_MSno30000Backoff cap
PORTno3000Server port

Adding a source template

Drop a JavaScript file in src/templates/. It receives the parsed payload and returns { subject, markdown }, or null to fall through to the default JSON formatter. The renderer derives the HTML and plain-text bodies from your Markdown.

// src/templates/stripe.js
module.exports = function format(payload) {
  if (payload.type === 'invoice.paid') {
    const inv = payload.data.object
    const amount = (inv.amount_paid / 100).toFixed(2)
    return {
      subject: `Invoice paid: ${amount} GBP`,
      markdown: [
        '# Invoice paid',
        '',
        `**Amount:** ${amount} GBP`,
        `**Customer:** ${inv.customer_email}`,
      ].join('\n'),
    }
  }
  return null
}

POST to /hooks/stripe and the template fires. See examples/ and the bundled Stripe, GitHub, Cal.com and Linear templates for more.

When to use this

  • You want a single notification destination for webhooks from several SaaS tools instead of one inbox rule per service.
  • You want a readable, formatted email per event rather than raw JSON in a logging tool, plus optional Slack and Telegram copies.
  • You want durable delivery on a single container: retries with backoff and a dead-letter inbox, without standing up Redis or a database.
  • You want something small, auditable and self-hosted that you can extend with a few lines of JavaScript.

When not to use this

  • You need delivery guarantees across process restarts and crashes for in-flight jobs. The retry queue is in-memory; undelivered jobs are flushed to the dead-letter file on a clean shutdown, but a hard crash can drop a job that is mid-retry. Put a real broker in front if that matters.
  • You need to fan a single source out to many recipients with per-event routing rules. Comma-separated recipients are supported, but rule-based routing is not.
  • You need very high throughput. A single Node process is fine for typical webhook volumes; beyond that, run several behind a queue.

Deploy

# Docker
docker build -t webhook-to-email .
docker run -d --env-file .env -p 3000:3000 -v webhook-data:/app/data webhook-to-email

# docker-compose
docker compose up -d

It also runs unchanged on Fly.io, Render and Railway. Set the env vars and point your webhooks at /hooks/<source>. Mount a volume at /app/data to persist the dead-letter inbox.

Documentation

Full docs, deeper architecture, per-source template guides, an HMAC reference, retry and dead-letter internals, fan-out setup, a production checklist and troubleshooting live in the project wiki.

Licence

MIT. Built by Sarma.


More open source by Sarma

Part of a portfolio of production-shaped open-source repositories built and maintained by Sarma.

RepositoryWhat it is
Sarmalink-aiMulti-provider OpenAI-compatible AI gateway with 14-engine failover and intent-based plugin auto-routing
agent-orchestratorDurable multi-agent workflows in TypeScript with deterministic replay and Inspector UI
voice-agent-starterSub-second full-duplex voice agent loop. WebRTC, mediasoup, pluggable STT / LLM / TTS
ai-eval-runnerEvals as code. Python, DuckDB, FastAPI viewer, regression mode for CI
mcp-server-toolkitProduction Model Context Protocol server starter (Python / FastAPI)
local-llm-routerOpenAI-compatible proxy that routes to Ollama or cloud providers based on policy
rag-over-pdfMinimal end-to-end RAG starter for PDF corpora
receipt-scannerVision OCR for receipts with Zod-validated JSON output
webhook-to-emailWebhook receiver that forwards events to email via Resend
k8s-ops-toolkitHelm chart for shipping Next.js to Kubernetes with full observability stack
terraform-stackVercel + Supabase + Cloudflare + DigitalOcean modules in one Terraform repo
staff-portalOpen-source HR / ops portal: leave, attendance, expenses, kiosk mode

Engineering essays at sarmalinux.com/blog. All projects at sarmalinux.com/open-source