Tutorial ·
How to Verify Webhook Signatures in Node.js (Standard Webhooks)
Verify Standard Webhooks signatures in Node.js with the official library or with node:crypto in 20 lines, reject replayed events and handle retries without processing an event twice.
By the end of this tutorial you'll have a Node.js webhook endpoint that verifies signatures in the Standard Webhooks format, rejects old or replayed requests and processes each event exactly once even when the sender retries. You'll see two ways to check the signature: the official standardwebhooks library in three lines, and the same check by hand with node:crypto so you know what the library does. The examples use ShopLens monitor webhooks, which follow the Standard Webhooks specification, but the code works for any sender that does.
Prerequisites
- Node.js 20 or later.
- The webhook secret from the sender. For a ShopLens monitor it is the secret field returned when you create or get the monitor, and it starts with whsec_.
- A route that can read the raw request body. Next.js route handlers, Express with express.raw(), Hono and plain node:http all can.
Step 1: know what you're verifying
A Standard Webhooks request carries three headers:
- webhook-id identifies the event. It stays the same when the event is sent again.
- webhook-timestamp is when the request was sent, in Unix seconds.
- webhook-signature is v1, followed by the base64 HMAC-SHA256 of the string {webhook-id}.{webhook-timestamp}.{body}. The key is the base64-decoded part of the secret after whsec_. The header can hold several space-separated signatures during a secret rotation.
The signature covers the exact bytes of the body. If you parse the JSON and serialize it again, whitespace or key order can change and the check fails. Always verify the raw body first.
Step 2: verify with the library
Install the library:
In a Next.js route handler:
verify throws when the signature doesn't match or when the timestamp is too far from the current time, and returns the parsed body when everything checks out. handleEvent is your own code; step 5 shows how to make it safe to run twice.
Step 3: verify by hand with node:crypto
If you'd rather not add a dependency, or you want to see the mechanics, the check fits in one function.
Three details matter here:
- Decode the secret. The HMAC key is the base64-decoded bytes after whsec_, not the secret string itself. Using the string is the most common reason a hand-written check fails.
- Compare in constant time. timingSafeEqual avoids leaking how many bytes matched. It throws if the buffers differ in length, hence the length check first.
- Accept any listed signature. During a secret rotation the sender may sign with both the old and new secrets.
Step 4: reject old requests
The timestamp check in both versions stops replay attacks: someone who captured a valid request can't send it again an hour later, because the signed timestamp would be too old. Five minutes is a common tolerance. Keep your server clock synced with NTP, or legitimate requests can fail this check.
Step 5: process each event once
Senders retry when your endpoint doesn't answer 2xx in time, and a network failure can make you receive an event you already processed. ShopLens counts a webhook as delivered when your endpoint answers any 2xx within 10 seconds, and the event ID stays the same on every attempt. Use it as an idempotency key.
The db and queue calls stand in for your own database and job queue. The pattern is what matters: record the event ID with a unique constraint, and only continue when the insert actually created a row. Then hand the work to a queue and answer right away, so slow processing never pushes you past the 10-second window.
Rotating the secret safely
Sooner or later a secret ends up somewhere it shouldn't: a log line, a screenshot, a former contractor's laptop. Plan for rotation from the start:
- Keep the secret in an environment variable or a secrets manager, never in the repository.
- Accept a list of secrets in your verifier, so you can deploy the new one before the sender switches.
- Once the sender signs with the new secret and no events have arrived signed with the old one for a day, remove the old secret.
With the library, create one Webhook instance per secret and try each in turn. With the hand-written function, loop over the secrets and return true if any of them verifies.
Common reasons verification fails
When a valid webhook keeps failing the check, the cause is almost always one of these:
- The body was parsed before verification. Framework middleware turned the body into an object, and re-serializing it changed the bytes. Read the raw body.
- The wrong key. The secret string was used as the HMAC key instead of the base64-decoded bytes after whsec_.
- The wrong encoding. The signature was compared as hex, or the computed HMAC was encoded as hex while the header holds base64.
- Clock drift. The server's clock is minutes off, so every timestamp looks too old or too new.
- A proxy rewrote the request. Some proxies and platforms change the body's encoding or strip headers. Log the raw headers on a failing request and check the three webhook- headers arrive intact.
- The wrong secret for the monitor. Each ShopLens monitor has its own secret. If you have several monitors pointing at one endpoint, pick the secret based on the monitor.id in the body after verification fails with the first, or give each monitor its own URL.
Step 6: wire it into Express
Express parses JSON by default, which breaks verification. Use express.raw() on the webhook route so req.body is a Buffer:
Logging without leaking
Log enough to debug failures and nothing that helps an attacker. For each request, log the webhook-id, the timestamp, the result of the check and the event type once verified. Don't log the secret, the computed signature or full bodies in production. If you need bodies to debug, keep them for a short period in storage only your team can read. A clean log line per webhook also gives you a delivery history to compare with the sender's when someone asks whether an event was received.
Testing it
Create a test secret, sign a body yourself and send it to your local server:
Run your server with SHOPLENS_WEBHOOK_SECRET set to the same $SECRET. You should get 204. Change one character of the body and you should get 401. Send the same request twice and the second one should be skipped by the idempotency check.
What to build next
With a verified endpoint in place, subscribe to the events you need. How to track competitor prices on Shopify builds a price history on top of this receiver, and the docs list every monitor topic and payload.