Python 88.3%
TypeScript 7.6%
Shell 4.1%
1# Webhook verification23**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)4**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)5**Last verified:** 2026-09-1967## Who sends what89| Provider | Mechanism | Signature |10|---|---|---|11| OpenAI | project webhooks (`/v1/webhooks` events: `response.completed`, `batch.completed`, `fine_tuning.job.*`, …) | Standard Webhooks HMAC-SHA256, secret `whsec_…` |12| Anthropic | inference hooks (`webhook-id` = `request_id`) | Standard Webhooks HMAC |13| 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) |14| 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` |15| 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 |1617## What arrives1819OpenAI (and Gemini static webhooks) deliver events as `POST` requests to the URL you register per project. Each request carries:2021```22webhook-id: wh_685342e6c53c8190a1be43f081506c5223webhook-timestamp: 175028707824webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=25```2627This 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.2829Gemini **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.3031## Verify — don't trust the network32331. **Read the raw body bytes** before any JSON parsing/framework re-serialisation; signature is over the exact bytes.342. **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.353. **Manual verification** (other stacks): compute HMAC as above; **constant-time compare** against each `v1,` signature; reject if none match.364. **Replay window**: reject if `|now − webhook-timestamp| > 5 min` (Standard Webhooks default; the SDK enforces a tolerance). Tighten if your clock is reliable.375. **Idempotency**: store `webhook-id` (unique per delivery; OpenAI documents rare duplicate deliveries) and ignore repeats. Process side effects exactly once, keyed on it.386. **Respond 2xx fast** (< a few seconds), then process asynchronously; non-2xx triggers retries — make your handler idempotent so retries are harmless.397. **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.408. **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.419. **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).4210. **Least privilege for the handler**: the webhook receiver needs no API key for verification; give it only the read scopes needed for step 7.4344## Reference (Python, stdlib)4546```python47import base64, hmac, hashlib, time4849def verify(secret: str, headers: dict[str, str], raw_body: bytes, tolerance_s: int = 300) -> None:50 h = {k.lower(): v for k, v in headers.items()}51 wid, ts, sigs = h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"].split()52 if abs(time.time() - int(ts)) > tolerance_s:53 raise ValueError("timestamp outside tolerance")54 key = base64.b64decode(secret.split("_", 1)[1] if secret.startswith("whsec_") else secret)55 expected = base64.b64encode(hmac.new(key, f"{wid}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()56 if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in sigs):57 raise ValueError("signature mismatch")58```59(Prefer `client.webhooks.unwrap`; this is for stacks without the SDK. Verify against a real delivery before relying on it — UNVERIFIED here.)6061## Checklist6263- [ ] Raw body preserved; signature verified with constant-time compare (SDK `unwrap` or equivalent).64- [ ] Timestamp tolerance enforced; clock synced.65- [ ] `webhook-id` persisted for deduplication; handler idempotent.66- [ ] 2xx returned quickly; work queued.67- [ ] Event used as a trigger; authoritative data fetched with your API key.68- [ ] Signing secret in secret manager; rotation procedure known; overlapping signatures accepted during rotation.69- [ ] Endpoint HTTPS-only; minimal privileges; access logs kept with `webhook-id`.70- [ ] 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.71- [ ] xAI: deferred/batch `request_id`s treated as capabilities (not logged in plaintext); polling backoff on 202.72