Vidtreo

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/vidtreo

Subscribing 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

SituationWebhook
A run finishes with status completedSent 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 lensesThree events, one per run, each with its own data.run.id
A recording is analyzed again with the same lensA 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

HeaderValue for this event
Content-Typeapplication/json
X-Webhook-Signaturesha256=<hex> — HMAC-SHA256 of the raw body with your endpoint's secret
X-Vidtreo-Event-Id<runId>:completed
X-Vidtreo-Event-Typeintelligence.lens_analysis.completed
X-Vidtreo-Event-Version1
X-Vidtreo-Emitted-Byvidtreo.backend
X-Vidtreo-Webhook-Endpoint-IdThe receiving endpoint's ID
X-Vidtreo-Webhook-Delivery-IdThis 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

FieldTypeDescription
idstringEvent ID, <runId>:completed. Stable across retries and redrives — use it to deduplicate
eventstringAlways intelligence.lens_analysis.completed
eventNamestringInternal event name (intelligence.lens-run.completed). Route on event, not on this field
eventVersionstringPayload schema version, currently "1"
emittedBystringvidtreo.backend
occurredAtstringISO 8601 time the run completed
timestampnumberSame instant as occurredAt, in Unix milliseconds
data.runobjectThe run that produced the result — see below
data.video / data.audioobjectThe analyzed recording. Exactly one of the two is present, matching data.run.mediaKind
data.resultobjectThe structured lens output — see Result reference

data.run

FieldTypeDescription
idstring (UUID)Run ID
lensIdstring (UUID)The lens that was applied
lensNamestringLens name at the time of the run
lensVersionIdstring (UUID)The exact published version used — results are reproducible against it
environmentIdstringEnvironment of the recording
mediaKind"video" | "audio"Kind of recording analyzed
videoId / audioIdstringRecording ID. videoId when mediaKind is video, audioId when it is audio
headlinestringFirst sentence of the summary (max. 500 characters) — ready for notifications and list views
scorestringHuman-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:

FieldTypeDescription
idstringRecording ID
publicIdstringPublic identifier used in share links and the player
environmentIdstringEnvironment ID
filenamestringOriginal file name
fileSizenumberSize in bytes
mimeTypestringe.g. video/mp4, audio/webm
durationnumber | nullDuration in seconds
statusstringRecording status (completed for analyzed recordings)
storageTypestringdefault, r2, or s3
transferStatusstring | nullExternal storage transfer status, if configured
metadataobject | nullSystem metadata
userMetadataobject | nullYour metadata, echoed verbatim from the userMetadata you attached when recording
createdAtstringISO 8601 creation time
completedAtstring | nullISO 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

FieldTypeDescription
textstring | nullThe 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.

FieldTypeDescription
speakerIdstringDiarization label: A, B, C...
namestring | nullSpeaker name, only when it can be established from the evidence
nameSourcestringHow the name was established: self_introduction, answered_when_addressed, visual_cues, participant_list, or none
rolestring | nullRole in the conversation (e.g. Candidate, Interviewer)
nameVerifiedboolean | nulltrue 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 / invalidEvidenceSee 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.

FieldTypeDescription
titlestringSection title as configured
status"covered" | "insufficient_evidence"insufficient_evidence when the recording does not contain enough information for this section
points[].labelstringShort title for the point (2–6 words)
points[].textstringThe point itself
points[].supportedbooleantrue when at least one valid citation backs the point
points[].evidence / invalidEvidenceSee Evidence

chapters[]

Chronological navigation points, sorted by start.

FieldTypeDescription
titlestringChapter title
segmentIdstringFirst transcript segment of the chapter (e.g. S5)
startnumberStart time in seconds — use it to seek your player
summarystringOne-line description

findings[]

The values extracted for the lens fields.

FieldTypeDescription
fieldstringField name as configured in the lens
valuestring | string[] | nullExtracted value — see Reading field values
status"supported" | "insufficient_evidence" | "contradictory"contradictory when the recording contains conflicting statements
supportedbooleanfalse 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 / invalidEvidenceSee Evidence

scorecard

null when the lens has no rubric. Otherwise:

FieldTypeDescription
criteria[]arrayOne entry per rubric criterion, in the configured order
criteria[].id / label / maxScoreAs configured in the lens
criteria[].scorenumber | nullBetween 0 and maxScore, rounded to one decimal. null when the criterion could not be scored
criteria[].feedbackstringJustification of the score
criteria[].status"scored" | "insufficient_evidence"
criteria[].downgradedbooleantrue when a score was proposed without valid evidence and was therefore discarded
criteria[].evidence / invalidEvidenceSee Evidence
totalnumber | nullSum of the criteria scores, computed by VIDTREO (not by the model). null unless every criterion was scored
scalenumberThe lens scale (sum of maxScore when not set explicitly)
completebooleantrue when every criterion has a score

checks

Quality counters for the whole result — useful to route low-confidence analyses to human review.

FieldTypeDescription
citationsnumberTotal citations made
invalidCitationsnumberCitations that did not match any segment or frame and were discarded
unsupportedItemsnumberPoints, findings, or criteria left without valid evidence
abstentionsnumberSections, 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" }
FieldTypeDescription
idstringS<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 / endnumberPosition in the recording, in seconds (equal for frames)
speakerstring | nullSpeaker 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 typevalue in the resultExample
textstring"Payments platform migration"
numberstring containing a number"6", "12.5"
booleanstring, yes/no in the lens output language"yes", "no" ("sí" / "no" with Spanish output)
enumstring, one of the configured enumValues"advanced"
arraystring[]["Go", "PostgreSQL"]
any type, no evidencenullnull

Recommended parsing rules:

  • A field can be missing from findings — treat a missing field the same as value: null.
  • Only use values whose status is supported. Treat insufficient_evidence and contradictory as "unknown" or send them to review.
  • Validate enum values against your own list before storing them, and parse numbers defensively (Number(value) and check Number.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 "", 200

Compute 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-Signature over the raw body with a constant-time comparison
  • Deduplicate on X-Vidtreo-Event-Id — retries and manual redrives reuse it
  • Respond 2xx within 10 seconds; move heavy processing to a background job
  • Route on event, and branch on data.run.mediaKind if you analyze both video and audio
  • Read numbers from scorecard.total / scale, not from the run.score display string
  • Store run.lensVersionId with the result so you can tell which lens version produced it
  • Handle null everywhere the reference allows it — insufficient_evidence is a valid, honest answer
  • Use checks to flag analyses for human review

On this page