NodeJSBuilding & Receiving Webhooks

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

Text
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 down
Webhooks push events to you in real time — far more efficient than polling, but you must expose a public endpoint
Without webhooks, staying in sync with a provider means **polling**: calling their API on a timer to check for changes. That's wasteful (most polls return "nothing new"), laggy (you're stale until the next poll), and burns rate limits. A **webhook** inverts this: you give the provider a public URL, and it sends a `POST` request carrying the event payload the instant something happens — real-time, with zero wasted requests. The trade-off is that *you* now run a publicly reachable endpoint that **acts on data sent by an outside party**, which makes verification non-negotiable. Think of webhooks as the provider calling your API: you must authenticate the caller, validate the payload, and respond reliably, exactly as you'd expect of anyone consuming *your* API.
Receiving a webhook — verify the signature

JS
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)
})
ALWAYS verify the signature over the RAW body with a constant-time compare — an unverified webhook endpoint is a public door
A webhook URL is public, so **anyone** who finds it can `POST` forged events — fake "payment succeeded" callbacks, bogus data — unless you verify each request is genuinely from the provider. The standard mechanism is an **HMAC signature**: the provider signs the payload with a shared secret and sends the result in a header; you recompute the HMAC with your copy of the secret and compare. Three details are essential. **(1)** Compute the HMAC over the **raw request body bytes** — `express.json()` parses and discards them, and re-serializing changes the bytes and breaks the signature, so use `express.raw()` (or capture the raw body) on webhook routes. **(2)** Compare with **`crypto.timingSafeEqual`**, not `===`, to avoid leaking the secret through timing. **(3)** Many providers also sign a **timestamp** — reject old signatures to prevent replay attacks. Reject anything that fails with `401`. Most SDKs (e.g. `stripe.webhooks.constructEvent`) do all this for you — prefer them.
Respond fast, process async

JS
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).
Acknowledge with `200` immediately and offload slow work to a queue — providers time out and will retry, causing duplicates
Providers expect a **fast** `2xx` acknowledgement, often within a few seconds; exceed their timeout and they treat the delivery as failed and **retry it**, so doing slow work (database writes, sending email, calling other APIs) *inline* before responding causes timeouts and a flood of duplicate deliveries. The robust pattern is: verify the signature, push the event onto a [job queue](/nodejs/message-queues) (or other durable buffer), and return `200` *immediately* — then a separate [worker](/nodejs/worker-threads) does the actual processing asynchronously. This keeps the endpoint snappy, decouples your processing speed from the provider's patience, and means a slow downstream step can't trigger redelivery storms. Return a non-2xx only when you genuinely couldn't accept the event (bad signature, malformed) and *want* a retry.
Idempotency — handle duplicate deliveries

JS
// 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
  })
}
Webhook delivery is at-least-once — store each event id and skip ones you've already processed
Webhook delivery is **at-least-once**, never exactly-once: retries after timeouts, network hiccups, and provider quirks mean the *same* event will sometimes be delivered **more than once**. If processing isn't idempotent, that double-delivery double-ships an order or double-credits an account. The fix is to **deduplicate on the provider's unique event id**: record the id of every event you've fully processed, and on each incoming event check whether you've seen it — if so, skip it. Record the "processed" marker in the **same transaction** as the work itself so a crash can't leave you having done the work but not recorded it (or vice versa). Designing every webhook handler to be safely repeatable is the only reliable way to cope with at-least-once delivery.
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 type so 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.

When you send webhooks, you owe receivers what providers owe you: signatures, retries with backoff, stable event ids, and timeouts
Building a webhook *producer* means giving your subscribers the same guarantees you rely on from Stripe or GitHub. **Sign** each payload (per-subscriber secret + timestamp) so they can verify and prevent replay. **Retry** failed deliveries with exponential backoff — receivers go down, and at-least-once delivery is what makes webhooks dependable — then dead-letter after a cap. **Deliver from a [queue](/nodejs/message-queues) asynchronously** with a per-request timeout, so a slow subscriber can't stall your system or your other deliveries. Give every event a **stable unique id** (so receivers can dedupe) and a **`type`** (so they can route). And operationally, a **delivery log** with manual replay and secret rotation turns your webhooks from a black box into something integrators can actually debug. Treat the receivers as you'd want a provider to treat you.
Next
Switch gears to building tools that run in the terminal: [Building Command-Line Tools](/nodejs/cli-intro).