Skip to content
Webhooks SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 — the webhook-id header, the spec’s canonical idempotency key.
  • event.type — the body’s type.
  • event.timestamp — the signed webhook-timestamp.
  • event.payload — the thin event object; the resource id is at payload.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.

Last updated on September 1, 2026

Was this page helpful?