OpenAI Webhooks: Events, Signature Headers and unwrap() in Node and Python

Updated September 26, 202611 min read

OpenAI sends webhooks when asynchronous work finishes: background-mode Responses, Batch jobs, fine-tuning jobs, eval runs and incoming Realtime SIP calls. You add an HTTPS endpoint in your project's Webhooks settings, OpenAI POSTs a small JSON event signed with the Standard Webhooks headers webhook-id, webhook-timestamp and webhook-signature, and you verify it with client.webhooks.unwrap() in the official SDK. Everything below was checked against OpenAI's webhook guide and the openai-python and openai-node SDK source in September 2026.

Key takeaways

  • OpenAI webhooks fire for asynchronous work only: background-mode Responses (response.completed, response.failed, response.cancelled, response.incomplete), Batch, fine-tuning and eval jobs, and incoming Realtime SIP calls.
  • Deliveries follow the Standard Webhooks spec: the headers are webhook-id, webhook-timestamp and webhook-signature (v1,<base64>), not an openai-signature header.
  • Verify with client.webhooks.unwrap(rawBody, headers) in the official Python and Node SDKs; it reads OPENAI_WEBHOOK_SECRET, rejects events older than 5 minutes and returns the typed event. In Node it is async and needs the raw body string.
  • Payloads are thin: data only holds the object ID (for example resp_abc123), so fetch the result with responses.retrieve(id) or batches.retrieve(id).
  • OpenAI retries non-2xx responses with exponential backoff for up to 72 hours, so return 2xx fast and dedupe on webhook-id.
  • To see a real OpenAI event before writing the handler, register a Hooklistener endpoint as the webhook URL: it captures the exact headers and body, forwards them to localhost through the CLI tunnel, and lets Claude Code or Codex wait for the event through the Hooklistener MCP server.

Does OpenAI support webhooks?

Yes, for work that runs in the background. A normal chat.completions.create or responses.create call returns the result on the same HTTP connection (or streams it with stream=true), so there is nothing to push. Once you start work that outlives the request (a Responses call with background=true, a batch, a fine-tuning job, an eval run), OpenAI can POST to your endpoint when it reaches a final state instead of you polling.

That makes webhooks the right fit for deep research runs, long reasoning tasks, overnight batches and anything else where holding a connection open for minutes is fragile. Short chat turns should keep using streaming.

Which OpenAI events trigger a webhook?

You choose event types per endpoint. These are the job events from the webhook events reference (as of September 2026); a few account-level notices, such as safety warnings, are listed there too.

EventEmitted byMeaning
response.completedResponses API (background)A background response finished successfully.
response.failedResponses API (background)A background response ended in an error.
response.cancelledResponses API (background)A background response was cancelled.
response.incompleteResponses API (background)A background response stopped early, for example at max output tokens.
batch.completedBatch APIBatch finished; the output file is ready.
batch.failedBatch APIBatch failed.
batch.expiredBatch APIBatch did not finish inside its completion window.
batch.cancelledBatch APIBatch was cancelled.
fine_tuning.job.succeededFine-tuningFine-tuning job succeeded.
fine_tuning.job.failedFine-tuningFine-tuning job failed.
fine_tuning.job.cancelledFine-tuningFine-tuning job was cancelled.
eval.run.succeededEvalsEval run succeeded.
eval.run.failedEvalsEval run failed.
eval.run.canceledEvalsEval run was canceled (note the single l).
realtime.call.incomingRealtime API (SIP)An incoming SIP call is waiting to be accepted or rejected.

How do webhooks work with Responses API background mode?

Create the response with background=True; when it finishes, OpenAI sends a response.* event to every project endpoint subscribed to it. The call returns right away with status queued. Keep the response ID so you can match the webhook to your own record.

start_job.py
from openai import OpenAI

client = OpenAI()

resp = client.responses.create(
    model="your-model",          # any model that supports background mode
    input="Research the 2026 EU AI Act obligations for SaaS vendors.",
    background=True,             # returns immediately with status "queued"
)
print(resp.id, resp.status)      # store resp.id; the webhook will reference it

The webhook body is intentionally thin. It tells you what happened and to which object, not the output itself:

response.completed
{
  "object": "event",
  "id": "evt_685343a1381c819085d44c354e1b330e",
  "type": "response.completed",
  "created_at": 1750287018,
  "data": { "id": "resp_abc123" }
}

Fetch the output with client.responses.retrieve(event.data.id). The same pattern applies to batch.* (retrieve the batch, then its output_file_id), fine-tuning jobs and eval runs. Deep research models run through this same background flow; there is no separate deep research webhook.

How do I set up an OpenAI webhook endpoint?

  1. In the OpenAI dashboard, open the project that makes the async calls and go to Settings → Project → Webhooks (platform.openai.com/settings/project/webhooks).
  2. Create an endpoint with a public HTTPS URL.
  3. Select the event types you want delivered, for example response.completed and batch.completed.
  4. Copy the signing secret right away (OpenAI won't show it again) and store it as OPENAI_WEBHOOK_SECRET in your secrets manager. Both SDKs read that variable automatically.
  5. Use the settings page to send a test event with sample data and confirm your endpoint answers with a 2xx.

Endpoints are per project. If the API key that starts a job belongs to another project, that project's endpoints receive the event.

What headers does an OpenAI webhook include?

OpenAI signs every delivery with the three Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature. If you were looking for an openai-signature or x-openai-signature header, it doesn't exist.

POST /openai/webhook HTTP/1.1
user-agent: OpenAI/1.0 (+https://platform.openai.com/docs/webhooks)
content-type: application/json
webhook-id: wh_685342e6c53c8190a1be43f081506c52
webhook-timestamp: 1750287078
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
  • webhook-id: unique per event and, per the Standard Webhooks spec, the same when a delivery is retried. Use it as your idempotency key.
  • webhook-timestamp: Unix seconds. The SDKs reject anything more than 300 seconds old or ahead.
  • webhook-signature: v1, followed by a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}. During secret rotation it can hold several space-separated signatures; accept any match.

How do I verify OpenAI webhooks with unwrap()?

Pass the raw request body and the headers to client.webhooks.unwrap(); it verifies the signature and timestamp, then returns the parsed, typed event. It raises InvalidWebhookSignatureError on a bad or stale signature. If you only want the check, verify_signature() (verifySignature() in Node) does that and takes an optional tolerance in seconds.

Python (Flask)

app.py
import os
from flask import Flask, request
from openai import OpenAI, InvalidWebhookSignatureError

app = Flask(__name__)
client = OpenAI()  # reads OPENAI_API_KEY and OPENAI_WEBHOOK_SECRET

@app.post("/openai/webhook")
def openai_webhook():
    try:
        # request.data is the raw body; unwrap checks webhook-id,
        # webhook-timestamp and webhook-signature, then parses the event
        event = client.webhooks.unwrap(request.data, request.headers)
    except InvalidWebhookSignatureError:
        return "invalid signature", 400

    if already_processed(request.headers["webhook-id"]):
        return "", 200

    if event.type == "response.completed":
        response = client.responses.retrieve(event.data.id)
        save_output(event.data.id, response.output_text)
    elif event.type in ("response.failed", "response.incomplete", "response.cancelled"):
        mark_unfinished(event.data.id, event.type)
    elif event.type == "batch.completed":
        batch = client.batches.retrieve(event.data.id)
        enqueue_download(batch.output_file_id)

    return "", 200

Node.js (Express)

Two details break most Node handlers: unwrap() returns a Promise, and it expects the body as a string. If express.json() has already parsed it, the signature can't match.

server.js
import express from "express";
import OpenAI, { InvalidWebhookSignatureError } from "openai";

const app = express();
const client = new OpenAI(); // reads OPENAI_API_KEY and OPENAI_WEBHOOK_SECRET

// unwrap() needs the raw body as a string, so don't use express.json() here
app.post("/openai/webhook", express.text({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await client.webhooks.unwrap(req.body, req.headers); // async in Node
  } catch (err) {
    if (err instanceof InvalidWebhookSignatureError) {
      return res.status(400).send("invalid signature");
    }
    throw err;
  }

  res.sendStatus(200); // acknowledge within a few seconds, then do the work

  if (event.type === "response.completed") {
    const response = await client.responses.retrieve(event.data.id);
    await saveOutput(event.data.id, response.output_text);
  }
});

Without the SDK

Any Standard Webhooks library (for example the standardwebhooks package) works too. This is the same check by hand:

verify-openai.ts
import crypto from "node:crypto";

// Standard Webhooks verification without the OpenAI SDK
export function verifyOpenAIWebhook(rawBody: string, headers: Record<string, string>, secret: string) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = (headers["webhook-signature"] ?? "").split(" "); // may hold several during rotation

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = secret.startsWith("whsec_")
    ? Buffer.from(secret.slice("whsec_".length), "base64")
    : Buffer.from(secret, "utf8");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  return signatures.some((sig) => {
    const candidate = Buffer.from(sig.replace(/^v1,/, ""), "base64");
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
  });
}

How do I test OpenAI webhooks locally?

Give OpenAI a public HTTPS URL that forwards to your machine, then start a real background job. OpenAI can't reach localhost, so you need a tunnel or a hosted capture URL. With the Hooklistener CLI:

npm i -g hooklistener        # or: brew tap hooklistener/tap && brew install hooklistener
hooklistener login
hooklistener tunnel --port 5000   # prints a public HTTPS URL for localhost:5000

Register that URL plus your path (for example /openai/webhook) in the project's webhook settings. ngrok and other localhost tunnels work the same way.

One catch when replaying a captured event later: the SDK rejects it as "Webhook timestamp is too old" after 5 minutes, because the signature covers the original timestamp. In development only, verify with a wider window, for example client.webhooks.verify_signature(body, headers, tolerance=86400), then parse the body yourself.

How should I handle retries and duplicates?

Respond with a 2xx within a few seconds and dedupe on webhook-id. If your endpoint times out or returns a non-2xx status, OpenAI retries with exponential backoff for up to 72 hours, so the same event can arrive more than once. Don't rely on delivery order either; re-read the object's current status when it matters.

# Return 2xx quickly for anything you accepted (even if you queue the work).
# Return 400 for bad signatures: retrying will never fix them.
# Return 5xx only for transient failures you want OpenAI to retry (up to 72 h).

seen = redis.set(f"openai:webhook:{webhook_id}", 1, nx=True, ex=4 * 24 * 3600)
if not seen:
    return "", 200  # duplicate delivery, already handled

Why is my OpenAI webhook failing?

  • Signature never matches: the body was parsed or re-serialized before verification, or the secret belongs to another project or endpoint. Verify the raw bytes with the secret of the endpoint that received the event.
  • "Webhook timestamp is too old": server clock drift, or you are replaying an old delivery. Sync the clock with NTP; widen the tolerance only in development.
  • No events arrive: the job wasn't started in background mode, the endpoint isn't subscribed to that event type, or the job ran under a different project's API key.
  • event.data has no output: expected. Webhook events only carry the object ID; retrieve the object for its content.

Test OpenAI webhooks with Hooklistener

Hooklistener gives you a public HTTPS URL that records every OpenAI delivery, so you can read the real headers and body before your handler exists.

  1. Capture: create an endpoint in the Hooklistener dashboard, register its URL in your OpenAI project's webhook settings, and start a background response. The captured event shows the exact webhook-* headers and JSON.
  2. Forward to localhost: run hooklistener tunnel --port 5000 so live events reach your dev server while you step through the handler.
  3. Replay: resend a captured response.completed or batch.failed event to your handler instead of starting another paid job (mind the 5-minute signature window above).
  4. From your coding agent: connect the Hooklistener MCP server (claude mcp add --transport http hooklistener https://app.hooklistener.com/api/mcp). Claude Code or Codex can call create_endpoint, start the job, call wait_for_request, which waits for OpenAI's event (as a task the agent follows, or blocking up to 60 s with blocking: true), then replay_request it against the handler it just wrote.

FAQ

How do I use webhooks in OpenAI?

Open your project's Webhooks settings in the OpenAI dashboard (platform.openai.com/settings/project/webhooks), add an HTTPS endpoint, pick the events you want (for example response.completed or batch.completed), and store the signing secret as OPENAI_WEBHOOK_SECRET. Then start async work, such as a Responses API call with background: true, and verify each delivery with client.webhooks.unwrap() before acting on it.

Does OpenAI send an openai-signature header or webhook-signature?

webhook-signature. OpenAI follows the Standard Webhooks spec, so every delivery carries webhook-id, webhook-timestamp and webhook-signature headers. The signature is v1,<base64 HMAC-SHA256> over webhook-id.webhook-timestamp.body; there is no openai-signature header. client.webhooks.unwrap() in the official SDKs reads all three headers for you.

Does the Responses API background mode send webhooks?

Yes. When you create a response with background set to true and your project has a webhook endpoint subscribed to response events, OpenAI sends response.completed, response.failed, response.cancelled or response.incomplete when the job reaches a final state. The event only contains the response ID, so call responses.retrieve(id) to get the output.

What is the difference between unwrap() and verify_signature() in the OpenAI SDK?

unwrap() verifies the signature and returns the parsed, typed event; verify_signature() (verifySignature() in Node) only verifies and raises InvalidWebhookSignatureError on failure. Both reject deliveries older than 5 minutes by default; verify_signature accepts a tolerance argument if you need a wider window, for example when replaying captured events in development.

How long does OpenAI retry a failed webhook?

OpenAI retries deliveries that don't get a 2xx response within a few seconds, with exponential backoff, for up to 72 hours. Use the webhook-id header to dedupe, because the same event can arrive more than once.

Related Resources