# ffpipe — media processing for agents, by VidDay > Convert video - inspect media, pull frames - with one HTTP call. Pay per job in USDC > over x402: no account, no API key, no invoice, no human in the loop. STATUS: **live** on base mainnet (eip155:8453). Payments settle in real USDC. Prices below are what you will actually be charged. Base URL: https://ffpipe.dev OpenAPI: https://ffpipe.dev/openapi.json (3.1, complete, authoritative) Terms: https://ffpipe.dev/terms Privacy: https://ffpipe.dev/privacy (incl. what the public blockchain exposes) ## What it does You supply a source URL (or upload the bytes directly — no hosting needed), we convert it and hand back a downloadable result. Everything is asynchronous: a paid POST returns 202 immediately with a status URL to poll. Two intake modes, EXACTLY ONE per request (both or neither → 400, no charge): - pull: "sourceUrl": "https://…" — we fetch it (≤ 100 MiB) - upload: "upload": true — the 202 returns an uploadUrl; PUT your bytes there (same cap) before uploadExpiresAt (default 1 h). You are charged at the 202, before the bytes move: abandon the window and the job errors with no refund; if WE then fail after your upload completes, you get a retryVoucher automatically. ## Endpoints POST /v1/convert $0.02 video → scaled MP4 body: { "sourceUrl": "https://…" | "upload": true, "maxWidth"?: int, "maxHeight"?: int } defaults 1920x1080, max 4096 per side (JSON numbers, not strings) → 202 { "jobId", "status": "queued", "statusUrl" } (upload mode: "status": "awaiting_upload" + "uploadUrl", "uploadExpiresAt") POST /v1/probe $0.01 any media → technical metadata body: { "sourceUrl": "https://…" | "upload": true, "outputFormat"?: "json"|"xml"|"csv"|"flat"|"ini", (default json) "sections"?: ["format"|"streams"|"chapters"|"programs"], (default format+streams+chapters) "summary"?: bool, "selectStreams"?: "v:0", "showFrames"?: bool, "showPackets"?: bool, "showEntries"?: "frame=pict_type,pts_time,key_frame", "readIntervals"?: "%+60", "countFrames"?: bool, "countPackets"?: bool } → 202, same shapes as /v1/convert; the result is a FILE, not the 202 body json + summary (both default) adds ONE key, "ffpipe_summary": { container, duration_s, size_bytes, has_video, has_audio, video_codec, audio_codec, coded_width/height, display_width/height, rotation, fps, is_hdr, audio_channels, audio_sample_rate } READ display_width/display_height, not the raw stream width/height: a phone stores portrait video as a landscape frame plus a rotation flag, and acting on the coded pair is how output ends up sideways a file that cannot be decoded is a SUCCESS carrying the diagnosis (an "error" section) — "why won't this play?" is the question it answers frame/packet detail without "readIntervals" gets a bounded window applied for you, so the analysis cannot outgrow what we can deliver POST /v1/frames $0.01 video → still frames + a manifest body: { "sourceUrl": "https://…" | "upload": true, "mode": "all"|"every_nth"|"interval"|"keyframes"|"evenly_spaced"|"timestamps", (REQUIRED) "n"?: int (every_nth), "fps"?: number (interval, may be < 1), "count"?: int (evenly_spaced), "timestamps"?: [seconds] (timestamps), "startTime"?: sec, "endTime"?: sec, "maxOutputWidth"?: int, "maxOutputHeight"?: int, (fit box, never upscales) "format"?: "jpeg"|"webp"|"png", "quality"?: 1-100, (jpeg, 85) "maxFrames"?: int } (default and hard cap 1000) mode has NO default — every candidate implies a sampling rate you did not ask for → 202, same shapes as /v1/convert; resultUrl is a JSON MANIFEST: { source: { duration, width, height, coded_width, coded_height, fps, codec, rotation, is_hdr }, params: {…}, frame_count, frames: [ { index, pts_time, url, width, height } ] } each frames[].url is a download on this gateway with the same token — fetch the manifest, then fetch the frames you want; no second API call ONE frame at a moment (a thumbnail/poster/screenshot at a given second): "mode":"timestamps", "timestamps":[5], "format":"jpeg" — the result is a one-entry manifest pointing at that image. Want the image bytes returned directly instead of via a manifest? Tell us at POST /v1/feedback "all"/"interval" on a long source and "keyframes" on a dense one can exceed 1000 frames: the job then fails fast (with a free retry) — for long inputs use "interval", "evenly_spaced" or "timestamps" unreadable input, audio-only files and still images FAIL here (unlike a probe): there are no frames to deliver PUT {uploadUrl} free (already paid) upload-mode byte delivery raw body, ≤ 100 MiB → 201 { "jobId", "status": "queued", "bytes" } 413 = over the cap, nothing stored, window still open — retry 410 = window closed (already queued, expired, or terminal) GET /v1/jobs/{id}?t={token} free job status (token comes from statusUrl) {id} and {token} are placeholders — substitute them, or just request the whole statusUrl you were handed; a path still containing {id} answers 404 literal_path_template → 200 { "jobId", "status": "awaiting_upload"|"queued"|"finished"|"error", "uploadExpiresAt"?, "resultUrl"?, "thumbnailUrl"?, "durationMs"?, "errorMessage"?, "retryVoucher"? } GET /v1/jobs/{id}/result?t={token} free the finished output itself GET /v1/jobs/{id}/thumbnail?t={token} free poster frame (video jobs) GET /v1/jobs/{id}/frames/manifest?t={token} free a frames job's manifest GET /v1/jobs/{id}/frames/{index}?t={token} free one frame's bytes same token as the status URL; always served by this gateway (downloads are free and unmetered, byte Range supported, never a redirect) POST /v1/feedback free tell us what you needed (see "Missing a capability?") body: { "message": string (1-2000 chars), "type"?: "feature_request" | "bug" | "feedback" (default "feedback"), "jobId"?: string, "contact"?: string } only "message" is required, and it is also read from "feedback", "text", "comment" or "body" — or send the sentence as a text/plain body → 202 { "ok": true } POST /mcp MCP server (streamable HTTP) — same jobs as tools, see below GET /health free liveness GET /openapi.json free this API, machine-readable GET /.well-known/x402 free discovery manifest (paid resources + payment terms) GET / free human landing page ## Pricing | endpoint | price | what you get | input cap | |----------------|-------|-----------------------------------|-----------| | POST /v1/convert | $0.02 | one video conversion | 100 MiB | | POST /v1/probe | $0.01 | one media inspection | 100 MiB | | POST /v1/frames | $0.01 | one job, up to 1000 frames | 100 MiB | | everything else | free | status, docs, health | — | Paid in USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) on base (eip155:8453), x402 "exact" scheme. $0.02 = 20000 atomic units (6 decimals). Facilitator: https://api.cdp.coinbase.com/platform/v2/x402 ## The x402 handshake, in 5 lines 1. POST the endpoint with NO payment → 402 plus a "PAYMENT-REQUIRED" response header: base64 JSON { x402Version, accepts: [ { scheme, network, amount, asset, payTo, extra } ] }. (The 402 body is just {}.) 2. Pick an entry from accepts and sign an EIP-3009 TransferWithAuthorization for that amount, asset and payTo with your wallet. Nothing moves yet. 3. Retry the IDENTICAL request with header "payment-signature: ". 4. We verify with the facilitator, validate, stage your source and record the job — and settle only after we answer 2xx; the job is queued for processing once your payment has settled. Every non-2xx answer (400/402/403/413/429/502/503) charges you nothing. 5. You get 202 { jobId, statusUrl } and a "PAYMENT-RESPONSE" header with the settlement transaction. Poll statusUrl until "finished" or "error". Any x402 client library does 1-3 for you. ## curl # 1. see the price (no payment, no charge) curl -sS -D - -o /dev/null -X POST https://ffpipe.dev/v1/convert \ -H 'content-type: application/json' \ -d '{"sourceUrl":"https://storage.example.com/clip.mov"}' # → HTTP/2 402 + payment-required: eyJ4NDAyVmVyc2lvbiI6Miwi… # decode it: echo '' | base64 -d # 2. pay: retry the same request with a signed payload from your wallet curl -sS -X POST https://ffpipe.dev/v1/convert \ -H 'content-type: application/json' \ -H "payment-signature: $SIGNED_PAYLOAD_BASE64" \ -d '{"sourceUrl":"https://storage.example.com/clip.mov","maxWidth":1280,"maxHeight":720}' # → 202 {"jobId":"…","status":"queued","statusUrl":"https://ffpipe.dev/v1/jobs/…?t=…"} # 3. poll (free) curl -sS "https://ffpipe.dev/v1/jobs/?t=" # → {"status":"finished","resultUrl":"https://…","durationMs":12480} ## curl, upload mode (no hosting needed) curl -sS -X POST https://ffpipe.dev/v1/convert -H 'content-type: application/json' \ -H "payment-signature: $SIGNED_PAYLOAD_BASE64" -d '{"upload":true}' # → 202 {"status":"awaiting_upload","uploadUrl":"https://ffpipe.dev/ingest/…?t=…","uploadExpiresAt":"…"} curl -sS -X PUT "" --data-binary @clip.mov # → 201 queued — then poll statusUrl ## JavaScript / TypeScript (@x402/fetch) import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; import { ExactEvmScheme } from "@x402/evm"; import { privateKeyToAccount } from "viem/accounts"; const account = privateKeyToAccount(process.env.PRIVATE_KEY); const pay = wrapFetchWithPaymentFromConfig(fetch, { schemes: [{ network: "eip155:8453", client: new ExactEvmScheme(account) }], }); const res = await pay("https://ffpipe.dev/v1/convert", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceUrl, maxWidth: 1280, maxHeight: 720 }), }); const { statusUrl } = await res.json(); // 202 let job; do { await new Promise((r) => setTimeout(r, 3000)); job = await (await fetch(statusUrl)).json(); // free, no payment } while (job.status === "queued"); // job.status === "finished" → job.resultUrl ## statusUrl semantics (read this) - The "t" query parameter in statusUrl IS the proof that you paid for the job. Store the whole statusUrl. We cannot regenerate or look it up for you. - Without a valid token the endpoint answers 404 — the same answer as an unknown job id, on purpose. A 404 does not mean your job vanished. - status goes [awaiting_upload →] queued → finished | error. There is no "processing" today. While awaiting_upload the status shows uploadExpiresAt but NEVER repeats the uploadUrl — that credential is handed out exactly once, in the 202. Store both URLs. - An upload window you let expire becomes error ("upload never completed") with NO voucher: you were charged at acceptance and the no-show is on you, same as an unfetchable sourceUrl would have been pre-charge. - Results are served BY US: once the result is ready, resultUrl/thumbnailUrl point at GET /v1/jobs/{id}/result and /thumbnail on this gateway (same "t" token, free, re-downloadable, byte Range supported) — always, from the first poll after completion. Download within 24 hours of completion. Download what you need; we are not your storage. - If no completion arrives within 6 hours the job is marked error. Note the gap: a voucher is minted when a processing failure is reported, so a job that times out because its completion never reached us has none — tell us the job id and we will sort it out. - Polling is free and unmetered. Every 3-5 seconds is plenty. ## Retry vouchers (what happens when a paid job fails) We charge when we accept the job, so if the render then fails — or the job could not be queued after your payment settled — refunding is our problem. The failed job's status response carries: "retryVoucher": "." Send it back as "x-retry-voucher: " on the same endpoint kind and the retry is free — no payment, no signature, no on-chain movement: curl -sS -X POST https://ffpipe.dev/v1/convert \ -H 'content-type: application/json' \ -H "x-retry-voucher: ." \ -d '{"sourceUrl":"https://storage.example.com/clip.mov"}' Rules: single use; a voucher only pays for the same kind of job it was issued for; a failed retry earns a fresh voucher up to 3 times per original job; anything invalid, replayed or expired gets the ordinary 402 (we do not say which). No voucher is issued when the failure cost you nothing, e.g. your payment never settled. There are no on-chain refunds. ## MCP server (if you speak tools, start here) https://ffpipe.dev/mcp is a remote MCP server (streamable HTTP transport, stateless — no session id required) exposing the same API as tools: convert_video { sourceUrl?|upload?, maxWidth?, maxHeight?, retryVoucher? } $0.02 probe_media { sourceUrl?|upload?, outputFormat?, sections?, … } $0.01 extract_frames { sourceUrl?|upload?, mode, fps?/n?/count?/timestamps?, … } $0.01 get_job_status { jobId, token } free get_balance { } (needs a connected account, not a wallet) free leave_feedback { type, message, jobId?, contact? } free Every tool carries MCP annotations, so a client can see before calling which ones commit it to work: the paid job tools are writes and are NOT idempotent (two identical calls are two jobs and two charges), probe_media reports readOnlyHint (it inspects a file and leaves no artifact), and the free lookups are read-only. Nothing here is marked destructive. Upload mode works over MCP too: pass upload: true (exactly one of sourceUrl or upload) and the paid tool result carries uploadUrl + uploadExpiresAt — then PUT the bytes over plain HTTP, exactly like REST. Payment rides the MCP protocol itself (the 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 the REST 402; sign it and retry the call with _meta["x402/payment"] = base64 payload. With the agents package this is automatic: import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { withX402Client } from "agents/x402"; import { privateKeyToAccount } from "viem/accounts"; const client = new Client({ name: "my-agent", version: "1.0.0" }); await client.connect(new StreamableHTTPClientTransport(new URL("https://ffpipe.dev/mcp"))); const paid = withX402Client(client, { account: privateKeyToAccount(process.env.PRIVATE_KEY), network: "eip155:8453", }); const res = await paid.callTool(null, { name: "convert_video", arguments: { sourceUrl, maxWidth: 1280, maxHeight: 720 }, }); // → { jobId, statusUrl } — then poll get_job_status(jobId, token) Same capture semantics as REST: payment is verified before the tool runs and settles only when the tool result is NOT an error — a failed tool call never charges you. Same limits (10 paid calls/min/IP, daily cap), same statusUrl/ token semantics, and retry vouchers are redeemed via the optional retryVoucher tool input instead of the x-retry-voucher header. ## Source URL caveat (the most common failure) We fetch sourceUrl from cloud datacenter IP ranges, not from a browser. Plenty of CDNs and asset hosts answer datacenter egress with 403 or a bot-check page, which surfaces here as 502 { "error": { "code": "ingest_failed" } }. - PREFER a presigned URL from storage you control (S3/R2/GCS) with a short expiry. That is the reliable path. - Or skip URLs entirely: upload mode ("upload": true) PUTs the bytes to us directly — no hosting needed, and nothing for a CDN to block. - Requirements: https only, no IP-literal hosts, no localhost/.internal/ .local, at most 3 redirects (each hop re-checked), body ≤ 100 MiB. - The bytes are staged in our storage transiently and deleted after processing; the render workers only ever read our copy, never your URL. ## Errors All errors are { "error": { "code", "message" } }. Codes: invalid_request, invalid_source_url (400) · source_too_large (413) · rate_limited, daily_cap_exceeded (429, retry-after) · ingest_failed, pipeline_error (502) · not_found, literal_path_template (404) · gone (410, upload window closed) · not_configured, unavailable (503). Limits: 10 paid requests/minute per IP, 200 accepted jobs per payer per UTC day. Both answer 429 and, being non-2xx, cost nothing. ## Missing a capability? Tell us — it decides what we build next The endpoints listed above are everything ffpipe offers today. If you came here needing something else — a different output format, a trimmed clip, some other transformation — do not just leave: say so. curl -sS -X POST https://ffpipe.dev/v1/feedback \ -H 'content-type: application/json' \ -d '{"type":"feature_request","message":"needed the frames as a single contact sheet"}' # → 202 {"ok":true} Over MCP the same thing is the free leave_feedback tool. Also takes "type":"bug" and "type":"feedback". Optional "jobId" (context only — it is not checked) and "contact" if you want a reply. One-way: nothing is returned but the acknowledgement. These requests are aggregated and they are the primary input to what gets built next, so one concrete line beats none. Free, no payment, no account. Limits: 5 submissions/minute per IP, 50 accepted per IP per day. ## Acceptable use No illegal content (illegal material is reported to the appropriate authorities as Canadian law requires), no infringing material you have no right to process. Abuse gets the payer address and IP banned. Report abuse on this service: abuse@vidday.com (include the job id). Full terms: https://ffpipe.dev/terms · Privacy: https://ffpipe.dev/privacy ## Not yet - Composition/multi-clip renders, captions, webhooks to you instead of polling. Want one of these — or anything else? POST /v1/feedback (above); that is how the list gets shorter.