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.
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:
- An OpenAI Platform account
- A webhook signing secret from the OpenAI Dashboard
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
- Go to OpenAI Dashboard → Webhooks
- Click Create webhook
- Enter your webhook URL:
https://your-app.api.codehooks.io/dev/openai/webhook - Select the events you want to receive
- Copy the signing secret — you'll need it for verification
- Click Save
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 Type | Description |
|---|---|
response.completed | Background response finished successfully |
response.failed | Background response encountered an error |
response.cancelled | Response was cancelled |
response.incomplete | Response hit limits or timed out |
Batch Processing Events
For batch API operations:
| Event Type | Description |
|---|---|
batch.completed | All batch items processed successfully |
batch.failed | Batch processing failed |
batch.expired | Batch expired before completion |
batch.cancelled | Batch was cancelled |
Fine-Tuning Events
For model fine-tuning jobs:
| Event Type | Description |
|---|---|
fine_tuning.job.succeeded | Fine-tuning completed successfully |
fine_tuning.job.failed | Fine-tuning encountered an error |
fine_tuning.job.cancelled | Fine-tuning was cancelled |
Eval Run Events
For evaluation runs:
| Event Type | Description |
|---|---|
eval.run.succeeded | Evaluation completed successfully |
eval.run.failed | Evaluation encountered an error |
eval.run.canceled | Evaluation 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:
| Header | Description |
|---|---|
webhook-id | Unique identifier for this delivery (use for idempotency) |
webhook-timestamp | Unix timestamp when the webhook was sent |
webhook-signature | Signature for verification (format: v1,base64_signature) |
content-type | Always 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.
Using the OpenAI SDK (Recommended)
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?
| Feature | DIY Setup | Codehooks.io |
|---|---|---|
| Setup time | Hours | 5 minutes |
| SSL/HTTPS | Configure certificates | Built-in |
| Signature validation | Manual implementation | Use OpenAI SDK |
| Database for results | Set up separately | Built-in |
| Async processing | Configure queues | Built-in workers |
| Monitoring | Set up logging | Built-in logs |
| Cost | Server costs | Free tier available |
Related Resources
- OpenAI Webhooks Documentation
- OpenAI Batch API
- Standard Webhooks Specification
- Codehooks.io Quick Start
- Building LLM Workflows with Codehooks.io
- Secure Zapier/Make/n8n/IFTTT Webhooks - Forward verified AI results to automation platforms
- Webhook Delivery System - Build your own outgoing webhook system
- What are Webhooks? - Learn webhook fundamentals
- Stripe Webhooks Example - Payment webhook handler with similar patterns
- Browse All Examples - Explore more templates and integrations
OpenAI Webhooks FAQ
Common questions about handling OpenAI webhooks
How do I verify OpenAI webhook signatures?
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?
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?
Does OpenAI retry failed webhook deliveries?
webhook-id header for idempotency.What events can I receive from OpenAI webhooks?
Where do I find my webhook signing secret?
How do I handle duplicate webhook deliveries?
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.