DocsReference
Webhooks
The signed request a job sends when it ends, how to verify it, and how deliveries are retried.
When a job ends, the API sends one webhook to the job's webhook_url and one to your account's
default endpoint, if it has one and it is enabled. Each is an HTTPS POST in the
Standard Webhooks (opens in a new tab) format, signed with your account's
signing secret. Use webhooks instead of polling GET /v1/jobs/{id}, or next to it.
Events#
| Event | Sent when |
|---|---|
job.succeeded | The job succeeded; its outputs and usage are set. |
job.failed | The job failed; error.code says why. |
job.cancelled | The job was cancelled before it ended. |
A job sends one event, once it ends. Jobs still queued or running send nothing.
Where webhooks go#
- The job's
webhook_url: set it inPOST /v1/jobs, anhttpsURL of up to 2,048 characters. - The default endpoint: one URL per account that gets the end of every job while it is
enabled. Set it on the dashboard's Webhooks page or with
POST /v1/webhook-endpoints.
A job with a webhook_url in an account with an enabled endpoint sends two deliveries, each with
its own webhook-id.
Use a publicly reachable HTTPS endpoint on the default port, without a user name or password in the
URL. A *.workers.dev address is not supported; serve the receiver from your own domain. Other
URLs return 400 invalid_request when you set them. Redirects are not supported: a 3xx response
counts as a failed attempt, so use the final URL. To receive webhooks on your own machine, expose
your local server through a public HTTPS tunnel.
The request#
POST /hooks/videxo HTTP/1.1
content-type: application/json
user-agent: Videxo-Webhooks/1
webhook-id: msg_5d0c8f7a1b2e4c6d9e0f1a2b
webhook-timestamp: 1793812200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=Copy failed. The text is selected; copy it with your keyboard.
The body is JSON with the event's type, its timestamp (when the job ended, the same on every
attempt) and data, the job exactly as GET /v1/jobs/{id} returns it when the
attempt is sent:
{
"type": "job.succeeded",
"timestamp": "2026-11-04T12:01:09.000Z",
"data": {
"id": "job_3k9x0c2mfq8w1n7r5t4b6v2z",
"state": "succeeded",
"kind": "operations",
"operation": "transcode",
"spec": {
"inputs": {
"source": {
"url": "https://videxo.io/samples/talk.mp4"
}
},
"outputs": [
{
"name": "video",
"type": "video",
"preset": "web-720p",
"input": "source",
"filename": "talk-720p.mp4",
"format": "mp4",
"resize": {
"width": 1280,
"height": 720,
"fit": "contain",
"enlarge": false
},
"video": {
"codec": "h264",
"crf": 23,
"encoder_preset": "veryfast",
"pixel_format": "yuv420p",
"faststart": true
},
"audio": {
"codec": "aac",
"bitrate": "128k"
}
}
]
},
"command": "-i {{in_source}} -map 0:v:0 -vf \"scale='min(iw,1280)':'min(ih,720)':force_original_aspect_ratio=decrease:force_divisible_by=2\" -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map 0:a:0? -c:a aac -b:a 128k -movflags +faststart {{out_video}}",
"input_files": {
"in_source": "https://videxo.io/samples/talk.mp4"
},
"output_files": {
"out_video": "talk-720p.mp4"
},
"ffmpeg_command": "ffmpeg -i https://videxo.io/samples/talk.mp4 -map 0:v:0 -vf 'scale='\\''min(iw,1280)'\\'':'\\''min(ih,720)'\\'':force_original_aspect_ratio=decrease:force_divisible_by=2' -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map '0:a:0?' -c:a aac -b:a 128k -movflags +faststart talk-720p.mp4",
"steps": [
{
"tool": "ffmpeg",
"inputs": [
"source"
],
"outputs": [
"video"
],
"command": "ffmpeg -i https://videxo.io/samples/talk.mp4 -map 0:v:0 -vf 'scale='\\''min(iw,1280)'\\'':'\\''min(ih,720)'\\'':force_original_aspect_ratio=decrease:force_divisible_by=2' -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -map '0:a:0?' -c:a aac -b:a 128k -movflags +faststart talk-720p.mp4"
}
],
"webhook_url": null,
"metadata": {
"order": "1234"
},
"progress": 1,
"error": null,
"outputs": [
{
"alias": "out_video",
"name": "video",
"filename": "talk-720p.mp4",
"size_bytes": 300000000,
"sha256": "9f2c4e1a7b3d5f60c8e2a4b6d8f0e1c3a5b7d9f1e3c5a7b9d1f3e5c7a9b1d3f5",
"content_type": "video/mp4",
"expires_at": "2026-11-05T12:01:09.000Z",
"download_url": "https://api.videxo.io/v1/files/job_3k9x0c2mfq8w1n7r5t4b6v2z/out_video?account=acc_example&expires=1793797269&signature=example",
"download_url_expires_at": "2026-11-04T13:01:09.000Z",
"deleted_at": null
}
],
"usage": {
"seconds": 61,
"input_bytes": 1200000000,
"output_bytes": 300000000,
"credits": 45,
"bill": {
"lines": [
{
"kind": "video",
"seconds": 300,
"per_minute": 6,
"multiplier": 1,
"credits": 30
}
],
"data": {
"input_bytes": 1200000000,
"output_bytes": 300000000,
"credits": 15
},
"processing": {
"seconds": 61,
"included_seconds": 900,
"credits": 0
}
}
},
"created_at": "2026-11-04T12:00:00.000Z",
"started_at": "2026-11-04T12:00:04.000Z",
"finished_at": "2026-11-04T12:01:09.000Z"
}
}Copy failed. The text is selected; copy it with your keyboard.
| Header | What it holds |
|---|---|
webhook-id | The delivery's id, msg_ and 24 hex digits. The same on every attempt of the delivery. |
webhook-timestamp | When this attempt was sent, in seconds since the Unix epoch. |
webhook-signature | v1, and the base64 HMAC-SHA256 of webhook-id.webhook-timestamp.body, made with your signing secret. After a rotation, two signatures separated by a space. |
The body is usually a few kilobytes. A job with a long command and long input URLs carries them
several times (command, ffmpeg_command, input_files), so its webhook can reach several hundred
kilobytes. If your framework limits request bodies (Express's express.raw() allows 100 KB by
default, SvelteKit's adapter-node 512 KB), allow 2 MB on the webhook route.
The signing secret#
One secret per account signs every webhook, to the endpoint and to every job's webhook_url. It
is whsec_ followed by the base64 key. Read it on the dashboard's Webhooks page (Reveal)
or with GET /v1/webhook-secret, and keep it on your server, for example in
VIDEXO_WEBHOOK_SECRET.
Rotate it on the same page or with POST /v1/webhook-secret/rotate. For
24 hours after a rotation every webhook carries two signatures, one made
with the new secret and one with the previous one, so a receiver that still has the previous secret
keeps verifying while you deploy the new one.
Verify a webhook#
Verify every request before you trust it: check the signature over the raw body, exactly as received (parsing the JSON and serializing it again changes the bytes), and reject a timestamp more than five minutes away from your clock, so an old request cannot be replayed.
With the standardwebhooks (opens in a new tab) library
(npm install standardwebhooks), which does both:
import { createServer } from 'node:http';
import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(process.env.VIDEXO_WEBHOOK_SECRET);
createServer((request, response) => {
let body = '';
request.setEncoding('utf8'); // whole characters, also when one is split across chunks
request.on('data', (chunk) => (body += chunk));
request.on('end', () => {
let event;
try {
// The raw body exactly as received: parsing and serializing it again breaks the signature.
event = webhook.verify(body, request.headers);
} catch {
response.writeHead(400).end();
return;
}
// Answer 2xx quickly. handleOnce is your code: it keeps the webhook-id (the same on every
// retry) in a unique column and skips an id it has already handled.
handleOnce(request.headers['webhook-id'], event);
response.writeHead(204).end();
});
}).listen(3000);Copy failed. The text is selected; copy it with your keyboard.
By hand, with Node's crypto and no dependency:
import crypto from 'node:crypto';
// The event when the headers prove the body came from us, null otherwise.
function verifyWebhook(secret, headers, body, now = Date.now()) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return null;
// Refuse a timestamp more than five minutes away: an old delivery replayed.
if (Math.abs(now / 1000 - Number(timestamp)) > 300) return null;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
// After a rotation the header carries two signatures, space separated: either may match.
for (const signature of signatures.split(' ')) {
const [version, value = ''] = signature.split(',');
const given = Buffer.from(value, 'base64');
if (version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
return JSON.parse(body);
}
}
return null;
}Copy failed. The text is selected; copy it with your keyboard.
Compare signatures in constant time (crypto.timingSafeEqual), never with ===.
In your language#
Each integration example verifies webhooks in its own server code:
| Language | Example |
|---|---|
| Node with Express | Receive the webhook |
| React with Next.js | Receive the webhook |
| SvelteKit | Receive the webhook |
| Python with FastAPI | Receive the webhook |
| Go | Receive the webhook |
| PHP without a framework | Receive the webhook |
| Laravel | Receive the webhook |
| Ruby on Rails | Receive the webhook |
| iOS with Swift, Android with Kotlin | Verify on your backend with one of the above; the app never holds the secret. |
Test webhooks locally#
A webhook URL must be publicly reachable, so expose your local receiver through an HTTPS tunnel:
- Start your receiver, for example on port 3000, with
VIDEXO_WEBHOOK_SECRETset to the secret from the Webhooks page. - Start a tunnel to it, such as
cloudflared tunnel --url http://localhost:3000orngrok http 3000, and copy thehttpsaddress it prints. - Create a small job with that address and your webhook path as its
webhook_url:
curl --fail-with-body -sS "$VIDEXO_API_URL/v1/transcode" \
-H "Authorization: Bearer $VIDEXO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "https://videxo.io/samples/talk.mp4", "preset": "web-720p", "webhook_url": "https://<your tunnel address>/webhooks/videxo"}'Copy failed. The text is selected; copy it with your keyboard.
- When the job ends, your receiver gets the delivery. The dashboard's Webhooks page shows it
with your receiver's status code and response. Send again repeats it with the same
webhook-id, which tests that your receiver handles each id once.
If a delivery fails, its last_error says why (Deliveries). A 400 from your own
receiver usually means the signature check failed: verify the raw body as received, use the
current secret, and check that your server's clock is correct.
Respond quickly, handle each id once#
Respond with any 2xx status within 10 seconds. Do the work after
responding, or in a queue of your own; a response that takes longer counts as a failed attempt and
the webhook is sent again.
A delivery can arrive more than once: after a timeout whose request you did process, or after
Send again. The webhook-id is the same each time, so keep the ids you have handled (a unique
column is enough) and skip one you have seen.
Retries#
An attempt fails when the response is not 2xx, when it is a redirect, when it takes longer than
10 seconds, or when the connection fails. A failed attempt is sent again
with growing waits, 8 attempts in all over about a day:
| Attempt | Sent | After the first attempt |
|---|---|---|
| 1 | when the job ends | 0 |
| 2 | 5 s after attempt 1 failed | about 5 s |
| 3 | 5 min after attempt 2 failed | about 5 min |
| 4 | 30 min after attempt 3 failed | about 35 min |
| 5 | 2 h after attempt 4 failed | about 2.6 h |
| 6 | 5 h after attempt 5 failed | about 7.6 h |
| 7 | 10 h after attempt 6 failed | about 17.6 h |
| 8 | 10 h after attempt 7 failed | about 27.6 h |
After the last attempt the delivery is failed and is not sent again on its own. Each attempt is
signed with a new webhook-timestamp; the webhook-id and the body's type, timestamp and job
stay the same. Output download_url and download_url_expires_at values are refreshed on each
attempt and can differ. An output deleted between attempts has deleted_at set and null download
fields. Deduplicate by webhook-id, not by comparing payload bytes.
Deliveries#
Every delivery is recorded with its state (pending while it will be attempted, delivered after
a 2xx, failed after the last attempt), its attempts, the HTTP status, how long the response
took and the first 2 KB of the response. See them on the dashboard (the
Webhooks page and each job's page) or with the API:
GET /v1/jobs/{id}/webhook-deliveries: the deliveries of one job.GET /v1/webhook-deliveries: the account's deliveries, newest first, a page at a time.POST /v1/webhook-deliveries/{id}/retry: Send again, one more attempt now with the samewebhook-id, for a delivery that isdeliveredorfailed.
A failed attempt records why in last_error:
last_error | Meaning |
|---|---|
http_status | The endpoint answered with a status other than 2xx; last_status_code has it. |
redirect | The endpoint answered with a redirect. Redirects are not followed; use the final URL. |
timeout | The endpoint did not answer within 10 seconds. |
network_error | The connection failed: DNS, TLS or a refused connection. |
url_refused | The URL is not a public https URL on the default port, so it is never called. |
endpoint_disabled | The endpoint was disabled before this attempt. |
endpoint_deleted | The endpoint was deleted before this attempt. |
internal_error | The attempt failed on our side before it was sent. It is retried like any failed attempt. |
Disabling or deleting the endpoint stops its deliveries that are still pending at their next
attempt. A job's own webhook_url deliveries are not affected.