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.
| filename | Required. |
| content_type | Optional. 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.mp4POST/v1/videos
Create a video and queue it for indexing. Returns 201 with the video object.
| upload_id | From POST /v1/uploads. Provide exactly one of upload_id or source_url. |
| source_url | A public http(s) URL to the file. |
| filename | Optional. Stored on the video for your reference. |
| mode | Optional. "standard" (default) or "precise". See credit costs. |
| transcribe | Optional boolean, default true. Set false to skip speech to text. |
| webhook_url | Optional. A public http(s) URL to POST to when the video is ready or fails. |
| metadata | Optional 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.
| limit | Optional. 1 to 100, default 20. |
| cursor | Optional. 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.
| question | Required. 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.
| q | Required. Postgres websearch syntax: "quoted phrases" match exactly, a leading - excludes a word. |
| video_id | Optional. Limit to one video. |
| limit | Optional. 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.
| Plan | Per month | Credits | Max video | Concurrent jobs | Requests per minute |
|---|---|---|---|---|---|
| Starter | $20 | 1,500 | 30 min | 1 | 60 |
| Builder | $40 | 3,500 | 60 min | 2 | 120 |
| Pro | $60 | 6,000 | 120 min | 4 | 300 |
| Scale | $100 | 10,000 | 240 min | 8 | 600 |
| Index one minute, standard | 10 credits |
| Index one minute, precise | 25 credits |
| Ask a question | 5 credits |
| Search | 1 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" } }| unauthenticated | 401 | No Authorization header. |
| invalid_api_key | 401 | The key is wrong or has been revoked. |
| no_subscription | 402 | The account has no active plan. |
| insufficient_credits | 402 | Not enough credits left for this call. |
| not_found | 404 | No such video on this account. |
| not_ready | 409 | The video is not ready yet. Poll its status first. |
| validation_error | 422 | A field is missing or has the wrong shape. The message names it. |
| invalid_json | 422 | The body is not JSON. |
| invalid_url | 422 | source_url or webhook_url is not a public http(s) address. |
| upload_not_found | 422 | Nothing has been PUT to that upload_id. |
| rate_limited | 429 | Over your plan's requests per minute. Retry-After says how many seconds to wait. |
| internal | 500 | Something 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.