Ask for an invitation
Menu

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

Table: The signature
HeaderOrrerie-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
AlgorithmHMAC with SHA-256
KeyThe endpoint's signing secret, the whole string including whsec_, as UTF-8 bytes
Signed messageThe decimal timestamp, a full stop, then the raw request body: <t>.<body>
Output64 lower-case hex characters
Several v1While a secret is being rolled, one signature per valid secret. Accept if any v1 matches.
Other schemesIgnore any element that is not t or v1. A later scheme would be v2 and would be announced as a new API version.
Timestamp tolerance300 seconds either way
ComparisonConstant-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", 200

PHP

<?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'
end

Each 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=a6e57ea3d029c6fd580984a9926230d06425882512aff3d5dc7abf650b481107

You 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.