Results & Webhook
Receive every completed analysis through the intelligence.lens_analysis.completed webhook — full payload reference, evidence model, TypeScript types, and a production-ready handler.
Results & Webhook
When an analysis completes, VIDTREO sends the entire result to your systems in a single signed webhook: the run, the recording (including your own userMetadata), and the structured output of the lens. There is nothing to poll and no second request to make — your handler receives everything it needs to update your records.
Recording completes
→ Active lenses analyze it (one run per lens)
→ Run completes
→ intelligence.lens_analysis.completed → POST https://your-app.com/webhooks/vidtreoSubscribing to the Event
Sign in to the VIDTREO Dashboard and open Environments → the environment where the lens is active
Go to the Webhook tab and click Create Endpoint (or edit an existing endpoint)
Enter your https:// URL and, under Event Types, select intelligence.lens_analysis.completed (grouped under Intelligence)
Store the signing secret (whsec_...) in your server's configuration — it is shown only once
See Webhooks for endpoint management, signature verification, retries, and redelivery. Everything described there applies to this event.
When the Event Fires
| Situation | Webhook |
|---|---|
A run finishes with status completed | Sent once per subscribed endpoint |
| A run fails (no speech, recording deleted, insufficient credits, timeout) | Not sent — failed runs are free and visible in the dashboard |
| The same recording is analyzed by three active lenses | Three events, one per run, each with its own data.run.id |
| A recording is analyzed again with the same lens | A new event for the new run |
Each run produces a different event ID (<runId>:completed). Redeliveries of the same event — automatic retries or manual redrives from the dashboard — keep that ID, so you can use X-Vidtreo-Event-Id (or id in the body) as your idempotency key.
Delivery Headers
| Header | Value for this event |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | sha256=<hex> — HMAC-SHA256 of the raw body with your endpoint's secret |
X-Vidtreo-Event-Id | <runId>:completed |
X-Vidtreo-Event-Type | intelligence.lens_analysis.completed |
X-Vidtreo-Event-Version | 1 |
X-Vidtreo-Emitted-By | vidtreo.backend |
X-Vidtreo-Webhook-Endpoint-Id | The receiving endpoint's ID |
X-Vidtreo-Webhook-Delivery-Id | This delivery's ID — traceable in the dashboard |
VIDTREO waits up to 10 seconds for your response. Any 2xx status counts as delivered; anything else (or a timeout) is retried.
Payload
A complete example for a video analyzed with a "Candidate Screening" lens that has fields, a rubric, and one visual check:
{
"id": "5d0c6f1e-2b7a-4e0f-9a51-3c8e2d4b7f10:completed",
"event": "intelligence.lens_analysis.completed",
"eventName": "intelligence.lens-run.completed",
"eventVersion": "1",
"emittedBy": "vidtreo.backend",
"occurredAt": "2026-10-15T14:22:07.000Z",
"timestamp": 1792074127000,
"data": {
"run": {
"id": "5d0c6f1e-2b7a-4e0f-9a51-3c8e2d4b7f10",
"lensId": "0b6f8f52-6a3e-4c55-9d1b-7f3a2e9c4d21",
"lensName": "Candidate Screening",
"lensVersionId": "e41a7c90-1d2b-4f6e-8a3c-5b9d0e7f6a12",
"environmentId": "3c9e1f4a-7b2d-4e8f-a6c5-1d0b9e8f7a63",
"mediaKind": "video",
"videoId": "a8d3e6f1-4c2b-4a9e-b7d5-6f1e0c9b8a74",
"headline": "Ana Torres is a backend engineer with six years of Go experience who is open to relocating.",
"score": "8/10"
},
"video": {
"id": "a8d3e6f1-4c2b-4a9e-b7d5-6f1e0c9b8a74",
"publicId": "k3Jx9QpL2mVb",
"environmentId": "3c9e1f4a-7b2d-4e8f-a6c5-1d0b9e8f7a63",
"filename": "screening-ana-torres.mp4",
"fileSize": 48213504,
"mimeType": "video/mp4",
"duration": 294.5,
"status": "completed",
"storageType": "default",
"transferStatus": null,
"metadata": {},
"userMetadata": {
"candidateId": "cand_8231",
"jobId": "job_114"
},
"createdAt": "2026-10-15T14:12:41.000Z",
"completedAt": "2026-10-15T14:17:03.000Z"
},
"result": {
"lensId": "0b6f8f52-6a3e-4c55-9d1b-7f3a2e9c4d21",
"lensName": "Candidate Screening",
"question": {
"text": "Tell us about your experience and why you want this role.",
"source": "provided"
},
"summary": "Ana Torres is a backend engineer with six years of Go experience who is open to relocating. She explains her move from monoliths to services clearly but gives few metrics.",
"speakers": [
{
"speakerId": "A",
"name": "Ana Torres",
"nameSource": "self_introduction",
"role": "Candidate",
"nameVerified": true,
"evidence": [
{ "id": "S1", "kind": "segment", "start": 0.4, "end": 6.2, "speaker": "A" }
],
"invalidEvidence": []
}
],
"sections": [
{
"title": "Background",
"status": "covered",
"points": [
{
"label": "Six years building Go services",
"text": "Ana has worked with Go for six years, most recently migrating a payments monolith to services.",
"supported": true,
"evidence": [
{ "id": "S2–S4", "kind": "segment", "start": 6.2, "end": 41.8, "speaker": "A" }
],
"invalidEvidence": []
}
]
},
{
"title": "Motivation",
"status": "covered",
"points": [
{
"label": "Wants product ownership",
"text": "She is looking for a role with more ownership over product decisions.",
"supported": true,
"evidence": [
{ "id": "S11", "kind": "segment", "start": 182.3, "end": 197.0, "speaker": "A" }
],
"invalidEvidence": []
}
]
}
],
"chapters": [
{ "title": "Introduction", "segmentId": "S1", "start": 0.4, "summary": "Ana introduces herself and her current role." },
{ "title": "Migration project", "segmentId": "S5", "start": 41.8, "summary": "Details of the monolith-to-services migration." }
],
"findings": [
{
"field": "years_of_experience",
"value": "6",
"status": "supported",
"supported": true,
"evidence": [
{ "id": "S2", "kind": "segment", "start": 6.2, "end": 14.9, "speaker": "A" }
],
"invalidEvidence": []
},
{
"field": "tools_mentioned",
"value": ["Go", "PostgreSQL", "Kubernetes"],
"status": "supported",
"supported": true,
"evidence": [
{ "id": "S3", "kind": "segment", "start": 14.9, "end": 29.1, "speaker": "A" },
{ "id": "S7", "kind": "segment", "start": 88.0, "end": 103.4, "speaker": "A" }
],
"invalidEvidence": []
},
{
"field": "reads_from_notes",
"value": "no",
"status": "supported",
"supported": true,
"evidence": [
{ "id": "F4", "kind": "frame", "start": 60.0, "end": 60.0, "speaker": null }
],
"invalidEvidence": []
}
],
"scorecard": {
"criteria": [
{
"id": "communication",
"label": "Communication",
"maxScore": 5,
"score": 4.5,
"feedback": "Clear, structured answers with a concrete example.",
"status": "scored",
"downgraded": false,
"evidence": [
{ "id": "S5–S8", "kind": "segment", "start": 41.8, "end": 121.6, "speaker": "A" }
],
"invalidEvidence": []
},
{
"id": "technical_depth",
"label": "Technical depth",
"maxScore": 5,
"score": 3.5,
"feedback": "Explains the architecture but gives no performance metrics.",
"status": "scored",
"downgraded": false,
"evidence": [
{ "id": "S6", "kind": "segment", "start": 61.2, "end": 79.5, "speaker": "A" }
],
"invalidEvidence": []
}
],
"total": 8,
"scale": 10,
"complete": true
},
"checks": {
"citations": 9,
"invalidCitations": 0,
"unsupportedItems": 0,
"abstentions": 0
}
}
}
}Envelope
| Field | Type | Description |
|---|---|---|
id | string | Event ID, <runId>:completed. Stable across retries and redrives — use it to deduplicate |
event | string | Always intelligence.lens_analysis.completed |
eventName | string | Internal event name (intelligence.lens-run.completed). Route on event, not on this field |
eventVersion | string | Payload schema version, currently "1" |
emittedBy | string | vidtreo.backend |
occurredAt | string | ISO 8601 time the run completed |
timestamp | number | Same instant as occurredAt, in Unix milliseconds |
data.run | object | The run that produced the result — see below |
data.video / data.audio | object | The analyzed recording. Exactly one of the two is present, matching data.run.mediaKind |
data.result | object | The structured lens output — see Result reference |
data.run
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Run ID |
lensId | string (UUID) | The lens that was applied |
lensName | string | Lens name at the time of the run |
lensVersionId | string (UUID) | The exact published version used — results are reproducible against it |
environmentId | string | Environment of the recording |
mediaKind | "video" | "audio" | Kind of recording analyzed |
videoId / audioId | string | Recording ID. videoId when mediaKind is video, audioId when it is audio |
headline | string | First sentence of the summary (max. 500 characters) — ready for notifications and list views |
score | string | Human-readable score: "8/10" when the scorecard is complete, "incomplete score" when a criterion could not be scored, or "<n> citations" when the lens has no rubric |
data.run.score is a display string. For numeric logic, read data.result.scorecard.total and data.result.scorecard.scale instead.
data.video / data.audio
The same recording snapshot used by the other platform events:
| Field | Type | Description |
|---|---|---|
id | string | Recording ID |
publicId | string | Public identifier used in share links and the player |
environmentId | string | Environment ID |
filename | string | Original file name |
fileSize | number | Size in bytes |
mimeType | string | e.g. video/mp4, audio/webm |
duration | number | null | Duration in seconds |
status | string | Recording status (completed for analyzed recordings) |
storageType | string | default, r2, or s3 |
transferStatus | string | null | External storage transfer status, if configured |
metadata | object | null | System metadata |
userMetadata | object | null | Your metadata, echoed verbatim from the userMetadata you attached when recording |
createdAt | string | ISO 8601 creation time |
completedAt | string | null | ISO 8601 time the upload completed |
Audio recordings additionally include format, bitrate, channels, and sampleRate.
Attach your own identifiers to every recording with userMetadata (candidateId, ticketId, courseId...). They come back in data.video.userMetadata, so your handler knows which record to update without any lookup.
Result Reference
data.result is the lens output after VIDTREO has validated every citation against the transcript and frames and computed the scores.
question
| Field | Type | Description |
|---|---|---|
text | string | null | The question the recording answers |
source | "provided" | "inferred" | "unknown" | provided when a question was supplied for the run, inferred when the recording states it explicitly, unknown otherwise |
summary
string — an overall summary of the recording, written in the lens's output language.
speakers[]
One entry per speaker identified by the analysis.
| Field | Type | Description |
|---|---|---|
speakerId | string | Diarization label: A, B, C... |
name | string | null | Speaker name, only when it can be established from the evidence |
nameSource | string | How the name was established: self_introduction, answered_when_addressed, visual_cues, participant_list, or none |
role | string | null | Role in the conversation (e.g. Candidate, Interviewer) |
nameVerified | boolean | null | true when the name matches a provided participant name or a name shown on screen, false when it matches none of them, null when there were no known names to compare against |
evidence / invalidEvidence | See Evidence |
Speakers are never named by guesswork. If a name cannot be established from a self-introduction, a direct address, on-screen cues, or the participant list, name is null.
sections[]
One entry per summary section defined in the lens, always in the configured order.
| Field | Type | Description |
|---|---|---|
title | string | Section title as configured |
status | "covered" | "insufficient_evidence" | insufficient_evidence when the recording does not contain enough information for this section |
points[].label | string | Short title for the point (2–6 words) |
points[].text | string | The point itself |
points[].supported | boolean | true when at least one valid citation backs the point |
points[].evidence / invalidEvidence | See Evidence |
chapters[]
Chronological navigation points, sorted by start.
| Field | Type | Description |
|---|---|---|
title | string | Chapter title |
segmentId | string | First transcript segment of the chapter (e.g. S5) |
start | number | Start time in seconds — use it to seek your player |
summary | string | One-line description |
findings[]
The values extracted for the lens fields.
| Field | Type | Description |
|---|---|---|
field | string | Field name as configured in the lens |
value | string | string[] | null | Extracted value — see Reading field values |
status | "supported" | "insufficient_evidence" | "contradictory" | contradictory when the recording contains conflicting statements |
supported | boolean | false when the value was claimed as supported but no valid citation backs it. In that case value is set to null and status to insufficient_evidence |
evidence / invalidEvidence | See Evidence |
scorecard
null when the lens has no rubric. Otherwise:
| Field | Type | Description |
|---|---|---|
criteria[] | array | One entry per rubric criterion, in the configured order |
criteria[].id / label / maxScore | As configured in the lens | |
criteria[].score | number | null | Between 0 and maxScore, rounded to one decimal. null when the criterion could not be scored |
criteria[].feedback | string | Justification of the score |
criteria[].status | "scored" | "insufficient_evidence" | |
criteria[].downgraded | boolean | true when a score was proposed without valid evidence and was therefore discarded |
criteria[].evidence / invalidEvidence | See Evidence | |
total | number | null | Sum of the criteria scores, computed by VIDTREO (not by the model). null unless every criterion was scored |
scale | number | The lens scale (sum of maxScore when not set explicitly) |
complete | boolean | true when every criterion has a score |
checks
Quality counters for the whole result — useful to route low-confidence analyses to human review.
| Field | Type | Description |
|---|---|---|
citations | number | Total citations made |
invalidCitations | number | Citations that did not match any segment or frame and were discarded |
unsupportedItems | number | Points, findings, or criteria left without valid evidence |
abstentions | number | Sections, findings, or criteria answered with insufficient_evidence |
Evidence
Every speaker, point, finding, and criterion carries the evidence that supports it. VIDTREO validates each citation against the actual recording before sending the result: citations that do not exist are moved to invalidEvidence and never reach evidence.
{ "id": "S2–S4", "kind": "segment", "start": 6.2, "end": 41.8, "speaker": "A" }| Field | Type | Description |
|---|---|---|
id | string | S<n> transcript segment, F<n> video frame, P<n> speaker frame. Consecutive ranges are joined with an en dash: S2–S4 |
kind | "segment" | "frame" | |
start / end | number | Position in the recording, in seconds (equal for frames) |
speaker | string | null | Speaker label for segments; null for frames or ranges spanning several speakers |
evidence is sorted by start. Use start to build "jump to moment" links in your own UI — every claim in the result can be traced back to the exact second of the recording that supports it.
Frame evidence (F<n>, P<n>) only appears for videos analyzed with a lens that defines visual checks. Audio recordings and lenses without visual checks only cite transcript segments.
Reading Field Values
Field values are returned as text so that every type shares one shape. Convert them on your side:
| Lens field type | value in the result | Example |
|---|---|---|
text | string | "Payments platform migration" |
number | string containing a number | "6", "12.5" |
boolean | string, yes/no in the lens output language | "yes", "no" ("sí" / "no" with Spanish output) |
enum | string, one of the configured enumValues | "advanced" |
array | string[] | ["Go", "PostgreSQL"] |
| any type, no evidence | null | null |
Recommended parsing rules:
- A field can be missing from
findings— treat a missing field the same asvalue: null. - Only use values whose
statusissupported. Treatinsufficient_evidenceandcontradictoryas "unknown" or send them to review. - Validate
enumvalues against your own list before storing them, and parse numbers defensively (Number(value)and checkNumber.isFinite). - A list with nothing to list is
null, never[].
Handling the Webhook
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
function isFromVidtreo(rawBody: Buffer, signature: string | undefined, secret: string) {
if (!signature) return false
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)
}
// Keep the raw body: the signature is computed over the exact bytes received
app.post('/webhooks/vidtreo', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.header('x-webhook-signature')
if (!isFromVidtreo(req.body, signature, process.env.VIDTREO_WEBHOOK_SECRET!)) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
if (event.event !== 'intelligence.lens_analysis.completed') {
return res.status(200).end()
}
// Idempotency: the same event can be delivered more than once
if (await alreadyProcessed(event.id)) {
return res.status(200).end()
}
// Acknowledge fast, process asynchronously
res.status(200).end()
const { run, result } = event.data
const recording = event.data.video ?? event.data.audio
const { candidateId } = recording.userMetadata ?? {}
const finding = (name: string) =>
result.findings.find((f) => f.field === name && f.status === 'supported')?.value ?? null
await saveScreening({
candidateId,
runId: run.id,
lensVersionId: run.lensVersionId,
headline: run.headline,
score: result.scorecard?.complete ? result.scorecard.total : null,
scale: result.scorecard?.scale ?? null,
yearsOfExperience: Number(finding('years_of_experience')) || null,
tools: finding('tools_mentioned') ?? [],
needsReview: result.checks.unsupportedItems > 0 || result.checks.abstentions > 0,
})
await markProcessed(event.id)
})import hashlib
import hmac
import json
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["VIDTREO_WEBHOOK_SECRET"].encode()
def is_from_vidtreo(raw_body: bytes, signature: str | None) -> bool:
if not signature:
return False
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/webhooks/vidtreo")
def vidtreo_webhook():
raw_body = request.get_data() # exact bytes, before any JSON parsing
if not is_from_vidtreo(raw_body, request.headers.get("X-Webhook-Signature")):
return "", 401
event = json.loads(raw_body)
if event["event"] != "intelligence.lens_analysis.completed":
return "", 200
if already_processed(event["id"]): # idempotency
return "", 200
run = event["data"]["run"]
result = event["data"]["result"]
recording = event["data"].get("video") or event["data"].get("audio")
candidate_id = (recording.get("userMetadata") or {}).get("candidateId")
scorecard = result.get("scorecard")
enqueue_screening_update( # do heavy work in a background job
candidate_id=candidate_id,
run_id=run["id"],
headline=run["headline"],
score=scorecard["total"] if scorecard and scorecard["complete"] else None,
findings={f["field"]: f["value"] for f in result["findings"] if f["status"] == "supported"},
)
mark_processed(event["id"])
return "", 200Compute the signature over the raw request body. Frameworks that parse JSON before your handler runs (for example express.json()) change the bytes and make every signature check fail.
TypeScript Types
Copy these types into your project to get end-to-end type safety for this event:
export type EvidenceRef = {
id: string // "S4", "F3", "P2" or a range such as "S4–S7"
kind: 'segment' | 'frame'
start: number // seconds
end: number // seconds
speaker: string | null
}
type Checked = { evidence: EvidenceRef[]; invalidEvidence: string[] }
export type LensResult = {
lensId: string
lensName: string
question: { text: string | null; source: 'provided' | 'inferred' | 'unknown' }
summary: string
speakers: Array<Checked & {
speakerId: string
name: string | null
nameSource: 'self_introduction' | 'answered_when_addressed' | 'visual_cues' | 'participant_list' | 'none'
role: string | null
nameVerified: boolean | null
}>
sections: Array<{
title: string
status: 'covered' | 'insufficient_evidence'
points: Array<Checked & { label: string; text: string; supported: boolean }>
}>
chapters: Array<{ title: string; segmentId: string; start: number; summary: string }>
findings: Array<Checked & {
field: string
value: string | string[] | null
status: 'supported' | 'insufficient_evidence' | 'contradictory'
supported: boolean
}>
scorecard: {
criteria: Array<Checked & {
id: string
label: string
maxScore: number
score: number | null
feedback: string
status: 'scored' | 'insufficient_evidence'
downgraded: boolean
}>
total: number | null
scale: number
complete: boolean
} | null
checks: { citations: number; invalidCitations: number; unsupportedItems: number; abstentions: number }
}
export type RecordingSnapshot = {
id: string
publicId: string
environmentId: string
filename: string
fileSize: number
mimeType: string
duration: number | null
status: string
storageType: string
transferStatus: string | null
metadata: Record<string, unknown> | null
userMetadata: Record<string, unknown> | null
createdAt: string
completedAt: string | null
}
type RunBase = {
id: string
lensId: string
lensName: string
lensVersionId: string
environmentId: string
headline: string
score: string
}
export type LensAnalysisCompletedEvent = {
id: string // "<runId>:completed"
event: 'intelligence.lens_analysis.completed'
eventName: string
eventVersion: '1'
emittedBy: string
occurredAt: string
timestamp: number
data:
| { run: RunBase & { mediaKind: 'video'; videoId: string }; video: RecordingSnapshot; result: LensResult }
| {
run: RunBase & { mediaKind: 'audio'; audioId: string }
audio: RecordingSnapshot & { format: string; bitrate: number; channels: number; sampleRate: number | null }
result: LensResult
}
}Production Checklist
- Verify
X-Webhook-Signatureover the raw body with a constant-time comparison - Deduplicate on
X-Vidtreo-Event-Id— retries and manual redrives reuse it - Respond
2xxwithin 10 seconds; move heavy processing to a background job - Route on
event, and branch ondata.run.mediaKindif you analyze both video and audio - Read numbers from
scorecard.total/scale, not from therun.scoredisplay string - Store
run.lensVersionIdwith the result so you can tell which lens version produced it - Handle
nulleverywhere the reference allows it —insufficient_evidenceis a valid, honest answer - Use
checksto flag analyses for human review