Webhooks

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:

StatusSent when
completedOutput is in storage and a signed link exists
failedProcessing 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 http or https.
  • 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, .local or .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"
}
FieldPresentDescription
jobIdalwaysThe job's UUID
userIdalwaysYour account identifier
timestampalwaysWhen the notification was built
statusalwayscompleted or failed
outputUrlon successSigned download link
durationon successSource duration in milliseconds
fileSizeon successOutput size in bytes
resolutionon successOutput resolution, as WIDTHxHEIGHT
presetbothPreset identifier, or edl_custom for timeline jobs
typeon successvideo or edl
erroron failureWhy 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 responseWhat we do
2xxDelivered. Nothing further
4xxStop immediately. You rejected the payload; repeating it will not change your answer
5xxRetry, up to three attempts in total
Timeout, refused connection, DNS failureRetry, 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 dashboard

localhost, .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.