# OpenAI Webhooks — atlas **Status:** `DOCUMENTED` + `LIVE_VERIFIED` for `GET /v1/webhook_endpoints` (200, empty list) and `GET /v1/webhook_event_types` (200, 24 types) with our **project key** on 2026-09-18; endpoint create/update/delete/rotate/test are `DOCUMENTED` only (never called — rotation is destructive). Signature verification is `LIVE_VERIFIED` offline (SDK `webhooks.unwrap` + manual HMAC, Python and TypeScript). **Sources:** [Webhooks guide](https://developers.openai.com/api/docs/guides/webhooks) · [Webhook events reference](https://developers.openai.com/api/reference/resources/webhooks) · [Agents API session webhooks](https://developers.openai.com/api/docs/guides/agents-api/sessions/webhooks) · [Video generation → webhooks](https://developers.openai.com/api/docs/guides/video-generation#use-webhooks-for-notifications) · OpenAPI `/webhook_endpoints*`, `/webhook_event_types`, `Webhook*` schemas · SDK source `openai/lib/_webhooks.py` (3.16.2), `openai` 7.18.0 `resources/webhooks.js` · [Standard Webhooks spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). **Last verified:** 2026-09-18. Machine-readable: `generated/fragments/endpoints/openai-webhooks.json`, `parameters/openai-webhooks.json`, `webhook-events/openai-webhook-events.json` (28 event records). ## 1. Delivery model | Aspect | Behaviour | |---|---| | Scope | Webhook endpoints are **per project** (dashboard *Settings → Project → Webhooks* or the API below, which authenticates with a normal project key — no Admin key needed). | | Request | `POST ` with `content-type: application/json`, `user-agent: OpenAI/1.0 (+https://platform.openai.com/docs/webhooks)`, headers `webhook-id`, `webhook-timestamp`, `webhook-signature`. | | Envelope | `{"object":"event","id":"evt_…","type":"","created_at":,"data":{…}}` — `data` is a thin pointer (usually just `id`); fetch the resource afterwards. | | Acknowledge | Return any `2xx` within a few seconds; do the work asynchronously. `3xx` are **not followed** (treated as failures). | | Retries | Non-2xx / timeout → retried with exponential backoff for up to **72 hours**. | | Duplicates | Possible; deduplicate on `webhook-id`. | | Secret | `whsec_…` signing secret shown **once** at creation (`signing_secret`) or rotation; afterwards only `signing_secret_hint` is visible. | | Local testing | Needs a public URL (ngrok, Codespaces, Cloudflare Workers, …); the dashboard and `POST /v1/webhook_endpoints/{id}/test` send sample events. | ## 2. Management API (`/v1/webhook_endpoints`) | Method | Path | Body / query | Returns | Status | |---|---|---|---|---| | `GET` | `/v1/webhook_endpoints` | `limit` (default 20), `after` | `{object:"list", data:[webhook_endpoint], first_id, last_id, has_more}` | LIVE_VERIFIED (200, `data: []`) | | `POST` | `/v1/webhook_endpoints` | `name`* (1–256), `url`* (`^https://`, ≤ 2048), `event_types`*[] (≥ 1, from the project enum) | `webhook_endpoint` **with `signing_secret`** | DOCUMENTED | | `GET` | `/v1/webhook_endpoints/{id}` | — | `webhook_endpoint` (`signing_secret_hint` only) | DOCUMENTED | | `POST` | `/v1/webhook_endpoints/{id}` | any of `name`, `url`, `event_types` (full replacement set) | `webhook_endpoint` | DOCUMENTED | | `DELETE` | `/v1/webhook_endpoints/{id}` | — | `{object:"webhook_endpoint.deleted", id, deleted:true}` | DOCUMENTED | | `POST` | `/v1/webhook_endpoints/{id}/rotate_secret` | `keep_old_secret_active_for_24_hours` (bool, default false → old secret dies immediately) | `webhook_endpoint` with new `signing_secret` | DOCUMENTED (destructive, not called) | | `POST` | `/v1/webhook_endpoints/{id}/test` | `event_type`* | `{object:"webhook_endpoint.test", webhook_endpoint_id, event_type, status_code, success:true}` — `success` is always true; inspect `status_code` | DOCUMENTED | | `GET` | `/v1/webhook_event_types` | — | `{object:"list", data:[string]}` | LIVE_VERIFIED | `webhook_endpoint` object: `id, object:"webhook_endpoint", created_at, updated_at (config or secret change only; tests don't bump it), name, url, event_types[], signing_secret_hint, [signing_secret]`. SDK: Python `client.webhooks.create/retrieve/update/list/delete/rotate_secret/test`, `client.webhooks.event_types.list()`; Node `client.webhooks.*`, `client.webhooks.eventTypes.list()`. ## 3. Event types catalogue Live list returned by `GET /v1/webhook_event_types` on 2026-09-18 (24): `batch.completed, batch.failed, batch.expired, batch.cancelled, response.completed, response.failed, response.cancelled, response.incomplete, eval.run.succeeded, eval.run.failed, eval.run.canceled, fine_tuning.job.succeeded, fine_tuning.job.failed, fine_tuning.job.cancelled, realtime.call.incoming, video.completed, video.failed, safety.alert.created, agent.session.created, agent.session.action_required, agent.session.in_progress, agent.session.idle, agent.session.failed, live.transport.incoming`. | Event | When | `data` | Status | |---|---|---|---| | `response.completed` / `.failed` / `.cancelled` / `.incomplete` | background Responses (`background: true`) reach a terminal state | `{id: "resp_…"}` → `GET /v1/responses/{id}` | DOCUMENTED, LIVE_VERIFIED (listed) | | `batch.completed` / `.failed` / `.expired` / `.cancelled` | Batch API job terminal states | `{id: "batch_…"}` | DOCUMENTED, LIVE_VERIFIED | | `fine_tuning.job.succeeded` / `.failed` / `.cancelled` | fine-tuning job terminal states | `{id: "ftjob_…"}` | DOCUMENTED, LIVE_VERIFIED | | `eval.run.succeeded` / `.failed` / `.canceled` (note US spelling) | eval run terminal states | `{id: "evalrun_…"}` | DOCUMENTED, LIVE_VERIFIED | | `realtime.call.incoming` | inbound SIP call awaiting Realtime accept/reject | `{call_id: "rtc_…", sip_headers:[{name,value}], sip_media_security?: "rtp"|"srtp"|string}` | DOCUMENTED, LIVE_VERIFIED | | `live.transport.incoming` | same pending SIP session offered to the Live API | `{type:"sip", session_id:"live_…", sip_headers[], sip_media_security?}` | DOCUMENTED, LIVE_VERIFIED | | `live.call.incoming` | **DEPRECATED** predecessor of `live.transport.incoming`; new subscriptions refused | `{session_id, sip_headers, sip_media_security}` | DOCUMENTED, DEPRECATED (not in live list) | | `video.completed` / `video.failed` | video generation job finished | `{id: "video_…"}` | DOCUMENTED (video guide), LIVE_VERIFIED | | `safety.alert.created` | approved safety alert for the project | `{id: "alert_…"}` → `GET /v1/safety/alerts/{id}` | DOCUMENTED, LIVE_VERIFIED | | `safety.org_alert.created` | alert for an enterprise workspace | `{id}` | DOCUMENTED (not project-subscribable in our list) | | `safety.warning_issued` / `safety.deactivation_issued` | safety identifier warning / deactivation | `{id: "C-…"}` → `GET /v1/safety/cases/{id}` | DOCUMENTED (not in our live list) | | `agent.session.created` / `.action_required` / `.in_progress` / `.idle` / `.failed` | Agents API sessions (beta) | `created`: `{id:"sess_…", environment_id, environment_type, connect{remote_url}}`; `action_required`: `{id, required_action{type: function_call|environment_connection}}`; others `{id}` | DOCUMENTED (agents guide, `OpenAI-Beta: agents=v1` surface), LIVE_DISCOVERED in the event-type list, BETA | Payloads never contain outputs — always re-fetch with your API key (data-residency: `background: true` is unavailable in the EU region, so `response.*` webhooks are US/global only). ## 4. Signature verification (Standard Webhooks) ``` signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body key = base64_decode(secret minus the "whsec_" prefix) signature = base64( HMAC-SHA256(key, signed_content) ) webhook-signature: v1,[ v1, …] # any match passes; two entries during rotation reject when |now − webhook-timestamp| > tolerance (SDK default 300 s) ``` Verified facts (from SDK sources, exercised offline): the body must be the **raw bytes** (never re-serialise); comparison is constant-time; Python raises `openai.InvalidWebhookSignatureError`, Node `OpenAI.InvalidWebhookSignatureError`; missing headers raise before HMAC. | Language | SDK | Manual | |---|---|---| | Python | `client = OpenAI(webhook_secret=…)`; `event = client.webhooks.unwrap(request.data, request.headers)` (typed `UnwrapWebhookEvent`) or `client.webhooks.verify_signature(payload, headers, secret=…, tolerance=300)` | `examples/openai/webhooks/verify_manual.py` | | Node | `await client.webhooks.unwrap(rawText, req.headers, secret?, tolerance?)` / `verifySignature(...)` — use `express.text({type:"application/json"})` or `express.raw`, **not** `express.json()` | `examples/openai/webhooks/verify_manual.ts` | | Ruby | `client.webhooks.unwrap(request.body, headers)` → `OpenAI::Models::Webhooks::ResponseCompletedWebhookEvent` … | — | | Rust / PHP | `standardwebhooks` reference libraries (`Webhook::new(secret).verify(payload, headers)`) | — | Runnable proof (2026-09-18): `python examples/openai/webhooks/offline_test.py` and `node examples/openai/webhooks/offline_test.ts` — each signs a payload with a fake `whsec_` secret and passes 11 checks (SDK unwrap, manual verify, tampered body, wrong secret, stale timestamp ±300 s, rotated double signature, missing header). Servers: `server_express.ts`, `server_fastapi.py` (UNVERIFIED as running processes — express/fastapi not installed here). ## 5. Operational checklist 1. Create the endpoint via API to capture `signing_secret` programmatically; store it in a secret manager as `OPENAI_WEBHOOK_SECRET`. 2. Verify → dedupe on `webhook-id` → enqueue → `200` immediately. 3. Rotate with `keep_old_secret_active_for_24_hours: true`, deploy the new secret, then let the old one lapse; during the window the header carries two `v1,` signatures. 4. Alert on `status_code` from `/test`, and on missing `response.*` events (webhooks complement, not replace, polling `GET /v1/responses/{id}`). 5. Errors: bad/missing signature → respond `400` (never `2xx`), so a tampered delivery is retried rather than silently dropped.