Verifying signatures
Every delivery is signed so you can confirm it really came from Orchard and wasn’t tampered with or replayed. Verify every webhook before acting on it.
The header
X-Orchard-Signature: t=1796000000,v1=4f1a...e9t— the unix timestamp (seconds) when Orchard signed the request.v1— the signature:HMAC-SHA256of`${t}.${rawBody}`using your endpoint’s signing secret (the value shown once when you created or regenerated the endpoint), hex-encoded.
The signed string is the literal timestamp, a ., then the raw request body
bytes — exactly as received.
Algorithm
- Read the
tandv1values from the header. - Reject if
tis more than 5 minutes from now (replay protection). - Recompute
HMAC-SHA256(secret, t + "." + rawBody)and hex-encode it. - Constant-time compare your value to
v1. If they differ, reject.
Use the raw body. Compute the HMAC over the exact bytes you received — do not parse and re-serialize the JSON first (key order and whitespace would change and the signature won’t match). Capture the raw body before any JSON middleware.
Example (Node.js / Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.ORCHARD_WEBHOOK_SECRET; // shown once at endpoint creation
// Capture the RAW body — do not use express.json() before this.
app.post('/webhooks/orchard', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Orchard-Signature') ?? '';
const rawBody = req.body.toString('utf8');
if (!verifyOrchardSignature(header, rawBody, SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(rawBody);
const deliveryId = req.get('X-Orchard-Delivery');
// Idempotency: ignore a delivery id you've already processed.
if (alreadyProcessed(deliveryId)) return res.status(200).send('ok');
switch (event.event_type) {
case 'order.paid':
// fetch + fulfill using event.data.order_id
break;
case 'subscription.canceled':
// react using event.data.stripe_subscription_id
break;
// ...
}
markProcessed(deliveryId);
res.status(200).send('ok'); // any 2xx acknowledges; non-2xx triggers a retry
});
function verifyOrchardSignature(header, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
}),
);
const t = Number.parseInt(parts.t ?? '', 10);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay guard
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`, 'utf8')
.digest();
let received;
try {
received = Buffer.from(parts.v1 ?? '', 'hex');
} catch {
return false;
}
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}The header format follows the Stripe webhook convention (t=…,v1=…), so most
existing signature-verification helpers can be adapted by signing over
`${t}.${rawBody}` with your Orchard endpoint secret.
Rotating the secret
Regenerate the secret in Settings → Webhooks if it leaks. The new secret is shown once and the old one stops verifying immediately, so deploy the new secret to your endpoint as part of the rotation.