Conventions
- Base URL:
https://carpe-diem.xyz/api/operator - Auth: pass
Authorization: Bearer <token>—<token>is either an API key (cdm_…) or a wallet session JWT. Each endpoint states what it accepts. - Format: JSON in/out, except file uploads (
multipart/form-data) and binary responses (audio, video). - OpenAI-compatible endpoints live under two prefixes — pick one as your SDK base URL:
…/api/operator/v1— served by Carpe's own TEE network.…/api/operator/router— the best-price router: prices each request across Carpe and the external markets, serves the cheapest, falls back to Carpe. Same key, same bodies; swapping/v1→/routeris the whole integration.
Contracts (Base mainnet)
Deployed on Base mainnet (chain ID 8453). The escrow source is verified and reproducible from the repository — Sourcify reports a full bytecode match.
| Contract | Address | Verify |
|---|---|---|
| CarpeEscrow (UUPS proxy) | 0x15917768b31CB1DC61d9d858f2419BF45044005d | BaseScan · Sourcify ✓ |
| ↳ Implementation | 0xA67327207219d84973D0d5B988e902B8e5f8945C | BaseScan |
| USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | BaseScan |
| DIEM (Venice) | 0xF4d97F2da56e8c3098f3a8D538DB630A2606a024 | BaseScan |
TEE attestation (digest ↔ running enclave) is verifiable from the Privacy page.
1. Authentication
POST /auth/session
Create a wallet session (SIWE). Returns a JWT.
- Auth: none
- Body:
{ "wallet": "0x…", "message": "<signed SIWE message>", "signature": "0x…" } - 200:
{ "token": "<jwt>", "wallet": "0x…", "expiresIn": "30m" } - Errors:
400bad input ·401AUTH_FAILED (signature mismatch / expired message) ·403OFAC_BLOCKED
POST /auth/refresh
Exchange a still-valid JWT for a fresh one.
- Auth: Bearer JWT
- 200:
{ "token", "wallet", "expiresIn" } - 401: expired → re-authenticate
POST /auth/api-keys
Create a persistent API key. JWT only (an API key can't mint keys).
- Auth: Bearer JWT
- Body:
{ "name": "optional label" } - 201:
{ "id", "key": "cdm_…", "prefix": "cdm_xxxxxxxx…" }— full key shown once.
GET /auth/api-keys
List your keys (no secrets).
- Auth: Bearer JWT
- 200:
{ "keys": [{ "id", "name", "prefix", "createdAt", "revokedAt" }] }
DELETE /auth/api-keys/:id · POST /auth/api-keys/:id/revoke
Revoke a key (two forms; the POST alias is for clients that mishandle DELETE).
- Auth: Bearer JWT
- 200:
{ "status": "revoked", "id" }· 404 if not found
2. Chat & Messages
Best-price twin. The core endpoints in this section also exist under
/router/…, which prices the request across Carpe and the external markets and serves the cheapest — same auth, same body, same response. See Best-price router for the exact path mapping. Endpoints not in that mapping still answer under/router— they are simply always served by Carpe, with no market comparison.
POST /v1/chat/completions
OpenAI-compatible chat completions.
- Auth: API key or JWT
- Body: standard OpenAI shape —
{ "model", "messages": [...], "temperature"?, "max_tokens"?, "top_p"?, "stop"?, "stream"?, "tools"? } - 200: OpenAI completion object (
choices[].message); SSE stream when"stream": true(terminated bydata: [DONE]) - Headers:
X-Carpe-*-Creditsreport the request cost and remaining balance - Errors:
402PAYMENT_REQUIRED (insufficient credits) ·400invalid model/params ·429rate limited ·502VENICE_ERROR ·503no providers / TEE not ready
POST /v1/messages
Anthropic-compatible Messages API (Claude Code, Cursor, Cline).
- Auth: API key or JWT
- Body: Anthropic shape —
{ "model", "max_tokens", "messages": [...], "system"?, "stream"? } - 200: Anthropic message object (
content[].text); SSE stream when"stream": true - Note:
modelis a Venice model id (see Models), not a Claude model name - Errors: same set as
/v1/chat/completions
Claude-specific caveats
Two parameters behave differently on Claude models because of upstream constraints. Verified 2026-06-10 by cross-testing the same payloads against openai-gpt-52 and claude-opus-4-8 through the same operator (both work there), so the limits live upstream — Carpe Diem forwards the payload faithfully in all cases.
| Param | Behaviour | Scope |
|---|---|---|
tool_choice: { "type": "function", "function": { "name": "..." } } (forced specific tool) — and tool_choice: "required" (any tool, but force one) | Rejected on claude-fable-5 with HTTP 400 and message tool_choice forces tool use is not compatible with this model. Works on every other Claude (e.g. claude-opus-4-8) and on OpenAI/Grok/etc. | claude-fable-5 only — Anthropic published this as part of Fable 5's intentional safety envelope, which also blocks responses in cybersecurity / biology / chemistry and reroutes those to Claude Opus 4.8 (CNBC, TechCrunch). |
thinking: { "type": "enabled", "budget_tokens": N } (Anthropic-native extended thinking block) | Silently dropped — the response is shape-valid but never contains a { "type": "thinking" } block, only { "type": "text" }. The model still reasons internally and returns correct answers; only the separate thinking block is missing. | All Claude models on Venice. Venice exposes Anthropic reasoning through its own reasoning_content field (see Venice's reasoning models guide) using reasoning_effort: low | medium | high instead of Anthropic's native thinking parameter. |
Workarounds for tool_choice forced (Fable 5 only):
- Use
claude-opus-4-8for the request that needs a guaranteed function call — opus-4-8 honourstool_choicefully. Switch back to Fable 5 for everything else. - Or leave
tool_choiceunset and instruct the model in the system prompt:"You MUST call the <fn_name> function. Do not respond with prose."Fable 5 follows multi-constraint instructions reliably (it scored 7/7 on a strict instruction-following audit).
Workarounds for thinking:
- Prompt-driven: prefix the user message with
"Show your reasoning step by step before answering."The chain appears in the standardcontent[].textblock. - Venice-native: switch to
claude-opus-4-6/4-7/claude-sonnet-4-5/4-6and passreasoning_effort: "high"— the chain is then returned in a separatereasoning_contentfield (Venice convention, not Anthropic's).claude-fable-5does not expose reasoning effort levels (supportsReasoningEffort: falseinGET /v1/models).
GPT-5.6 codename caveat — function tools and reasoning_effort
openai-gpt-56-terra and openai-gpt-56-luna reject function tools combined with any reasoning_effort other than "none" on /v1/chat/completions. Verified 2026-09-02 against the live upstream; openai-gpt-56-sol, the third codename of the same generation, accepts the combination, as do openai-gpt-55 and every earlier GPT-5.x.
| Request | Result |
|---|---|
tools + reasoning_effort absent | 400 — omitting the parameter does not help, the upstream applies a non-"none" default before validating |
tools + reasoning_effort: "low" | "medium" | "high" | 400 Function tools with reasoning_effort are not supported for gpt-5.6-terra in /v1/chat/completions |
tools + reasoning_effort: "none" + max_tokens | 400 Unsupported parameter: 'max_tokens' … use 'max_completion_tokens' — the budget field follows the effort, not the model |
tools + reasoning_effort: "none" + max_completion_tokens | 200, finish_reason: "tool_calls" |
No tools, any effort, max_tokens | 200 — unaffected |
You do not have to handle this. The operator rewrites the request for you: when a body carries function tools for an affected model it sets reasoning_effort: "none" and renames max_tokens to max_completion_tokens. It applies on every rail — /v1/chat/completions, /v1/messages, /router/… and the x402 endpoints — because the constraint belongs to the upstream, not to the endpoint you reached it through.
When the rewrite happens, the response carries:
X-Carpe-Compat: reasoning_effort=none (requested medium; upstream refuses it alongside function tools)
That header is the only signal that you asked for reasoning and did not get it. Tools win over effort deliberately: an agent whose tool calls fail has nothing to fall back on, while an agent reasoning at "none" still completes the task.
To detect it up front, read carpe_diem_constraints from GET /v1/models (or GET /models):
{
"id": "openai-gpt-56-terra",
"capabilities": { "supportsFunctionCalling": true, "reasoningEffortOptions": ["none", "low", "medium", "high", "xhigh", "max"] },
"carpe_diem_constraints": { "toolsRequireReasoningEffortNone": true }
}
Note the two blocks disagree, and both are honest: capabilities is Venice's, and each of its claims is true in isolation. carpe_diem_constraints is ours, and it is the only place the combination is described. The field is absent when there is nothing to declare — never read a negative claim from its absence.
If you need reasoning and tools together, use openai-gpt-56-sol (same generation, no constraint) or any GPT-5.5 / Claude / GLM model.
3. Embeddings
Best-price twin. The core endpoints in this section also exist under
/router/…, which prices the request across Carpe and the external markets and serves the cheapest — same auth, same body, same response. See Best-price router for the exact path mapping. Endpoints not in that mapping still answer under/router— they are simply always served by Carpe, with no market comparison.
POST /v1/embeddings
OpenAI-compatible vector embeddings.
- Auth: API key or JWT
- Body:
{ "model", "input": "text" | ["text", …] }— single string or array (batch) - 200:
{ "object": "list", "data": [{ "object": "embedding", "index": 0, "embedding": [float, …] }], "model", "usage": { "prompt_tokens", "total_tokens" } } - Billing: per input token
- Errors:
402PAYMENT_REQUIRED ·400invalid model/params ·429rate limited ·502VENICE_ERROR ·503no providers / TEE not ready
Web search & scrape (augment)
Live web tools (Venice augment/*) — used by agent web_search / web_fetch integrations.
POST /v1/augment/search · POST /v1/augment/scrape
- Auth: API key or JWT
/searchbody:{ "query": "…", … }(Venice augment/search params passed through) — returns ranked web results./scrapebody:{ "url": "https://…", … }— returns the page content.- Billing: flat per call (
AUGMENT_SEARCH_USD/AUGMENT_SCRAPE_USD) - 200: the Venice augment payload · Errors:
402PAYMENT_REQUIRED ·400bad params ·4xxsurfaced from Venice ·502VENICE_ERROR ·503no providers
4. Images
Best-price twin. The core endpoints in this section also exist under
/router/…, which prices the request across Carpe and the external markets and serves the cheapest — same auth, same body, same response. See Best-price router for the exact path mapping. Endpoints not in that mapping still answer under/router— they are simply always served by Carpe, with no market comparison.
Pricing is a fixed cost per image by model (then the dynamic multiplier applies); discover models via GET /v1/models.
Heavy models — use the async queue.
/v1/image/generateis synchronous and the edge proxy in front of the operator caps a single request at ~60s. Models that routinely take longer (e.g.gpt-image-2,nano-banana-pro,recraft-v4-pro) will return a502at the edge even though the image was generated. For those, use the async pattern below (/queue+/retrieve), which has no duration limit. Billing is only charged on a stored, retrievable success — a failed generation is never billed.
POST /v1/image/generate
Synchronous (one request → result). Best for fast models that finish well under 60s.
- Auth: API key or JWT
- Body:
{ "model", "prompt", "variants"? }—variants1–4 (default 1), billed per image - 200: Venice image payload (base64-encoded image(s))
- Errors:
402PAYMENT_REQUIRED ·400invalid/missing prompt (max 10,000 chars) ·502VENICE_ERROR ·503no providers
POST /v1/image/generate/queue → /retrieve → /complete (async)
For heavy models. Same shape as the video/audio async pattern — each call returns in <1s, so no edge timeout regardless of generation duration.
No
variantson the async path. It returns a single binary image, sovariants > 1is rejected with400 VARIANTS_NOT_SUPPORTEDrather than billed. Use the synchronousPOST /v1/image/generatewhen you want multiple variants.
- POST
/v1/image/generate/queue— Auth: API key or JWT · Body: same as/v1/image/generate· 202:{ "queue_id", "status": "pending" }· Errors:402·400·503QUEUE_FULL (too many in-flight jobs) ·503no providers / no capacity - POST
/v1/image/generate/retrieve— Body:{ "queue_id" }· 200 (pending): JSON{ "status": "pending", "queue_id" }— poll again · 200 (done): the binary image (Content-Type: image/*, headerX-Carpe-Image-Status: completed) · Errors:403queue_id belongs to another wallet ·404JOB_NOT_FOUND (unknown or expired) · upstream error code on failed generation - POST
/v1/image/generate/complete— Body:{ "queue_id" }· 200:{ "status": "released" }— best-effort cleanup; the job also expires on its own TTL (15 min)
Flow: queue → poll retrieve every ~2s until you get binary (or an error) → optionally complete. Retries are idempotent: re-queue starts a fresh job; in-flight jobs aren't cancelled but don't accumulate as zombies.
BASE=https://carpe-diem.xyz/api/operator/v1
AUTH="Authorization: Bearer $CARPE_KEY"
# 1. Queue → { "queue_id", "status": "pending" }
QID=$(curl -s -X POST "$BASE/image/generate/queue" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-image-2","prompt":"a neon koi pond"}' | jq -r .queue_id)
# 2. Poll: pending → JSON; done → binary image; failure → error JSON
while :; do
ct=$(curl -s -o out.webp -w "%{content_type}" -X POST "$BASE/image/generate/retrieve" \
-H "$AUTH" -H "Content-Type: application/json" -d "{\"queue_id\":\"$QID\"}")
case "$ct" in
image/*) echo "saved out.webp"; break ;; # completed
application/json*) sleep 2 ;; # still pending — poll again
*) echo "error:"; cat out.webp; break ;;
esac
done
# 3. Optional cleanup (frees the buffer; TTL is the backstop)
curl -s -X POST "$BASE/image/generate/complete" -H "$AUTH" \
-H "Content-Type: application/json" -d "{\"queue_id\":\"$QID\"}" > /dev/null
POST /v1/image/edit
Transform an existing image from a prompt.
- Auth: API key or JWT
- Body:
{ "model", "prompt", "image": "<base64>", "aspect_ratio"? } imageformat: data URI —data:image/<mime>;base64,<…>(e.g.image/png,image/jpeg,image/webp). HTTP URLs are not fetched server-side.- 200: edited image payload · Errors: same set as generate
Heavy edit models — use the async queue. Like generation,
/v1/image/editis synchronous and the edge caps a request at ~60–120s. The highest-quality edit models — notablygpt-image-2-edit— routinely run longer and return a502at the edge even though the edit was produced. Use the async pattern below; no duration limit, billed only on a stored, retrievable success.
POST /v1/image/edit/queue → /edit/retrieve → /edit/complete (async)
Same shape as /v1/image/generate/queue — each call returns in <1s, so no edge timeout regardless of edit duration.
- POST
/v1/image/edit/queue— Auth: API key or JWT · Body: same as/v1/image/edit({ "model", "prompt", "image", "aspect_ratio"? }) · 202:{ "queue_id", "status": "pending" }· Errors:402·400(missing prompt/image) ·413image > 5 MB ·503QUEUE_FULL ·503no providers / no capacity - POST
/v1/image/edit/retrieve— Body:{ "queue_id" }· 200 (pending):{ "status": "pending", "queue_id" }— poll again · 200 (done): the binary image (Content-Type: image/*) · Errors:403other wallet ·404JOB_NOT_FOUND · upstream error on failed edit - POST
/v1/image/edit/complete— Body:{ "queue_id" }· 200:{ "status": "released" }— best-effort cleanup (15-min TTL backstop)
POST /v1/image/multi-edit → /multi-edit/queue → /multi-edit/retrieve (async)
Compose/edit from 1–3 source images and a prompt (Venice image/multi-edit).
- Auth: API key or JWT
- Body:
{ "model", "prompt", "images": ["<img>", …], "aspect_ratio"?, "output_format"?, "resolution"?, "quality"?, "safe_mode"? } imagesformat: array of 1–3 items, each a data URI or raw base64, ≤ 5 MB each.- 200: edited image payload (
{ "images": ["<base64>"], "format": "png" }) · Errors:400(bad prompt/images) ·413image > 5 MB ·409MODEL_REQUIRES_ASYNC→ use/v1/image/multi-edit/queue·503no providers / no capacity - Async:
/v1/image/multi-edit/queue(→202 { queue_id }) then/v1/image/multi-edit/retrieve— same poll/binary contract as/v1/image/edit/queue.
POST /v1/image/upscale
Increase resolution.
- Auth: API key or JWT
- Body:
{ "model": "upscaler", "image": "<base64>", "scale"? }—scale1–4 (default 2) imageformat: raw base64, nodata:prefix. Different from/v1/image/editwhich wants the full data URI.- Constraints: source ≥ 256×256 pixels. Smaller inputs return
400BAD_REQUEST(Invalid or corrupt image). - 200: upscaled image payload · Errors:
400scale out of range / unknown model / source too small
POST /v1/image/share · GET /v1/image/share/:id
Publish a generated image to a shareable link, then fetch it.
- POST — Auth: API key or JWT · Body: the image to share · 200:
{ "id", "url" } - GET
/v1/image/share/:id— Auth: none (public) · returns the shared image
5. Audio
Best-price twin. The core endpoints in this section also exist under
/router/…, which prices the request across Carpe and the external markets and serves the cheapest — same auth, same body, same response. See Best-price router for the exact path mapping. Endpoints not in that mapping still answer under/router— they are simply always served by Carpe, with no market comparison.
POST /v1/audio/speech
Text-to-speech (OpenAI-compatible).
- Auth: API key or JWT
- Body:
{ "model", "input", "voice"?, "response_format"? }—input1–50,000 chars voiceis model-specific. OpenAI's generic voice names (alloy,echo, …) are not portable — empirically rejected by 11/11 Venice TTS models. Each model exposes its own enum:- Safest default: omit
voiceentirely, Venice picks the model's default. - To pick a specific voice, query
GET /v1/models— every entry withcarpe_diem_type: "tts"carries avoicesarray of accepted values (e.g.tts-kokorohas 54,tts-gemini-3-1-flashhas 30).
- Safest default: omit
- 200: binary audio (e.g.
audio/mpeg) - Billing: per character
- Errors:
402PAYMENT_REQUIRED ·400invalid/missing input / unknown voice for this model ·502VENICE_ERROR
POST /v1/audio/transcriptions
Speech-to-text (OpenAI Whisper-compatible). multipart/form-data.
- Auth: API key or JWT
- Body (multipart):
file(audio) +model - 200:
{ "text": "…" } - Errors:
400multipart required / no file ·502VENICE_ERROR
POST /v1/audio/music/queue · POST /v1/audio/music/retrieve
Music generation — asynchronous (same pattern as video). This is also where
sound effects and the speech models Venice serves off the music pipeline
live: the endpoint follows the model's carpe_diem_type, not what it sounds
like. A music id sent to /v1/audio/speech is refused by the upstream, which
only serves carpe_diem_type: "tts".
- queue — Auth: API key or JWT · Body:
{ "model", "prompt", "lyrics_prompt"?, "duration_seconds"?, "force_instrumental"? }· 200:{ "queue_id" } - retrieve — Body:
{ "queue_id", "model" }· 200:{ "status": "processing" }while running, or the finished track when"completed" - Same account-scoping and pending rules as video (see Video)
Semantics: prompt describes the musical style; lyrics_prompt carries the sung lyrics.
Read every parameter off the catalogue, not off this page. Every music model
in GET /v1/models carries a constraints block — its own words, refreshed
hourly — and the music bucket is heterogeneous enough that a value from the
wrong model is a 400:
constraints field | What it decides |
|---|---|
duration_options | The exact lengths accepted. ace-step-15 takes 60/90/120/150/180/210 and nothing between. |
min_duration / max_duration | The accepted range, when the model publishes bounds instead of a list (3–600s on elevenlabs-music, 1–22s on elevenlabs-sound-effects-v2). |
neither, and no default_duration | The model takes no duration_seconds at all — sending one is a 400. This is the case on the minimax-music-* line and lyria-3-pro. |
supports_lyrics · lyrics_required · lyrics_character_limit | Whether lyrics_prompt is forbidden, required or optional, and how long it may be. |
supports_force_instrumental | Whether force_instrumental may be sent at all. Only 3 of the 12 models accept the key, and sending it with false is refused just the same — it is an unknown field upstream, not a no-op. |
min_prompt_length · prompt_character_limit | The prompt window. 10–512 on ace-step-15, 10–300 on minimax-music-v2. |
supports_speed · min_speed / max_speed | Whether speed may be sent, and its bounds. |
supported_formats · default_format | The container the finished track arrives in — flac, mp3, wav or m4a. Name the download accordingly. |
A request that breaks one of those published limits is refused before it is
priced or queued, on all three rails (credits, /router, x402):
{
"error": "This model does not accept these values — duration_seconds: 30s is not one of this model's lengths (accepts 60, 90, 120, 150, 180, 210)",
"code": "MUSIC_PARAM_REJECTED",
"model": "ace-step-15",
"details": { "issues": [{ "param": "duration_seconds", "reason": "…", "accepted": ["60", "90", "…"] }] }
}
Every refused field is named in one response, so a two-mistake payload takes one
round trip to fix rather than two. Anything the catalogue does not publish is
still the upstream's call and comes back as its 400, with its own
details.issues attached.
6. Video (async)
Best-price twin. The core endpoints in this section also exist under
/router/…, which prices the request across Carpe and the external markets and serves the cheapest — same auth, same body, same response. See Best-price router for the exact path mapping. Endpoints not in that mapping still answer under/router— they are simply always served by Carpe, with no market comparison.
Video takes minutes, so it's asynchronous: (optionally) quote, queue, poll retrieve, fetch file.
POST /v1/video/quote
Price estimate before committing — optional but recommended (queue runs its own balance check anyway). Proxied to Venice's quote.
- Auth: API key or JWT
- Body: the same passthrough body as
/v1/video/queue(model,prompt,duration,aspect_ratio, and any image fields — see queue below). Use it to validate an unfamiliarduration/aspect_ratio/ param set without being charged. - A price here means the payload passed parameter validation. Quote and queue run the same pre-flight against the model's published
constraints(seeGET /v1/models), so a quote can no longer price a render that queue would refuse. Anything the catalogue doesn't publish is still Venice's call, and comes back as its400. - 200: Venice's quote payload
- Errors:
400VIDEO_PARAM_REJECTED (a value the model's published enum excludes — carriesdetails.issues) ·400(Venice rejected a field we can't check) ·502VENICE_ERROR
POST /v1/video/queue
Start a job. Runs a balance check and refuses if you can't afford it.
-
Auth: API key or JWT
-
Body:
{ "model", "prompt", "duration", "aspect_ratio"?, ...image params } -
Read the accepted values off the catalogue, not this page. Every video model in
GET /v1/modelscarries aconstraintsblock with its owndurations,aspect_ratios,resolutionsandprompt_character_limit— that is the authoritative, always-current source, and what a picker should be built from. No video model publishes nothing: an id the upstream serves without publishing is measured against its validator directly and re-verified at every catalogue sync (scripts/probe_unlisted_video.mjs).The per-family table below is a hand-maintained snapshot kept for orientation, and it is coarser than the catalogue in both directions — it groups models that differ, and it has been wrong. The Seedance families accept every whole second in their range (
7s,9s,11s,13s/14son the2-0line, and every second up to30son2-5, none of which appear below), they accept21:9, and theirresolutionsdiffer per model —seedance-2-0*reaches4kwhileseedance-2-0-fast*,-mini*andseedance-2-5*stop at720p. Build fromconstraints.Model ids carry a tier suffix. Venice publishes the whole Seedance family as
*-basic—seedance-2-5-reference-to-video-basic, notseedance-2-5-reference-to-video. The older spellings still reach the same models upstream, but the catalogue lists each model once, under the id Venice publishes. Match on the catalogue id. -
durationis required. String with"s"suffix (e.g."5s", not5or5.0). Omitting it returns400. The accepted enum is per-family — empirically validated via/v1/video/quote2026-06-07 (seedance re-validated 2026-06-24 — the2-0line now accepts"15s"):Family Accepted durationsora-*"4s""8s""12s""16s"veo3*"4s""6s""8s"wan-*"5s""10s"kling-*"5s""10s"seedance-2-5*every second "4s"–"30s"seedance-2-0*(incl.-fast,-mini)every second "4s"–"15s"seedance-1-5*every second "4s"–"12s"pixverse-*"3s""5s""8s""10s"ltx-*"5s""8s""10s"longcat*"5s""10s"grok-imagine*"5s""10s"happyhorse-*"3s""4s""5s""6s""8s""10s""12s"vidu-*"3s""5s""8s""10s""12s""16s" -
Source-matched
durationandaspect_ratio(Seedance reference-to-video). On an edit or extend job you can ask the output to follow the source clip instead of naming a size or a length:Field Values Behaviour aspect_ratio"adaptive"or"auto"output ratio matches the source video (Seedance 2.0 and 2.5 R2V) duration"auto"or"-1"output length matches the source video (Seedance 2.5 R2V edit; source must be 4–30 s) Neither value appears in
constraints, and the pre-flight lets both through on purpose — the published enum is a floor of what works, not a ceiling. Both requirereference_video_urlson queue, andreference_video_total_durationon quote; a source-matched duration billsceil(reference_video_total_duration)seconds. The two are independent — match one without the other. -
aspect_ratiois optional."16:9"and"9:16"are accepted by every family."1:1"works on most (rejected bysora-*,veo3*,longcat*,happyhorse-*)."4:3"/"3:4"accepted only byseedance*,pixverse-*,ltx-*,grok-imagine*,vidu-*;seedance*also takes"21:9". Omit to let Venice pick a sensible default.On an image-to-video model the parameter is usually meaningless — the source image dictates the frame — and the catalogue says so with
aspect_ratios: []. Hide the control when the list is empty rather than sending a value that does nothing. -
Image conditioning (image-to-video / frame control). For models that take a source image, pass the image fields alongside
prompt:image_url(string) — the source / first frame. Required by image-to-video models (*-image-to-video); the start frame for first-last-frame models. Accepts anhttps://URL or adata:URI.end_image_url(string) — the last frame, for first-last-frame interpolation models.
-
Reference conditioning (reference-to-video). A
*-reference-to-videomodel does not take a first frame — it takes a set of references it keeps consistent across the shot (a character, a product, a style). Reference them from the prompt where the model supports it.-
reference_image_urls(string[]) — reference images for character / style consistency. URLs ordata:URIs. -
reference_video_urls(string[]) — reference clips, to inherit subject motion and camera movement, or to be edited / extended / stitched. ≤50 MB each. -
reference_audio_urls(string[]) — voice / SFX donors. Must be paired with at least one reference image or video — audio-only is rejected upstream. ≤15 MB each. -
reference_video_total_duration(number, seconds) — the sum of every reference clip's length. Pass it on quote whenever reference clips are present, or the quote and the amountqueuecharges can differ. Required for a source-matchedduration. -
The counts and clip lengths are per FAMILY, and Venice publishes none of them — they come from its Seedance guide:
Seedance 2.0 (incl. -fast,-mini)Seedance 2.5 reference_image_urls1–9 1–30 reference_video_urls≤ 3 ≤ 10 reference_audio_urls≤ 3 ≤ 10 per reference clip [2, 15]s[2, 30]sall clips combined ≤ 15 s ≤ 30 s output duration 4–15 s 4–30 s Shared floors, published per model in
constraints: reference images need a short side ≥reference_image_min_short_side_pixels(300) and an aspect ratio strictly inside (reference_image_min_aspect_ratio,reference_image_max_aspect_ratio) —(0.4, 2.5). Video containers.mp4/.mov(H.264 / H.265); audio.wav/.mp3. -
elements(object[], max 4) — advanced element control (e.g. Kling O3 R2V):{ frontal_image_url, reference_image_urls (max 3), video_url }, addressed in the prompt as@Element1,@Element2… -
scene_image_urls(string[], max 4) — scene references, addressed in the prompt as@Image1,@Image2… -
Which of these a given model accepts is not published in the catalogue —
constraintscoversdurations/aspect_ratios/resolutions, not the conditioning fields.POST /v1/video/quoteis the authority for the rest: it validates the exact payload for free and names the field it rejects.
-
-
Seedance reference-to-video: one model, four workflows, routed by the PROMPT. There is no
taskorworkflowfield.seedance-*-reference-to-video-basicperforms four different jobs and infers which one from the shape of your prompt:Workflow What it does Prompt shape Reference a new video, using your files as donors for subject / motion / style / voice Refer to <Subject 1> in <Image 1> to generate …Edit change one thing in a clip; anything unnamed is preserved Strictly edit <Video 1>, changing its … to …Extend continue a clip forward or backward Extend <Video 1>, generate …Stitch join clips with a generated transition <Video 1> + [transition] + followed by <Video 2>The token syntax is canonical and case-sensitive — angle brackets, capital first letter, one space before the number:
<Image 1>,<Video 1>,<Audio 1>. Other reference families use a different spelling (@Element1on Kling O3,@Image1forscene_image_urls); on a Seedance prompt those are silently ignored rather than refused.The common failures are all misroutings, and none of them errors — you simply get a different job:
- wanting Extend but writing
Refer to …→ your clip is treated as a donor for a new video, not a canvas to continue; - wanting Stitch but writing
Refer to …→ the model picks one clip as the donor and ignores the rest; - wanting Edit but writing
Generate a video based on <Video 1>→ ambiguous; the model tends to default to Reference.
Two behaviours worth knowing: Extend returns only the new footage by default (say
start with <Video 1>, then …to keep the input), and Edit preserves everything you do not name.Layer the authoring formula on top of the prefix — Subject + Motion + Environment + Camera + Aesthetic + Audio — rather than replacing it.
- wanting Extend but writing
-
The body passes through verbatim; three values are checked first. The operator forwards your request body to the upstream model as-is — it does not strip or rename anything, and any field the model accepts (
seed,negative_prompt, motion/camera controls, …) reaches Venice unchanged. The one thing it does before forwarding is refuse aduration,aspect_ratioorresolutionthat the model's own publishedconstraintsexclude, because that request cannot render and would otherwise be discovered at retrieve. A model that publishes no constraints is never pre-checked, and neither is any other field. -
Request size: 35 MB.
/v1/video/quoteand/v1/video/queueaccept bodies up to 35 MB, matching the upstream — the rest of the API stays at 20 MB. Inlinedata:URIs for clips overshoot that fast (a 50 MB clip is ~67 MB of base64), so preferhttps://URLs for reference clips, and especially for a multi-clip stitch. Every reference field takes either form. -
Prompt length is per model.
constraints.prompt_character_limitcarries it — 3 500 on the Seedance Fast and 1.5-pro lines, 10 000 on Seedance 2.0 and-mini, 15 000 on Seedance 2.5, 4 096 on Grok Imagine. A prompt over that model's limit is refused here, before any hold, naming both numbers. -
Validate before queueing. Call
POST /v1/video/quotefirst with the exact payload — no charge, and a400names the field at fault. Cheaper than debugging inqueue(which commits a hold against your balance). -
Person-bearing media is not supported on the public Seedance tier. Venice's guide is explicit: the public
*-basicmodels — which is every Seedance id in the catalogue — do not run a consent flow at all. Media with recognisable people may be refused as a content-policy or provider error, and no attestation unlocks it. That refusal comes back asUNSUPPORTED_MEDIA, with the reason spelled out rather than left as upstream prose:{ "status": "failed", "code": "UNSUPPORTED_MEDIA", "error": "… — Person-bearing media is not supported on the public Seedance API, and no consent attestation unlocks it (see https://docs.venice.ai/guides/media/seedance-2-0). Use media without recognisable people." }Do not build an attestation flow for it. Use media without recognisable people, or the Venice app.
-
The consent gate, where an upstream still runs one. Outside the public tier a job carrying a human face can be refused until the submitter attests to the model's face-media policy. The refusal arrives on retrieve (the queue call returns immediately and the upstream call runs in the background), as
409 NEEDS_CONSENT, carrying the policy text to display:{ "error": "…", "code": "NEEDS_CONSENT", "consent_flow": "seedance", "face_media_roles": ["reference_image"], "consent": { "consent_version": "v2.0", "policy_text": "…" }, "docs_url": "https://docs.venice.ai/guides/media/seedance-face-consent" }Show
policy_textto the person submitting the media, then resubmit the identical job with their attestation:{ "consents": { "seedance": { "confirmed_terms_and_privacy": true, "confirmed_legal_right": true, "confirmed_screening_acknowledged": true } } }All three booleans must be
true, and do not echoconsent_version— it is server-set, and sending it back is a400. Nothing is charged for the refused job. Consent is deduped on the media's exact bytes: a re-encoded, resized or cropped image gates again.Send
consentsonly in answer to a gate that fired. Venice validates with a strict schema, so an attestation attached pre-emptively to a model that has no such key is a rejected request, not a harmless extra field. -
200: Venice's queue payload. The job id is in
id(current schema) orqueue_id(older shape) — use whichever is present when you call retrieve. Credit headers included. -
Errors:
402PAYMENT_REQUIRED ·400INVALID_MODEL / BAD_REQUEST (prompt missing, or longer than this model'sprompt_character_limit) ·400VIDEO_PARAM_REJECTED (unsupportedduration/aspect_ratio/resolution, refused before any hold is placed) ·413(body over 35 MB) ·503NO_PROVIDERS / INSUFFICIENT_PROVIDER_CAPACITY / TEE_NOT_READY ·502VENICE_ERROR -
Rejected parameters come back structured. A
VIDEO_PARAM_REJECTEDbody carries every offending field at once, with the values that model does accept — so a two-field mistake is one round trip, not two:{ "error": "This model does not accept these values — duration: \"7s\" …", "code": "VIDEO_PARAM_REJECTED", "model": "kling-2-5-turbo-pro-text-to-video", "details": { "issues": [ { "param": "duration", "value": "7s", "accepted": ["5s", "10s"] }, { "param": "aspect_ratio", "value": "21:9", "accepted": ["16:9", "9:16", "1:1"] } ] } }When the refusal comes from Venice rather than the pre-flight,
details.issuescarries its validator's own rows instead —{ "path", "message", "expected"? }, whereexpectedlists the accepted values when the validator names them. For a model the upstream serves without publishing constraints, that is the only source of its enums. A key the model does not recognise at all (Venice validates with a strict schema, soseedandcamera_fixedare refused on some models whileresolutionandnegative_promptare fine) arrives as one row per key, with the key name inpath.The same
details.issuesshape is used by/v1/video/quote,/v1/video/queue,/v1/video/retrieveand their x402 siblings, so a client parses one form regardless of which hop reported the problem. The Seedance consent gate is the one exception, and it predates this: its keys stay at the top level (seeNEEDS_CONSENTabove).
POST /v1/video/retrieve
Poll until the job completes. queue_id is required; model is optional — the job stores it at queue time, so a poll that omits it recovers it rather than being refused.
- Auth: API key or JWT — any key on the same account that queued
- Body:
{ "queue_id", "model"? } - 200: while running, the upstream status lower-cased (e.g.
{ "status": "processing" }/"queued"/ …); when done{ "status": "completed", "video_url": "/v1/video/file/<id>" } - 200 with
status: "failed"— the job died upstream (the render failed after being accepted). The envelope carries the reason:{ "status": "failed", "queue_id", "error", "code" }, plusdetails.issueswhen the upstream validator reported structured rows. This is terminal — stop polling. It is deliberately not a raw5xx: a5xxon a poll means the poll broke and should be retried; a dead job is an answer, not an outage. - Errors:
400BAD_REQUEST (missing or malformedqueue_id) ·404VIDEO_JOB_NOT_FOUND (unknown/expired — an unknownqueue_idanswers this whatevermodelsays) ·403FORBIDDEN (queue_id belongs to another account) ·409NEEDS_CONSENT (a gate to answer — see queue above) ·410VIDEO_KEY_REVOKED (upstream provider key revoked mid-job — re-queue) ·502VENICE_ERROR · plusstatus: "failed"withUNSUPPORTED_MEDIAfor media the public tier will not accept at all - Terminal reads are idempotent. A completed job stays readable for as long as its cached file lives (~10 min), and a failed one until its TTL — so a response lost in transit (proxy cut, tab reload) costs you a retry, not the result or the reason. Polling again after either answer returns the same answer.
- Note: the operator pins the job to the Venice key that created it and routes every retrieve back to it automatically — you only ever handle your
cdm_key.
GET /v1/video/file/:id
Download the finished video (cached on the operator, not base64).
- Auth: none — served by its opaque
id(returned invideo_url) - 200: the video file (
video/mp4) · 404 NOT_FOUND (expired/unknown)
7. Models & capacity
GET /models
Native catalog grouped by category (text / image / video / …), with tiers & capabilities.
- Auth: none · 200: catalog grouped by type
GET /v1/models
OpenAI-compatible flat model list (works with client.models.list()), enriched with carpe_diem_type, tier, privacy, capabilities, context_length, constraints, voices (TTS), pricing.
-
Auth: none · 200:
{ "object": "list", "data": [{ "id", "object": "model", "owned_by", "tier", "carpe_diem_type", … }] } -
constraints— what that model will accept, in its own words:durations,aspect_ratios,resolutions, and audio flags where the model has them. Build video pickers from this rather than a hard-coded union — the sets differ per model, and a value from the wrong model is a400. An empty array means the model takes no such parameter;nullmeans the model publishes nothing. Models the upstream serves without publishing are verified against its validator at every catalogue sync — their constraints are read live off its refusals, and a model the upstream stops serving drops out of the catalogue within the hour instead of failing at queue time. A model re-listed under a new id spelling is published once, under the id the upstream publishes.model_typeis stripped — that is our routing discriminator, not a parameter you send.Beyond the three enums,
constraintsalso carriesprompt_character_limit,audio_input(the model takes reference audio), and — on models that condition on images — the media floorsreference_image_min_short_side_pixels,reference_image_min_aspect_ratioandreference_image_max_aspect_ratio.Music models publish a different set, because what they constrain is different: lengths, lyrics, the instrumental flag, the prompt window, speed and the output container. See Music for the field-by-field meaning — the same rule applies, build the picker from the block rather than from a union.
{ "id": "kling-2-5-turbo-pro-text-to-video", "carpe_diem_type": "video", "constraints": { "durations": ["5s", "10s"], "aspect_ratios": ["16:9", "9:16", "1:1"], "resolutions": ["1080p", "720p"], "audio_configurable": false } } -
carpe_diem_type— the catalog discriminator that routes a model to its endpoint. Values:text | code | embedding | image | imageEdit | upscale | tts | asr | music | video | imageToVideo | referenceToVideo. Use this rather than parsing model names — names are not reliable indicators (venice-uncensored-role-playis atextmodel,topaz-video-upscaleis video upscaling, etc.). Mapping:carpe_diem_typeEndpoint text,code/v1/chat/completionsor/v1/messagesembedding/v1/embeddingsimage/v1/image/generate(sync) or/v1/image/generate/queue(async, heavy models)imageEdit/v1/image/edit(sync) or/v1/image/edit/queue(async, heavy models e.g. gpt-image-2-edit)upscale/v1/image/upscaletts/v1/audio/speechasr/v1/audio/transcriptionsmusic/v1/audio/music/queuevideo,imageToVideo,referenceToVideo/v1/video/queueSend a model to the wrong row and the endpoint tells you which row it is on, rather than reporting the model as gone:
{ "error": "This endpoint does not serve \"elevenlabs-music\" — it is a \"music\" model, served by POST /v1/audio/music/queue.", "code": "VENICE_ERROR" }The distinction is worth knowing, because the upstream cannot make it: it answers "model not found" both for a model it retired and for a model it serves elsewhere. When the catalogue still lists the model, you get the sentence above. When it does not — the model really is gone — you get "This model is no longer available upstream." So the two messages mean different things, and only the second one means stop.
-
voices— present onttsentries only, lists the model's acceptedvoiceenum (passed through from Venice'smodel_spec.voices). Empty array means Venice didn't publish a catalog for that model — omitvoiceand let the model use its default.
GET /pricing
Per-model pricing + fixed (per-image / per-video) costs.
- Auth: none · 200:
{ "models", "fixedCost", "updatedAt" }
GET /v1/capacity · ?model=<id>
Marketplace capacity snapshot — the numbers behind dynamic pricing.
- Auth: none (cached 10s)
- 200 (no model):
{ "headroomUsd", "activeKeyCount", "keyCount", "keysByState", "multiplier": { "demand", "markup", "total" }, "warmingUp", "diemUtilization": { "u", "totalCapDiem", "totalCurrentDiem", "totalConsumedDiem", "keyCount", "hasData" } } - 200 (
?model=): per-model view{ "active_providers", "total_providers", "available_rpm", "queue_depth", "health" }
GET /v1/limits/:model
Venice's per-model rate limits (RPM / TPM / RPD), aggregated across provider keys — to self-throttle before a 429.
- Auth: none (cached 30s)
- 200:
{ "model", "totals": { "rpm", "tpm", "rpd" }, "keys": [...], "coverage" }
GET /v1/rate_limits
Your request-rate limit at the operator (per-wallet throttle) — distinct from Venice's per-model limits above.
- Auth: API key or JWT
- 200:
{ "max", "windowMs", "keying", "remaining", "resetSeconds" }
8. Credits & billing
GET /v1/credits · GET /v1/billing/balance
Same payload — your live spendable balance, for agents to check before a call.
- Auth: API key or JWT
- 200:
{ "escrowUsdc", "pendingUsdc", "holdsUsdc", "availableUsdc", "escrowCredits", "pendingCredits", "holdsCredits", "availableCredits", "updatedAt" } escrow= purchased ·pending= spent, not yet settled ·holds= reserved for in-flight requests ·available= actually spendable. 1 credit = $0.01.- Errors:
503BALANCE_CHECK_FAILED (balance source unavailable)
GET /buyer/usage
Per-request usage history (buyer side), newest first, paginated.
- Auth: API key or JWT
- Query:
limit(1–100, default 20),offset - 200:
{ "events": [{ "id", "model", "provider", "prompt_tokens", "completion_tokens", "cost_usdc", "multiplier", "created_at" }], "total" }
GET /buyer/usage/summary
Aggregated by day and model.
- Auth: API key or JWT · Query:
days(1–90, default 7) - 200:
{ "byModel": [...], "byDay": [...], "totals": { "requests", "tokens", "cost" } }
GET /buyer/api-keys/usage
Usage broken down per API key.
- Auth: API key or JWT · Query:
days - 200:
{ "keys": [...], "windowDays" }
GET /buyer/debt
Pending (unsettled) debt for your wallet.
- Auth: API key or JWT
- 200:
{ "pendingUsdc", "pendingUsdcMicro", "pendingCredits" }
9. Deposits
GET /deposits/quote
Quote for buying credits with a non-USDC token — swaps token → USDC into the escrow in a single depositWithSwap tx. (Plain USDC deposits need no quote.)
- Auth: none
- Query:
token(ERC-20 address),amount(integer, raw token units) - 200:
{ "swapTarget", "approvalTarget", "swapData", "minUsdcOut", "estimatedUsdcOut" }— feed these into the escrow'sdepositWithSwap - Errors:
400missing/invalid token or amount ·502QUOTE_FAILED
Payment rails (prepaid · wallet-x402 · migration)
Three ways to pay for inference. Credits (section 9) are the classic pre-buy model. The two rails below let you pay per request by granting the escrow a USDC allowance — no pooled balance, fully non-custodial.
| Rail | Source of funds | Signing | Best for |
|---|---|---|---|
| Credits | pre-bought balance in the escrow | one deposit | anyone |
| Prepaid account | a dedicated account you own (smart account or EOA) | approve once | an isolated, withdrawable Carpe balance |
| Wallet (x402) | your EOA directly | permit per session | agents / pay-as-you-go |
Prepaid & wallet both use the escrow's allowance-pull (pullFromUser / pullWithPermit); the difference is only the source address. Contract: CarpeEscrow (see Contracts table).
Rail preference (credits vs prepaid)
Credits and prepaid use the same endpoints as the rest of this reference (sections 2–6) — the operator picks the rail server-side and bills automatically on every paid endpoint (chat, image, audio, video, embeddings, augment…). This preference lets you force one when you have both.
GET /v1/prepaid/rail
- Auth:
cdm_…or SIWE JWT - 200:
{ "rail": "auto|credits|prepaid", "hasPrepaidAccount": bool }—auto= prepaid if registered, else credits.
POST /v1/prepaid/rail
- Body:
{ "rail": "auto" | "credits" | "prepaid" }· 200:{ "ok": true, "rail" }
Prepaid account
Fund a dedicated account you own; the operator pulls the exact per-request cost. The account can be a user-owned ERC-4337 smart account (isolated + withdrawable) or simply your EOA.
POST /v1/prepaid/register
Link your buyer identity to the account the operator should pull from.
- Auth:
cdm_…or SIWE JWT · Body:{ "account": "0x…" }· 200:{ "ok": true, "buyer", "account" }
GET /v1/prepaid/status
- Auth:
cdm_…or SIWE JWT - 200:
{ "registered", "account", "usdcBalance", "escrowAllowance", "spentUsdc" }(on-chain reads, 6-dec strings)
Full API flow (no browser):
1. (optional) deploy/derive a smart account you own, OR use your EOA as `account`.
2. Fund `account` with USDC (a plain ERC-20 transfer).
3. From `account`, approve(escrow, cap) USDC ← the standing "prepaid authorization".
4. POST /v1/prepaid/register { account } ← tell the operator to pull from it.
5. Call /v1/chat/completions with your cdm_ key → billed to `account` per request.
6. Anytime: withdraw (transfer USDC out) or revoke (approve escrow 0). Non-custodial.
Wallet — x402 direct
Pay per request straight from your wallet. You sign ONE gasless EIP-2612 USDC permit authorizing the escrow up to a session budget; the operator serves the request and pulls the actual cost. No deposit, no gas on your side.
Coverage — every paid modality has an /v1/x402/… sibling (same permit flow; async ones mirror the queue/retrieve pattern of their credit counterpart):
| Modality | x402 endpoint(s) |
|---|---|
| Chat | /v1/x402/chat/completions |
| Embeddings | /v1/x402/embeddings |
| Image | /v1/x402/image/generate (+ /queue + /retrieve), /v1/x402/image/edit (+ /queue + /retrieve), /v1/x402/image/multi-edit (+ /queue + /retrieve), /v1/x402/image/upscale |
| Audio | /v1/x402/audio/speech, /v1/x402/audio/music/queue (+ /retrieve), /v1/x402/audio/transcriptions (multipart) |
| Video | /v1/x402/video/queue (+ /retrieve) |
| Web tools | /v1/x402/augment/search, /v1/x402/augment/scrape |
The request/response body of each x402 endpoint is identical to its credit sibling in sections 2–6 — only the auth (a permit instead of a key) and the X-Payment-Tx / X-Payment-Amount-Usdc response headers differ.
/v1/x402/video/queue runs the same parameter pre-flight as its credit sibling, and runs it before the 402 challenge: a value the model's published constraints exclude comes back as 400 VIDEO_PARAM_REJECTED with no payment ever requested, rather than after you have signed a permit for a render that cannot happen.
The permit flow (identical for every endpoint above)
- Auth: none — the signed permit is the payment + identity.
- No
X-PAYMENTheader → 402 with the challenge:{ "accepts": [{ "scheme": "eip2612-permit", "payTo": "<escrow>", "asset": "<usdc>", "maxAmountRequired": "<micros>", "extra": { "name": "USD Coin", "version": "2" } }] } - Sign an EIP-2612 permit (
owner=you,spender=payTo,value=session budget in µUSDC,nonce=USDCnonces(owner),deadline), then resend with:X-PAYMENT: base64( { "payload": { owner, spender, value, nonce, deadline, signature } } ) - 200: the completion; response header
X-Payment-Txcarries the on-chain pull tx once settled. Reuse the same permit across the session until the budget is spent or the deadline passes; a fresh nonce is needed after that. - 402
X402_INVALID(bad/expired permit) · 502 upstream error (no charge).
Credit migration (credits → your prepaid account)
One-time: move your remaining credit balance OUT of the pool into your dedicated prepaid smart account (self-custodial, withdrawable). The destination is prepaid-only — migration can never cash out to a raw wallet/EOA (a direct credit→wallet payout would make credits look like a redeemable deposit). You sign; the operator relays escrow.migrateWithAuth.
GET /v1/credits/migrate/quote
- Auth:
cdm_…or SIWE JWT - 200:
{ "migratableMicros", "migratableUsdc", "enabled", "alreadyMigratedUsdc", "prepaidAccount", "eip712": { domain, primaryType: "MigrateCredits", types } }—prepaidAccountis the only valid destination (your smart account, derived server-side from your wallet). Sign against exactly this.
POST /v1/credits/migrate
- Auth:
cdm_…or SIWE JWT - Body:
{ "account", "amount", "deadline", "nonce", "v", "r", "s" }— sign theMigrateCredits(user, account, amount, deadline, nonce)typed data from the quote.accountmust equal the quote'sprepaidAccount. - 200:
{ "ok": true, "txHash", "account", "migratedUsdc" }— the signature bindsaccount+amount, so the operator can only relay, never redirect. Credits are debited only on a confirmed on-chain pull (refunded if it reverts). - 400
MIGRATE_DEST_NOT_PREPAID(destination isn't your prepaid account) · 409INSUFFICIENT_CREDITS(balance changed — re-quote) · 503MIGRATION_DISABLED/DEST_VERIFY_UNAVAILABLE(couldn't verify destination — retry).
Best-price router (aggregator)
Live, open to every wallet, across three markets: Carpe (its own TEE network), Surplus (x402) and AntSeed (P2P).
Point your SDK at …/api/operator/router instead of …/api/operator/v1 and every request is priced across the three markets and served by whichever is genuinely cheapest — you're billed that price, plus a routing fee on external routes only.
It is a real best-price router, not just an overflow valve: an external market wins whenever it beats Carpe on price, not only under scarcity. Carpe still wins often (its dynamic price floor is aggressive, and it's the only market for some models). Whoever wins, you get the actual result back — never a "call this other endpoint" envelope.
Guarantees:
- A request never fails because an external market does — it falls back to Carpe transparently, and you're never double-charged.
- The routing fee is already inside the compared price, so an external market is only picked if it still wins after the fee. It is currently 20 % of the external cost — read the live value from
externalFeePctinGET /router/marketsrather than hard-coding it. - Every modality is routed: chat (OpenAI and Anthropic shapes), completions, embeddings, image generation/editing, text-to-speech, transcription, video and music. Carpe-native image operations (multi-edit, upscale) and web search are served but not arbitrated — no external market offers them today.
Where the fee goes. Half of it is reserved on-chain for the providers the router routed around — those who ended the day with staked DIEM left idle. Every external route puts that share into the escrow's rebatePoolUsdc automatically, so it is a reserve you can read off Base rather than a promise; the rest is treasury margin. Nothing is added to a Carpe-served request: a request the house wins carries no routing fee at all.
The pool is distributed pro-rata to idle capacity. The distribution is a separate switch from the reserve — check rebate.paying in GET /stats/router before treating the pool as paid income.
Live figures — the fee rate, what it collected, what was earmarked for providers and how much of that has actually been distributed — are public on /stats and in the fees block of GET /stats/router.
/router is a complete base URL
Every endpoint on this page answers under /router, not just the arbitrated ones. /router/models, /router/credits, /router/capacity, /router/prepaid/*, /router/augment/search, /router/image/generate and the rest all work — identical auth, body and response to their /v1 twin, because they are the same handler. You never need to juggle two prefixes: point your SDK at /router and everything resolves.
Two distinct things follow, and it's worth keeping them apart:
- Availability — the endpoint answers under
/router. True for all of them. - Routing — the endpoint prices the request across markets and serves the cheapest. True only for those in the mapping below.
Anything outside that mapping is available but not arbitrated: it is served by Carpe, at Carpe's price. That is usually because no external market sells it — listing models or reading your credit balance has nothing to compare, and no market currently offers web search or image upscaling.
Path mapping
These are the endpoints that genuinely arbitrate between markets. The router mirrors OpenAI's paths, so an SDK pointed at the router base URL just works. Note images: the arbitrated route uses OpenAI's names, /v1 uses Carpe's.
| Capability | Router (best-price) | Carpe-only |
|---|---|---|
| Chat | /router/chat/completions | /v1/chat/completions |
| Messages (Anthropic) | /router/messages | /v1/messages |
| Completions (legacy) | /router/completions | — |
| Embeddings | /router/embeddings | /v1/embeddings |
| Text-to-speech | /router/audio/speech | /v1/audio/speech |
| Transcription (ASR) | /router/audio/transcriptions | /v1/audio/transcriptions |
| Image generation | /router/images/generations (→ /router/images/retrieve if queued) | /v1/image/generate |
| Image editing | /router/images/edits (→ /router/images/retrieve if queued) | /v1/image/edit |
| Video (async) | /router/video/generations → /router/video/retrieve | /v1/video/queue → /v1/video/retrieve |
| Music (async) | /router/audio/music/queue → /router/audio/music/retrieve | /v1/audio/music/queue → /v1/audio/music/retrieve |
curl https://carpe-diem.xyz/api/operator/router/chat/completions \
-H "Authorization: Bearer cdm_your_key" \
-H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"Hello!"}]}'
With an OpenAI SDK, the base URL is the only line that changes — the SDK calls
{base}/chat/completions either way, so it reaches the router without knowing it.
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://carpe-diem.xyz/api/operator/router", // /v1 → /router
apiKey: "cdm_your_key",
});
const r = await client.chat.completions.create({
model: "llama-3.3-70b",
messages: [{ role: "user", content: "Hello!" }],
});
from openai import OpenAI
client = OpenAI(
base_url="https://carpe-diem.xyz/api/operator/router", # /v1 → /router
api_key="cdm_your_key",
)
r = client.chat.completions.create(
model="llama-3.3-70b",
messages=[{"role": "user", "content": "Hello!"}],
)
The same swap works in any tool that takes an OpenAI base URL (Cursor, Cline, LangChain, LlamaIndex…).
GET /router/markets
- Auth: none, but send your key or JWT anyway — it is what resolves
execute.walletAllowlisted. - 200:
{ "enabled", "externalFeePct", "surplusFlatUsd", "markets": ["carpe","surplus",…], "antseedCoolingOff", "surplus": { "resolved", "parsed", "lastRefreshMs" }, "antseed": { "models", "lastRefreshMs" }, "openrouter": { "models", "lastRefreshMs" }, "chutes": { "models", "lastRefreshMs" }, "execute": { "enabled", "walletAllowlisted", "maxBudgetUsd", "floatAllowsExternal" } }. markets[]is the only statement of membership — a market absent from it is switched off, and the per-market blocks carry catalogue size and freshness, not an on/off flag.executeis the gate between aforceMarketpin and an actual external purchase. All ofenabled,walletAllowlistedandfloatAllowsExternalmust be true, or the router serves Carpe and your pin is ignored.walletAllowlisteddescribes you, not the list, and isnullon an anonymous read.
POST /router/quote
- Auth: none (read-only, ZERO money). Body:
{ "model", "messages", "max_tokens" }(an OpenAI-shaped chat body — only the token counts are used). - 200:
{ "model", "tokens": {input,output}, "veniceCostUsd", "carpeCostUsd", "best": {market,costUsd,available}, "candidates": [ {market,costUsd,available,…} ], "savedVsCarpeUsdc", "savedPct" }. Compare Carpe vs each external market for this request, before spending anything.
curl -X POST https://carpe-diem.xyz/api/operator/router/quote \
-H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b","max_tokens":2000,"input_tokens":1000}'
{
"model": "llama-3.3-70b",
"tokens": { "input": 1000, "output": 2000 },
"veniceCostUsd": 0.002,
"carpeCostUsd": 0.000991,
"best": { "market": "antseed", "costUsd": 0.000155, "available": true },
"candidates": [
{ "market": "carpe", "costUsd": 0.000991, "available": true, "deliverability": "internal" },
{ "market": "surplus", "costUsd": 0.009765, "available": true, "deliverability": "quoted" },
{ "market": "antseed", "costUsd": 0.000155, "available": true, "deliverability": "quoted" },
{ "market": "openrouter", "costUsd": null, "available": false, "note": "…" },
{ "market": "chutes", "costUsd": 0.000210, "available": true, "deliverability": "quoted" }
],
"savedVsCarpeUsdc": 0.000836,
"savedPct": 84.4
}
Figures are illustrative — live prices move with each market. veniceCostUsd is the underlying provider's list price — the retail reference. carpeCostUsd is what Carpe would bill (list price × its dynamic multiplier, usually a discount). External figures already include the routing fee.
candidates always carries every market, so an entry is not evidence the market is on: available: false means "no price for this request", and markets[] in GET /router/markets is what says whether it is switched on at all. deliverability grades the price — internal (ours), quoted (derived from a real quote), unverified (indicative until a paid request confirms delivery).
input_tokens is worth setting explicitly (it defaults to 1000). Surplus is priced as a fraction of the provider's blended rate while AntSeed and OpenRouter bill their own per-Mtok input and output rates — so the input:output ratio, not just the total size, is what decides which market wins.
POST /router/chat/completions
- Auth:
cdm_…key or SIWE JWT (same as/v1/chat/completions). Body is the standard OpenAI chat body. - Routes to the cheapest deliverable market, executes there, and bills you: external → actual external cost × (1 +
externalFeePct); Carpe → normal token pricing. The response body is the OpenAI completion; routing is reported in headers:
| Header | Meaning |
|---|---|
X-Carpe-Route-Market | carpe | surplus | antseed | openrouter | chutes — who actually served |
X-Carpe-Route-Cost-Usdc | what you were billed for this call |
X-Carpe-Route-Saved-Usdc | savings vs the Carpe price (can be negative if a market was forced) |
X-Carpe-Route-Reason | why this market served — the cascade's own words (carpe no capacity → surplus fallback, router float below floor — external routing paused, …) |
X-Carpe-Route-Fallback | present when the cascade fell through: an earlier, cheaper market could not serve. Its value repeats the reason above |
X-Carpe-Route-Sticky | affinity when the conversation was pinned to its incumbent market (cache/privacy) |
- Optional
"forceMarket": "carpe"|"surplus"|"antseed"|"openrouter"|"chutes"— admin/debug only: forces a market instead of the cheapest pick (honored only for allowlisted wallets with execution enabled; ignored otherwise). Lets you exercise external routing without waiting for scarcity. The per-request budget cap and the Carpe fallback still apply. When a pin is ignored,X-Carpe-Route-ReasonandGET /router/markets→executesay which switch was closed.
The other routed endpoints
All take the same auth as their /v1 twin, the same request body, and return the same response — plus the X-Carpe-Route-* headers above. AntSeed is text-only, so media requests compare Carpe vs Surplus.
Images are the one place where the shape of the reply depends on who serves: the heavy generators (nano-banana*, gpt-image*, flux-2*, recraft*, qwen-image*) and every edit model cannot be served synchronously on Carpe, so a Carpe win on one of them comes back as a queue_id to poll on /router/images/retrieve. An external market answers inline. Branch on the presence of queue_id, not on the model.
| Endpoint | Notes |
|---|---|
POST /router/messages | Anthropic Messages shape (Claude Code, Cursor, Cline) — same market arbitration as /router/chat/completions, including streaming. |
POST /router/completions | Legacy (non-chat) completions. |
POST /router/embeddings | Returns the embedding vectors. |
POST /router/audio/speech | Returns the audio binary (mp3 by default). |
POST /router/audio/transcriptions | multipart/form-data in, transcription out. |
POST /router/images/generations | OpenAI-shaped body; returns { "images": [...] } — or, for a queue-only model served on Carpe, 202 { "queue_id", "market": "carpe", "status": "pending" }. |
POST /router/images/edits | Same, with the source image. Every edit model is queue-only, so a Carpe-served edit always answers 202. |
POST /router/images/retrieve | Body { "queue_id" } → 200 JSON while pending, the image binary once ready. |
POST /router/video/generations | Async → { "queue_id", "market", "status" }. |
POST /router/video/retrieve | Body { "queue_id" } → the MP4 once ready. |
POST /router/audio/music/queue | Async → { "queue_id", … }. |
POST /router/audio/music/retrieve | Body { "queue_id" } → the audio once ready. |
GET/POST /router/rail is an alias of /v1/prepaid/rail. It sets which rail your account pays from — credits or prepaid — so it applies to /v1/* and /router/* alike; it is a setting, not a routed request.
GET /stats/router
- Auth: none. Public best-price report: how many requests each market won, and how much routing saved versus serving everything on Carpe or buying direct from the underlying provider.
?range=24h|7d|30d. fees— the economics of routing, so no client has to hard-code them:{ "externalFeePct", "chargedUsd", "rebate": { "pct", "armed", "paying", "pooledUsd", "paidUsd", "providers", "days" } }externalFeePct— the live rate, applied to external routes only. It is configuration, not a constant: do not hard-code it.chargedUsd— what the fee actually collected over the window.rebate.poolOnChainUsd— the reserve as the escrow contract holds it (rebatePoolUsdc), withrebate.escrowso you can verify it on Base. This is the authoritative figure: it accrues automatically on every external route, independently of anything this operator computes.nullmeans unknown (older escrow, or the RPC was unreachable) — never treat it as0.rebate.pooledUsd/rebate.paidUsd— the operator's own daily computation: what was split among providers, and how much of it has actually been distributed.armedvspaying.armed: the daily split is computed and recorded.paying: it is distributed on-chain. Whenarmed && !payingthe reserve is real and growing but has not been handed out —paidUsdis0, and the pool must not be presented as provider income.
10. Provider API
For DIEM holders who provision a Venice key (see Guide §6).
POST /tee/provision
Provision (or re-provision) your Venice key into the TEE.
- Auth: wallet signature —
signaturemust recover to your provider wallet (the operator never sees the private key) - Body:
{ "apiKey", "message", "signature", "persist": true }apiKey— your Venice inference key (must matchVENICE_INFERENCE_KEY_<40-48 chars>, ≤ 200 chars).message— the human-readable string you signed. The server only requires that it contain a lineTimestamp: <ms-since-epoch>(rejected if older than 5 min). Recommended canonical format (matches the dashboard):Provision Venice API key Wallet: 0x<your-wallet> Timestamp: 1748345678901signature—personal_sign(EIP-191) overmessage. The recovered address becomes the provider wallet.persist(optional, default true) — set tofalsefor an ephemeral key (RAM-only, lost on operator restart).
- 200:
{ "status": "Provisioned", "provider": "0x…", "keyId": "<16-hex>", "walletKeyCount", "totalProviders", "totalKeys", "persist" } - Errors:
400missing/invalid input ·403INVALID_SIGNATURE·400REPLAY_REJECTED(timestamp expired/missing) ·400INVALID_KEY_FORMAT·409DUPLICATE_KEY·409MAX_KEYS_REACHED
GET /tee/status
TEE security status + provisioned providers (public — transparency).
- Auth: none
- 200:
{ "status", "encryption", "memoryProtection", "providerCount", "keyCount", "persistCount", "ramOnlyCount", "backup", "providers": [...], "wallets": [...] }
GET /tee/providers
List provider addresses.
- Auth: none
GET /attestation
Hardware attestation of the running TEE (verifiable proof of the code in the enclave).
- Auth: none
DELETE /tee/providers/:address · DELETE /tee/providers/:address/keys/:keyId
Revoke a whole provider, or a single key.
- Auth: admin JWT or a wallet signature matching
:address - 200: revoked — the key(s) stop receiving traffic immediately
GET /provider/stats
Request counts + earnings. Public returns aggregate only; authenticated returns your own; admin sees all + per-provider.
- Auth: optional (more detail when authenticated)
GET /provider/usage
Per-request log of what your key served, with timestamps (see Guide §6.4).
- Auth: API key or JWT (your own); admin can target any via
?provider=0x… - Query:
limit(1–100, default 20),offset - 200:
{ "events": [{ "id", "model", "prompt_tokens", "completion_tokens", "cost_usdc", "multiplier", "created_at" }], "total" }
GET /provider/usage/summary
Aggregated by day and model.
- Auth: your own; admin via
?provider=0x…or?all=1· Query:days(1–90, default 7) - 200:
{ "byModel", "byDay", "totals" }
GET /providers/:wallet/yield
The provider's yields and their exact decomposition, for the earnings dashboard.
- Auth: the wallet itself (or admin)
- 200:
{ "apr24h", "apr7d", "aprOnUsed7d", "utilization7dPct", "rewards24hDiem", "consumed24hDiem", "rewards7dDiem", "consumed7dDiem", "weightedAvgAvailable7dDiem", "currentDailyAllowanceDiem", "warmupRemainingHours", "asOf" } - Notes: the 7-day figures satisfy the exact identity
apr7d = aprOnUsed7d × utilization7d— the yield on deployed capital is the yield on used capacity, scaled by the share of capacity that was actually used. The gap between the two rates is idle capacity, never fees (every numerator is already net of fees).currentDailyAllowanceDiemis the wallet's live staked-DIEM capacity (= $/day) with no warm-up;apr7dstill needs 48 h of balance snapshots (warmupRemainingHourscounts down).
GET /providers/:wallet/rewards/daily
Per-UTC-day breakdown of consumption vs DIEM reward, with APR and the gross-fee waterfall — powers the provider Revenue dashboard.
- Auth: the wallet itself (or admin)
- Query:
days(1–90, default 30) - 200:
{ "wallet", "days", "rows": [{ "day", "consumedUsd", "grossUsd", "surchargeUsd", "rewardDiem", "rewardUsd", "aprPct", "consumedUnavailable" }], "totals": { "consumedUsd", "grossUsd", "surchargeUsd", "rewardDiem", "rewardUsd", "aprPct", "consumedDataFrom" }, "markupRate" } - Notes:
aprPct = rewardDiem / consumedUsd × 365 × 100(DIEM-price-independent).consumedUnavailable: truemarks days with reward but no consumption history (data predates the local ledger); the totalaprPctis aligned to days that have consumption. - The fee waterfall:
grossUsdis what buyers paid for the requests your keys served (Venice cost × dynamic multiplier);surchargeUsdis the protocol-surcharge slice inside it (markupRate× Venice cost, skimmed before the provider/protocol split). Your net share is(grossUsd − surchargeUsd) × providerBps/10000, withproviderBpson the escrow contract — which is why the flat "65%" never applies to the gross: measured againstgrossUsdthe provider's share floats between ~43% (pricing floor) and ~62% (cap). - Idle-capacity rebate: providers that end a day with spare DIEM headroom share a pool funded from the best-price router's external-routing fees, pro-rata to that headroom. The pool is reserved inside the escrow contract on every external route — see
rebate.poolOnChainUsdinGET /stats/routerto read it off Base. SeeGET /providers/:wallet/idle-rebatefor your own share — and read itsdistributedflag: the reserve is real, but it is only credited to your claimable DIEM once the daily distribution is switched on. Until then it is an accrual, not a balance.
GET /providers/:wallet/idle-rebate
Your share of the best-price router's idle-capacity rebate, per UTC day.
- Auth: the wallet itself (or admin)
- Query:
days(1–90, default 30) - 200:
{ "wallet", "days", "feePct", "rebatePct", "armed", "distributed", "rows": [{ "day", "idleUsd", "sharePct", "rebateUsd", "rebateDiem", "poolUsd", "paid" }], "totals": { "rebateUsd", "rebateDiem", "paidUsd", "paidDiem" } } - How the pool is funded: external routes carry a routing fee (
feePct, live inGET /router/markets);rebatePctof that fee is set aside for providers whose DIEM sat idle that day. No external routing on a given day → no fee → no pool. armedvsdistributed— read both.armedmeans the daily split is being computed and recorded.distributedmeans it is actually being paid on-chain. They are separate flags and the second one can be false while the first is true, in which caserebateDiemis an accrual held by the treasury, not DIEM you can claim.paidper row andpaidUsd/paidDiemin the totals tell you exactly how much has settled.
11. Errors & retries
All errors return JSON: { "error": "<message>", "code": "<CODE>" }. Some add fields — e.g. 402 includes credits_available / credits_required; rate-limit errors include a reset hint.
| HTTP | code | Meaning | Retry? |
|---|---|---|---|
| 400 | BAD_REQUEST · INVALID_MODEL · MODEL_ERROR | Malformed request / unknown model / bad params | No — fix it |
| 401 | AUTH_REQUIRED · AUTH_FAILED · TOKEN_EXPIRED | Missing/invalid key or expired session | No — re-auth |
| 402 | PAYMENT_REQUIRED | Insufficient credits | No — buy credits |
| 403 | OFAC_BLOCKED · FORBIDDEN · JWT_REQUIRED | Not allowed for this caller | No |
| 404 | NOT_FOUND · VIDEO_JOB_NOT_FOUND | Unknown / expired resource | No |
| 409 | NEEDS_CONSENT | Face-bearing media awaiting the submitter's attestation | Yes — resubmit with consents |
| 410 | VIDEO_KEY_REVOKED | Provider key revoked mid-job | Re-queue |
| 413 | PAYLOAD_TOO_LARGE | Request body too large | No |
| 429 | ENDPOINT_RATE_LIMITED · UPSTREAM_RATE_LIMIT | Operator throttle / Venice 429 | Yes — back off |
| 451 | — | Geo-restricted region | No |
| 502 | VENICE_ERROR · QUOTE_FAILED | Upstream Venice / quote failure | Yes — transient |
| 503 | NO_PROVIDERS · NO_PROVIDER_CAPACITY · INSUFFICIENT_PROVIDER_CAPACITY · MODEL_INFRA_SATURATED · TEE_NOT_READY · BALANCE_CHECK_FAILED | No capacity / warming up | Yes — transient |
Retry strategy
- 429 — back off and retry; respect the reset hint (or
GET /v1/rate_limits). - 502 / 503 — transient (Venice, provider, or RPC); retry with exponential backoff (e.g. 1s → 2s → 4s, a few attempts).
- 402 — stop and top up credits.
- Other 4xx (400 / 401 / 403 / 404 / 410 / 413) — fatal; fix the request, don't retry blindly.
12. Health
GET /health
Service health for monitoring / uptime checks.
- Auth: none
- 200:
{ "status": "healthy" | "degraded", "components": { "database", "settlement", "providers", "creditsBootstrap", "settlementFreshness" } }— each component is{ "status", "detail"? }. Returnsdegraded(still HTTP 200) if any component is degraded.
13. On-chain verification
The operator publishes per-user weekly snapshots of points and USDC credits on-chain via OperatorSnapshotRegistry so the full state is reconstructible from Base alone, without trusting the operator's database. Every Friday after the weekly points snapshot, the operator emits one event per active user for each topic, locked one-shot per (weekNumber, topic).
- Contract —
0x23417CF66DF7cde16bE929576f6246cCa180b05Fon Base (deploy block 46,499,216) - Events:
PointsSnapshot(address indexed user, uint64 indexed weekNumber, uint256 pointsEarnedMicros, uint256 totalPointsMicros)CreditsSnapshot(address indexed user, uint64 indexed weekNumber, uint256 balanceUsdcMicros)
- Scaling — all amounts are
uint256micros (1e-6 units), matching USDC's 6-decimal convention. Divide by 1e6 to recover the human-readable value (e.g.19_204_567_000= 19,204.567 points;5_000_000= $5.00).
GET /snapshot/status
Public summary of which weeks have been published.
- Auth: none
- 200:
{ "currentWeek", "registry", "pointsPublished": [<weeks>], "creditsPublished": [<weeks>] }
Reconstruct your data yourself
Scan the events directly via your favorite Base RPC (mainnet.base.org) with eth_getLogs filtered by the contract address and an address topic (your wallet). The reference CLI is at operator/scripts/reconstruct-from-chain.ts — it produces a JSON dump re-injectable into an empty SQLite if you ever need to verify the full state without going through the operator API.