SPB Git forge

spb/doc-api

Public
2commits 1branches 0releases
15.7 MBsize
maindefault branch
13 days agolast push
Python 88.3% TypeScript 7.6% Shell 4.1%
8.8 KB

# Webhook verification

Status: DOCUMENTED (OpenAI webhook endpoints are covered by generated/fragments/endpoints/openai-webhooks.json and docs/openai/; Gemini webhooks by docs/gemini/interactions-api.md — POST/GET /v1/webhooks, rotate_secret, webhook_config; no webhook was received live in this run. xAI has no webhooks) Sources: https://developers.openai.com/api/docs/guides/webhooks (signing secret whsec_… shown once; headers webhook-id, webhook-timestamp, webhook-signature: v1,<base64>; client.webhooks.unwrap(body, headers); dedupe on webhook-id; retries until 2xx) · OpenAI OpenAPI spec (/v1/webhooks event schemas) · https://platform.claude.com/docs/en/manage-claude/inference-hooks-endpoint (webhook-id = request_id, idempotency key, first component of the signed payload) · Gemini: https://ai.google.dev/gemini-api/docs/webhooks (static webhooks: client.webhooks.create(name, subscribed_events, uri) → new_signing_secret returned once, "Standard Webhook Headers" webhook-id / webhook-timestamp / webhook-signature verified with the standardwebhooks library, rotate_secret; dynamic webhooks: webhook_config {uris[], user_metadata} on background: true Interactions/Batch requests, signed with an asymmetric JWT in the Webhook-Signature header, verified against https://generativelanguage.googleapis.com/.well-known/jwks.json (RS256, kid lookup, audience check)), https://ai.google.dev/gemini-api/docs/agent-hooks, https://ai.google.dev/api/interactions (events interaction.completed | failed | cancelled | requires_action, batch.succeeded | failed) · xAI: https://docs.x.ai/developers/advanced-api-usage/deferred-chat-completions and …/batch-api (polling only — no webhook feature documented) · Standard Webhooks specification (the header scheme OpenAI, Anthropic and Gemini static webhooks follow) Last verified: 2026-09-19

# Who sends what

Provider Mechanism Signature
OpenAI project webhooks (/v1/webhooks events: response.completed, batch.completed, fine_tuning.job.*, …) Standard Webhooks HMAC-SHA256, secret whsec_…
Anthropic inference hooks (webhook-id = request_id) Standard Webhooks HMAC
Gemini — static POST /v1/webhooks (project-wide; subscribed_events e.g. batch.succeeded, batch.failed, interaction.*); POST /v1/webhooks/{id}/rotate_secret; SDK client.webhooks.{create,list,get,update,delete,ping,rotate_signing_secret} Standard Webhooks HMAC with the new_signing_secret (shown once at creation)
Gemini — dynamic webhook_config: {uris: [...], user_metadata: {...}} on a background: true request (Interactions, Batch); fires interaction.completed | failed | cancelled | requires_action JWT (RS256) in Webhook-Signature, public keys from Google's JWKS endpoint; verify kid, algorithm allowlist ["RS256"], aud (your configured audience), exp/iat
xAI none — poll GET /v1/chat/deferred-completion/{id} (202 until ready, result fetchable once within 24 h) or GET /v1/batches/{id} n/a — but the deferred request_id is a capability: anyone with it can fetch (and consume) the result — keep it out of logs

# What arrives

OpenAI (and Gemini static webhooks) deliver events as POST requests to the URL you register per project. Each request carries:

text
webhook-id: wh_685342e6c53c8190a1be43f081506c52
webhook-timestamp: 1750287078
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

This is the Standard Webhooks layout: signature = HMAC-SHA256 over "{webhook-id}.{webhook-timestamp}.{raw body}" with the base64-decoded secret (whsec_ prefix stripped), base64-encoded, possibly several v1,… entries space-separated during secret rotation. Anthropic's inference hooks use the same webhook-id semantics (equal to the request's request_id, reused on connection-failure retries). Gemini static webhooks use the same three headers and Google's samples verify them with the standardwebhooks library (Webhook(secret).verify(payload, headers)), so the reference verifier below works for all three; after rotate_secret accept both secrets during the overlap.

Gemini dynamic webhooks are different: the Webhook-Signature header carries a JWT, not an HMAC. Verification = fetch Google's JWKS (https://generativelanguage.googleapis.com/.well-known/jwks.json; cache by kid, refetch on unknown kid), select the key by the token's kid, decode with an explicit algorithms=["RS256"] allowlist (never accept alg: none or HS256), check aud against the audience you configured and the standard time claims, then compare the claims with the raw body. Because the signer is Google's key, there is no shared secret to leak — but the JWKS fetch itself must go over TLS to the pinned host.

# Verify — don't trust the network

  1. Read the raw body bytes before any JSON parsing/framework re-serialisation; signature is over the exact bytes.
  2. Use the SDK helper when available: client.webhooks.unwrap(raw_body, headers) (OpenAI Python/Node) verifies the signature and timestamp and returns the typed event; it throws on failure. Configure the secret via OPENAI_WEBHOOK_SECRET or the webhook_secret client option. Never expose the secret to the browser.
  3. Manual verification (other stacks): compute HMAC as above; constant-time compare against each v1, signature; reject if none match.
  4. Replay window: reject if |now − webhook-timestamp| > 5 min (Standard Webhooks default; the SDK enforces a tolerance). Tighten if your clock is reliable.
  5. Idempotency: store webhook-id (unique per delivery; OpenAI documents rare duplicate deliveries) and ignore repeats. Process side effects exactly once, keyed on it.
  6. Respond 2xx fast (< a few seconds), then process asynchronously; non-2xx triggers retries — make your handler idempotent so retries are harmless.
  7. Fetch, don't trust, the payload details: treat the event as a notification. For response.completed, call GET /v1/responses/{id}; for Gemini interaction.completed call GET /v1beta/interactions/{id} (and for requires_action continue the tool loop with previous_interaction_id) with your API key to obtain the actual result — the webhook body proves that an event happened, your authenticated read proves what. Gemini user_metadata in the envelope is echoed from your own request, not a trust signal.
  8. Secret hygiene: the signing secret is shown once at creation (OpenAI whsec_…, Gemini new_signing_secret); store it in the secret manager; rotate via project settings (OpenAI) or POST /v1/webhooks/{id}/rotate_secret (Gemini) if exposed — rotation yields overlapping signatures, accept both during the window.
  9. Transport: HTTPS only, valid certificate; optionally restrict the endpoint by source IP if the provider publishes ranges (Anthropic does for its outbound calls; check OpenAI's current documentation before relying on IP filtering).
  10. Least privilege for the handler: the webhook receiver needs no API key for verification; give it only the read scopes needed for step 7.

# Reference (Python, stdlib)

python
import base64, hmac, hashlib, time

def verify(secret: str, headers: dict[str, str], raw_body: bytes, tolerance_s: int = 300) -> None:
    h = {k.lower(): v for k, v in headers.items()}
    wid, ts, sigs = h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"].split()
    if abs(time.time() - int(ts)) > tolerance_s:
        raise ValueError("timestamp outside tolerance")
    key = base64.b64decode(secret.split("_", 1)[1] if secret.startswith("whsec_") else secret)
    expected = base64.b64encode(hmac.new(key, f"{wid}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in sigs):
        raise ValueError("signature mismatch")

(Prefer client.webhooks.unwrap; this is for stacks without the SDK. Verify against a real delivery before relying on it — UNVERIFIED here.)

# Checklist

  • Raw body preserved; signature verified with constant-time compare (SDK unwrap or equivalent).
  • Timestamp tolerance enforced; clock synced.
  • webhook-id persisted for deduplication; handler idempotent.
  • 2xx returned quickly; work queued.
  • Event used as a trigger; authoritative data fetched with your API key.
  • Signing secret in secret manager; rotation procedure known; overlapping signatures accepted during rotation.
  • Endpoint HTTPS-only; minimal privileges; access logs kept with webhook-id.
  • Gemini dynamic webhooks: JWKS fetched over TLS and cached by kid; algorithms=["RS256"] pinned; aud checked; webhook_config.uris point only at endpoints you control.
  • xAI: deferred/batch request_ids treated as capabilities (not logged in plaintext); polling backoff on 202.