> ## Documentation Index
> Fetch the complete documentation index at: https://opentrain.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](/docs/developers/guides/stay-in-sync) and the [Platform API](/docs/developers/annotation-platforms/webhooks): one verifier works for both.

## The Headers

```text theme={null}
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:

| 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

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

```ts theme={null}
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:

```ts theme={null}
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);
```

<Warning>
  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.
</Warning>

### Python

```python theme={null}
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):

```python theme={null}
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](/docs/developers/guides/stay-in-sync#retries-and-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](/docs/developers/guides/stay-in-sync#the-decision-framework), 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:

```bash theme={null}
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))`).

## Related

<CardGroup cols={2}>
  <Card title="Stay in Sync" href="/docs/developers/guides/stay-in-sync" icon="refresh-cw">
    Subscribing, the event catalog, retries, and the agent loop around deliveries.
  </Card>

  <Card title="Platform Webhooks" href="/docs/developers/annotation-platforms/webhooks" icon="plug">
    The platform-side delivery surface — same signature scheme, plus redelivery.
  </Card>
</CardGroup>
