{"openapi":"3.1.0","info":{"title":"ffpipe — media processing for agents, by VidDay","version":"0.1.0","summary":"Machine-payable video conversion, priced per job in USDC over x402.","description":"ffpipe converts video for callers that pay per request with x402 — no account, no API key, no invoice.\n\n**base-sepolia testnet preview.** Payments settle in testnet USDC; mainnet is not enabled yet, so treat this as a preview and not a production dependency.\n\nFlow: POST a paid endpoint without payment → `402` with a `PAYMENT-REQUIRED` challenge → an x402 client signs and retries → `202 { jobId, statusUrl }` → poll `statusUrl` (free, token-gated) until `finished` or `error`.\n\nPrefer tools over REST? The same API is served as a remote MCP server at `/mcp` (streamable HTTP) — see that path's description.\n\nJobs are asynchronous. Download results within 24 hours of completion.\n\nIf a paid job fails inside our pipeline, its status response carries a `retryVoucher` — one free retry, chained up to 3 times per job lineage. That is the refund mechanism; there are no on-chain refunds.\n\nCaveat for `sourceUrl`: we fetch it from datacenter IP ranges, so origins that block datacenter egress (some CDNs answer 403) fail with `ingest_failed`. Prefer a presigned URL from storage you control.","contact":{"name":"VidDay","url":"https://www.vidday.com"},"termsOfService":"https://staging.ffpipe.dev/terms"},"servers":[{"url":"https://staging.ffpipe.dev","description":"this gateway"}],"tags":[{"name":"jobs","description":"Paid job submission and free status polling."},{"name":"discovery","description":"Unpaid, cacheable descriptions of this service."},{"name":"mcp","description":"The same job API as MCP tools (Model Context Protocol, streamable HTTP). JSON-RPC, so the operation below is a pointer, not a full description — connect an MCP client and list the tools."}],"x-x402":{"protocol":"x402","version":2,"network":"eip155:84532","networkName":"base-sepolia","scheme":"exact","asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","assetSymbol":"USDC","assetDecimals":6,"facilitator":"https://api.cdp.coinbase.com/platform/v2/x402","payTo":"0xC31892feD0d242EE374934821C5D8Ac310cAA906","testnet":true,"prices":{"POST /v1/convert":"$0.05"},"challengeHeader":"PAYMENT-REQUIRED","paymentHeader":"payment-signature","settlementHeader":"PAYMENT-RESPONSE","capture":"verify before the handler, settle after a 2xx — non-2xx responses are never charged"},"paths":{"/v1/convert":{"post":{"operationId":"convertVideo","tags":["jobs"],"summary":"Queue a video conversion ($0.05 USDC)","description":"Two intake modes, exactly ONE per request: pull — we stage `sourceUrl` into our storage (≤ 100 MiB); or push — send `\"upload\": true` and PUT the bytes to the returned `uploadUrl` yourself (same cap; see `PUT /ingest/{jobId}`). Then a conversion scaled to fit `maxWidth`×`maxHeight` (defaults 1920×1080, cap 4096) is queued. Asynchronous: the 202 hands back a `statusUrl`; the result appears there when the render finishes. Pay with x402 ($0.05 USDC, exact scheme, base-sepolia) or with a convert-kind `x-retry-voucher`.","parameters":[{"name":"payment-signature","in":"header","required":false,"description":"x402 v2 payment payload (base64 JSON) produced by an x402 client from the PAYMENT-REQUIRED challenge. The legacy `x-payment` header is also accepted. Omit it to receive the challenge.","schema":{"type":"string"}},{"name":"x-retry-voucher","in":"header","required":false,"description":"Retry-voucher credential ({uuid}.{hmac}) from a failed paid job's status response. Pays for this request instead of x402; must match the endpoint kind. Anything invalid falls through to the ordinary 402.","schema":{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.[0-9a-f]{32}$"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConvertRequest"},"example":{"sourceUrl":"https://storage.example.com/clip.mov?signature=…","maxWidth":1280,"maxHeight":720}}}},"responses":{"202":{"description":"Accepted — and, for an x402-paid request, only now settled. Pull mode (`sourceUrl`): the job is durably queued and paid work will happen. Upload mode (`upload: true`): the response instead carries `status: \"awaiting_upload\"` plus `uploadUrl` and `uploadExpiresAt` — PUT the raw bytes to `uploadUrl` before the deadline (this response is the ONLY place that credential is handed out; the status route never repeats it). You are charged now, before any bytes: an abandoned window becomes a terminal `error` with no refund, while a post-upload pipeline failure earns a `retryVoucher`.","headers":{"PAYMENT-RESPONSE":{"description":"Base64-encoded settlement details (transaction hash, network). Present for x402-paid requests; absent when the request was paid with a retry voucher.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAccepted"}}}},"400":{"description":"Invalid request body or source URL (also returned when a source redirects to a disallowed URL). Never charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_request","message":"…"}}}},"x-error-codes":["invalid_request","invalid_source_url"]},"402":{"description":"Payment required ($0.05 USDC on base-sepolia). For API callers the body is an empty JSON object — the machine-readable requirements are in the base64 `PAYMENT-REQUIRED` response header (x402 v2). Also returned when a presented payment fails verification, when a retry voucher is invalid/replayed/wrong-kind, and when settlement fails after acceptance (in which case the job is voided and no money moves). A browser (Accept: text/html + a Mozilla user agent) gets the SDK's HTML paywall page instead of JSON.","headers":{"PAYMENT-REQUIRED":{"description":"Base64-encoded JSON: { x402Version, accepts: [ { scheme, network, amount, asset, payTo, extra } ], error? }.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","additionalProperties":true},"example":{}},"text/html":{"schema":{"type":"string"}}}},"403":{"description":"This payer address has ≥ 3 failed settlements on record and is refused before any work. Never charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"payment_denied","message":"…"}}}},"x-error-codes":["payment_denied"]},"412":{"description":"Emitted by the x402 middleware when a permit2-based payment attempt needs an allowance first. Payers using the advertised `exact` (EIP-3009) scheme never see this.","headers":{"PAYMENT-REQUIRED":{"description":"Base64-encoded JSON payment requirements.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"413":{"description":"The source exceeded the ingest byte cap (100 MiB by default), enforced while streaming. Never charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"source_too_large","message":"…"}}}},"x-error-codes":["source_too_large"]},"429":{"description":"Rate limited (per-IP request rate) or over the per-payer daily job cap. Never charged — and a rate-limited request cannot burn a retry voucher either.","headers":{"retry-after":{"description":"Seconds to wait before retrying.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"rate_limited","message":"…"}}}},"x-error-codes":["rate_limited","daily_cap_exceeded"]},"500":{"description":"The gateway cannot accept payment right now. Note the legacy shape: a plain `error` string, not the `{ error: { code, message } }` envelope.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}},"502":{"description":"The source could not be fetched/staged, or the job could not be queued. Never charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"ingest_failed","message":"…"}}}},"x-error-codes":["ingest_failed","pipeline_error"]},"503":{"description":"The gateway is misconfigured and cannot accept this request right now. Never charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_configured","message":"…"}}}},"x-error-codes":["not_configured"]}},"x-x402":{"x402Version":2,"accepts":[{"scheme":"exact","network":"eip155:84532","networkName":"base-sepolia","price":"$0.05","amountAtomic":"50000","asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","assetSymbol":"USDC","assetDecimals":6,"payTo":"0xC31892feD0d242EE374934821C5D8Ac310cAA906"}],"facilitator":"https://api.cdp.coinbase.com/platform/v2/x402","capture":"capture-at-acceptance: the facilitator VERIFIES before the handler runs and SETTLES only after a 2xx. Any non-2xx answer (400/402/403/413/429/502/503) is never charged. Pull mode (sourceUrl) queues the job before the 202; upload mode (upload: true) charges at the 202, BEFORE any bytes arrive — abandoning the upload window refunds nothing, while a failure to queue the job after your upload mints a retry voucher.","alternativePayment":{"header":"x-retry-voucher","description":"A retry-voucher credential ({uuid}.{hmac}) issued when a paid job failed on our side. Pays for exactly one request of the same kind; no on-chain movement. Invalid, replayed or wrong-kind vouchers get the ordinary 402."}}}},"/v1/jobs/{id}":{"get":{"operationId":"getJob","tags":["jobs"],"summary":"Job status (free)","description":"Free to call. The `t` token from the 202's `statusUrl` proves you bought the job — without it (or with a wrong one) the answer is the same 404 as a nonexistent job, so ids cannot be probed. `status` goes [`awaiting_upload` →] `queued` → `finished` | `error` (`awaiting_upload` only for upload-mode jobs; `processing` is reserved and not emitted today). While `awaiting_upload` the body carries `uploadExpiresAt` but never the `uploadUrl` — that credential is handed out exactly once, in the 202. An upload window that expires becomes `error` (\"upload never completed\") with NO voucher: abandoning the upload is buyer-caused. A job whose pipeline failed carries `retryVoucher`; a job whose payment never settled is `error` with no voucher. Download results within 24 hours of completion; a queued job with no completion within 6 hours is marked `error`.","parameters":[{"name":"id","in":"path","required":true,"description":"jobId from the 202 response.","schema":{"type":"string","format":"uuid"}},{"name":"t","in":"query","required":true,"description":"Ownership token minted with the job (32 hex chars). Present in the `statusUrl` you were handed; not derivable.","schema":{"type":"string","pattern":"^[0-9a-f]{32}$"}}],"responses":{"200":{"description":"Current job state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"},"examples":{"queued":{"summary":"still working","value":{"jobId":"6f1e…","status":"queued"}},"finished":{"summary":"result ready","value":{"jobId":"6f1e…","status":"finished","resultUrl":"https://…/output.mp4?X-Amz-…","thumbnailUrl":"https://…/thumb.jpg?X-Amz-…","durationMs":12480}},"failed":{"summary":"pipeline failed — free retry attached","value":{"jobId":"6f1e…","status":"error","errorMessage":"processing failed","retryVoucher":"3f2a51c8-1d6b-4c7e-9f10-6b0a1c2d3e4f.9a1b2c3d4e5f60718293a4b5c6d7e8f9"}}}}}},"404":{"description":"No such job, or a missing/incorrect `t` token (deliberately indistinguishable).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"…"}}}},"x-error-codes":["not_found"]},"503":{"description":"The gateway is temporarily misconfigured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_configured","message":"…"}}}},"x-error-codes":["not_configured"]}}}},"/v1/jobs/{id}/result":{"get":{"operationId":"downloadResult","tags":["jobs"],"summary":"Download the finished output (free)","description":"Streams the finished output from the gateway's own storage. This is where a finished job's `resultUrl` points. Free and unmetered — re-download as often as you need within the record's lifetime. The `t` query parameter is the SAME status token as `GET /v1/jobs/{id}`: bad/missing token and unknown/unfinished job are the same 404. A single `bytes` Range is honoured (206 with `content-range`; video players seek this way); multi-range or malformed Range headers are ignored and answered 200 with the full body; a range past the end is 416. If the gateway's stored copy is unavailable (for a few seconds right after completion, or if the copy failed) the route answers `302` to the original storage URL — follow the redirect.","parameters":[{"name":"id","in":"path","required":true,"description":"jobId from the 202 response.","schema":{"type":"string","format":"uuid"}},{"name":"t","in":"query","required":true,"description":"The job's ownership token — the same `t` as in your `statusUrl`.","schema":{"type":"string","pattern":"^[0-9a-f]{32}$"}},{"name":"range","in":"header","required":false,"description":"Optional single bytes range, e.g. `bytes=0-1023`.","schema":{"type":"string"}}],"responses":{"200":{"description":"The output bytes (content-type per the job: video/mp4, …).","headers":{"accept-ranges":{"description":"`bytes`","schema":{"type":"string"}},"cache-control":{"description":"`private, max-age=3600` — the URL embeds your ownership token; never shared-cacheable.","schema":{"type":"string"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"206":{"description":"The requested byte range, with `content-range`.","content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"302":{"description":"Fallback: the gateway's stored copy is unavailable; `location` is the original storage URL. Follow it.","headers":{"location":{"schema":{"type":"string","format":"uri"}}}},"404":{"description":"No such job, wrong/missing `t` token, job not `finished`, or nothing to serve — deliberately indistinguishable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"…"}}}},"x-error-codes":["not_found"]},"416":{"description":"The Range lies entirely past the end of the object (`content-range: bytes */{size}`)."},"503":{"description":"The gateway is temporarily misconfigured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_configured","message":"…"}}}},"x-error-codes":["not_configured"]}}}},"/v1/jobs/{id}/thumbnail":{"get":{"operationId":"downloadThumbnail","tags":["jobs"],"summary":"Download the poster frame (free)","description":"Streams the poster frame the pipeline produced. This is where a finished job's `thumbnailUrl` points. Free and unmetered — re-download as often as you need within the record's lifetime. The `t` query parameter is the SAME status token as `GET /v1/jobs/{id}`: bad/missing token and unknown/unfinished job are the same 404. A single `bytes` Range is honoured (206 with `content-range`; video players seek this way); multi-range or malformed Range headers are ignored and answered 200 with the full body; a range past the end is 416. If the gateway's stored copy is unavailable (for a few seconds right after completion, or if the copy failed) the route answers `302` to the original storage URL — follow the redirect.","parameters":[{"name":"id","in":"path","required":true,"description":"jobId from the 202 response.","schema":{"type":"string","format":"uuid"}},{"name":"t","in":"query","required":true,"description":"The job's ownership token — the same `t` as in your `statusUrl`.","schema":{"type":"string","pattern":"^[0-9a-f]{32}$"}},{"name":"range","in":"header","required":false,"description":"Optional single bytes range, e.g. `bytes=0-1023`.","schema":{"type":"string"}}],"responses":{"200":{"description":"The poster frame bytes (image/jpeg).","headers":{"accept-ranges":{"description":"`bytes`","schema":{"type":"string"}},"cache-control":{"description":"`private, max-age=3600` — the URL embeds your ownership token; never shared-cacheable.","schema":{"type":"string"}}},"content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"206":{"description":"The requested byte range, with `content-range`.","content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"302":{"description":"Fallback: the gateway's stored copy is unavailable; `location` is the original storage URL. Follow it.","headers":{"location":{"schema":{"type":"string","format":"uri"}}}},"404":{"description":"No such job, wrong/missing `t` token, job not `finished`, or nothing to serve — deliberately indistinguishable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"…"}}}},"x-error-codes":["not_found"]},"416":{"description":"The Range lies entirely past the end of the object (`content-range: bytes */{size}`)."},"503":{"description":"The gateway is temporarily misconfigured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_configured","message":"…"}}}},"x-error-codes":["not_configured"]}}}},"/health":{"get":{"operationId":"health","tags":["discovery"],"summary":"Liveness (free)","responses":{"200":{"description":"The gateway is up.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"service":{"type":"string"}},"required":["ok","service"]},"example":{"ok":true,"service":"ffpipe-x402-gateway"}}}}}}},"/":{"get":{"operationId":"landing","tags":["discovery"],"summary":"Human landing page (free)","responses":{"200":{"description":"Self-contained HTML overview.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/llms.txt":{"get":{"operationId":"llmsTxt","tags":["discovery"],"summary":"Agent primer (free)","description":"Plain-text orientation for LLM agents: endpoints, prices, the handshake, ready-to-run snippets.","responses":{"200":{"description":"Plain text.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/openapi.json":{"get":{"operationId":"openapi","tags":["discovery"],"summary":"This document (free)","responses":{"200":{"description":"OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/privacy":{"get":{"operationId":"privacy","tags":["discovery"],"summary":"Privacy policy (free)","responses":{"200":{"description":"The privacy policy, as HTML — includes what the public blockchain necessarily exposes about payments.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/terms":{"get":{"operationId":"terms","tags":["discovery"],"summary":"Terms of service (free)","responses":{"200":{"description":"The terms of service, as HTML.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/mcp":{"post":{"operationId":"mcp","tags":["mcp"],"summary":"MCP server — convert_video / get_job_status as tools","description":"Remote MCP server (streamable HTTP transport, stateless — no session id issued or required; responses are plain JSON). Tools: `convert_video` (paid, same terms as POST /v1/convert), `get_job_status` (free, same token semantics as GET /v1/jobs/{id}). Payment rides the MCP protocol (Cloudflare Agents SDK x402 convention), NOT HTTP headers: an unpaid paid-tool call returns an `isError` result whose `_meta[\"x402/error\"]` carries the same `accepts[]` as a REST 402; the client signs and retries the call with `_meta[\"x402/payment\"]` set to the base64 payload. `withX402Client` from the `agents` npm package automates this. Capture semantics are identical to REST: verify before the tool runs, settle only on a non-error result. Retry vouchers are redeemed via the optional `retryVoucher` tool input. The body is JSON-RPC 2.0 per the MCP spec — not usefully describable as an OpenAPI schema; connect an MCP client and call tools/list.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"description":"A JSON-RPC 2.0 MCP message (initialize, tools/list, tools/call, …)."}}}},"responses":{"200":{"description":"JSON-RPC response. Tool-level failures (including the x402 payment challenge) are `isError` tool results inside a 200, per MCP.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"x-x402":{"transport":"mcp","challenge":"_meta[\"x402/error\"]","payment":"_meta[\"x402/payment\"]","settlement":"_meta[\"x402/payment-response\"]","tools":{"convert_video":{"price":"$0.05","priceUSD":0.05,"amountAtomic":"50000"},"get_job_status":{"price":"free"}},"capture":"verify before the tool callback, settle only after a non-error tool result — failed tool calls are never charged"}}},"/ingest/{jobId}":{"put":{"operationId":"uploadSource","tags":["jobs"],"summary":"Upload-mode source delivery (buyers)","description":"Completes an upload-mode job: PUT the raw bytes (≤ 100 MiB) to the exact `uploadUrl` from the paid 202 before its `uploadExpiresAt`. The `t` token uses its own HMAC namespace — the status token does not work here, and this one does not work there. Send a correct `Content-Length` when you can; chunked bodies are accepted and streamed (the byte cap is enforced either way). On success (`201`) the job transitions to `queued` and processing begins — from here it behaves exactly like a pull-mode job. A 413 (over the cap) persists nothing and leaves the job `awaiting_upload`, so you can retry within your window. A second PUT after the job queued — or after the window expired — answers `410`. If the pipeline refuses the job AFTER your upload stored (502): you were already charged at acceptance, so that failure automatically mints a `retryVoucher` — poll your `statusUrl` to collect it.","parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"t","in":"query","required":true,"description":"Upload token from the 202's `uploadUrl` (32 hex chars, its own HMAC namespace). Not derivable, not repeated anywhere.","schema":{"type":"string","pattern":"^[0-9a-f]{32}$"}}],"requestBody":{"required":true,"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"*/*":{"schema":{"type":"string","format":"binary"}}}},"responses":{"201":{"description":"Bytes stored, job queued for processing. Poll your `statusUrl`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadComplete"}}}},"400":{"description":"Missing request body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"invalid_request","message":"…"}}}},"x-error-codes":["invalid_request"]},"404":{"description":"Unknown job or wrong/missing token (deliberately indistinguishable).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_found","message":"…"}}}},"x-error-codes":["not_found"]},"410":{"description":"The job is not awaiting an upload — already queued, terminal, or the upload window expired (deliberately indistinguishable). An expired window is a terminal `error` with no voucher.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"gone","message":"…"}}}},"x-error-codes":["gone"]},"413":{"description":"The body exceeded the ingest byte cap (100 MiB by default). Nothing was stored; the job stays `awaiting_upload` — retry within your window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"source_too_large","message":"…"}}}},"x-error-codes":["source_too_large"]},"502":{"description":"Your bytes were stored but the job could not be queued (or the stream failed mid-flight). For a stored-then-refused job a `retryVoucher` was minted automatically — poll your `statusUrl`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"pipeline_error","message":"…"}}}},"x-error-codes":["pipeline_error","ingest_failed"]},"503":{"description":"The gateway is temporarily misconfigured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"code":"not_configured","message":"…"}}}},"x-error-codes":["not_configured"]}}}}},"components":{"securitySchemes":{"x402":{"type":"apiKey","in":"header","name":"payment-signature","description":"Not an API key: an x402 v2 payment payload, freshly signed per request from the PAYMENT-REQUIRED challenge. Modelled as apiKey because OpenAPI has no x402 security type — see the `x-x402` extensions."},"retryVoucher":{"type":"apiKey","in":"header","name":"x-retry-voucher","description":"Single-use retry credential issued when a paid job failed on our side."}},"schemas":{"ConvertRequest":{"type":"object","additionalProperties":true,"description":"Unknown properties are ignored. Validation runs before any charge, so a bad body costs nothing. EXACTLY ONE of `sourceUrl` or `upload: true` is required — both present or both absent is a 400 (never charged).","properties":{"sourceUrl":{"$ref":"#/components/schemas/SourceUrl"},"upload":{"$ref":"#/components/schemas/UploadFlag"},"maxWidth":{"type":"integer","minimum":1,"maximum":4096,"default":1920,"description":"Max output width. Must be a positive integer (JSON number, not a string)."},"maxHeight":{"type":"integer","minimum":1,"maximum":4096,"default":1080,"description":"Max output height."}}},"SourceUrl":{"type":"string","format":"uri","description":"https URL we fetch the input from. Rejected before any charge if it is not https, is an IP literal, or resolves to a localhost/.internal/.local name; redirects are followed (max 3) and every hop is re-checked. We fetch from datacenter IP ranges — hosts that block datacenter egress will fail with `ingest_failed`, so prefer a presigned URL from storage you control. Body size cap: 100 MiB by default, enforced while streaming.","examples":["https://storage.example.com/clip.mov?signature=…"]},"UploadFlag":{"type":"boolean","const":true,"description":"Push intake: instead of a `sourceUrl`, reserve an upload window and PUT the raw bytes (≤ 100 MiB, same cap) to the `uploadUrl` in the 202 before `uploadExpiresAt`. You are charged at the 202, before any bytes move: letting the window expire is a terminal `error` with no refund; a pipeline failure after your upload earns a `retryVoucher`."},"JobAccepted":{"type":"object","required":["jobId","status","statusUrl"],"properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["queued","awaiting_upload"],"description":"`queued` for pull mode; `awaiting_upload` for upload mode (the job queues when your PUT completes)."},"statusUrl":{"type":"string","format":"uri","description":"Free, token-gated status endpoint for this job. Keep it: the token is the only proof of ownership and is not recoverable."},"uploadUrl":{"type":"string","format":"uri","description":"Upload mode only: PUT the raw bytes here before `uploadExpiresAt`. Keep it — this response is the ONLY place the upload token is handed out (the status route never repeats it), and it is not recoverable."},"uploadExpiresAt":{"type":"string","format":"date-time","description":"Upload mode only: deadline for the PUT. Past it the job becomes a terminal `error` with no refund."}}},"UploadComplete":{"type":"object","required":["jobId","status","bytes"],"properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","const":"queued"},"bytes":{"type":"integer","description":"Bytes stored from your upload."}}},"JobStatus":{"type":"object","required":["jobId","status"],"properties":{"jobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["awaiting_upload","queued","processing","finished","error"],"description":"`awaiting_upload` only for upload-mode jobs whose PUT has not completed. `processing` is reserved and not emitted today."},"uploadExpiresAt":{"type":"string","format":"date-time","description":"Only while `awaiting_upload`: the PUT deadline. The `uploadUrl` itself is deliberately NOT repeated here — it was handed out exactly once, in the 202."},"resultUrl":{"type":"string","format":"uri","description":"Download URL for the output — download within 24 hours of completion. Only on `finished`. Normally points at this gateway's `GET /v1/jobs/{id}/result` (free, re-downloadable, Range-supporting, same `t` token); for a few seconds right after completion — or if the background copy into our storage failed — it is the direct presigned storage URL instead. Both forms work: just GET it (following redirects)."},"thumbnailUrl":{"type":"string","format":"uri","description":"Poster frame download URL, when the pipeline produced one — same serving semantics as `resultUrl` (`GET /v1/jobs/{id}/thumbnail`, with the same presigned-URL window/fallback)."},"durationMs":{"type":"integer","description":"Output duration in milliseconds."},"errorMessage":{"type":"string","description":"Human-readable failure reason. Only on `error`."},"retryVoucher":{"type":"string","description":"Free-retry credential ({uuid}.{hmac}) for a job we charged for and failed to deliver. Send it as `x-retry-voucher` on the same kind of endpoint. Absent when the failure cost you nothing (settlement voided) or when the lineage already used its 3 retries."}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["invalid_request","invalid_source_url","source_too_large","ingest_failed","pipeline_error","payment_denied","rate_limited","daily_cap_exceeded","not_found","gone","unknown_job","unauthorized","not_configured"]},"message":{"type":"string"}}}}}}}}