Skip to content

Verify Signatures

Your endpoint URL is public, so anyone can POST to it. Every delivery is signed with your endpoint’s signing secret; verify that signature before you parse or act on the body.

OpenShop-Signature: t=1791481331,v1=5f0c2b8e4a7d1c9b3e6f8a2d4c7b9e1f3a5c8d0b2e4f6a8c1d3e5f7a9b0c2d4e
PartWhat it is
tWhen this attempt was signed, in Unix seconds. Each retry is signed again with a fresh timestamp.
v1The signature: lowercase hex HMAC-SHA256.

A header may carry more than one v1= pair. Accept the request if any of them matches. Ignore any other keys, so a future signature scheme can be added alongside v1 without breaking you.

signed string = <t> + "." + <raw request body>
v1 = hex( HMAC_SHA256( key = signing secret, message = signed string ) )

To verify:

  1. Split the header on , and each part on the first =. Take t and every v1.
  2. Reject the request if t is more than 300 seconds away from your server’s clock, in either direction. This stops an old, captured request from being replayed.
  3. Build the signed string from the t value exactly as it appears in the header, a ., and the raw body bytes.
  4. Compute HMAC-SHA256 over it and hex-encode the result in lowercase.
  5. Compare it with each v1 using a constant-time comparison.
  • Use the full secret as the key. The key is the whole string shown in the admin, whsec_ prefix included. Do not strip the prefix or hex-decode the rest.
  • Use the raw body. Sign the exact bytes you received. Parsing the JSON and serialising it again changes whitespace and key order, and the signature will not match. Most frameworks parse JSON for you, so turn that off for this route.
  • Compare in constant time. A plain === or == string comparison leaks timing information. Use timingSafeEqual, hmac.compare_digest, or hash_equals.

When verification fails, return a 400 and do nothing else. The delivery is retried on the normal schedule, which is what you want if the failure was a stale secret in your config. Never return 410 for a bad signature: that permanently disables the endpoint.

Each verifier reads the secret from an OPEN_SHOP_WEBHOOK_SECRET environment variable, verifies, then hands the parsed event off and answers 200.

verifyWebhookSignature.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* @param {Buffer | string} rawBody the request body exactly as received
* @param {string} header the OpenShop-Signature header
* @param {string} secret the endpoint's signing secret, whsec_ prefix included
*/
export function verifyWebhookSignature(rawBody, header, secret, nowMs = Date.now()) {
let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const separator = part.indexOf('=');
if (separator === -1) continue;
const key = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

With Express, give the webhook route express.raw() so req.body stays a Buffer. Register it before any app-wide express.json(), which would otherwise consume the body first. A request with another content type leaves req.body unset, so check for the Buffer before verifying. Acknowledge with 200 only once the event is safely queued; if queueing fails, answer 5xx so Open Shop retries.

server.js
import express from 'express';
import { verifyWebhookSignature } from './verifyWebhookSignature.js';
const app = express();
app.post('/webhooks/open-shop', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.get('OpenShop-Signature') ?? '';
if (
!Buffer.isBuffer(req.body) ||
!verifyWebhookSignature(req.body, signature, process.env.OPEN_SHOP_WEBHOOK_SECRET)
) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
try {
await enqueue(event);
} catch {
return res.sendStatus(500);
}
res.sendStatus(200);
});
app.use(express.json());

enqueue stands in for however you hand work to a background job. See Best Practices for why the work should not happen inside the request.

Rotate on the endpoint’s detail view replaces the signing secret immediately. There is no window where both secrets are accepted: deliveries are signed with the new secret from that moment, and the old one stops verifying.

Deliveries your server rejects in the gap are not lost. They fail with a 400 and are retried on the normal schedule, so once your receiver has the new secret they go through on their next attempt. Update the receiver’s configuration promptly after rotating; the first retries come 1 and 6 minutes after the original attempt.