DEVELOPER DOCUMENTATION
Verify Webhook Signatures
Authenticate OpenTrain webhook deliveries with HMAC signature verification, in Node.js and Python.
Every webhook delivery from OpenTrain is signed with the subscription’s secret (the whsec_... value returned once, when the subscription was created). Verifying the signature proves the delivery came from OpenTrain and wasn’t tampered with — never act on an unverified delivery.
This scheme is shared by the Public API and the Platform API: one verifier works for both.
The Headers
Section titled “The Headers”X-OpenTrain-Event: proposal.receivedX-OpenTrain-Delivery: <delivery id>X-OpenTrain-Signature: t=<unix seconds>,v1=<hex hmac>The signature header has two comma-separated parts:
| Part | Meaning |
|---|---|
t | Unix timestamp (seconds) of when the delivery was signed |
v1 | Hex-encoded HMAC-SHA256(secret, "<t>.<rawBody>") |
The signed message is the timestamp, a literal ., and the raw request body bytes — exactly as received, before any JSON parsing.
The Verification Recipe
Section titled “The Verification Recipe”- Read the raw body bytes. Not
JSON.parse-then-re-stringify — key ordering and whitespace differences will change the bytes and the verification will fail (or worse, falsely pass a forged body if you re-serialize attacker-controlled JSON). - Parse
tandv1fromX-OpenTrain-Signature. - Reject if
|now − t|exceeds your tolerance window (5 minutes is a good default) — this bounds replay attacks. - Compute
HMAC-SHA256(secret, "<t>.<rawBody>")and hex-encode it. - Compare with
v1using a constant-time comparison.
Node.js / TypeScript
Section titled “Node.js / TypeScript”import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyOpenTrainSignature( secret: string, signatureHeader: string, rawBody: string | Buffer, toleranceSeconds = 300): boolean { const parts = Object.fromEntries( signatureHeader.split(',').map((kv) => kv.split('=') as [string, string]) ); const timestamp = Number(parts.t); if (!Number.isFinite(timestamp)) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex'); const provided = String(parts.v1 ?? ''); if (provided.length !== expected.length) return false; return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'));}Wired into a minimal HTTP server that preserves the raw body:
import { createServer } from 'node:http';
const SECRET = process.env.OPENTRAIN_WEBHOOK_SECRET!; // the whsec_... value
createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const rawBody = Buffer.concat(chunks); const signature = String(req.headers['x-opentrain-signature'] ?? '');
if (!verifyOpenTrainSignature(SECRET, signature, rawBody)) { res.writeHead(400).end('invalid signature'); return; }
// Acknowledge first, process async. res.writeHead(200).end('ok');
const event = JSON.parse(rawBody.toString('utf8')); const deliveryId = String(req.headers['x-opentrain-delivery'] ?? ''); void handleEvent(event, deliveryId); });}).listen(8080);Python
Section titled “Python”import hashlibimport hmacimport time
def verify_opentrain_signature( secret: str, signature_header: str, raw_body: bytes, tolerance_seconds: int = 300,) -> bool: parts = dict(kv.split("=", 1) for kv in signature_header.split(",")) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > tolerance_seconds: return False
signed_payload = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() return hmac.compare_digest(parts.get("v1", ""), expected)For example with Flask (request.get_data() returns the raw bytes):
from flask import Flask, request
app = Flask(__name__)SECRET = os.environ["OPENTRAIN_WEBHOOK_SECRET"]
@app.post("/hooks/opentrain")def opentrain_webhook(): if not verify_opentrain_signature( SECRET, request.headers.get("X-OpenTrain-Signature", ""), request.get_data(), ): return "invalid signature", 400
delivery_id = request.headers.get("X-OpenTrain-Delivery", "") enqueue_for_processing(request.get_json(), delivery_id) # process async return "ok", 200Operational Rules
Section titled “Operational Rules”- Respond
2xxwithin 10 seconds. Acknowledge immediately and do real work asynchronously. A slow handler is indistinguishable from a failing one and burns your retry budget. - Return
5xx(or time out) to request a retry. Deliveries retry up to 5 attempts with 1m/5m/30m/120m backoff. Return2xxfor deliveries you choose to skip — a4xxstill counts as a failure toward auto-disable. - Dedupe by
X-OpenTrain-Delivery. Retries reuse the delivery ID; treat it as an idempotency key. (If you follow the trigger-then-poll pattern, duplicates are naturally harmless.) - A
400on signature failure is fine — but log it. Repeated signature failures usually mean a body-parsing middleware is mangling the raw bytes, or you rotated the subscription (new secret) without updating your handler. - Secrets are per-subscription. If you run multiple subscriptions, key your secrets by webhook ID. Deleting and re-creating a subscription issues a new secret.
Testing Your Verifier
Section titled “Testing Your Verifier”You don’t need to wait for a real event. Compute a signature yourself and POST it:
SECRET="whsec_test"BODY='{"id":"1","type":"proposal.received","apiVersion":"v1","resourceId":"x","jobId":null,"data":{}}'T=$(date +%s)SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS -X POST http://localhost:8080/ \ -H "Content-Type: application/json" \ -H "X-OpenTrain-Event: proposal.received" \ -H "X-OpenTrain-Delivery: test-1" \ -H "X-OpenTrain-Signature: t=$T,v1=$SIG" \ -d "$BODY"Your handler should accept this, reject a tampered body (change one character), and reject a stale timestamp (T=$(($(date +%s) - 3600))).