Skip to main content

OpenAI Webhooks Example: Handle Deep Research & Batch Jobs

OpenAI webhooks notify your application when long-running AI tasks complete — Deep Research results, batch processing jobs, fine-tuning completions, and more. Instead of polling the OpenAI API, webhooks push real-time notifications when operations finish.

The problem? OpenAI's Deep Research and batch APIs can take minutes or hours. Polling wastes resources and introduces latency. You need instant notifications when tasks complete.

The solution? Deploy a production-ready OpenAI webhook handler with Codehooks.io in under 5 minutes.

New to webhooks?

If you're new to webhooks, check out our guide on What are webhooks? to understand the fundamentals before diving into this OpenAI-specific implementation.

Prerequisites

Before you begin, sign up for a free Codehooks.io account and install the CLI:

npm install -g codehooks

You'll also need:

Quick Start: OpenAI Webhook Handler

Create a new Codehooks project:

coho create my-openai-handler
cd my-openai-handler
npm install openai

Replace index.js with:

import { app, Datastore } from 'codehooks-js';
import OpenAI from 'openai';

const openai = new OpenAI();

// Bypass JWT auth for OpenAI webhook endpoints
app.auth('/openai/*', (req, res, next) => {
next();
});

// Handle OpenAI webhook events
app.post('/openai/webhook', async (req, res) => {
const webhookSecret = process.env.OPENAI_WEBHOOK_SECRET;

try {
// Verify signature and parse the event
const event = openai.webhooks.unwrap(req.rawBody, req.headers, webhookSecret);

console.log(`Received OpenAI event: ${event.type}`);

// Store the event
const conn = await Datastore.open();
await conn.insertOne('openai_events', {
eventId: event.id,
type: event.type,
data: event.data,
receivedAt: new Date().toISOString(),
});

// Handle specific event types
switch (event.type) {
case 'response.completed':
await handleResponseCompleted(event.data);
break;
case 'batch.completed':
await handleBatchCompleted(event.data);
break;
case 'fine_tuning.job.succeeded':
await handleFineTuningSucceeded(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}

res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook verification failed:', err.message);
res.status(400).json({ error: 'Invalid signature' });
}
});

async function handleResponseCompleted(data) {
console.log('Deep Research completed:', data.id);
// Process the completed research results
}

async function handleBatchCompleted(data) {
console.log('Batch job completed:', data.id);
// Fetch and process batch results
}

async function handleFineTuningSucceeded(data) {
console.log('Fine-tuning succeeded:', data.id);
// Store the new model ID for future use
}

export default app.init();

Deploy and configure:

coho deploy
coho set-env OPENAI_WEBHOOK_SECRET whsec_xxxxx --encrypted
coho set-env OPENAI_API_KEY sk-xxxxx --encrypted

Your webhook URL will be: https://my-openai-handler-xxxx.api.codehooks.io/dev/openai/webhook

Configure OpenAI Dashboard

  1. Go to OpenAI Dashboard → Webhooks
  2. Click Create webhook
  3. Enter your webhook URL: https://your-app.api.codehooks.io/dev/openai/webhook
  4. Select the events you want to receive
  5. Copy the signing secret — you'll need it for verification
  6. Click Save
Save Your Signing Secret

The signing secret is only shown once when you create the webhook. Store it securely in your environment variables.

OpenAI Webhook Event Types

Background Response Events

For Deep Research and other long-running response operations:

Event TypeDescription
response.completedBackground response finished successfully
response.failedBackground response encountered an error
response.cancelledResponse was cancelled
response.incompleteResponse hit limits or timed out

Batch Processing Events

For batch API operations:

Event TypeDescription
batch.completedAll batch items processed successfully
batch.failedBatch processing failed
batch.expiredBatch expired before completion
batch.cancelledBatch was cancelled

Fine-Tuning Events

For model fine-tuning jobs:

Event TypeDescription
fine_tuning.job.succeededFine-tuning completed successfully
fine_tuning.job.failedFine-tuning encountered an error
fine_tuning.job.cancelledFine-tuning was cancelled

Eval Run Events

For evaluation runs:

Event TypeDescription
eval.run.succeededEvaluation completed successfully
eval.run.failedEvaluation encountered an error
eval.run.canceledEvaluation was cancelled

Webhook Payload Format

OpenAI webhook payloads follow this structure:

{
"id": "evt_1234567890abcdef",
"object": "event",
"type": "response.completed",
"created_at": 1735123456,
"data": {
"id": "resp_abc123",
"object": "response",
"status": "completed",
"output": [
{
"type": "message",
"message": {
"role": "assistant",
"content": "..."
}
}
],
"completed_at": 1735123456
},
"api_version": "2025-01-01"
}

Webhook Headers

Each request includes these headers:

HeaderDescription
webhook-idUnique identifier for this delivery (use for idempotency)
webhook-timestampUnix timestamp when the webhook was sent
webhook-signatureSignature for verification (format: v1,base64_signature)
content-typeAlways application/json

Deep Research Webhook Handler

Handle completed Deep Research results:

import { app, Datastore } from 'codehooks-js';
import OpenAI from 'openai';

const openai = new OpenAI();

app.auth('/openai/*', (req, res, next) => {
next();
});

app.post('/openai/webhook', async (req, res) => {
const webhookSecret = process.env.OPENAI_WEBHOOK_SECRET;

try {
const event = openai.webhooks.unwrap(req.rawBody, req.headers, webhookSecret);

if (event.type === 'response.completed') {
const response = event.data;

// Extract the research results
const output = response.output || [];
const messageContent = output
.filter(item => item.type === 'message')
.map(item => item.message?.content)
.join('\n');

// Store the completed research
const conn = await Datastore.open();
await conn.insertOne('research_results', {
responseId: response.id,
status: 'completed',
content: messageContent,
completedAt: new Date(response.completed_at * 1000).toISOString(),
receivedAt: new Date().toISOString(),
});

console.log('Deep Research completed:', response.id);

// Optionally notify your users
await conn.enqueue('notifyUser', {
type: 'research_complete',
responseId: response.id,
});
}

res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
res.status(400).json({ error: 'Invalid signature' });
}
});

// Worker to notify users asynchronously
app.worker('notifyUser', async (req, res) => {
const { type, responseId } = req.body.payload;

// Send email, push notification, etc.
console.log(`Notifying user about ${type}: ${responseId}`);

res.end();
});

export default app.init();

Batch Processing Webhook Handler

Handle batch job completion:

import { app, Datastore } from 'codehooks-js';
import OpenAI from 'openai';

const openai = new OpenAI();

app.auth('/openai/*', (req, res, next) => {
next();
});

app.post('/openai/webhook', async (req, res) => {
const webhookSecret = process.env.OPENAI_WEBHOOK_SECRET;

try {
const event = openai.webhooks.unwrap(req.rawBody, req.headers, webhookSecret);

if (event.type === 'batch.completed') {
const batch = event.data;

console.log(`Batch ${batch.id} completed`);

// Fetch the batch results
const completedBatch = await openai.batches.retrieve(batch.id);

// Download and process the output file
if (completedBatch.output_file_id) {
const fileContent = await openai.files.content(completedBatch.output_file_id);
const results = await fileContent.text();

// Store batch results
const conn = await Datastore.open();
await conn.insertOne('batch_results', {
batchId: batch.id,
status: 'completed',
outputFileId: completedBatch.output_file_id,
results: results,
requestCounts: completedBatch.request_counts,
completedAt: new Date().toISOString(),
});
}

// Handle any errors
if (completedBatch.error_file_id) {
const errorContent = await openai.files.content(completedBatch.error_file_id);
console.error('Batch errors:', await errorContent.text());
}
}

if (event.type === 'batch.failed') {
const batch = event.data;
console.error(`Batch ${batch.id} failed`);

const conn = await Datastore.open();
await conn.insertOne('batch_results', {
batchId: batch.id,
status: 'failed',
failedAt: new Date().toISOString(),
});
}

res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
res.status(400).json({ error: 'Invalid signature' });
}
});

export default app.init();

Fine-Tuning Webhook Handler

Handle fine-tuning job completion:

import { app, Datastore } from 'codehooks-js';
import OpenAI from 'openai';

const openai = new OpenAI();

app.auth('/openai/*', (req, res, next) => {
next();
});

app.post('/openai/webhook', async (req, res) => {
const webhookSecret = process.env.OPENAI_WEBHOOK_SECRET;

try {
const event = openai.webhooks.unwrap(req.rawBody, req.headers, webhookSecret);

if (event.type === 'fine_tuning.job.succeeded') {
const job = event.data;

console.log(`Fine-tuning job ${job.id} succeeded`);
console.log(`New model: ${job.fine_tuned_model}`);

// Store the fine-tuned model info
const conn = await Datastore.open();
await conn.insertOne('fine_tuned_models', {
jobId: job.id,
modelId: job.fine_tuned_model,
baseModel: job.model,
status: 'succeeded',
trainedTokens: job.trained_tokens,
createdAt: new Date().toISOString(),
});

// You can now use job.fine_tuned_model in your API calls
}

if (event.type === 'fine_tuning.job.failed') {
const job = event.data;
console.error(`Fine-tuning job ${job.id} failed:`, job.error);

const conn = await Datastore.open();
await conn.insertOne('fine_tuned_models', {
jobId: job.id,
status: 'failed',
error: job.error,
failedAt: new Date().toISOString(),
});
}

res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
res.status(400).json({ error: 'Invalid signature' });
}
});

export default app.init();

Complete Multi-Event Handler

Handle all OpenAI webhook types in one deployment:

import { app, Datastore } from 'codehooks-js';
import OpenAI from 'openai';

const openai = new OpenAI();

// Bypass JWT for OpenAI webhook endpoint
app.auth('/openai/*', (req, res, next) => {
next();
});

// Main webhook handler
app.post('/openai/webhook', async (req, res) => {
const webhookSecret = process.env.OPENAI_WEBHOOK_SECRET;

let event;
try {
event = openai.webhooks.unwrap(req.rawBody, req.headers, webhookSecret);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(400).json({ error: 'Invalid signature' });
}

const conn = await Datastore.open();
const webhookId = req.headers['webhook-id'];

// Idempotency check
const existing = await conn.getOne('openai_events', { webhookId });
if (existing) {
console.log('Already processed:', webhookId);
return res.status(200).json({ received: true });
}

// Store the event
await conn.insertOne('openai_events', {
webhookId,
eventId: event.id,
type: event.type,
data: event.data,
receivedAt: new Date().toISOString(),
});

console.log(`Received event: ${event.type}`);

// Route to appropriate handler
switch (event.type) {
// Background Response events
case 'response.completed':
await handleResponseCompleted(conn, event.data);
break;
case 'response.failed':
await handleResponseFailed(conn, event.data);
break;
case 'response.cancelled':
case 'response.incomplete':
await handleResponseIncomplete(conn, event.data, event.type);
break;

// Batch events
case 'batch.completed':
await handleBatchCompleted(conn, event.data);
break;
case 'batch.failed':
case 'batch.expired':
case 'batch.cancelled':
await handleBatchFailed(conn, event.data, event.type);
break;

// Fine-tuning events
case 'fine_tuning.job.succeeded':
await handleFineTuningSucceeded(conn, event.data);
break;
case 'fine_tuning.job.failed':
case 'fine_tuning.job.cancelled':
await handleFineTuningFailed(conn, event.data, event.type);
break;

// Eval events
case 'eval.run.succeeded':
await handleEvalSucceeded(conn, event.data);
break;
case 'eval.run.failed':
case 'eval.run.canceled':
await handleEvalFailed(conn, event.data, event.type);
break;

default:
console.log('Unhandled event type:', event.type);
}

res.status(200).json({ received: true });
});

// Handler functions
async function handleResponseCompleted(conn, data) {
console.log('Response completed:', data.id);
await conn.updateOne('responses', { responseId: data.id }, {
$set: { status: 'completed', completedAt: new Date().toISOString() }
});
}

async function handleResponseFailed(conn, data) {
console.error('Response failed:', data.id, data.error);
await conn.updateOne('responses', { responseId: data.id }, {
$set: { status: 'failed', error: data.error, failedAt: new Date().toISOString() }
});
}

async function handleResponseIncomplete(conn, data, eventType) {
console.log(`Response ${eventType}:`, data.id);
await conn.updateOne('responses', { responseId: data.id }, {
$set: { status: eventType.split('.')[1], updatedAt: new Date().toISOString() }
});
}

async function handleBatchCompleted(conn, data) {
console.log('Batch completed:', data.id);
await conn.updateOne('batches', { batchId: data.id }, {
$set: { status: 'completed', completedAt: new Date().toISOString() }
});
}

async function handleBatchFailed(conn, data, eventType) {
const status = eventType.split('.')[1];
console.error(`Batch ${status}:`, data.id);
await conn.updateOne('batches', { batchId: data.id }, {
$set: { status, failedAt: new Date().toISOString() }
});
}

async function handleFineTuningSucceeded(conn, data) {
console.log('Fine-tuning succeeded:', data.id, data.fine_tuned_model);
await conn.updateOne('fine_tuning_jobs', { jobId: data.id }, {
$set: {
status: 'succeeded',
fineTunedModel: data.fine_tuned_model,
completedAt: new Date().toISOString()
}
});
}

async function handleFineTuningFailed(conn, data, eventType) {
const status = eventType.split('.')[2];
console.error(`Fine-tuning ${status}:`, data.id, data.error);
await conn.updateOne('fine_tuning_jobs', { jobId: data.id }, {
$set: { status, error: data.error, failedAt: new Date().toISOString() }
});
}

async function handleEvalSucceeded(conn, data) {
console.log('Eval run succeeded:', data.id);
await conn.insertOne('eval_results', {
evalId: data.id,
status: 'succeeded',
completedAt: new Date().toISOString(),
});
}

async function handleEvalFailed(conn, data, eventType) {
const status = eventType.split('.')[2];
console.error(`Eval ${status}:`, data.id);
await conn.insertOne('eval_results', {
evalId: data.id,
status,
failedAt: new Date().toISOString(),
});
}

export default app.init();

Signature Verification

OpenAI uses the Standard Webhooks specification for signature verification. The signature is computed using HMAC-SHA256.

import OpenAI from 'openai';

const openai = new OpenAI();

function verifyOpenAIWebhook(rawBody, headers, secret) {
try {
// unwrap() verifies the signature and parses the payload
const event = openai.webhooks.unwrap(rawBody, headers, secret);
return event;
} catch (err) {
console.error('Verification failed:', err.message);
throw err;
}
}

Important: Use req.rawBody (the raw JSON string) for verification, not req.body (the parsed object).

Using Standard Webhooks Library

Alternatively, use the standardwebhooks package directly:

import { Webhook } from 'standardwebhooks';

function verifyWithStandardWebhooks(rawBody, headers, secret) {
const wh = new Webhook(secret);

// Extract required headers
const webhookHeaders = {
'webhook-id': headers['webhook-id'],
'webhook-timestamp': headers['webhook-timestamp'],
'webhook-signature': headers['webhook-signature'],
};

try {
const payload = wh.verify(rawBody, webhookHeaders);
return JSON.parse(payload);
} catch (err) {
console.error('Verification failed:', err.message);
throw err;
}
}

Verify Only (Without Parsing)

If you need to verify the signature separately:

import OpenAI from 'openai';

const openai = new OpenAI();

// Just verify, don't parse
openai.webhooks.verifySignature(rawBody, headers, secret);

// Then parse separately
const event = JSON.parse(rawBody);

Response Requirements

OpenAI webhooks require a quick response:

  • Respond with 2xx within a few seconds to indicate success
  • Non-2xx responses trigger retries with exponential backoff (up to 72 hours)
  • 3xx redirects are not followed and treated as failures
app.post('/openai/webhook', async (req, res) => {
try {
const event = openai.webhooks.unwrap(req.rawBody, req.headers, secret);

// Quick acknowledge, process later
res.status(200).json({ received: true });

// Async processing after response
const conn = await Datastore.open();
await conn.enqueue('processEvent', { event });
} catch (err) {
res.status(400).json({ error: 'Invalid signature' });
}
});

Best Practices for OpenAI Webhooks

1. Always Verify Signatures

Never skip signature verification in production:

const event = openai.webhooks.unwrap(req.rawBody, req.headers, secret);

2. Use Environment Variables

coho set-env OPENAI_WEBHOOK_SECRET whsec_xxxxx --encrypted
coho set-env OPENAI_API_KEY sk-xxxxx --encrypted

3. Handle Idempotency

OpenAI typically doesn't send duplicates, but use webhook-id as an idempotency key:

const webhookId = req.headers['webhook-id'];
const existing = await conn.getOne('openai_events', { webhookId });

if (existing) {
console.log('Already processed:', webhookId);
return res.status(200).json({ received: true });
}

4. Respond Quickly

Acknowledge within seconds, process asynchronously:

// Store immediately
await conn.insertOne('events', { eventId: event.id, type: event.type });

// Queue for async processing
await conn.enqueue('processOpenAIEvent', { event });

// Respond immediately
res.status(200).json({ received: true });

5. Log Everything

console.log('OpenAI webhook received:', {
eventId: event.id,
type: event.type,
timestamp: new Date().toISOString(),
});

Check logs with: coho logs --follow

Why Use Codehooks.io for OpenAI Webhooks?

FeatureDIY SetupCodehooks.io
Setup timeHours5 minutes
SSL/HTTPSConfigure certificatesBuilt-in
Signature validationManual implementationUse OpenAI SDK
Database for resultsSet up separatelyBuilt-in
Async processingConfigure queuesBuilt-in workers
MonitoringSet up loggingBuilt-in logs
CostServer costsFree tier available

OpenAI Webhooks FAQ

Common questions about handling OpenAI webhooks

How do I verify OpenAI webhook signatures?
Use the openai.webhooks.unwrap() method from the official OpenAI SDK. It takes the raw request body, headers, and your webhook secret. This method verifies the signature and parses the payload in one step. Always use req.rawBody (the unparsed string), not req.body.
What signature format does OpenAI use?
OpenAI uses the Standard Webhooks specification with HMAC-SHA256 signatures. The signature is sent in the webhook-signature header in the format v1,base64_encoded_signature. You can use the OpenAI SDK or the standardwebhooks npm package for verification.
How long do I have to respond to OpenAI webhooks?
You should respond with a 2xx status code within a few seconds. If your endpoint doesn't respond in time or returns an error, OpenAI will retry with exponential backoff for up to 72 hours. Best practice: acknowledge immediately and process asynchronously using queues.
Does OpenAI retry failed webhook deliveries?
Yes, OpenAI retries failed webhooks (non-2xx responses or timeouts) with exponential backoff for up to 72 hours. Note that 3xx redirects are not followed and are treated as failures. Use the webhook-id header for idempotency.
What events can I receive from OpenAI webhooks?
OpenAI webhooks support four categories: Background Responses (response.completed, response.failed), Batch Jobs (batch.completed, batch.failed), Fine-Tuning (fine_tuning.job.succeeded, fine_tuning.job.failed), and Eval Runs (eval.run.succeeded, eval.run.failed). Select which events you want when configuring your webhook.
Where do I find my webhook signing secret?
The signing secret is shown once when you create a webhook in the OpenAI Dashboard. Save it immediately — you can't view it again. If lost, you'll need to delete the webhook and create a new one.
How do I handle duplicate webhook deliveries?
While OpenAI typically doesn't send duplicates, use the webhook-id header as an idempotency key. Before processing, check if you've already handled an event with that ID. Store processed webhook IDs in your database to prevent duplicate processing.
Can I use OpenAI webhooks for real-time streaming?
No, webhooks are for async completion notifications, not real-time streaming. For streaming responses, use the OpenAI streaming API directly. Webhooks are ideal for long-running tasks like Deep Research, batch jobs, and fine-tuning where you don't want to maintain an open connection.