WebhooksVerifying signatures

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...e9
  • t — the unix timestamp (seconds) when Orchard signed the request.
  • v1 — the signature: HMAC-SHA256 of `${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

  1. Read the t and v1 values from the header.
  2. Reject if t is more than 5 minutes from now (replay protection).
  3. Recompute HMAC-SHA256(secret, t + "." + rawBody) and hex-encode it.
  4. 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.