Jobs
Create, retrieve, list, and cancel video analysis jobs.
POST /v1/jobs
Create a new video analysis job. The API validates the request, estimates credits, and queues the job for processing.
Request body
Provide exactly one source: either url or upload_key.
| Prop | Type | Description |
|---|---|---|
| url | string | Video URL (YouTube, TikTok, or Instagram). Mutually exclusive with upload_key. |
| upload_key | string | Key returned by POST /v1/uploads/presign, for analyzing your own file. Mutually exclusive with url. |
| upload_filename | string | Original filename for an upload_key job. Becomes the video title. Max 255 chars. |
| capabilities | string[] | One or more of: transcription, summary, brands, metadata, custom_query, objects. At least one required. |
| queries | string[] | Custom questions about the video. Max 500 chars each, and at most your plan's max_queries_per_job. |
| brands | string[] | Brand names to target for brand detection. Max 50 items, 100 chars each. |
| surfaces | string[] | Surface slugs to target (e.g. "jersey"). Max 10. Unknown slugs return INVALID_SURFACE (422). |
| objects | string[] | Things to track through the video, in your own words (e.g. "water bottle"). Max 5. Required by the objects capability. |
| from_job | string | A job_id of your own on the same video. Its media and transcript are reused instead of downloading again, when they are still held. Falls back to a normal run once they are not. |
| language | string | Language hint for transcription (e.g. "en"). Max 10 chars. |
| webhook_url | string | HTTPS URL for webhook delivery. Starter plan and above. |
| fps | integer (1-4) | Frame-sampling rate, clamped to your plan's max_fps. Defaults to the plan ceiling. |
| return_proof | boolean | Include visual evidence: the storyboard filmstrip on brands jobs, and speech_context on speech-derived brand appearances. Defaults to true. |
| dry_run | boolean | Return a cost preview with HTTP 200 and create nothing. Defaults to false. |
| max_credits | integer | Fail the job with BUDGET_EXCEEDED once its real duration is known, if the estimate exceeds this cap. Checked after ingestion, not at submit. |
custom_query answers your own queries, looking at the picture as well as the words, and needs at least one
question. objects tracks up to five things you name and needs at least one. Both are rejected with
INVALID_CAPABILITIES (422) without them.
On Free and Starter, brand-detection jobs must be targeted — pass brands, surfaces, or both. An untargeted brands
job is rejected with PLAN_REQUIRED (403).
Idempotency
Include an Idempotency-Key header to prevent duplicate job creation. If a matching key is found within the 48-hour deduplication window, the original job is returned with HTTP 200 — no new job is created. After 48 hours the key expires and a new job would be created.
Keys are scoped to your workspace and matched on the key alone: reusing a key with a different body returns the original job, not a new one. dry_run requests are never deduplicated.
curl -X POST https://api.netraflow.com/v1/jobs \
-H "Content-Type: application/json" \
-H "X-Api-Key: sk_live_your_key_here" \
-H "Idempotency-Key: my-unique-key-123" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"capabilities": ["transcription", "summary", "brands"],
"brands": ["Nike"]
}'{
"data": {
"job_id": "job_abc123",
"status": "pending",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"capabilities": ["transcription", "summary", "brands"],
"brands": ["Nike"],
"language": null,
"video_title": null,
"video_duration_seconds": null,
"video_thumbnail_url": null,
"credits_estimated": 17,
"credits_charged": null,
"processing_seconds": null,
"return_proof": true,
"follow_id": null,
"created_at": "2026-04-03T12:00:00.000Z",
"started_at": null,
"completed_at": null
},
"meta": { "request_id": "req_xyz789" }
}Response headers include Location: /v1/jobs/job_abc123, Retry-After: 30, and X-Automated-Processing: true.
Jobs created with queries, brands, surfaces, objects or from_job echo those values back in the response.
GET /v1/jobs/:id
Retrieve a job by ID, including results when completed.
curl https://api.netraflow.com/v1/jobs/job_abc123 \
-H "X-Api-Key: sk_live_your_key_here"{
"data": {
"job_id": "job_abc123",
"status": "completed",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"capabilities": ["transcription", "summary"],
"language": null,
"video_title": "Never Gonna Give You Up",
"video_duration_seconds": 212,
"video_thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
"credits_estimated": 5,
"credits_charged": 3,
"processing_seconds": 43,
"results": {
"transcription": {
"text": "...",
"segments": [{ "start": 0.0, "end": 5.2, "text": "..." }],
"language": "en",
"duration_seconds": 212.0,
"word_count": 1250
},
"summary": {
"text": "...",
"key_topics": ["topic1", "topic2"]
}
},
"return_proof": true,
"follow_id": null,
"created_at": "2026-04-03T12:00:00.000Z",
"started_at": "2026-04-03T12:00:01.000Z",
"completed_at": "2026-04-03T12:00:45.000Z"
},
"meta": { "request_id": "req_abc456" }
}Result keys
results carries one key per produced capability — transcription, summary, brands, metadata, objects — plus:
| Prop | Type | Description |
|---|---|---|
| objects | object[] | Each thing you asked for and where it appeared: name, confidence, and appearances with start and end times, boxes and outlines. Only things you named are returned — there is no "find everything" mode — and the same thing seen after a cut is not known to be the same one. |
| query_results | object[] | Answers to your queries, each with query, answer, and evidence timestamps. |
| confidence_summary | object | Brand detection counts by confidence band: high, medium, low, total. |
| storyboard | object | Filmstrip manifest on return_proof brands and objects jobs. Its sheets are fetched via GET /v1/frames/:key. |
| meta | object | Processing-health flags, such as brand_detection_degraded. |
Failed jobs carry error: { code, message } instead of results.
What you are charged for
credits_charged has two parts: a usage charge for the analysis your job actually ran, plus a flat $0.10 per minute
of processing time — the wall-clock time your job occupied a worker, reported as processing_seconds.
Processing time is not the same as video_duration_seconds, and pricing on the video's length would be the wrong
number. A 27-minute YouTube video with publisher captions is transcribed without downloading anything and finishes in
seconds; a 30-second clip put through full brand detection extracts frames, reads every logo it finds, and works for
minutes. You pay for the second one.
credits_estimated is a guardrail, not a quote. It has to guess processing time before the job runs, so the settled
credits_charged can land either side of it — usually below. max_credits is checked against the estimate after
ingestion, once the video's real duration is known: over the cap, the job fails with BUDGET_EXCEEDED and is not
charged. It is not checked at submit, where the only duration available is your plan's ceiling, and it does not cap
the settled charge. Use dry_run: true to preview an estimate without creating anything.
processing_seconds is null until the job settles, and stays null on jobs that never finish — failed, cancelled, or
held. Queue time is never billed: the clock starts when a worker picks the job up (started_at), not when you submit
it. A retry after an infrastructure failure starts a fresh clock, so you are never billed twice for the same work.
Job status values
| Prop | Type | Description |
|---|---|---|
| pending | string | Job is queued and waiting to be picked up. |
| processing | string | Job is actively being processed. The progress object shows the current stage. |
| completed | string | Job finished successfully. Results are available in the results object. |
| failed | string | Job failed. The error object contains the failure code and message. Failed jobs are not charged. |
| cancelled | string | Job was cancelled via the cancel endpoint. |
| held | string | Non-terminal. The job passed the submit-time check, ran ingestion, then could not afford its real cost. Resumes automatically on a credit top-up; auto-cancels after 7 days. Nothing is charged. |
Progress
The progress object is included only while status is "processing". Pipeline stages run in order: ingestion → transcription → analysis → merging.
GET /v1/jobs
List jobs with cursor-based pagination.
Query parameters
| Prop | Type | Description |
|---|---|---|
| status | "pending" | "processing" | "completed" | "failed" | "cancelled" | "held" | Filter by job status. |
| follow_id | string | "any" | Only return jobs created by a Follow. Pass a follow id for one follow, or "any" for every follow-created job in the workspace. |
| created_after | string (ISO 8601) | Only return jobs created after this timestamp. |
| created_before | string (ISO 8601) | Only return jobs created before this timestamp. |
| limit | integer (1-100) | Number of jobs per page. Defaults to 20. |
| cursor | string | Pagination cursor from a previous response. |
Pass next_cursor from the previous response verbatim. Results are ordered newest-first by created_at, then id. next_cursor is null on the last page, and a malformed cursor returns VALIDATION_FAILED (422).
Every job carries follow_id — null for a job you submitted, otherwise the Follow that created it. Together with the filter above, that is the whole read side of Follows: a follow-created job is an ordinary job, so GET /v1/jobs?follow_id=any answers "what did my follows catch this week" with no extra endpoint. Follows themselves are set up in the console; there is no POST /v1/follows.
curl "https://api.netraflow.com/v1/jobs?status=completed&limit=5" \
-H "X-Api-Key: sk_live_your_key_here"{
"data": [
{
"job_id": "job_abc123",
"status": "completed",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"credits_estimated": 5,
"follow_id": null,
"created_at": "2026-04-03T12:00:00.000Z"
}
],
"pagination": {
"has_more": true,
"next_cursor": "eyJpZCI6IjEyMyJ9"
},
"meta": { "request_id": "req_xyz789" }
}POST /v1/jobs/:id/cancel
Cancel a pending, processing, or (unbatched) held job. Jobs belonging to a batch must be cancelled via POST /v1/batch/:id/cancel.
curl -X POST https://api.netraflow.com/v1/jobs/job_abc123/cancel \
-H "X-Api-Key: sk_live_your_key_here"{
"data": {
"job_id": "job_abc123",
"status": "cancelled",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"capabilities": ["transcription", "summary"],
"language": null,
"video_title": null,
"video_duration_seconds": null,
"video_thumbnail_url": null,
"credits_estimated": 5,
"credits_charged": 2,
"processing_seconds": null,
"return_proof": true,
"follow_id": null,
"created_at": "2026-04-03T12:00:00.000Z",
"started_at": "2026-04-03T12:00:01.000Z",
"completed_at": null
},
"meta": { "request_id": "req_xyz789" }
}Returns JOB_NOT_FOUND (404) if the job doesn't exist, or JOB_NOT_CANCELLABLE (409) if the job is already in a terminal state.
Cancelling a running job charges only for the compute already spent, which is typically a fraction of the full
price. Cancel before processing starts — or early enough that nothing has been spent — and the job is free
(credits_charged stays 0/null). A job that fails is always free.
Authentication and scopes
Every request needs an X-Api-Key header. Keys carry scopes:
| Scope | Grants |
|---|---|
jobs:write | POST to /v1/jobs, /v1/batch, /v1/uploads |
jobs:read | GET on jobs and batches, plus GET /v1/frames/:key |
account:read | GET /v1/account, GET /v1/usage |
A key without the required scope returns INSUFFICIENT_SCOPE (403).
Error codes
These are returned synchronously, as error.code with the listed HTTP status.
| Code | HTTP | Description |
|---|---|---|
INVALID_API_KEY | 401 | Missing, revoked, or invalid API key |
INSUFFICIENT_SCOPE | 403 | Key lacks the scope this endpoint requires |
INSUFFICIENT_CREDITS | 402 | Balance is empty at submit time |
BUDGET_EXCEEDED | 402 | A batch's estimate exceeds the max_credits you set |
SUBSCRIPTION_PAST_DUE | 402 | Subscription payment is overdue |
PLAN_REQUIRED | 403 | Your plan requires this job to be targeted |
TARGET_REQUIRED | 403 | Brand detection needs a brands or surfaces target |
MODE_NOT_ALLOWED | 403 | Feature not available on your plan (e.g. webhooks on Free) |
RATE_LIMITED | 429 | Per-plan rate limit exceeded |
CONCURRENT_LIMIT_REACHED | 429 | Too many jobs processing simultaneously |
PLATFORM_RATE_LIMITED | 429 | Too many requests to the video platform |
INVALID_URL | 422 | URL is malformed, unreachable, or not a supported platform |
VALIDATION_FAILED | 422 | Request body failed schema validation |
PLATFORM_UNAVAILABLE | 422 | Platform is temporarily unreachable |
VIDEO_TOO_LONG | 422 | Video exceeds your plan's max duration |
TOO_MANY_QUERIES | 422 | Exceeds your plan's max_queries_per_job (Free: 3, paid: 10) |
TOO_MANY_BRANDS | 422 | More than 50 target brands |
INVALID_SURFACE | 422 | Unknown or unavailable surface slug |
JOB_NOT_FOUND | 404 | Job does not exist or belongs to another workspace |
JOB_NOT_CANCELLABLE | 409 | Job is already completed, failed, or cancelled |
BODY_TOO_LARGE | 413 | Request body exceeds 10 KB |
Job failure codes
These appear as error.code inside a 200 response from GET /v1/jobs/:id when a job fails — they are not HTTP statuses.
| Code | Meaning |
|---|---|
EXTRACTION_FAILED | Video download or frame extraction failed |
PLATFORM_403 | The platform refused access to the video |
PLATFORM_UNAVAILABLE | Platform unreachable during extraction |
PLATFORM_RATE_LIMITED | Platform rate-limited the extraction |
VIDEO_TOO_LONG | Real duration exceeded your plan cap after ingestion |
BUDGET_EXCEEDED | Estimate on the real duration exceeded max_credits |
TRANSCRIPTION_FAILED | Transcription stage failed |
ANALYSIS_FAILED | Analysis stage failed |
SURFACE_VERIFICATION_UNAVAILABLE | Surface targeting could not be verified |
TIMEOUT | The job exceeded its overall time budget |
STAGE_DEADLINE_EXCEEDED | A single pipeline stage exceeded its deadline |
WORKER_STALLED | The processing worker stopped responding |
HELD_EXPIRED | A held job was not funded within 7 days |
PROCESSING_ERROR | Unclassified pipeline failure |
INSUFFICIENT_CREDITS (402) is returned at submit time when your balance is empty. The affordability check runs
again after ingestion against the video's real duration — but a job you can't cover is not failed or charged
there. It moves to the non-terminal held status and fires a job.held webhook carrying credits_needed and
balance. Held jobs resume automatically when you add credits, or are cancelled after 7 days with HELD_EXPIRED.
Held and failed jobs both cost nothing.
Rate limiting
Requests are rate-limited per plan using a token bucket: a bucket of burst_limit tokens that refills continuously at your plan's sustained rate. An idle client can spend a full burst at once; sustained traffic settles at the per-minute rate.
| Plan | Sustained | Burst |
|---|---|---|
| Free | 10 req/min | 15 |
| Starter | 30 req/min | 45 |
| Pro | 60 req/min | 90 |
| Scale | 300 req/min | 450 |
Successful responses carry X-RateLimit-Limit (the burst capacity) and X-RateLimit-Remaining. When rate-limited you get HTTP 429 with Retry-After: 60; the X-RateLimit-* headers are not present on error responses.