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 · Webhook events reference · Agents API session webhooks · Video generation → webhooks · 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.
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 <your https URL> 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":"<event>","created_at":<unix s>,"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" |
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}` |
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,<signature>[ v1,<signature2> …] # 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
- Create the endpoint via API to capture
signing_secretprogrammatically; store it in a secret manager asOPENAI_WEBHOOK_SECRET. - Verify → dedupe on
webhook-id→ enqueue →200immediately. - 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 twov1,signatures. - Alert on
status_codefrom/test, and on missingresponse.*events (webhooks complement, not replace, pollingGET /v1/responses/{id}). - Errors: bad/missing signature → respond
400(never2xx), so a tampered delivery is retried rather than silently dropped.