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.
The signature header
Section titled “The signature header”OpenShop-Signature: t=1791481331,v1=5f0c2b8e4a7d1c9b3e6f8a2d4c7b9e1f3a5c8d0b2e4f6a8c1d3e5f7a9b0c2d4e| Part | What it is |
|---|---|
t | When this attempt was signed, in Unix seconds. Each retry is signed again with a fresh timestamp. |
v1 | The 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.
How the signature is made
Section titled “How the signature is made”signed string = <t> + "." + <raw request body>v1 = hex( HMAC_SHA256( key = signing secret, message = signed string ) )To verify:
- Split the header on
,and each part on the first=. Taketand everyv1. - Reject the request if
tis more than 300 seconds away from your server’s clock, in either direction. This stops an old, captured request from being replayed. - Build the signed string from the
tvalue exactly as it appears in the header, a., and the raw body bytes. - Compute HMAC-SHA256 over it and hex-encode the result in lowercase.
- Compare it with each
v1using a constant-time comparison.
Where verification goes wrong
Section titled “Where verification goes wrong”- 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. UsetimingSafeEqual,hmac.compare_digest, orhash_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.
Verifiers
Section titled “Verifiers”Each verifier reads the secret from an OPEN_SHOP_WEBHOOK_SECRET environment variable, verifies, then hands the parsed event off and answers 200.
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.
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());import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_webhook_signature(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool: timestamp = None signatures = [] for part in header.split(","): key, separator, value = part.strip().partition("=") if not separator: continue if key == "t": timestamp = value elif key == "v1": signatures.append(value)
if timestamp is None or not timestamp.isdigit() or not signatures: return False current = time.time() if now is None else now if abs(current - int(timestamp)) > TOLERANCE_SECONDS: return False
signed = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode() return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)With Flask, read the body with request.get_data() rather than request.json:
import jsonimport os
from flask import Flask, abort, request
from verify_webhook_signature import verify_webhook_signature
app = Flask(__name__)
@app.post("/webhooks/open-shop")def open_shop_webhook(): raw_body = request.get_data() signature = request.headers.get("OpenShop-Signature", "") if not verify_webhook_signature(raw_body, signature, os.environ["OPEN_SHOP_WEBHOOK_SECRET"]): abort(400)
event = json.loads(raw_body) enqueue(event) return "", 200<?php
const OPEN_SHOP_TOLERANCE_SECONDS = 300;
function verify_webhook_signature(string $rawBody, string $header, string $secret, ?int $now = null): bool{ $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) !== 2) { continue; } [$key, $value] = $pair; if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; } }
if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) { return false; } if (abs(($now ?? time()) - (int) $timestamp) > OPEN_SHOP_TOLERANCE_SECONDS) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false;}Read the body from php://input. PHP exposes the header as HTTP_OPENSHOP_SIGNATURE:
<?php
require __DIR__ . '/verify_webhook_signature.php';
$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_OPENSHOP_SIGNATURE'] ?? '';
if (!verify_webhook_signature($rawBody, $signature, getenv('OPEN_SHOP_WEBHOOK_SECRET'))) { http_response_code(400); exit;}
$event = json_decode($rawBody, true);try { enqueue($event);} catch (Throwable $error) { http_response_code(500); exit;}http_response_code(200);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.
Rotating the secret
Section titled “Rotating the secret”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.