API reference

Everything lives under /v1, takes a bearer key, and returns JSON. The base URL is https://tenframe.dev.

Authentication

Create a key on the API keys page of the dashboard. Keys are shown once, at creation, and stored hashed; if you lose one, revoke it and create another.

Send it on every request:

Authorization: Bearer tfl_live_...

Requests without a valid key get a 401. Requests from an account with no active plan get a 402.

Quickstart

Index a video from a public URL. The response is the video object with status queued.

curl -X POST https://tenframe.dev/v1/videos \
  -H "Authorization: Bearer $VIDEOINTEL_KEY" \
  -H "content-type: application/json" \
  -d '{"source_url": "https://example.com/demo.mp4"}'

Poll it until status is ready, or pass webhook_url when you create it and skip polling.

curl https://tenframe.dev/v1/videos/$VIDEO_ID \
  -H "Authorization: Bearer $VIDEOINTEL_KEY"

Ask it a question. The answer cites the segments it relied on.

curl -X POST https://tenframe.dev/v1/videos/$VIDEO_ID/ask \
  -H "Authorization: Bearer $VIDEOINTEL_KEY" \
  -H "content-type: application/json" \
  -d '{"question": "When does the presenter switch to the live demo?"}'

Endpoints

Every video goes through queued, processing, then ready or failed. While processing, stage is one of probe, extract, describe or store. A failed video carries the reason in error.

POST/v1/uploads

Get a presigned URL for a file, then PUT the file to it. Use the returned upload_id when creating the video.

filenameRequired.
content_typeOptional. Defaults to "video/mp4".
{
  "upload_id": "2b6a4a4e-8a0f-4d3e-8b3f-6f0c7e5a2f11",
  "url": "https://storage.example.com/...",
  "method": "PUT",
  "headers": { "content-type": "video/mp4" },
  "expires_at": "2026-09-16T11:00:00.000Z"
}

Then upload:

curl -X PUT "$UPLOAD_URL" -H "content-type: video/mp4" --data-binary @demo.mp4

POST/v1/videos

Create a video and queue it for indexing. Returns 201 with the video object.

upload_idFrom POST /v1/uploads. Provide exactly one of upload_id or source_url.
source_urlA public http(s) URL to the file.
filenameOptional. Stored on the video for your reference.
modeOptional. "standard" (default) or "precise". See credit costs.
transcribeOptional boolean, default true. Set false to skip speech to text.
webhook_urlOptional. A public http(s) URL to POST to when the video is ready or fails.
metadataOptional object. Returned unchanged on the video.
{
  "id": "6f1c2e2a-1b4d-4c1e-9d3a-2f0b8e4c1a77",
  "status": "queued",
  "stage": null,
  "mode": "standard",
  "transcribe": true,
  "filename": null,
  "source_url": "https://example.com/demo.mp4",
  "duration_s": null,
  "sheet_count": null,
  "credits_charged": null,
  "error": null,
  "metadata": {},
  "created_at": "2026-09-16T10:00:00.000Z",
  "ready_at": null
}

GET/v1/videos

List your videos, newest first.

limitOptional. 1 to 100, default 20.
cursorOptional. The next_cursor from the previous page.
{
  "data": [ ...video objects... ],
  "next_cursor": "2026-09-15T18:22:09.412Z"
}

next_cursor is null on the last page.

GET/v1/videos/:id

One video object. Poll this until status is ready.

{
  "id": "6f1c2e2a-1b4d-4c1e-9d3a-2f0b8e4c1a77",
  "status": "ready",
  "stage": null,
  "mode": "standard",
  "transcribe": true,
  "filename": "launch-demo.mp4",
  "source_url": "https://example.com/demo.mp4",
  "duration_s": 252.4,
  "sheet_count": 26,
  "credits_charged": 50,
  "error": null,
  "metadata": {},
  "created_at": "2026-09-16T10:00:00.000Z",
  "ready_at": "2026-09-16T10:01:12.000Z"
}

GET/v1/videos/:id/segments

The full structured index, one entry per ten seconds. Free to read. Returns 409 not_ready until the video is ready.

{
  "video_id": "6f1c2e2a-1b4d-4c1e-9d3a-2f0b8e4c1a77",
  "segments": [
    {
      "index": 17,
      "start_s": 170,
      "end_s": 180,
      "transcript": "so let me just drop the file in here and you can watch it go",
      "summary": "Drags a video file into the upload area; progress bar fills.",
      "seconds": [
        { "t": 170, "description": "Cursor hovers over a dashed upload area." },
        { "t": 171, "description": "A file icon is dragged toward the area." },
        { "t": 172, "description": "File dropped; a progress bar appears at 0%." }
      ],
      "objects": ["laptop screen", "upload area", "progress bar"],
      "on_screen_text": ["Drop a video here", "demo.mp4"],
      "people_count": 0,
      "events": [
        { "t": 172, "event": "file dropped" },
        { "t": 178, "event": "upload completes" }
      ],
      "scene_change": false
    }
  ]
}

POST/v1/videos/:id/ask

Ask a question about one video. Costs 5 credits. Returns 409 not_ready until the video is ready. If the index does not contain the answer, the answer says so and timestamps is empty.

questionRequired. Up to 2000 characters.
{
  "answer": "The live demo starts at 02:50 when the presenter drags a file into the upload area. The screen share began at 01:30.",
  "timestamps": [
    { "start_s": 90, "end_s": 100, "why": "Screen share of the dashboard begins." },
    { "start_s": 170, "end_s": 180, "why": "File is dropped into the upload area." }
  ],
  "credits_charged": 5
}

GET/v1/search

Full-text search over summaries, per-second descriptions, events, on-screen text and transcript, across every ready video on your account or one video. Costs 1 credit per call.

qRequired. Postgres websearch syntax: "quoted phrases" match exactly, a leading - excludes a word.
video_idOptional. Limit to one video.
limitOptional. 1 to 50, default 10.
{
  "results": [
    {
      "video_id": "6f1c2e2a-1b4d-4c1e-9d3a-2f0b8e4c1a77",
      "start_s": 170,
      "end_s": 180,
      "summary": "Drags a video file into the upload area; progress bar fills.",
      "headline": "Drags a video file into the <b>upload</b> area; progress bar fills",
      "score": 0.0912
    }
  ],
  "credits_charged": 1
}

headline wraps each match in <b> tags. Escape it before rendering as HTML.

DELETE/v1/videos/:id

Delete the video, its index, and the stored file. Returns 204 with no body. Credits already spent are not refunded.

GET/v1/usage

Your plan, credits and limits. Works without an active plan, so you can check subscription_status.

{
  "plan": "builder",
  "subscription_status": "active",
  "credits_remaining": 2140,
  "credits_granted": 3500,
  "period_end": "2026-10-01T00:00:00.000Z",
  "limits": {
    "name": "Builder",
    "price": 40,
    "credits": 3500,
    "maxVideoS": 3600,
    "concurrency": 2,
    "rpm": 120
  }
}

Credits and limits

Credits are granted when each monthly invoice is paid and expire when the next one is. There is no overage; at zero the API returns 402 insufficient_credits until the next period.

PlanPer monthCreditsMax videoConcurrent jobsRequests per minute
Starter$201,50030 min160
Builder$403,50060 min2120
Pro$606,000120 min4300
Scale$10010,000240 min8600
Credit costs
Index one minute, standard10 credits
Index one minute, precise25 credits
Ask a question5 credits
Search1 credit

Indexing is billed per started minute of video and reserved when the job starts. Reading segments, listing videos and checking usage cost nothing. Rate limits apply per key; over the limit you get 429 with a Retry-After header.

Errors

Every error is JSON with the same shape:

{ "error": { "type": "not_ready", "message": "Video is processing" } }
unauthenticated401No Authorization header.
invalid_api_key401The key is wrong or has been revoked.
no_subscription402The account has no active plan.
insufficient_credits402Not enough credits left for this call.
not_found404No such video on this account.
not_ready409The video is not ready yet. Poll its status first.
validation_error422A field is missing or has the wrong shape. The message names it.
invalid_json422The body is not JSON.
invalid_url422source_url or webhook_url is not a public http(s) address.
upload_not_found422Nothing has been PUT to that upload_id.
rate_limited429Over your plan's requests per minute. Retry-After says how many seconds to wait.
internal500Something failed on our side. Retry the request.

Webhooks

Pass webhook_url when creating a video and we POST to it once, when indexing finishes or fails. The body is JSON:

{
  "event": "video.ready",
  "video": { ...video object... }
}

event is video.ready or video.failed. Each delivery carries an X-Signature header of the form sha256=<hex>: the HMAC-SHA256 of the raw request body using your account's webhook secret. Verify it before trusting the payload. Deliveries that do not get a 2xx are retried three times with backoff.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body as received, before any JSON parsing.
export function verify(rawBody, signature, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Both source_url and webhook_url must be public http(s) addresses. Private, loopback and link-local hosts are rejected with 422 invalid_url.