Webhooks
Receive signed HTTP notifications when platform events complete — payload anatomy, HMAC verification, and delivery guarantees.
Webhooks
Webhooks push platform events to your systems as they happen, so you never poll for status. Each delivery is a POST request with a JSON body, signed with HMAC-SHA256 so you can verify it came from VIDTREO.
Available Events
| Event | Fires when |
|---|---|
transcriptions.transcription.completed | AI Transcription finishes processing a video |
The event catalog is growing. The dashboard always shows the current list of events you can subscribe to when creating or editing an endpoint.
Creating an Endpoint
Webhook endpoints are managed per environment in the dashboard:
Sign in to the VIDTREO Dashboard
Open your environment and go to Settings → Webhooks
Click Add Endpoint, enter your https:// URL, and select the events to subscribe to
Copy the signing secret (whsec_...) shown after creation
The signing secret is displayed once. It is stored encrypted and can never be retrieved again — if you lose it, rotate it from the endpoint's settings to get a new one.
Delivery Headers
Every delivery includes identifying headers:
| Header | Content |
|---|---|
X-Webhook-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body |
X-Vidtreo-Event-Id | Unique event ID — use it to deduplicate processing |
X-Vidtreo-Event-Type | The event name, e.g. transcriptions.transcription.completed |
X-Vidtreo-Event-Version | Payload schema version |
X-Vidtreo-Webhook-Endpoint-Id | The receiving endpoint's ID |
X-Vidtreo-Webhook-Delivery-Id | This specific delivery attempt — traceable in the dashboard |
Payload: transcriptions.transcription.completed
{
"id": "9f4c1a2e-8d3b-4f6a-b1c9-2e7d5a8f0c3b",
"event": "transcriptions.transcription.completed",
"eventVersion": "2",
"occurredAt": "2026-08-03T14:22:07.000Z",
"data": {
"transcription": {
"id": "b7e2...",
"videoId": "vid_...",
"environmentId": "env_...",
"language": "en",
"wordCount": 812,
"duration": 294.5
},
"video": {
"id": "vid_...",
"publicId": "pub_...",
"filename": "interview-final.mp4",
"duration": 295,
"status": "COMPLETED",
"metadata": { },
"userMetadata": {
"candidateId": "cand_8231",
"jobId": "job_114"
}
},
"files": [
{ "format": "vtt", "url": "https://...", "expiresAt": "...", "contentType": "text/vtt" },
{ "format": "json", "url": "https://...", "expiresAt": "...", "contentType": "application/json" },
{ "format": "txt", "url": "https://...", "expiresAt": "...", "contentType": "text/plain" }
]
}
}Two fields deserve special attention:
data.video.userMetadata— whatever JSON you attached to the video at upload time, echoed back verbatim. Attach your own identifiers (candidate ID, course ID, ticket number) and your handler never needs a lookup table to know which record the video belongs to.data.files[].url— presigned URLs that expire 5 minutes after the delivery is generated. Download what you need when the event arrives; fetch fresh URLs later viaGET /api/v1/videos/{videoId}/transcription.
Verifying Signatures
Always verify the X-Webhook-Signature header before trusting a payload. The signature is an HMAC-SHA256 of the raw request body, hex-encoded, computed with your endpoint's signing secret:
import { createHmac, timingSafeEqual } from 'node:crypto'
function isFromVidtreo(rawBody: string, signature: string, secret: string) {
const expected = `sha256=${createHmac('sha256', secret)
.update(rawBody)
.digest('hex')}`
const a = Buffer.from(signature)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}Two rules that prevent the classic mistakes:
- Compute the HMAC over the raw body string, before any JSON parsing or re-serialization touches it.
- Compare with a constant-time function (
timingSafeEqual), never===.
A minimal handler:
app.post('/webhooks/vidtreo', async (req) => {
if (!isFromVidtreo(req.rawBody, req.headers['x-webhook-signature'], process.env.VIDTREO_WEBHOOK_SECRET)) {
return res.status(401).end()
}
const { video, files } = req.body.data
const { candidateId } = video.userMetadata
const txt = files.find(f => f.format === 'txt')
const transcript = await fetch(txt.url).then(r => r.text())
await processTranscript(candidateId, transcript)
return res.status(200).end()
})Respond with a 2xx status quickly — do heavy processing after acknowledging, or asynchronously.
Secret Lifecycle
- Secrets use the
whsec_prefix and are generated server-side; you can also supply your own when creating the endpoint - Stored encrypted at rest, shown in plaintext only at creation and rotation
- Rotate any time from the endpoint's settings in the dashboard — update your handler's secret when you do
Delivery Guarantees
- Automatic retries — failed processing is retried with a delay before giving up
- Dead-letter capture — events that exhaust retries are preserved, not dropped
- Full delivery history — every attempt, response code, and payload is inspectable per endpoint in the dashboard
- Manual redelivery — redrive a single event, a single delivery, or every failed delivery in a time window, from the dashboard
Use X-Vidtreo-Event-Id to make your handler idempotent — a redelivered event carries the same event ID as the original.