Skip to main content
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

X-OpenTrain-Event: proposal.received
X-OpenTrain-Delivery: <delivery id>
X-OpenTrain-Signature: t=<unix seconds>,v1=<hex hmac>
The signature header has two comma-separated parts:
PartMeaning
tUnix timestamp (seconds) of when the delivery was signed
v1Hex-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

  1. 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).
  2. Parse t and v1 from X-OpenTrain-Signature.
  3. Reject if |now − t| exceeds your tolerance window (5 minutes is a good default) — this bounds replay attacks.
  4. Compute HMAC-SHA256(secret, "<t>.<rawBody>") and hex-encode it.
  5. Compare with v1 using a constant-time comparison.

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);
In frameworks that parse JSON for you (Express with express.json(), Next.js API routes, Fastify), you must opt into the raw body — e.g. express.raw({ type: 'application/json' }) on the webhook route, or the verify callback that captures req.rawBody. Verifying a re-serialized body is the single most common webhook-verification bug.

Python

import hashlib
import hmac
import 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", 200

Operational Rules

  • Respond 2xx within 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. Return 2xx for deliveries you choose to skip — a 4xx still 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 400 on 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

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

Stay in Sync

Subscribing, the event catalog, retries, and the agent loop around deliveries.

Platform Webhooks

The platform-side delivery surface — same signature scheme, plus redelivery.