-
Notifications
You must be signed in to change notification settings - Fork 0
Home
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.
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.
- 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.
The service is a single Express app split into focused modules. The request lifecycle is deliberately linear:
-
Raw body capture. The JSON body parser stashes the exact raw bytes on
req.rawBodybefore parsing. HMAC must run against the raw bytes, because re-serialising the parsed object would change whitespace and break the signature. -
Per-provider signature check. If
WEBHOOK_SECRETis set, the verifier selects the provider profile from the:sourcesegment. 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 genericsha256=<hex>scheme. Comparison is constant-time. -
Formatting. The
:sourcesegment selects a template atsrc/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. -
Enqueue and respond. The rendered message is placed on an in-memory retry queue and the endpoint returns
202immediately. Response latency stays flat regardless of how Resend is behaving. -
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
See Architecture for the module map and design rationale.
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.
-
The service exits immediately on start.
RESEND_API_KEYandNOTIFY_EMAILare mandatory. The process logs the missing variable and exits with code 1 by design. Check your.envis present and loaded. -
Every request returns 401 Invalid signature.
WEBHOOK_SECRETis 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, unsetWEBHOOK_SECRETand restart. -
A request returns 202 but no email arrives. A
202means queued, not delivered. CheckGET /dead-letter: if the job failed every retry it will be listed there with the error. The most common cause is an invalidRESEND_API_KEYor an unverifiedFROM_EMAIL. -
Jobs keep landing in the dead-letter inbox. Read the
errorfield on each entry. It is the message Resend returned. Fix the underlying cause (key, sender domain, rate limit), then replay the failure. WithWEBHOOK_REPLAY_TOKENset you canPOST /dead-letter/:id/replayto 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_TOKENandTELEGRAM_CHAT_IDare set. -
My template never fires. The filename must match the
:sourcesegment exactly, for example/hooks/stripeneedssrc/templates/stripe.js, and the function must return an object with asubject. Returningnullis the documented way to fall through to the default formatter. -
Large payloads are rejected. The JSON body limit is 1mb. Raise the
bodyLimitpassed tocreateAppinsrc/app.jsif a source sends more.
- Architecture: module map, request sequence, design rationale
- Quick-Start: install, env vars, first webhook, Stripe and GitHub setup
- Configuration: every environment variable
- HMAC-Verification: per-provider signature schemes and setup
- Per-Source-Templates: writing Markdown formatters, bundled examples
- Retry-and-Dead-Letter: backoff, attempts, the dead-letter inbox
- Fan-Out: Slack and Telegram setup
- Deployment: Docker, docker-compose, Fly.io, Render, Railway
- Production-Checklist: TLS, rate limiting, domain verification, monitoring
- Roadmap: what is shipped and what is next