Webhook-to-Email
A tiny, production-grade webhook receiver. POST anything, get an email. Optional Slack fan-out, HMAC verification, single-attempt retry on 5xx. Stripe, GitHub, Cal.com, Typeform, internal cron jobs. One notification destination for the lot.
Why this exists
Modern SaaS tools all emit webhooks. Stripe, GitHub, Cal.com, Typeform, Vercel, Linear, Sentry, Twilio. Useful events live in webhook payloads — invoice paid, deploy succeeded, booking made. Most of those events deserve at least an email or a Slack ping.
The conventional answer is Zapier or Make.com. Per-event pricing, vendor lock-in, opaque retries, surprise bills. The other answer is "build it in the SaaS that emitted the event" — except the SaaS rarely lets you template the email properly, and now your notification logic is scattered across twelve different products.
Webhook-to-Email is the third answer. 200 lines of Node. Docker one-liner. POST anything to /hooks/<source>, get an email. Drop a JS template in src/templates/ when you want a custom format. HMAC verification when you need it. Slack fan-out when you want it. Self-hosted, predictable, MIT-licensed.
Built-in features
Everything below works out of the box. Clone, set RESEND_API_KEY, run.
Per-source endpoints
POST /hooks/stripe, /hooks/github, /hooks/cal, /hooks/anything. Each source resolves its own template if one exists, or falls through to the default JSON formatter.
HMAC SHA-256 verification
Set WEBHOOK_SECRET and every request must include an X-Signature header. Both raw hex and sha256= prefixed formats are accepted, since Stripe, GitHub, and others emit different prefixes.
Per-source templates
Drop a JS file in src/templates/. Export a format(payload) function that returns { subject, text, html }. POST to /hooks/<source> and the template fires. Return null to fall through to the default formatter.
Resend for delivery
Resend is the cleanest transactional email API on the market. Verified domains, deliverability tracking, generous free tier. Single env var and you are sending.
Slack fan-out
Set SLACK_WEBHOOK_URL and the same payload is also POSTed to Slack as a formatted message. Email for the audit trail, Slack for the immediate signal.
Single retry on 5xx
Resend has occasional transient 5xx errors. One retry with 500ms backoff catches almost all of them. More retries belong in a queue layer.
No database, no state
Stateless by design. No migrations, no connection pool, no failures from database outages. If you need a queue, put one in front of this service.
Docker one-liner
docker run -d --env-file .env -p 3000:3000 webhook-to-email. The image is ~80MB on Alpine Node 20. docker-compose.yml ships in the repo for one-command deploys.
High throughput on tiny resources
Single-process Node, ~50MB RSS at idle, ~80MB under sustained load. Handles roughly 1,000 requests per second on one CPU core before Resend rate limits become the bottleneck.
Examples for the common sources
examples/ ships working curl invocations and template files for Stripe (invoice.paid, customer.subscription.created), GitHub (push, pull_request), Cal.com (booking created), Typeform (form response).
Tech stack
Architecture sketch
Stateless. No database. Logs to stdout. Trivially deployable.
┌─────────────────────────────────────────────────────────────┐
│ POST /hooks/:source │
│ External service (Stripe, GitHub, Cal.com, ...) │
│ │ │
│ ▼ verify HMAC SHA-256 (if WEBHOOK_SECRET set) │
│ │ X-Signature: sha256=<hex(hmac(body, secret))> │
│ │ │
│ ▼ load src/templates/<source>.js (if exists) │
│ │ format(payload) → { subject, text, html } │
│ │ else: pretty-print JSON as default │
│ │ │
│ ▼ resend.emails.send({ from, to, subject, text, html })│
│ │ on 5xx → retry once with 500ms backoff │
│ │ │
│ ▼ if SLACK_WEBHOOK_URL set: │
│ │ POST formatted message to Slack incoming webhook │
│ │ │
│ ▼ 200 OK { ok: true } │
└─────────────────────────────────────────────────────────────┘Quick start
Local first, then Docker, then cloud. Same env vars throughout.
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
# In another terminal:
curl -X POST http://localhost:3000/hooks/test \
-H "Content-Type: application/json" \
-d '{"hello": "world", "user": {"name": "Sarma"}}'
# Check your inbox. Email titled: "Webhook · test"# Production: Docker docker build -t webhook-to-email . docker run -d --env-file .env -p 3000:3000 webhook-to-email
Use cases
What people actually run this for.
Single notification destination
Stop juggling 12 different webhook inboxes from 12 different SaaS tools. Route everything through this and into one email or Slack channel.
Stripe event summary
Stripe sends 100+ webhook types. Template the ones you care about (invoice.paid, subscription.created, payment_intent.failed) and ignore the rest.
Cal.com booking alerts
Forward every Cal.com booking to your personal email immediately. Customer details, time, service, on the device you actually check.
Internal cron job audit trail
Your nightly Postgres backup script POSTs success/failure to /hooks/cron. You get an email if anything goes wrong, no email if it works.
Use it. Fork it. Ship it.
MIT licensed. No strings attached. Pull requests welcome — SQS / Redis queue option, replay endpoint, multi-tenant routing are all on the roadmap.