Jobs
Create, retrieve, list and cancel video analysis jobs.
POST /v1/jobs
Queue a video for analysis. Returns 202 Accepted with the job.
Request body
Send exactly one of url or upload_key.
| Prop | Type | Description |
|---|---|---|
| url | string | A YouTube, TikTok or Instagram video link. |
| upload_key | string | The key from POST /v1/uploads/presign, to analyze your own file. |
| upload_filename | string | The file name of an upload. Becomes the video title. Up to 255 characters. |
| capabilities | string[] | At least one of: transcription, summary, brands, metadata, custom_query, objects. |
| queries | string[] | Your own questions about the video. Up to 10, 500 characters each, within your plan's limit. |
| brands | string[] | Brand names to report. Up to 50, 100 characters each. Leave out to get every brand. |
| surfaces | string[] | Surface slugs to look on, such as "jersey". Up to 10. |
| objects | string[] | Things to track, in your own words, such as "water bottle". Up to 5. Required by objects. |
| from_job | string | An earlier job of yours on the same video. Charged the same either way. An unknown id is JOB_NOT_FOUND; a job that failed, was cancelled or is on another platform is VALIDATION_FAILED. |
| language | string | Language hint for transcription, such as "en". Up to 10 characters. |
| webhook_url | string | An https URL for job webhooks. Starter and above. |
| fps | integer (1-4) | Frames looked at per second. Higher catches brief appearances and costs more. Default 1, clamped to your plan's max_fps. |
| return_proof | boolean | Include the storyboard, and speech_context on brands heard in speech. Defaults to true. |
| dry_run | boolean | Return the estimate with HTTP 200 and create nothing. Defaults to false. |
| max_credits | integer | Fail the job with BUDGET_EXCEEDED, free, if its estimate on the real video length passes this. Checked after the video is fetched. |
custom_query needs at least one question and objects at least one thing: without them the job is refused with
INVALID_CAPABILITIES (422). Every limit is on Limits.
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_abcdefghij123456",
"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": 66,
"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" }
}Headers: Location: /v1/jobs/job_abcdefghij123456, Retry-After: 30, X-Automated-Processing: true.
The job echoes back the queries, brands, surfaces, objects and from_job you sent. A job that starts at once
can already be processing in the 202.
Idempotency
Send an Idempotency-Key header to avoid creating a job twice. The same key returns the original job with HTTP 200 for
as long as that job exists. Keys are per workspace and matched on the key alone: a different body with the same key
still returns the original job, once that body passes validation. dry_run requests are never deduplicated.
Dry run
With "dry_run": true the request is checked, priced and returned with HTTP 200. Nothing is created, so there is no
job_id.
{
"data": {
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"duration_seconds": null,
"credits_estimated": 7,
"balance": 850,
"would_exceed_balance": false,
"note": "duration_unresolved"
},
"meta": { "request_id": "req_xyz789" }
}The length isn't known before the video is fetched, so the estimate assumes your plan's longest video and note says
duration_unresolved. would_exceed_balance is a warning, not a refusal. A dry run works on an empty balance too.
GET /v1/jobs/:id
A job, with results once it has completed.
curl https://api.netraflow.com/v1/jobs/job_abcdefghij123456 \
-H "X-Api-Key: sk_live_your_key_here"{
"data": {
"job_id": "job_abcdefghij123456",
"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": 17,
"credits_charged": 6,
"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" }
}A failed job has error: { code, message } instead of results. processing_seconds is how long the job took to
process, null until it settles. It doesn't change what you pay.
Result keys
results has one key per capability: transcription, summary, brands, metadata, objects, and
query_results for your questions. Each is described on its capability page. Also:
| Prop | Type | Description |
|---|---|---|
| confidence_summary | object | Brand detections per confidence band: high, medium, low, total. |
| storyboard | object | Frames from a brands or objects job, tiled into sheets. See Frames. |
| meta | object | brand_detection_degraded and object_detection_degraded when detection returned only part of its work; failed_capabilities when a capability could not run. |
results can hold keys you didn't ask for: a job on a link can return the video's metadata, and a brands job
returns the transcription it listened to. Neither adds to the cost.
When one capability cannot run
When brand detection or object tracking is unavailable, a job that asked only for it fails with a job failure code. A job that asked for more completes with the rest, and:
- the missing capability's key is absent from
results(an empty list would mean "none in the video"); results.meta.failed_capabilitieslists it, with the code and message a failed job would carry;- nothing it did is charged.
"meta": {
"failed_capabilities": [
{
"capability": "brands",
"code": "BRAND_DETECTION_UNAVAILABLE",
"message": "Brand detection is temporarily unavailable. Please try again."
}
]
}Submit the video again with just that capability to fill the gap. The job.completed webhook carries the same list.
What you are charged for
credits_charged is a flat $0.10 per video plus a usage charge for the analysis your job actually ran. 1 credit
is $0.10, and the total is rounded up to a whole credit. Every amount in the API is in credits.
The usage part follows the work, not the length: brand detection on a 30-second clip can cost more than transcribing a long video. It is measured on every run, so the same request can cost a little differently twice.
| Job | Charged |
|---|---|
| Completed | Flat fee + usage |
| Completed, a capability failed | Everything except that capability |
| Cancelled while running | The work already done |
| Cancelled before it started | Nothing |
| Failed or held | Nothing |
credits_charged is null until the job settles, and whenever nothing was billed.
credits_estimated is a guardrail, not a quote: the settled charge can land either side of it, usually below.
max_credits is checked against the estimate once the real length is known. It does not cap the settled charge.
Job status values
| Prop | Type | Description |
|---|---|---|
| pending | string | Queued. With error.code CREDITS_SET_ASIDE or CONCURRENT_LIMIT_REACHED it is waiting for your other jobs, not failed, and starts by itself. |
| processing | string | Running. progress shows the stage. |
| completed | string | Done. results is filled. |
| failed | string | error holds the code and message. Not charged. |
| cancelled | string | Cancelled with POST /v1/jobs/:id/cancel. |
| held | string | Not final. The video turned out to cost more than your balance. Resumes when you add credits, cancels itself after 7 days with HELD_EXPIRED. Not charged. |
A held job fires a job.held webhook with credits_needed and balance. A pending job with
CREDITS_SET_ASIDE is one your balance covers, but not beside the credits your running jobs have set aside. A submit
with nothing left to spend is refused with INSUFFICIENT_CREDITS (402).
Progress
While a job is processing, progress shows where it is. Stages run in order: ingestion, transcription,
analysis, merging.
"progress": {
"stage": "analysis",
"stages_completed": ["ingestion", "transcription"],
"stages_remaining": ["merging"]
}A stage the job doesn't need is listed in stages_completed once the job is past it.
GET /v1/jobs
Your jobs, newest first, with cursor pagination.
| Prop | Type | Description |
|---|---|---|
| status | "pending" | "processing" | "completed" | "failed" | "cancelled" | "held" | Only jobs with this status. |
| follow_id | string | "any" | Only the jobs this follow created, or with "any", every job a follow created. |
| created_after | string (ISO 8601) | Only jobs created after this time. |
| created_before | string (ISO 8601) | Only jobs created before this time. |
| limit | integer (1-100) | Jobs per page. Defaults to 20. |
| cursor | string | pagination.next_cursor from the previous page. |
next_cursor is null on the last page. A bad cursor or date returns VALIDATION_FAILED (422).
curl "https://api.netraflow.com/v1/jobs?status=completed&limit=5" \
-H "X-Api-Key: sk_live_your_key_here"{
"data": [
{
"job_id": "job_abcdefghij123456",
"status": "completed",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"platform": "youtube",
"capabilities": ["transcription", "summary"],
"video_title": "Never Gonna Give You Up",
"credits_estimated": 5,
"credits_charged": 3,
"follow_id": null,
"created_at": "2026-04-03T12:00:00.000Z",
"completed_at": "2026-04-03T12:00:45.000Z"
}
],
"pagination": {
"has_more": true,
"next_cursor": "MjAyNi0wNC0wM1QxMjowMDowMC4wMDBafGpvYl9hYmNkZWZnaGlqMTIzNDU2"
},
"meta": { "request_id": "req_xyz789" }
}List items
A list item is a summary: job_id, status, url (or the upload_key), platform, capabilities, video_title,
credits_estimated, credits_charged, follow_id, created_at and completed_at, each as on GET /v1/jobs/:id.
Fetch the job for its results.
follow_id is null on a job you submitted, and names the follow that created it otherwise. Follows
are set up in the console or over MCP; ?follow_id=any lists everything they caught.
POST /v1/jobs/:id/cancel
Cancel a pending, processing or held job. Returns HTTP 200 with the job as it stands right after the cancel.
curl -X POST https://api.netraflow.com/v1/jobs/job_abcdefghij123456/cancel \
-H "X-Api-Key: sk_live_your_key_here"{
"data": {
"job_id": "job_abcdefghij123456",
"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": null,
"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" }
}credits_charged is still null in the response. A running job is charged for the work it did a few seconds later:
read it with GET /v1/jobs/:id, or wait for the job.cancelled webhook, which carries the final charge.
A job in a batch can't be cancelled on its own: cancel the batch with POST /v1/batch/:id/cancel. Cancelling a job
that already ended, or is in a batch, returns JOB_NOT_CANCELLABLE (409).
Authentication and scopes
Every request needs an X-Api-Key header. Create keys in the console under API keys (owners and managers).
| 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 |
Every key gets all three scopes today, so treat any key as one that can spend credits. A key without a scope an
endpoint needs returns INSUFFICIENT_SCOPE (403).
Error codes
Every error has the same shape. Quote meta.request_id when you contact support.
{
"error": {
"code": "INVALID_QUERY",
"message": "queries[0] looks like an attempt to change the model's instructions. Ask about the video instead.",
"details": { "index": 0 }
},
"meta": { "request_id": "req_xyz789" }
}details comes with three codes only:
| Prop | Type | Description |
|---|---|---|
| VALIDATION_FAILED | { [field]: string[] } | Each refused field and its messages, such as { "queries.0": ["String must contain at most 500 character(s)"] }. A rule across fields, such as both url and upload_key, has a message only. |
| INVALID_QUERY | { index: number } | The position of the refused question in queries (or aggregate.queries). |
| RATE_LIMITED | { limit: number, period_seconds?: number } | For the request rate: your plan's burst, and period_seconds 60. For unused uploads: the upload cap. |
| Code | HTTP | Meaning |
|---|---|---|
INVALID_API_KEY | 401 | Missing, expired, revoked or invalid API key |
INSUFFICIENT_SCOPE | 403 | The key lacks the scope this endpoint needs |
MODE_NOT_ALLOWED | 403 | Not on your plan, such as webhook_url on Free |
QUERIES_DISABLED | 403 | Questions are switched off for now. Send the job without custom_query and queries, or retry later |
INSUFFICIENT_CREDITS | 402 | Nothing left to spend: balance empty, or all set aside for running jobs |
BUDGET_EXCEEDED | 402 | A batch's estimate passes its max_credits |
SUBSCRIPTION_PAST_DUE | 402 | The subscription payment is overdue |
NOT_FOUND | 404 | No such endpoint |
JOB_NOT_FOUND | 404 | No such job in your workspace |
JOB_NOT_CANCELLABLE | 409 | The job already ended, or is in a batch |
BODY_TOO_LARGE | 413 | The body is over 10 KB, or an upload over 500 MB |
VALIDATION_FAILED | 422 | Not JSON, or a field is invalid. See details |
INVALID_URL | 422 | Not a supported video link, or a refused webhook_url |
INVALID_CAPABILITIES | 422 | custom_query with no queries, or objects with nothing to track |
INVALID_QUERY | 422 | A question tries to change the model's instructions. details.index names it |
TOO_MANY_QUERIES | 422 | Over your plan's questions per job |
TOO_MANY_BRANDS | 422 | More than 50 brands. A name over 100 characters is VALIDATION_FAILED |
INVALID_SURFACE | 422 | A surface that isn't available yet. The message lists the ones you can use |
RATE_LIMITED | 429 | Over your plan's request rate, or too many unused uploads. Comes with Retry-After: 60 |
CONCURRENT_LIMIT_REACHED | 429 | Your plan's jobs-at-once limit is reached. Retry once one finishes |
PROCESSING_FAILED | 500 | Something went wrong on our side. Retry, and quote the request id to support |
A list longer than the every-plan maximum on Limits fails with VALIDATION_FAILED before the plan
limit is checked.
Successful responses carry X-RateLimit-Limit (your burst) and X-RateLimit-Remaining. Error responses don't.
Job failure codes
A failed job carries one of these in error.code, inside a 200 from GET /v1/jobs/:id. A failed job is never
charged and never retried for you.
| Code | Meaning |
|---|---|
EXTRACTION_FAILED | The video couldn't be fetched or read: removed, a post with no video, or a file that isn't a video |
PLATFORM_403 | The platform refused access to the video |
PLATFORM_LOGIN_REQUIRED | The video needs a login, such as a private one |
PLATFORM_UNAVAILABLE | Not a video the platform serves, or a live stream or premiere that hasn't ended |
PLATFORM_RATE_LIMITED | The platform is limiting requests. Retry later |
UPLOAD_NOT_FOUND | The upload is gone. Upload the file again |
VIDEO_TOO_LONG | The video is longer than your plan allows for this job |
BUDGET_EXCEEDED | The estimate on the real length passed max_credits |
TRANSCRIPTION_FAILED | Transcription failed |
ANALYSIS_FAILED | Analysis failed |
BRAND_DETECTION_UNAVAILABLE | Brand detection is temporarily unavailable. Retry |
OBJECT_DETECTION_UNAVAILABLE | Object detection is temporarily unavailable. Retry |
INVALID_SURFACE | A surface you asked for stopped being available. Resubmit with a current one |
STORAGE_UNAVAILABLE | A temporary storage problem. Retry |
TIMEOUT | The job ran out of time. Retry |
PROCESSING_INTERRUPTED | Processing stopped unexpectedly. Retry |
HELD_EXPIRED | A held job wasn't funded within 7 days. The job is cancelled, not failed |
PROCESSING_ERROR | Any other failure |
BRAND_DETECTION_UNAVAILABLE and OBJECT_DETECTION_UNAVAILABLE fail the job only when that capability is all it
asked for; otherwise see
When one capability cannot run. A brands job that runs short completes with what it
found and meta.brand_detection_degraded: true, and is charged for that work. TIMEOUT always fails the whole job.
CREDITS_SET_ASIDE and CONCURRENT_LIMIT_REACHED on a pending job are not failures: see
Job status values.