# ffpipe — media processing for agents, by VidDay > Convert video 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.05 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") 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) → 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) same token as the status URL; streamed from OUR storage (downloads are free and unmetered, byte Range supported); may 302 to a presigned storage URL when our copy is unavailable — follow it 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 / free human landing page ## Pricing | endpoint | price | what you get | input cap | |----------------|-------|-----------------------------------|-----------| | POST /v1/convert | $0.05 | one video conversion | 100 MiB | | everything else | free | status, docs, health | — | Paid in USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) on base (eip155:8453), x402 "exact" scheme. $0.05 = 50000 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, queue the job — and settle only after we answer 2xx. 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). If you poll in the first seconds after completion you may briefly see direct presigned storage URLs instead — both forms work; the gateway routes may also 302 to such a URL as a fallback. 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, 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.05 get_job_status { jobId, token } free 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) · payment_denied (403) · rate_limited, daily_cap_exceeded (429, retry-after) · ingest_failed, pipeline_error (502) · not_found (404) · gone (410, upload window closed) · not_configured (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. ## 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. Ask VidDay if you need one of these.