Building & Receiving Webhooks
A webhook flips the direction of integration: instead of you polling an API to ask "has anything changed?", the provider calls your endpoint the moment something happens — a payment succeeds, a commit is pushed, an email bounces. It's a user-defined HTTP callback: you register a URL, and the provider sends a POST to it on each event. Webhooks are how Stripe, GitHub, Slack, and most SaaS platforms deliver real-time events efficiently. But because you're exposing a public endpoint that acts on incoming data, security and reliability are paramount — an unverified webhook is a hole anyone can push events through. This page covers receiving webhooks safely (signature verification, idempotency, fast responses) and emitting your own.
Polling vs webhooks
POLLING — you repeatedly ask "anything new?" WEBHOOKS — they tell you when it happens
every 30s: GET /charges?since=... once, on the event:
→ mostly "nothing changed" (wasted calls) provider → POST https://you/webhooks/stripe
→ up to 30s stale → instant, no wasted requests
→ burns rate limit & CPU on both sides → provider retries if you're downReceiving a webhook — verify the signature
import express from 'express'
import crypto from 'node:crypto'
const app = express()
// CRITICAL: capture the RAW body — signatures are computed over raw bytes,
// and JSON.parse + re-stringify changes them. Use the raw parser for this route:
app.post('/webhooks/provider', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body) // req.body is a raw Buffer here
.digest('hex')
// Constant-time compare to prevent timing attacks:
const ok =
signature &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!ok) return res.status(401).send('invalid signature') // reject forgeries
const event = JSON.parse(req.body.toString()) // safe to parse now
handleEvent(event)
res.sendStatus(200)
})Respond fast, process async
app.post('/webhooks/provider', express.raw({ type: 'application/json' }), async (req, res) => {
verifySignature(req) // 1. verify (throws/401 on failure)
const event = JSON.parse(req.body.toString())
await queue.add('process-webhook', event) // 2. ENQUEUE the work — don't do it inline
res.sendStatus(200) // 3. ACK immediately (well under the provider's timeout)
})
// A separate worker consumes the queue and does the slow work (DB writes, emails, API calls).Idempotency — handle duplicate deliveries
// Providers guarantee AT-LEAST-once delivery → the same event WILL arrive twice.
// Dedupe on the provider's unique event id so processing is idempotent:
async function handleEvent(event) {
const seen = await db.processedEvents.exists(event.id)
if (seen) return // already handled — ignore the duplicate
await db.transaction(async (tx) => {
await applyEvent(event, tx) // do the real work
await tx.processedEvents.insert({ id: event.id }) // mark done in the SAME tx
})
}Emitting your own webhooks
Sign every payload — HMAC the body with a per-subscriber secret and send it in a header so receivers can verify you (and include a timestamp to thwart replay).
Retry with exponential backoff — receivers will be down sometimes; retry failed deliveries on a backoff schedule, then give up after a max and surface the failure.
Send asynchronously from a queue — never block your main request flow on delivering webhooks to slow or unreachable subscribers.
Include an event id and type — give each event a stable unique id (for receiver idempotency) and a clear
typeso consumers can route it.Time out each delivery — cap how long you wait for a subscriber so one slow endpoint can't back up the whole queue.
Offer a dashboard / delivery log — let subscribers see attempts, replay failed events, and rotate their signing secret.