Skip to content

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#

EventSent when
job.succeededThe job succeeded; its outputs and usage are set.
job.failedThe job failed; error.code says why.
job.cancelledThe 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 in POST /v1/jobs, an https URL 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#

text
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=

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:

JSON
{
  "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"
  }
}
HeaderWhat it holds
webhook-idThe delivery's id, msg_ and 24 hex digits. The same on every attempt of the delivery.
webhook-timestampWhen this attempt was sent, in seconds since the Unix epoch.
webhook-signaturev1, 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:

JavaScript
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);

By hand, with Node's crypto and no dependency:

JavaScript
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;
}

Compare signatures in constant time (crypto.timingSafeEqual), never with ===.

In your language#

Each integration example verifies webhooks in its own server code:

LanguageExample
Node with ExpressReceive the webhook
React with Next.jsReceive the webhook
SvelteKitReceive the webhook
Python with FastAPIReceive the webhook
GoReceive the webhook
PHP without a frameworkReceive the webhook
LaravelReceive the webhook
Ruby on RailsReceive the webhook
iOS with Swift, Android with KotlinVerify 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:

  1. Start your receiver, for example on port 3000, with VIDEXO_WEBHOOK_SECRET set to the secret from the Webhooks page.
  2. Start a tunnel to it, such as cloudflared tunnel --url http://localhost:3000 or ngrok http 3000, and copy the https address it prints.
  3. Create a small job with that address and your webhook path as its webhook_url:
bash
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"}'
  1. 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:

AttemptSentAfter the first attempt
1when the job ends0
25 s after attempt 1 failedabout 5 s
35 min after attempt 2 failedabout 5 min
430 min after attempt 3 failedabout 35 min
52 h after attempt 4 failedabout 2.6 h
65 h after attempt 5 failedabout 7.6 h
710 h after attempt 6 failedabout 17.6 h
810 h after attempt 7 failedabout 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 same webhook-id, for a delivery that is delivered or failed.

A failed attempt records why in last_error:

last_errorMeaning
http_statusThe endpoint answered with a status other than 2xx; last_status_code has it.
redirectThe endpoint answered with a redirect. Redirects are not followed; use the final URL.
timeoutThe endpoint did not answer within 10 seconds.
network_errorThe connection failed: DNS, TLS or a refused connection.
url_refusedThe URL is not a public https URL on the default port, so it is never called.
endpoint_disabledThe endpoint was disabled before this attempt.
endpoint_deletedThe endpoint was deleted before this attempt.
internal_errorThe 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.