OpenAI
Verify and handle OpenAI webhooks — response, batch, fine-tuning, eval, and call events.
Create the endpoint under your project’s webhook settings, pass its signing secret, and handle events by name:
import { createWebhookHandler } from 'webhooks-sdk'
import { openai } from 'webhooks-sdk/openai'
const handler = createWebhookHandler({
provider: openai({ secret: process.env.OPENAI_WEBHOOK_SECRET! }),
on: {
'response.completed': async (event) => {
await deliver(event.payload.data.id)
},
'fine_tuning.job.succeeded': async (event) => {
await promote(event.payload.data.id)
},
},
})
export const POST = handler.fetch
The secret is the whsec_… value shown when you create the endpoint. OpenAI
signs with Standard Webhooks over the
spec’s webhook-* headers — the id and timestamp are inside the signed
material, so a replayed request is rejected on its own.
Options
| Option | Type | Default | |
|---|---|---|---|
secret |
string | string[] |
— | The whsec_… signing secret(s). Pass an array during rotation. |
tolerance |
number |
300 |
Replay window in seconds. |
Events
response.completed · response.cancelled · response.failed ·
response.incomplete · batch.completed · batch.cancelled ·
batch.expired · batch.failed · fine_tuning.job.succeeded ·
fine_tuning.job.failed · fine_tuning.job.cancelled ·
eval.run.succeeded · eval.run.failed · eval.run.canceled ·
realtime.call.incoming · live.call.incoming · safety.alert.created
These autocomplete in the on map; any other string still routes, so an
event type OpenAI adds later is handled without an SDK update.
Mind the spelling: eval.run.canceled has one l while response.cancelled
and its siblings have two. That’s OpenAI’s own inconsistency, kept verbatim
because the strings must match the wire.
The payload is a pointer, not the resource
OpenAI webhook bodies are thin — { id, type, created_at, data }, where
data.id names the response, batch, job, run, or call. Fetch the full object
through the API client rather than expecting it in the delivery:
'response.completed': async (event) => {
const response = await client.responses.retrieve(event.payload.data.id!)
},
An incoming SIP call can emit both realtime.call.incoming and
live.call.incoming for the same pending session — whichever accept endpoint
answers first selects the runtime surface, so handle the one you use and
ignore the other.
The envelope
event.id— thewebhook-idheader, the spec’s canonical idempotency key.event.type— the body’stype.event.timestamp— the signedwebhook-timestamp.event.payload— the thin event object; the resource id is atpayload.data.id.
Standalone & testing
The wrapper is the Standard Webhooks
provider with id: 'openai', so the
standalone triple lives there:
import {
verifyStandardWebhook, // (raw, { secret }) — throws on failure
parseStandardWebhook, // (raw) — the envelope
signStandardWebhook, // (body, secret) — all three webhook-* headers, for tests
} from 'webhooks-sdk/standard-webhooks'
signStandardWebhook’s default webhook-* header names are exactly what
OpenAI sends. See Testing.