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. 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). |
| 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 | Reject the request with BUDGET_EXCEEDED (402) if the estimate exceeds this cap. |
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,
"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, or surfaces echo those arrays 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,
"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 — plus:
| Prop | Type | Description |
|---|---|---|
| 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 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.
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,
"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 | 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 |
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.