Webhooks
A webhook is an HTTP POST we send to your server when a job reaches a terminal state. It saves you from polling. Configuring one is the difference between a script and an integration.
When we call you
Only twice per job, and only for these two states:
| Status | Sent when |
|---|---|
completed | Output is in storage and a signed link exists |
failed | Processing stopped and will not be retried further |
There is no notification for queued, processing or cancelled. If your
interface needs to show that a job is running, read its status when somebody
looks at it.
Each status is sent once per job. A repeat of a status we have already delivered successfully is suppressed.
Configuring the endpoint
Set the URL in the dashboard under Webhooks. It applies to every job on the account.
There is no per-job webhook. webhookUrl is not a field on the job creation
request, and sending it has no effect. The webhookUrl you see in a job
response is an echo of your account setting at the time the job was created.
The URL must be publicly reachable over HTTP or HTTPS. It is checked when you save it and checked again at delivery, so a hostname that only later resolves somewhere private will still not be called.
These are rejected with 400 and a reason:
- Any protocol other than
httporhttps. - Credentials embedded in the URL, as in
https://user:[email protected]. - Anything resolving to loopback, a private range, link-local, or the cloud metadata address.
- The names
localhost, and anything ending in.localhost,.localor.internal.
Set a secret alongside the URL. Without one, deliveries carry no signature and you have no way to tell our calls from anybody else's.
Saving the URL without touching the secret field keeps the existing secret.
The payload
The body is a flat JSON object. There is no event field and no nested data
object.
Completed:
{
"jobId": "9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42",
"userId": "cmex7m27500008mlgcd6er2p7",
"timestamp": "2026-09-10T14:12:47.118Z",
"status": "completed",
"outputUrl": "https://sfo3.digitaloceanspaces.com/...&X-Amz-Signature=...",
"duration": 742,
"fileSize": 486703104,
"resolution": "1920x1080",
"preset": "hls_abr_264_standard",
"type": "video"
}Failed:
{
"jobId": "9d1f5c0a-4e7b-4a61-9b2e-8f1c3d5a7e42",
"userId": "cmex7m27500008mlgcd6er2p7",
"timestamp": "2026-09-10T14:11:02.664Z",
"status": "failed",
"error": "DURATION_EXCEEDED: Video duration (24 minutes) exceeds plan limit (10 minutes)",
"preset": "mp4_264_720p"
}| Field | Present | Description |
|---|---|---|
jobId | always | The job's UUID |
userId | always | Your account identifier |
timestamp | always | When the notification was built |
status | always | completed or failed |
outputUrl | on success | Signed download link |
duration | on success | Source duration in milliseconds |
fileSize | on success | Output size in bytes |
resolution | on success | Output resolution, as WIDTHxHEIGHT |
preset | both | Preset identifier, or edl_custom for timeline jobs |
type | on success | video or edl |
error | on failure | Why it failed, written for a person |
The outputUrl in the payload is signed when the notification is built, and it
expires on the same schedule as any other link: 4 hours on the free plan, 7
days on paid plans. Download promptly, or read the job later to sign a fresh
one.
Headers
Content-Type: application/json
User-Agent: VideoTranscoder/1.0
X-VideoTranscoder-Webhook-Id: 4f2c8b19-6a35-4de0-9b71-2c8e5a0d1f37
X-VideoTranscoder-Delivery-Attempt: 1
X-VideoTranscoder-Timestamp: 2026-09-10T14:12:47.118Z
X-VideoTranscoder-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b...X-VideoTranscoder-Webhook-Id stays the same across retries of one delivery,
which makes it a good deduplication key. X-VideoTranscoder-Delivery-Attempt
counts from 1.
The signature header is present only when you have configured a secret.
Verifying the signature
The signature is the HMAC-SHA256 of the raw request body, keyed with your secret, hex encoded.
Compute it over the bytes we sent, not over a re-serialised copy of the parsed object. Whitespace and key order differ after a parse and re-encode, and the signature will never match.
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
if (!header) return false
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(header, 'utf8')
// Constant time, so the comparison cannot be probed byte by byte.
return a.length === b.length && crypto.timingSafeEqual(a, b)
}import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)Reject anything that fails the check with 401. An unverified webhook body is
input from the internet.
Responding
Return any 2xx status. The response body is ignored.
You have 30 seconds. Acknowledge first and do the work afterwards, in a queue or a background task; a handler that downloads the output before responding will time out on a large file.
Retries
At most three attempts, with exponential backoff: about 2 seconds after the first failure and about 4 seconds after the second.
| Your response | What we do |
|---|---|
2xx | Delivered. Nothing further |
4xx | Stop immediately. You rejected the payload; repeating it will not change your answer |
5xx | Retry, up to three attempts in total |
| Timeout, refused connection, DNS failure | Retry, up to three attempts in total |
After three failures the notification is dropped. There is no dead letter queue and no manual replay.
That is the reason to reconcile rather than trust delivery. Any job your database still believes is running after its estimated processing time has passed is worth reading from the API. See Webhooks or polling.
Every attempt is logged with its status code and response time, and the history is visible in the dashboard.
Testing locally
Your endpoint has to be reachable from the internet, so a tunnel is the usual approach during development.
ngrok http 3000
# then set the printed https URL in the dashboardlocalhost, .local, .internal and private address ranges are all refused,
so pointing the webhook at your machine directly will not work.
A correct handler
app.post('/hooks/video', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verify(raw, req.get('X-VideoTranscoder-Signature'), process.env.VT_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Acknowledge inside the 30 second window, then work.
res.status(200).json({ received: true })
const payload = JSON.parse(raw)
void handle(payload) // idempotent on payload.jobId and payload.status
})Three properties make it reliable: it verifies before trusting, it answers before working, and it is idempotent because deliveries can repeat.