Webhooks
Pass a callback_url when you create a task and ofox delivers the result by webhook instead of you polling for it. When the task reaches a terminal state, ofox sends a POST to your URL with the full task object in the body (a failed delivery is retried — see Retries).
callback_url must be HTTPS and pass SSRF validation (private, loopback, and cloud-metadata addresses are rejected). An invalid URL is rejected at creation time with 400 invalid_callback_url — see Create video.
Events
One event is delivered per task, matching the terminal status it settled into (the same value appears in data.status).
| Event | Triggered when |
|---|---|
video.generation.completed | The task succeeded |
video.generation.failed | The task failed |
video.generation.cancelled | The task was cancelled |
video.generation.expired | The task expired |
Delivery payload
Each delivery is a POST to your callback_url with these headers:
POST https://your-app.example/webhooks/ofox-video
Content-Type: application/json
X-Ofox-Idempotency-Key: 9bcf3c60-7db2-4e1a-a1b2-c3d4e5f60718-completed
X-Ofox-Signature: <HMAC-SHA256, base64url>The body is an envelope. Its data field is identical to the Get video status response body for the same task:
{
"event": "video.generation.completed",
"id": "9bcf3c60-7db2-4e1a-a1b2-c3d4e5f60718",
"created_at": 1776211400,
"data": {
"id": "9bcf3c60-7db2-4e1a-a1b2-c3d4e5f60718",
"status": "completed",
"model": "google/veo-3.1",
"prompt": "A golden retriever running on the beach at sunset",
"unsigned_urls": ["https://upstream.example/out.mp4"],
"mirror_urls": ["https://cdn.ofox.ai/videos/vgen_xxx.mp4?sig=..."],
"usage": {
"video_seconds": 5,
"video_cost": "0.4000000000"
},
"created_at": 1776211362,
"updated_at": 1776211400
}
}| Field | Type | Description |
|---|---|---|
event | string | One of the event types above |
id | string | Task id (UUID) |
created_at | integer | Event timestamp, Unix seconds |
data | object | The full task object — same shape as GET /v1/videos/{id} |
Idempotency
The same event may be delivered more than once — a retry can fire after your endpoint already processed the first delivery. Deduplicate on the X-Ofox-Idempotency-Key header, which is <id>-<status> (for example 9bcf3c60-7db2-4e1a-a1b2-c3d4e5f60718-completed), and process each key at most once.
Respond with any 2xx status to acknowledge receipt. A non-2xx response or a timeout is treated as a failed delivery and triggers a retry, so return 2xx promptly and do any heavy work asynchronously.
Signature verification
Every delivery carries an X-Ofox-Signature header: HMAC-SHA256(raw request body, your webhook secret), base64url-encoded. Recompute it with your secret and compare in constant time before trusting the payload.
import crypto from 'node:crypto'
// X-Ofox-Signature = HMAC-SHA256(raw request body, your webhook secret),
// base64url-encoded.
function verifyOfoxSignature(rawBody, signatureHeader, secret) {
if (typeof signatureHeader !== 'string') return false
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('base64url')
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader)
// Constant-time compare; guard length first (timingSafeEqual throws on mismatch).
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Compute the HMAC over the raw bytes exactly as received, before any JSON parse or re-serialization — otherwise the signature will not match. In Express, capture the body with express.raw({ type: 'application/json' }).
Retries
A failed delivery (non-2xx or timeout) is retried automatically with capped exponential backoff plus jitter — up to 10 retries over a window of about 40 minutes. The delay before retry n is min(5·2^(n-1), 600) seconds, plus random jitter.
| Retry | Delay | Cumulative |
|---|---|---|
| 1 | 5s | 5s |
| 2 | 10s | 15s |
| 3 | 20s | 35s |
| 4 | 40s | 1m15s |
| 5 | 80s | 2m35s |
| 6 | 160s | 5m15s |
| 7 | 320s | 10m35s |
| 8 | 600s (capped) | 20m35s |
| 9 | 600s | 30m35s |
| 10 | 600s | 40m35s |
Fallback
If all retries are exhausted, ofox stops attempting delivery. The task result is never lost — you can always poll GET /v1/videos/{id} to fetch the final state (subject to that endpoint’s rate limit).