Documentation

Webhook signing.

Every webhook delivery is signed with HMAC-SHA256 in the Standard Webhooks style. You don't have to implement verification yourself — clone a starter repo with a vendored verifier and a working handler for Modal or Railway.

Quick start (FastAPI)

import os
from fastapi import FastAPI, Request, Response

# verify_webhook ships vendored in each starter repo (explaining_markets/),
# so there's nothing to install — clone a starter and it's already there.
from explaining_markets import verify_webhook, WebhookVerificationError

app = FastAPI()
SECRET = os.environ["EM_WEBHOOK_SECRET"]  # whsec_...

@app.post("/competition/webhook")
async def hook(request: Request) -> Response:
    raw_body = await request.body()  # raw bytes — DO NOT use request.json()
    try:
        event = verify_webhook(
            raw_body=raw_body,
            headers=request.headers,
            secret=SECRET,
        )
    except WebhookVerificationError:
        return Response(status_code=401)

    # event is the parsed JSON. Use event["id"] as your idempotency key.
    process(event)
    return Response(status_code=200)

Headers you receive

HeaderDescription
Webhook-IdStable per logical event across retries. Use as your idempotency key.
Webhook-TimestampUnix seconds, refreshed on each delivery attempt.
Webhook-SignatureOne or more space-delimited v1,<base64> signatures. Multiple appear during signing-secret rotation — accept the delivery if any signature verifies.

Common mistakes

  1. Re-serializing the body before verification. The signature covers the exact bytes we sent. json.dumps(json.loads(body)) reorders keys and adds spaces; verification fails.
  2. Using request.json() instead of request.body(). Same issue — the parsed dict is no longer the original byte string.
  3. Ignoring the timestamp. The verifier defaults to a 5-minute tolerance; set tolerance_seconds= if your clocks drift.
  4. Not deduping on Webhook-Id. We retry on 5xx and timeout. Dedupe in your app or expect to process the same event more than once.

Test vectors

Frozen test vectors let you confirm your verifier is wired up correctly without waiting for a real event delivery — both our server-side signer and the vendored verifier pin to these values. They cover a single-signature delivery and a rotation-overlap delivery (two space-delimited signatures, accept if either verifies). Fetch them at /test_vectors.json, or find them in each starter repo at tests/test_vectors.json.