Webhooks
Webhooks let an API notify your application when asynchronous work changes state. OpenAI documents webhooks for events such as response.completed, Batch completion, and fine-tuning job updates. In AvalAI, treat hosted provider webhooks as route- and account-dependent; when a hosted webhook is not available, emit your own application webhook from the worker that owns the job.
Warning
Feature Not Implemented!
This functionality is currently under development and not yet available in AvalAI. We’ll announce its release through our official channels. Stay tuned for updates!
When to Use Webhooks
Use webhooks when polling is not enough:
- background Responses or long-running jobs should notify a backend when finished;
- Batch, eval, or data-enrichment workers need completion callbacks;
- video generation, fine-tuning, or file processing should wake another service;
- resellers need downstream systems to receive request-cost or job-status events.
For browser-only workflows, prefer polling your own job endpoint. Webhooks are best for server-to-server notifications.
Hosted Webhook Boundary
If your AvalAI route or upstream provider exposes OpenAI-style webhooks, expect these operational rules:
- configure a public HTTPS endpoint and subscribe to explicit event types;
- keep the raw request body for signature verification;
- verify the signing secret before doing any work;
- respond quickly with
2xxand move expensive processing to a worker; - deduplicate by event ID, such as the
webhook-idheader or eventid; - expect retries with backoff when your endpoint fails.
OpenAI's reference delivery policy retries failed webhook deliveries for up to 72 hours. Do not assume the same retry window for AvalAI or provider-specific webhooks unless that route documents it.
Configuration and Header Checklist
For OpenAI-hosted webhooks, endpoints are configured per project in the provider dashboard and the signing secret is shown once. For AvalAI routes, first confirm whether the selected provider exposes hosted webhook configuration for your account. If not, use the app-managed pattern below with your own AVALAI_WEBHOOK_SECRET.
When a hosted webhook dashboard is available, subscribe only to the event types your worker handles, keep an allowlist in code, and send a provider test event before enabling production side effects. For OpenAI-style events, the full event catalog lives in the provider API reference; for AvalAI app-managed callbacks, define your own small event taxonomy and version it.
When receiving an OpenAI-style event, preserve and log these fields before enqueueing work:
| Field | Why it matters |
|---|---|
webhook-id | Stable delivery ID for idempotency and duplicate suppression. |
webhook-timestamp | Used with the raw body to detect stale or replayed requests. |
webhook-signature | Verifies the payload came from the holder of the signing secret. |
event id and type | Lets workers route response.completed, job.failed, or custom events safely. |
Rotate exposed signing secrets immediately. During rotation, support a short overlap window where the receiver accepts both the old and new secret, then remove the old secret after queued retries have drained.
Delivery Semantics and Local Testing
Design receivers as at-least-once delivery targets: a successful delivery means your endpoint returned 2xx, not that downstream work finished. Persist the event ID and raw verified payload before enqueueing slow work, use webhook-id as an idempotency key when present, and make duplicate deliveries safe. Treat 3xx redirects as failures; configure the final public HTTPS URL directly.
For local development, expose your receiver through a public tunnel such as ngrok, a cloud dev environment, or a temporary serverless endpoint. Plain localhost cannot receive provider webhooks.
Use a clear receiver contract:
| Case | Receiver behavior |
|---|---|
| Valid new event | Persist the event, enqueue work, and return 2xx immediately. |
Duplicate webhook-id or event id | Return 2xx without repeating side effects. |
| Invalid signature or stale timestamp | Return 400 and do not parse or enqueue the payload. |
| Temporary database or queue failure | Return 5xx only when you want the sender to retry later. |
Event Routing Matrix
Treat webhook event names as a routing contract, not just logs. Keep the handler small: verify the signature, deduplicate the delivery, persist the event, and enqueue the route-specific action.
| Event family | Typical action | Persist before 2xx |
|---|---|---|
response.completed | Retrieve the Response, extract final output, update the conversation or job record. | response_id, user/job correlation ID, output status. |
| Response failure or cancellation | Mark the job terminal, store a safe error code, and decide whether your app should retry with a new request. | response_id, terminal status, retry decision. |
| Batch or eval completion | Fetch the result file or report, update dashboards, and notify downstream systems. | batch/eval IDs, result file ID, aggregate counters. |
| Fine-tuning completion | Fetch the final job state and model identifier before enabling traffic. | fine-tuning job ID, model ID, validation status. |
| Video or media completion | Fetch the generated asset or failure reason, then expire temporary polling URLs. | media job ID, asset URL or storage key, status. |
| Cost or reseller callback | Reconcile usage, apply idempotent billing logic, and emit customer-facing notifications. | request/job ID, usage amount, ledger transaction ID. |
For OpenAI-hosted events, subscribe only to the exact event types your app handles. For AvalAI app-managed events, use a versioned namespace such as job.completed.v1 once external customers depend on the schema.
OpenAI-Style Receiver
When the sender follows the Standard Webhooks format used by OpenAI, prefer SDK or Standard Webhooks helpers over hand-rolled verification. They validate the raw body, headers, timestamp, and signature before returning an event. Use this only for routes that explicitly provide a compatible signing secret.
import os
from flask import Flask, Response, request
from openai import InvalidWebhookSignatureError, OpenAI
app = Flask(__name__)
client = OpenAI(
api_key=os.environ["AVALAI_API_KEY"],
base_url="https://api.avalai.ir/v1",
)
webhook_secret = os.environ["AVALAI_OR_PROVIDER_WEBHOOK_SECRET"]
@app.post("/webhooks/openai-style")
def receive_openai_style_webhook():
try:
event = client.webhooks.unwrap(
request.data,
request.headers,
secret=webhook_secret,
)
except InvalidWebhookSignatureError:
return Response("invalid signature", status=400)
delivery_id = request.headers.get("webhook-id") or event.id
# Store delivery_id before enqueueing work so retries are safe.
print("verified event:", delivery_id, event.type, event.data)
return Response(status=200)import express from "express";
import OpenAI from "openai";
const app = express();
const client = new OpenAI({
apiKey: process.env.AVALAI_API_KEY,
baseURL: "https://api.avalai.ir/v1",
});
const webhookSecret = process.env.AVALAI_OR_PROVIDER_WEBHOOK_SECRET;
app.use("/webhooks/openai-style", express.text({ type: "application/json" }));
app.post("/webhooks/openai-style", async (req, res) => {
try {
const event = await client.webhooks.unwrap(req.body, req.headers, {
secret: webhookSecret,
});
const deliveryId = req.header("webhook-id") ?? event.id;
// Store deliveryId before enqueueing work so retries are safe.
console.log("verified event:", deliveryId, event.type, event.data);
return res.sendStatus(200);
} catch (error) {
if (error instanceof OpenAI.InvalidWebhookSignatureError) {
return res.status(400).send("invalid signature");
}
throw error;
}
});
app.listen(8000, () => console.log("Webhook receiver listening on :8000"));Do not put express.json() or any parser that changes the payload before signature verification. If the route uses a different signature scheme, follow the provider-specific headers and canonical payload exactly.
For response.completed, the event payload normally contains a Response ID. Keep the receiver fast: verify the event, persist its ID, return 2xx, and let a worker retrieve the final Response.
# Run this in your queue worker after the webhook is verified.
response_id = event.data.id
response = client.responses.retrieve(response_id)
print(response.output_text)// Run this in your queue worker after the webhook is verified.
const responseId = event.data.id;
const response = await client.responses.retrieve(responseId);
console.log(response.output_text);App-Managed Webhook Pattern
For AvalAI-safe async jobs today, have your worker emit a signed callback after it stores final results. The receiver verifies the signature, deduplicates the event ID, stores the event, and returns 2xx before running slow work.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, Response, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["AVALAI_WEBHOOK_SECRET"].encode()
WEBHOOK_TOLERANCE_SECONDS = 300
seen_event_ids = set()
def verify_signature(raw_body: bytes, timestamp: str, signature: str) -> bool:
try:
event_time = int(timestamp)
except ValueError:
return False
if abs(time.time() - event_time) > WEBHOOK_TOLERANCE_SECONDS:
return False
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, f"v1={expected}")
@app.post("/webhooks/avalai-jobs")
def receive_job_webhook():
raw_body = request.get_data()
event_id = request.headers.get("webhook-id", "")
timestamp = request.headers.get("webhook-timestamp", "")
signature = request.headers.get("webhook-signature", "")
if not verify_signature(raw_body, timestamp, signature):
return Response("invalid signature", status=400)
event = json.loads(raw_body)
event_id = event_id or event.get("id", "")
if not event_id:
return Response("missing event id", status=400)
if event_id in seen_event_ids:
return Response(status=200)
seen_event_ids.add(event_id)
# Persist event first, then enqueue slow processing.
print("Received event:", event["type"], event["data"]["job_id"])
return Response(status=200)
if __name__ == "__main__":
app.run(port=8000)import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = Buffer.from(process.env.AVALAI_WEBHOOK_SECRET, "utf8");
const webhookToleranceSeconds = 300;
const seenEventIds = new Set();
app.use("/webhooks/avalai-jobs", express.raw({ type: "application/json" }));
function verifySignature(rawBody, timestamp, signature) {
const eventTime = Number(timestamp);
if (!Number.isFinite(eventTime)) {
return false;
}
if (Math.abs(Date.now() / 1000 - eventTime) > webhookToleranceSeconds) {
return false;
}
const expected =
"v1=" +
crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
if (signature.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
app.post("/webhooks/avalai-jobs", (req, res) => {
const eventId = req.header("webhook-id") ?? "";
const timestamp = req.header("webhook-timestamp") ?? "";
const signature = req.header("webhook-signature") ?? "";
if (!verifySignature(req.body, timestamp, signature)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
const dedupeId = eventId || event.id;
if (!dedupeId) {
return res.status(400).send("missing event id");
}
if (seenEventIds.has(dedupeId)) {
return res.sendStatus(200);
}
seenEventIds.add(dedupeId);
// Persist event first, then enqueue slow processing.
console.log("Received event:", event.type, event.data.job_id);
return res.sendStatus(200);
});
app.listen(8000, () => console.log("Webhook receiver listening on :8000"));Use a durable store for seen_event_ids in production; an in-memory set is only for local examples.
Event Shape
Keep application webhook payloads small and stable:
{
"object": "event",
"id": "evt_job_01J...",
"type": "job.completed",
"created_at": 1760000000,
"data": {
"job_id": "job_abc123",
"response_id": "resp_abc123",
"status": "completed"
}
}Recommended event types:
| Event | Use for |
|---|---|
job.completed | Final answer, batch shard, report, or media asset is ready. |
job.failed | Worker failed after retries; include a safe error code. |
job.cancelled | User or system cancelled before completion. |
cost.available | Final billable usage is ready for reseller or chargeback systems. |
Sender Checklist
- Generate a unique event ID for every delivery.
- Sign
timestamp.raw_bodywith a secret and includewebhook-signature. - Retry failed
5xxor timeout deliveries with exponential backoff. - Do not retry forever; define an expiration window and dead-letter queue.
- Never include API keys, raw user secrets, or oversized model outputs in webhook payloads.
Receiver Checklist
- Keep the raw request body for verification.
- Reject invalid signatures before parsing or side effects.
- Reject stale timestamps to reduce replay risk.
- Deduplicate by event ID.
- Respond with
2xxquickly; enqueue slow work. - Log event ID, request ID, job ID, status, and signature-verification result.
- Rotate signing secrets if exposed and support a short overlap window for migration.
- Test locally with a public tunnel or cloud dev environment; webhooks cannot reach plain
localhost. - Treat redirects as failures; configure the final public HTTPS URL directly.