Webhooks
Checking a webhook signature
No Orrerie library is needed. Each function is about twenty lines of the language's standard library, and each can be checked against the test vector below before you have an endpoint.
The scheme
| Header | Orrerie-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>] |
|---|---|
| Algorithm | HMAC with SHA-256 |
| Key | The endpoint's signing secret, the whole string including whsec_, as UTF-8 bytes |
| Signed message | The decimal timestamp, a full stop, then the raw request body: <t>.<body> |
| Output | 64 lower-case hex characters |
Several v1 | While a secret is being rolled, one signature per valid secret. Accept if any v1 matches. |
| Other schemes | Ignore any element that is not t or v1. A later scheme would be v2 and would be announced as a new API version. |
| Timestamp tolerance | 300 seconds either way |
| Comparison | Constant-time |
The function, in four languages
The JavaScript function is for Node.js 18 or later and is shown inside an Express handler. The Python function is for Python 3.8 or later, inside a Flask handler. The PHP function is for PHP 7.4 or later and uses no framework. The Ruby function is for Ruby 3.0 or later, inside a Sinatra handler.
JavaScript
const crypto = require('node:crypto');
const express = require('express');
const SECRET = process.env.ORRERIE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
function verifyOrrerieSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
if (typeof secret !== 'string' || secret === '') return false; // no secret configured: refuse everything
if (typeof header !== 'string') return false;
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') t = value;
if (key === 'v1') signatures.push(value);
}
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret)
.update(t + '.', 'utf8').update(rawBody).digest(); // rawBody is a Buffer
return signatures.some((hex) => {
if (!/^[0-9a-f]{64}$/.test(hex)) return false;
return crypto.timingSafeEqual(expected, Buffer.from(hex, 'hex'));
});
}
const app = express();
// express.raw keeps the exact bytes. Do not use express.json() on this route.
app.post('/webhooks/orrerie', express.raw({ type: 'application/json', limit: '300kb' }), async (req, res) => {
if (!verifyOrrerieSignature(req.body, req.get('Orrerie-Signature'), SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
if (await alreadyHandled(event.id)) return res.status(200).send('duplicate'); // your own table, unique on event.id
await saveEvent(event); // then work on it in the background
res.status(200).send('ok');
});
module.exports = { verifyOrrerieSignature };Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["ORRERIE_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
def verify_orrerie_signature(raw_body: bytes, header: str, secret: str, now: float = None) -> bool:
if not secret: # no secret configured: refuse everything
return False
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
now = time.time() if now is None else now
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
app = Flask(__name__)
@app.post("/webhooks/orrerie")
def orrerie_webhook():
raw = request.get_data(cache=False) # the exact bytes; do not use request.json first
if not verify_orrerie_signature(raw, request.headers.get("Orrerie-Signature", ""), SECRET):
return "bad signature", 400
event = json.loads(raw)
if already_handled(event["id"]): # your own table, unique on the event id
return "duplicate", 200
save_event(event) # then work on it in the background
return "ok", 200PHP
<?php
const ORRERIE_TOLERANCE_SECONDS = 300;
function verify_orrerie_signature(string $rawBody, string $header, string $secret, ?int $now = null): bool
{
if ($secret === '') { // no secret configured: refuse everything
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$timestamp = $pair[1];
} elseif ($pair[0] === 'v1') {
$signatures[] = $pair[1];
}
}
if ($timestamp === null || !ctype_digit($timestamp) || count($signatures) === 0) {
return false;
}
$now = $now ?? time();
if (abs($now - (int) $timestamp) > ORRERIE_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // the exact bytes
$header = $_SERVER['HTTP_ORRERIE_SIGNATURE'] ?? '';
if (!verify_orrerie_signature($raw, $header, getenv('ORRERIE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
if (already_handled($event['id'])) { // your own table, unique on the event id
http_response_code(200);
exit('duplicate');
}
save_event($event); // then work on it in the background
http_response_code(200);
echo 'ok';Ruby
require 'json'
require 'openssl'
require 'sinatra'
SECRET = ENV['ORRERIE_WEBHOOK_SECRET'] # whsec_...
TOLERANCE_SECONDS = 300
def verify_orrerie_signature(raw_body, header, secret, now = Time.now.to_i)
return false if secret.nil? || secret.empty? # no secret configured: refuse everything
return false if header.nil? || header.empty?
timestamp = nil
signatures = []
header.split(',').each do |part|
key, value = part.strip.split('=', 2)
next if value.nil?
timestamp = value if key == 't'
signatures << value if key == 'v1'
end
return false if timestamp.nil? || !timestamp.match?(/\A\d+\z/) || signatures.empty?
return false if (now - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
signatures.any? { |signature| signature.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(expected, signature) }
end
post '/webhooks/orrerie' do
raw = request.body.read # the exact bytes; do not parse the JSON first
halt 400, 'bad signature' unless verify_orrerie_signature(raw, request.env['HTTP_ORRERIE_SIGNATURE'], SECRET)
event = JSON.parse(raw)
halt 200, 'duplicate' if already_handled(event['id']) # your own table, unique on the event id
save_event(event) # then work on it in the background
'ok'
endEach function refuses everything when it is handed an empty secret. A server started without ORRERIE_WEBHOOK_SECRET would otherwise check signatures under an empty key, which anyone can compute, and accept a forged order.paid. Keep that first check if you rewrite the function.
The test vector
Run your function on these values. It must accept the header as given, and must refuse it when one character of the body, the secret or the signature is changed. The timestamp in the vector is fixed, so pass it as "now" when you test, or your tolerance check will refuse it as stale.
Test vector
secret whsec_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAk3Qw
timestamp 1791727795
body {"id":"evt_9s2e3tbehv3qd6zp7jjg1gbx","object":"event","type":"ping","api_version":"2026-10-11","created":"2026-10-11T14:09:55.000Z","livemode":false,"data":{"object":{"object":"ping","endpoint":"we_tx7j2vmn3r2px2vpwbtz5h7b"}},"request":{"id":null,"idempotency_key":null},"pending_webhooks":1}
header Orrerie-Signature: t=1791727795,v1=a6e57ea3d029c6fd580984a9926230d06425882512aff3d5dc7abf650b481107You can also paste these values into the checker on the webhooks page.
Common mistakes
Each of these makes every signature fail:
- Verifying a body that was serialised again instead of the raw bytes, because a framework's JSON parser ran first.
- A proxy or content network that rewrites the body.
- Using the API key instead of the endpoint's
whsec_secret. - Using the live endpoint's secret for the test endpoint.
- A server clock more than five minutes off.
After the signature
A valid signature is step two of five. What your endpoint must do has the rest: drop duplicates by event id, answer within 10 seconds, and act on the state of the object.