OpenAI Webhooks: Events, Signature Headers and unwrap() in Node and Python
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-timestampandwebhook-signature(v1,<base64>), not anopenai-signatureheader. - Verify with
client.webhooks.unwrap(rawBody, headers)in the official Python and Node SDKs; it readsOPENAI_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:
dataonly holds the object ID (for exampleresp_abc123), so fetch the result withresponses.retrieve(id)orbatches.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.
| Event | Emitted by | Meaning |
|---|---|---|
response.completed | Responses API (background) | A background response finished successfully. |
response.failed | Responses API (background) | A background response ended in an error. |
response.cancelled | Responses API (background) | A background response was cancelled. |
response.incomplete | Responses API (background) | A background response stopped early, for example at max output tokens. |
batch.completed | Batch API | Batch finished; the output file is ready. |
batch.failed | Batch API | Batch failed. |
batch.expired | Batch API | Batch did not finish inside its completion window. |
batch.cancelled | Batch API | Batch was cancelled. |
fine_tuning.job.succeeded | Fine-tuning | Fine-tuning job succeeded. |
fine_tuning.job.failed | Fine-tuning | Fine-tuning job failed. |
fine_tuning.job.cancelled | Fine-tuning | Fine-tuning job was cancelled. |
eval.run.succeeded | Evals | Eval run succeeded. |
eval.run.failed | Evals | Eval run failed. |
eval.run.canceled | Evals | Eval run was canceled (note the single l). |
realtime.call.incoming | Realtime 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.
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 itThe webhook body is intentionally thin. It tells you what happened and to which object, not the output itself:
{
"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?
- In the OpenAI dashboard, open the project that makes the async calls and go to Settings → Project → Webhooks (
platform.openai.com/settings/project/webhooks). - Create an endpoint with a public HTTPS URL.
- Select the event types you want delivered, for example
response.completedandbatch.completed. - Copy the signing secret right away (OpenAI won't show it again) and store it as
OPENAI_WEBHOOK_SECRETin your secrets manager. Both SDKs read that variable automatically. - 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)
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 "", 200Node.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.
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:
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:5000Register 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 handledWhy 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.datahas 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.
- 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. - Forward to localhost: run
hooklistener tunnel --port 5000so live events reach your dev server while you step through the handler. - Replay: resend a captured
response.completedorbatch.failedevent to your handler instead of starting another paid job (mind the 5-minute signature window above). - 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 callcreate_endpoint, start the job, callwait_for_request, which waits for OpenAI's event (as a task the agent follows, or blocking up to 60 s withblocking: true), thenreplay_requestit 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.