Agent skill

OpenAI Webhooks Skill

Receive and verify OpenAI webhooks. Use when setting up OpenAI webhook handlers for fine-tuning jobs, batch completions, or async events like fine_tuning.job.completed, batch.completed, or realtime.call.incoming.

Install this skill

npx skills add hookdeck/webhook-skills --skill openai-webhooks


When to Use This Skill

  • Setting up OpenAI webhook handlers for async operations
  • Debugging signature verification failures
  • Handling fine-tuning job completion events
  • Processing batch API completion notifications
  • Handling realtime API incoming calls

Essential Code (USE THIS)

Express Webhook Handler

const express = require('express');
const crypto = require('crypto');

const app = express();

// Standard Webhooks signature verification for OpenAI
function verifyOpenAISignature(payload, webhookId, webhookTimestamp, webhookSignature, secret) {
  if (!webhookSignature || !webhookSignature.includes(',')) {
    return false;
  }

  // Check timestamp is within 5 minutes to prevent replay attacks
  const currentTime = Math.floor(Date.now() / 1000);
  const timestampDiff = currentTime - parseInt(webhookTimestamp);
  if (timestampDiff > 300 || timestampDiff < -300) {
    console.error('Webhook timestamp too old or too far in the future');
    return false;
  }

  // Extract version and signature
  const [version, signature] = webhookSignature.split(',');
  if (version !== 'v1') {
    return false;
  }

  // Create signed content: webhook_id.webhook_timestamp.payload
  const payloadStr = payload instanceof Buffer ? payload.toString('utf8') : payload;
  const signedContent = `${webhookId}.${webhookTimestamp}.${payloadStr}`;

  // Decode base64 secret (remove whsec_ prefix if present)
  const secretKey = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const secretBytes = Buffer.from(secretKey, 'base64');

  // Generate expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent, 'utf8')
    .digest('base64');

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// CRITICAL: Use express.raw() for webhook endpoint - OpenAI needs raw body
app.post('/webhooks/openai',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const webhookId = req.headers['webhook-id'];
    const webhookTimestamp = req.headers['webhook-timestamp'];
    const webhookSignature = req.headers['webhook-signature'];

    // Verify signature
    if (!verifyOpenAISignature(
      req.body,
      webhookId,
      webhookTimestamp,
      webhookSignature,
      process.env.OPENAI_WEBHOOK_SECRET
    )) {
      console.error('Invalid OpenAI webhook signature');
      return res.status(400).send('Invalid signature');
    }

    // Parse the verified payload
    const event = JSON.parse(req.body.toString());

    // Handle the event
    switch (event.type) {
      case 'fine_tuning.job.succeeded':
        console.log('Fine-tuning job succeeded:', event.data.id);
        break;
      case 'fine_tuning.job.failed':
        console.log('Fine-tuning job failed:', event.data.id);
        break;
      case 'batch.completed':
        console.log('Batch completed:', event.data.id);
        break;
      case 'batch.failed':
        console.log('Batch failed:', event.data.id);
        break;
      case 'batch.cancelled':
        console.log('Batch cancelled:', event.data.id);
        break;
      case 'batch.expired':
        console.log('Batch expired:', event.data.id);
        break;
      case 'realtime.call.incoming':
        console.log('Realtime call incoming:', event.data.id);
        break;
      default:
        console.log('Unhandled event:', event.type);
    }

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

Python (FastAPI) Webhook Handler

import os
import hmac
import hashlib
import base64
import time
from fastapi import FastAPI, Request, HTTPException, Header

app = FastAPI()

def verify_openai_signature(
    payload: bytes,
    webhook_id: str,
    webhook_timestamp: str,
    webhook_signature: str,
    secret: str
) -> bool:
    if not webhook_signature or ',' not in webhook_signature:
        return False

    # Check timestamp is within 5 minutes
    current_time = int(time.time())
    timestamp_diff = current_time - int(webhook_timestamp)
    if timestamp_diff > 300 or timestamp_diff < -300:
        return False

    # Extract version and signature
    version, signature = webhook_signature.split(',', 1)
    if version != 'v1':
        return False

    # Create signed content
    signed_content = f"{webhook_id}.{webhook_timestamp}.{payload.decode('utf-8')}"

    # Decode base64 secret (remove whsec_ prefix if present)
    secret_key = secret[6:] if secret.startswith('whsec_') else secret
    secret_bytes = base64.b64decode(secret_key)

    # Generate expected signature
    expected_signature = base64.b64encode(
        hmac.new(
            secret_bytes,
            signed_content.encode('utf-8'),
            hashlib.sha256
        ).digest()
    ).decode('utf-8')

    return hmac.compare_digest(signature, expected_signature)

@app.post("/webhooks/openai")
async def openai_webhook(
    request: Request,
    webhook_id: str = Header(None, alias="webhook-id"),
    webhook_timestamp: str = Header(None, alias="webhook-timestamp"),
    webhook_signature: str = Header(None, alias="webhook-signature")
):
    payload = await request.body()

    # Verify signature
    if not verify_openai_signature(
        payload,
        webhook_id,
        webhook_timestamp,
        webhook_signature,
        os.environ.get("OPENAI_WEBHOOK_SECRET")
    ):
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Parse and handle event
    event = await request.json()

    # Handle event...
    return {"received": True}

For complete working examples with tests, see:

Common Event Types

EventDescription
fine_tuning.job.succeededFine-tuning job finished successfully
fine_tuning.job.failedFine-tuning job failed
fine_tuning.job.cancelledFine-tuning job was cancelled
batch.completedBatch API job completed
batch.failedBatch API job failed
batch.cancelledBatch API job was cancelled
batch.expiredBatch API job expired
realtime.call.incomingRealtime API incoming call

For full event reference, see OpenAI Webhook Events

Environment Variables

OPENAI_API_KEY=sk-xxxxx          # Your OpenAI API key
OPENAI_WEBHOOK_SECRET=whsec_xxxxx # Your webhook signing secret

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 openai --path /webhooks/openai

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 1, 2026

View on GitHub →