Node.js 5 min read

Reliable webhook receivers in Node.js: a practical guide

Build Node.js webhook receivers that don't lose events: verify signatures, respond fast, queue work, handle duplicates and out-of-order delivery.

Crest Design
Node.js logo with webhook events flowing into a queue

A reliable webhook receiver in Node.js does five things: it verifies the signature against the raw request body, replies with a 2xx status within seconds, hands the real work to a queue, ignores duplicate deliveries, and doesn't assume events arrive in order. Add a scheduled reconciliation job as a safety net and you greatly reduce the chance of losing an order, payment or form submission.

This guide explains each step with the delivery rules published by Stripe, Shopify and GitHub, and shows the Node.js patterns we use. It is general technical information, not security advice for your specific setup.

Why webhook integrations break

Most webhook bugs come from treating a webhook like a normal API call. Providers send events at their pace, retry when you are slow, and make no promise about order. Here is how three common providers describe their delivery behaviour at the time of writing (November 2026):

Provider Time to respond Retries Duplicates and order
Stripe Return 2xx quickly, before complex logic Up to three days with exponential backoff in live mode Duplicates possible; order not guaranteed
Shopify Delivery fails after five seconds Up to eight times over four hours; subscription removed if failures persist Use X-Shopify-Webhook-Id to spot duplicates; no ordering within a topic
GitHub 2xx within 10 seconds Redeliver missed deliveries after an outage X-GitHub-Delivery identifies each delivery

The practical lesson: a receiver that does heavy work inline (calling a CRM, generating a PDF, sending email) will time out under load, trigger retries, and process the same event several times.

Step 1: verify the signature on the raw body

Anyone who finds your webhook URL can send it fake events. Providers sign each delivery with an HMAC-SHA256 of the request body: Stripe in the Stripe-Signature header, Shopify as a base64 value in X-Shopify-Hmac-Sha256. The signature is calculated over the exact bytes sent, so you must verify the raw body before any JSON parsing changes it.

In Express, mount express.raw({ type: 'application/json' }) on the webhook route only, before any global express.json() middleware reaches it. Then:

  1. 1 Compute the HMAC with crypto.createHmac('sha256', secret).update(rawBody).
  2. 2 Compare it with the header value using crypto.timingSafeEqual, not ===, to avoid timing attacks. Check the two buffers are the same length first, because timingSafeEqual throws if they differ.
  3. 3 Reject failures with a 4xx status and log them.

Where a provider ships an official library, use it. Stripe recommends its libraries, which also check the signed timestamp with a default tolerance of five minutes to block replayed requests. Stripe warns against a tolerance of zero, which disables that check. Keep secrets in environment variables and rotate them; Stripe lets you keep the old secret active for up to 24 hours while you switch.

Step 2: acknowledge fast, process later

Once the signature checks out, store the event and reply. Stripe's documentation recommends processing events with an asynchronous queue, noting that spikes (such as start-of-month subscription renewals) can overwhelm endpoints that work synchronously. GitHub gives the same advice.

A simple, robust pattern in Node.js:

  • The HTTP handler writes the raw payload, provider, event ID and received time to a webhook_events table, then returns 200.
  • A worker picks up new rows, or the handler pushes a job onto a queue such as BullMQ (backed by Redis) or a managed cloud queue.
  • The worker does the slow work with its own retry policy and backoff, and moves repeatedly failing jobs to a dead-letter list for a human to review.

Storing the payload first means you can replay events after a bug fix without asking the provider to resend them.

Step 3: make processing idempotent

Duplicates are normal, not an edge case. Stripe advises logging the event IDs you have processed and skipping any you've already seen; Shopify suggests the X-Shopify-Webhook-Id header for the same purpose. In practice:

  • Put a unique constraint on the provider's event ID in your events table and insert with "do nothing on conflict". The database then rejects duplicates even when two deliveries arrive at the same moment.
  • Make side effects safe to repeat: upsert records by external ID rather than inserting, and check whether an email or order update has already happened before doing it again.
  • Watch for separate events describing the same change. Stripe notes this can happen and suggests combining the object ID with the event type to spot it.

Step 4: don't depend on order

Stripe and Shopify both say events may arrive out of order. An "order updated" event can land before "order created". Two approaches work well together:

  • Compare timestamps. Shopify suggests the X-Shopify-Triggered-At header or the payload's updated_at. Ignore an event if you already hold a newer version of that record. Stripe warns not to rely on its created field for ordering, since several events can share the same second.
  • Fetch the latest state. Treat the webhook as a signal, then read the current object from the provider's API before acting. Stripe's thin events work this way: the handler fetches the related object from the API.

Step 5: reconcile and monitor

Shopify is explicit that apps shouldn't rely only on webhooks, and recommends a periodic job that fetches everything updated since the last run. A nightly or hourly reconciliation job, using the same idempotent upserts, catches anything missed during outages or after a subscription was removed.

Finally, monitor the pipeline: alert on signature failures, rising queue depth, dead-letter jobs and any gap in events from a provider that normally sends them every hour. Without monitoring, a broken integration can go unnoticed until someone spots missing data.

Verify, store, acknowledge, then process: in that order, every time.

Frequently asked questions

What status code should I return if processing fails?

If you have safely stored the event, return 200 and retry inside your own queue. Return an error only if you couldn't store it, so the provider retries delivery.

Can I run a webhook receiver on serverless functions?

Yes, as long as the function verifies, stores and responds within the provider's time limit, and the processing happens in a separate job or queue.

Should I also restrict by IP address?

Where a provider publishes its sending IPs, as Stripe does, allowlisting adds a useful extra layer. Treat it as an addition to signature checks, not a replacement.

Need help with webhook integrations?

We build and maintain Node.js integrations that connect stores, CRMs and payment systems through our Node.js development service. See our React front end and Node.js API for a startup incubator, and read our guide to Node.js integrations for Shopify, HubSpot and CRMs. If your webhooks are dropping events, get in touch.

Sources: Receive Stripe events in your webhook endpoint (Stripe docs), Webhook best practices (Shopify), Troubleshooting webhooks (Shopify), HTTPS webhook delivery (Shopify), Best practices for using webhooks (GitHub Docs), Crypto (Node.js docs)

(over to you)

Planning a build like this?

Tell us what you're building and we'll come back with ideas and next steps.