● Documentation

Carpe Diem — API Reference

Complete endpoint reference: authentication, chat, images, audio, video, embeddings, credits, and provider endpoints.

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 → /router is 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.

ContractAddressVerify
CarpeEscrow (UUPS proxy)0x15917768b31CB1DC61d9d858f2419BF45044005dBaseScan · Sourcify ✓
↳ Implementation0xA67327207219d84973D0d5B988e902B8e5f8945CBaseScan
USDC0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913BaseScan
DIEM (Venice)0xF4d97F2da56e8c3098f3a8D538DB630A2606a024BaseScan

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: 400 bad input · 401 AUTH_FAILED (signature mismatch / expired message) · 403 OFAC_BLOCKED

Email accounts (no wallet)

An email account has no wallet: it gets a derived address with no private key, which takes the wallet slot everywhere (credits, API keys, usage). Its JWT carries authType: "email". Without a private key nothing can be signed, so card-bought credits stay closed usage credits.

Sign-in sends one mail with a 6-digit code (to type in) and a sign-in link (fallback, same power). Using one closes the other.

POST /auth/email/start

  • Auth: none · Rate limit: 5/min per IP, and per address 1 mail/min and 10 mails/hour
  • Body: { "email": "you@example.com" }
  • 200: always { "ok": true } — whether or not the account exists, and also when the per-address limit is reached (then nothing is sent).
  • Code: 6 digits, valid 10 min, single use, 5 wrong attempts then closed. A new code replaces the previous one.
  • Link: {APP_BASE_URL}/auth/verify?token=<64 hex>, valid 15 min, single use.
  • Errors: 400 BAD_REQUEST (invalid email)

POST /auth/email/verify-code

  • Auth: none · Rate limit: 10/min per IP
  • Body: { "email": "you@example.com", "code": "123456", "deviceLabel"?: "Firefox on Linux" } — spaces and dashes in the code are ignored; deviceLabel (≤ 64 chars) labels the session.
  • 200: { "token": "<jwt>", "refreshToken": "<opaque>", "refreshExpiresAt": "<ISO>", "wallet": "0x…", "email", "expiresIn": "30m" } — the account is created on first sign-in.
  • Errors: 400 BAD_REQUEST (malformed email or code; not counted as an attempt) · 400 CODE_MISMATCH { "attemptsLeft": 0–4 } · 410 CODE_EXPIRED (expired, used, replaced, or no code pending for this address) · 429 TOO_MANY_ATTEMPTS (5 wrong attempts: request a new code) · 429 CODE_LOCKED { "retryAfterSeconds" } (20 wrong codes for this address in 24 hours: codes are refused, the link in the email still works)

POST /auth/email/verify

The link from the mail (still accepted for mails already sent).

  • Auth: none · Rate limit: 20/min per IP
  • Body: { "token": "<64 hex>", "deviceLabel"?: "…" }
  • 200: same as /auth/email/verify-code
  • 401: AUTH_FAILED (unknown, expired or used link)

POST /auth/refresh

Two forms.

Email session — refresh token (body wins over any Authorization header):

  • Body: { "refreshToken": "<opaque>" }
  • 200: { "token": "<jwt>", "refreshToken": "<new opaque>", "refreshExpiresAt": "<ISO>", "wallet", "expiresIn": "30m" }
  • Every call rotates the refresh token: store the new one, the old one is spent. The session lasts 30 days from sign-in; rotation never extends it.
  • Reuse detection: presenting a refresh token that was already exchanged revokes the whole session (every token derived from the same sign-in) → 401 REFRESH_REUSED. Clients must serialise refreshes (one tab at a time).
  • 401: REFRESH_REUSED · REFRESH_EXPIRED (30 days elapsed: sign in again) · REFRESH_INVALID (unknown, revoked or malformed)

Wallet session (SIWE) — still-valid JWT:

  • Auth: Bearer JWT
  • 200: { "token", "wallet", "expiresIn" }
  • 401: TOKEN_EXPIRED → re-authenticate. Sessions are capped at 8 hours from the original sign-in. · REFRESH_TOKEN_REQUIRED: an email session renews only with its refresh token (so signing out of all devices ends a stolen token within 30 minutes).

POST /auth/logout

Ends one email session (revokes its refresh token and every rotation of it).

  • Auth: none · Body: { "refreshToken": "<opaque>" }
  • 200: { "ok": true } — also for an unknown token

POST /auth/logout-all

Ends every email session of the account. JWTs already issued stay valid until they expire (30 min at most).

  • Auth: Bearer JWT (not an API key)
  • 200: { "ok": true, "revokedSessions": <n> }
  • Errors: 401 AUTH_REQUIRED · 403 JWT_REQUIRED

Re-verification (email sessions)

An email session must re-verify with a fresh code before creating or revoking an API key, or deleting the account. Wallet sessions (SIWE) and API keys are unaffected: the wallet signature already proves control.

POST /auth/step-up/start

  • Auth: Bearer JWT of an email session · Rate limit: 5/min per IP, and per address 1 mail/min and 10 mails/hour
  • 200: { "ok": true, "emailHint": "y***@example.com", "expiresAt": "<ISO>" } — the code goes to the account's own address.
  • Errors: 400 STEP_UP_NOT_APPLICABLE (wallet session) · 401 AUTH_REQUIRED · 403 JWT_REQUIRED (API key) · 429 CODE_THROTTLED { "retryAfter": <seconds> }

POST /auth/step-up/verify

  • Auth: Bearer JWT of an email session · Rate limit: 10/min per IP
  • Body: { "code": "123456" }
  • 200: { "token": "<short jwt>", "wallet", "expiresIn": "10m", "stepUpExpiresAt": "<ISO>" } — use this token for the sensitive call(s) within 10 minutes. Refreshing it (either form of /auth/refresh) gives an ordinary session token without the re-verification.
  • Errors: same as /auth/email/verify-code (CODE_MISMATCH with attemptsLeft, CODE_EXPIRED, TOO_MANY_ATTEMPTS, CODE_LOCKED), plus those of /auth/step-up/start. A sign-in code is never accepted here.

POST /auth/api-keys

Create a persistent API key. JWT only (an API key can't mint keys).

  • Auth: Bearer JWT — for an email session, the short token from /auth/step-up/verify
  • Body: { "name": "optional label" }
  • 201: { "id", "key": "cdm_…", "prefix": "cdm_xxxxxxxx…" } — full key shown once.
  • 403: STEP_UP_REQUIRED (email session without a re-verification less than 10 minutes old) · JWT_REQUIRED

GET /auth/api-keys

List your keys (no secrets).

  • Auth: Bearer JWT
  • 200: { "keys": [{ "id", "name", "prefix", "createdAt", "revokedAt", "expiresAt" }] } — expiresAt (ISO) is set only on the browser-device keys a partner obtains (7-day life); once past, the key is refused even if revokedAt is null.

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 — for an email session, the short token from /auth/step-up/verify
  • 200: { "status": "revoked", "id" } · 404 if not found · 403 STEP_UP_REQUIRED (email session)

Account deletion

Delete your account yourself, at once. JWT only (an API key can't delete the account).

GET /me/account/deletion

What a deletion would do, to show before confirming.

  • Auth: Bearer JWT · Rate limit: 30/min per IP
  • 200: { "method": "email_code" | "wallet_signature", "email": "a***@example.com" | null, "balanceCredits", "balanceUsd", "activeApiKeys", "partnerDevices", "provider", "requestsInFlight", "deletionPhrase", "domain", "chainId" } — provider: true means the deletion is refused until the wallet's Venice keys are removed; requestsInFlight: true, until the running requests finish.

POST /me/account/delete

  • Auth: Bearer JWT — for an email session, the short token from /auth/step-up/verify · Rate limit: 5/min per IP
  • Body: wallet session: { "message", "signature" }, where message is exactly Delete my Carpe Diem account\nDomain: <domain>\nChain ID: <chainId>\nWallet: <your address, lowercase>\nTimestamp: <ms epoch> (domain and chainId as returned by GET /me/account/deletion, e.g. carpe-diem.xyz and 8453; nothing before, after or in between), signed (personal_sign) by the session's wallet less than 5 minutes ago. Email session: {}.
  • 200: { "ok": true, "closedAt": "<ISO>", "forfeitedCredits", "revokedApiKeys", "revokedPartnerKeys", "revokedSessions" }
  • Effect: every API key and session is revoked, including session tokens already issued and the keys a partner app obtained for your devices; the email address and partner links are erased; the remaining credits are forfeited; points and referrals are erased (an invitation the account accepted stays, without its address; points already published on-chain stay there). Signing in again with the same wallet or email opens a new, empty account: purchases, usage history and summaries, routing summaries and drains, API keys and the ledger show nothing from before the deletion.
  • Errors: 400 SIGNATURE_REQUIRED · MESSAGE_INVALID · MESSAGE_EXPIRED · 401 AUTH_REQUIRED · SIGNATURE_INVALID · 403 JWT_REQUIRED · STEP_UP_REQUIRED (email session) · 409 PROVIDER_ACCOUNT (remove your Venice keys on Provide first) · REQUESTS_IN_FLIGHT (a request is still running, such as a video or music generation: finish or cancel it, then try again; checked before the code or the signature) · 503 TEE_NOT_READY (the key vault is still loading after a restart, so a provider wallet cannot be told apart yet: try again in a few minutes; also on GET /me/account/deletion)

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 by data: [DONE])
  • Headers: X-Carpe-*-Credits report the request cost and remaining balance
  • Web search: off unless you ask for it. "venice_parameters": { "enable_web_search": "auto" } (or "on") turns on Venice's web search, and "enable_web_scraping": true the reading of the URLs in the last user message. What they retrieve is added to the prompt and counted as input tokens. Each search that actually runs and each page actually read also carries Venice's flat fee of $0.01 (X search on Grok models: $0.01 per result), charged on top with the same dynamic-pricing multiplier as the tokens.
  • Errors: 402 PAYMENT_REQUIRED (insufficient credits) · 400 invalid model/params · 429 rate limited · 502 VENICE_ERROR · 503 no 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: model is a Venice model id (see Models), not a Claude model name
  • Web search: off unless you ask for it, as on /v1/chat/completions: send venice_parameters in the body (extra_body in Anthropic's SDKs).
  • 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.

ParamBehaviourScope
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):

  1. Use claude-opus-4-8 for the request that needs a guaranteed function call — opus-4-8 honours tool_choice fully. Switch back to Fable 5 for everything else.
  2. Or leave tool_choice unset 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:

  1. Prompt-driven: prefix the user message with "Show your reasoning step by step before answering." The chain appears in the standard content[].text block.
  2. Venice-native: switch to claude-opus-4-6 / 4-7 / claude-sonnet-4-5 / 4-6 and pass reasoning_effort: "high" — the chain is then returned in a separate reasoning_content field (Venice convention, not Anthropic's). claude-fable-5 does not expose reasoning effort levels (supportsReasoningEffort: false in GET /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.

RequestResult
tools + reasoning_effort absent400 — 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_tokens400 Unsupported parameter: 'max_tokens' … use 'max_completion_tokens' — the budget field follows the effort, not the model
tools + reasoning_effort: "none" + max_completion_tokens200, finish_reason: "tool_calls"
No tools, any effort, max_tokens200 — 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: 402 PAYMENT_REQUIRED · 400 invalid model/params · 429 rate limited · 502 VENICE_ERROR · 503 no 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
  • /search body: { "query": "…", … } (Venice augment/search params passed through) — returns ranked web results.
  • /scrape body: { "url": "https://…", … } — returns the page content.
  • Billing: flat per call (AUGMENT_SEARCH_USD / AUGMENT_SCRAPE_USD)
  • 200: the Venice augment payload · Errors: 402 PAYMENT_REQUIRED · 400 bad params · 4xx surfaced from Venice · 502 VENICE_ERROR · 503 no providers

Decisions (JEV)

Venice's JEV is a System-1 (non-autoregressive) decision model: instead of generating tokens one at a time, it returns a single typed decision (a probabilistic yes/no, a choice from a set, or a score) in one shot — typically far faster and cheaper than a chat completion for the same "just decide" task (routing, moderation gates, ranking, classification). Carpe proxies it with the same auth, payment rails and per-token billing as chat.

POST /v1/decisions

  • Auth: API key or JWT

  • Body: { "model"?, "state", "questions" } — passed through untouched. model is optional and defaults to jev-latest. state is the context/facts to decide on. questions is a named object (not an array): each entry declares a type — "noul" (probabilistic yes/no), "choice", or "score" — plus its parameters (noul takes instructions).

    {
      "model": "jev-latest",
      "state": "The weather report says it is a clear day with a bright blue sky.",
      "questions": {
        "skyBlue": { "type": "noul", "instructions": "Decide whether the sky is blue, based on the state." }
      }
    }
    
  • 200: the Venice decision payload verbatim — { "model", "answers": { "<name>": { "type", "noul": 0.97 } }, "usage": { "input_tokens", "output_tokens" } }. Each answers.<name> mirrors the question's type; noul is a 0–1 confidence.

  • Billing: per token, off the usage.input_tokens / usage.output_tokens the decision surface reports (mapped to Carpe's input/output pricing exactly like chat). jev-latest is currently a $0 beta model upstream, so decisions are served free until Venice sets a price (which then applies automatically).

  • Errors: 402 PAYMENT_REQUIRED · 400 INVALID_MODEL (only if the model is absent from the upstream decision catalog) · 429 rate limited · 502 VENICE_ERROR · 503 no providers / TEE not ready

  • Discover: GET /v1/decisions/models lists the decision models Carpe can serve (public, mirrors the upstream ?type=decision catalog).

  • Also under /router: POST /router/decisions and GET /router/decisions/models answer identically. No external market currently serves JEV, so the router always serves it on Carpe (no price comparison) — same auth, body and response.

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/generate is 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 a 502 at 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"?, "resolution"?, "quality"? } — variants 1–4 (default 1), billed per image
  • Price = the tier rendered. A model with a resolution × quality price matrix (gpt-image-2, ideogram-v4-5, nano-banana-*, …) is billed at the tier Venice renders: the resolution ("1K", "2K", "4K"; size is read in its place when it carries one of these names) and quality ("low", "medium", "high") you ask, when the model publishes them in its constraints, else the model's defaultResolution / defaultQuality — which is what Venice renders and bills when a request names none (gpt-image-2: 1K/high). The tier priced is forwarded to Venice, so it is the tier rendered; a value the model does not publish (an OpenAI "1024x1024") names no tier.
  • 200: Venice image payload (base64-encoded image(s))
  • Errors: 402 PAYMENT_REQUIRED · 400 invalid/missing prompt (max 10,000 chars) · 400 INVALID_MODEL (no price is known for this model — see GET /v1/models) · 502 VENICE_ERROR · 503 no providers / PRICE_UNAVAILABLE

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 variants on the async path. It returns a single binary image, so variants > 1 is rejected with 400 VARIANTS_NOT_SUPPORTED rather than billed. Use the synchronous POST /v1/image/generate when 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 · 503 QUEUE_FULL (too many in-flight jobs) · 503 no 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/*, header X-Carpe-Image-Status: completed) · Errors: 403 queue_id belongs to another wallet · 404 JOB_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"?, "resolution"?, "quality"? }
  • image format: data URI — data:image/<mime>;base64,<…> (e.g. image/png, image/jpeg, image/webp). HTTP URLs are not fetched server-side.
  • Price. With no resolution / quality, an edit costs the model's per-edit price (its default tier). Name one the model publishes (gpt-image-2-edit: "4K", "low"…) and it is billed on that tier — size is read in place of resolution when it carries a tier name — and the tier is forwarded in Venice's spelling. A value the model does not publish is forwarded as is, for Venice to accept or refuse; it is not priced.
  • 200: edited image payload · Errors: same set as generate

Heavy edit models — use the async queue. Like generation, /v1/image/edit is synchronous and the edge caps a request at ~60–120s. The highest-quality edit models — notably gpt-image-2-edit — routinely run longer and return a 502 at 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) · 413 image > 5 MB · 503 QUEUE_FULL · 503 no 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: 403 other wallet · 404 JOB_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"? }
  • images format: array of 1–3 items, each a data URI or raw base64, ≤ 5 MB each.
  • Price: as /v1/image/edit, plus Venice's fee for each input image beyond the first on the models that charge one (flux-2-max-edit: 3 images = $0.12 + 2 × $0.0345 before the multiplier).
  • 200: edited image payload ({ "images": ["<base64>"], "format": "png" }) · Errors: 400 (bad prompt/images) · 413 image > 5 MB · 409 MODEL_REQUIRES_ASYNC → use /v1/image/multi-edit/queue · 503 no 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"? } — scale 1–4 (default 2)
  • image format: raw base64, no data: prefix. Different from /v1/image/edit which wants the full data URI.
  • Constraints: source ≥ 256×256 pixels. Smaller inputs return 400 BAD_REQUEST (Invalid or corrupt image).
  • 200: upscaled image payload · Errors: 400 scale 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"?, "streaming"? } — input 1–4,096 chars, the provider's own limit: split a longer text into several requests. Past it the answer is 400 BAD_REQUEST, before anything is held (on /v1/x402/audio/speech, before the payment challenge). /router/audio/speech sends a longer text to an external market when one offers the model, and answers the same 400 otherwise.
  • streaming: true streams the audio as the provider renders it (chunked, no Content-Length) instead of answering once the whole file is ready, so playback can start on the first bytes. Same price, by input characters, but charged when the render is over:
    • Render completes, or you hang up mid-stream (the provider has rendered and paid for it either way): charged.
    • The provider breaks off mid-stream (error, no data for 90 s, or the 10-minute ceiling): not charged. The connection is dropped instead of the body ending cleanly, so a cut-off file never looks whole — treat a stream that ends with a network error as failed.
    • No audio at all (the provider ends the stream before the first byte): 502 VENICE_ERROR, not charged.
    • X-Carpe-Cost-Usdc-Micro is sent with the first bytes, before the outcome is known: it is what the render costs if it completes, and nothing is charged if the provider breaks off. The X-Carpe-*-Credits headers are a snapshot taken at the same moment, with this request still on hold.
    • Which models stream: tts-xai-v1 (mp3, pcm, wav), tts-gradium-v1 (wav, pcm, opus) and tts-kokoro (mp3, pcm, wav, opus, aac) — the models the provider really streams, measured on 2026-10-10 (first audio after about 0.3–2 s instead of the whole render). The other models accept the flag but send nothing before the render is done, so they are not worth streaming. A streamed wav may declare an unknown length in its header (0xFFFFFFFF or 0x7FFFFFFF), which players read as "until the end of the stream". Any other model or format asking for streaming: true is answered buffered, exactly as without the flag (same body, with Content-Length). So is a request the provider turns down with streaming: it is retried once without it, at no extra cost. Every response to streaming: true carries X-Carpe-Audio-Streaming: streamed or buffered.
    • /v1/audio/speech only. /router/audio/speech and /v1/x402/audio/speech accept the field and ignore it: they always answer buffered (with Content-Length, without X-Carpe-Audio-Streaming).
  • voice is 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 voice entirely, Venice picks the model's default.
    • To pick a specific voice, query GET /v1/models — every entry with carpe_diem_type: "tts" carries a voices array of accepted values (e.g. tts-kokoro has 54, tts-gemini-3-1-flash has 30).
  • 200: binary audio (e.g. audio/mpeg)
  • Billing: per character
  • Errors: 402 PAYMENT_REQUIRED · 400 invalid/missing input / unknown voice for this model · 502 VENICE_ERROR

POST /v1/audio/voices

Voice cloning — upload a short reference sample, get a handle to speak with. multipart/form-data.

  • Auth: API key or JWT · a funded account (checked like any paid call, never charged)
  • Body (multipart): file (one speaker, at least voice_cloning.min_sample_seconds, in one of voice_cloning.accepted_formats) + model — a TTS model whose constraints.voice_cloning is set in GET /v1/models (today tts-chatterbox-hd) + consent=true: the voice is yours, or its speaker agreed to it being cloned. Anything but true is refused; the field becomes required (400 VOICE_CONSENT_REQUIRED without it) once Sub Rosa ships it — send it now.
  • 200: { "id": "vv_…", "model", "expires_at" } — pass id as voice to POST /v1/audio/speech with the same model. The handle is opaque: Venice's own identifier stays inside the TEE.
  • The handle is yours and short-lived. It is spoken only for the account that uploaded the sample, on /v1/audio/speech only (/router and x402 refuse it with VOICE_HANDLE_V1_ONLY), and it expires after voice_cloning.retention_days. Keep the sample: an expired or unknown handle answers 404 VOICE_EXPIRED, and uploading again gives a new one. 503 VOICE_TEMPORARILY_UNAVAILABLE means the provider holding the voice is short of capacity — retry later, do not upload again.
  • Retention: Venice keeps the sample for voice_cloning.retention_days (7) on its provider's storage, and nothing can delete it sooner. Carpe Diem keeps no copy (see Privacy).
  • Limits: 5 uploads a minute, 10 a day and 20 active voices per wallet — past them 429 ENDPOINT_RATE_LIMITED, 429 VOICE_DAILY_LIMIT, 409 VOICE_LIMIT.
  • Billing: the upload is free; speech with the handle is billed like any other. A model that makes one container only (tts-chatterbox-hd: wav) publishes supported_formats / default_format, and speech without response_format uses that default.
  • Errors: 400 VOICE_CLONING_UNSUPPORTED (model does not clone) · 400 VOICE_MODEL_MISMATCH (handle used with another model) · 404 VOICE_EXPIRED · 502 VENICE_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: 400 multipart required / no file · 502 VENICE_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"?, "voice"?, "speed"? } · 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. On a speaking model (elevenlabs-tts-v4, elevenlabs-tts-v4-turbo, elevenlabs-tts-v3, elevenlabs-tts-multilingual-v2, seed-audio-1-0) prompt is the text read aloud, voice picks one of the model's voices, and the job is billed per thousand characters (per second on seed-audio-1-0).

What a job costs is fixed when it is queued, from the same rules Venice quotes with (POST /audio/quote): characters of the prompt counted in blocks of 100, seconds of duration_seconds — or the model's default_duration when you send none, since that is the length Venice renders — and every computed price rounded up to the cent, before Carpe Diem's multiplier. A 5-character line on elevenlabs-tts-v3 therefore costs the same as a 100-character one. duration_seconds is a number or a string of digits ("600" is read, priced and sent as 600, as Venice reads it); any other string ("600.0", "10m") is refused with 400 MUSIC_PARAM_REJECTED.

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 fieldWhat it decides
duration_optionsThe exact lengths accepted. ace-step-15 takes 60/90/120/150/180/210 and nothing between.
min_duration / max_durationThe 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_durationThe 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_limitWhether lyrics_prompt is forbidden, required or optional, and how long it may be.
supports_force_instrumentalWhether 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_limitThe prompt window. 10–512 on ace-step-15, 10–300 on minimax-music-v2.
supports_speed · min_speed / max_speedWhether speed may be sent, and its bounds.
voices · default_voiceThe voices a speaking model accepts in voice, and the one it uses when none is sent.
supports_custom_voice_idvoice also accepts an ElevenLabs Voice ID, not only a listed name. Use a public or premade voice: the job runs on a provider's key, not on your ElevenLabs account, so a voice from your own library will not resolve.
supported_formats · default_formatThe 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 before committing — optional but recommended (queue prices the job itself and runs its own balance check anyway). Venice's free quote, converted to what Carpe charges.

  • 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 unfamiliar duration / 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 (see GET /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 its 400.
  • 200: { "cost_usdc", "quote", "venice_quote_usd", "multiplier" } — cost_usdc (repeated as quote) is what /v1/video/queue charges for this payload at the current price: Venice's own quote × the dynamic multiplier, the same as every fixed-cost job. venice_quote_usd is Venice's figure — what the provider's key is debited. The multiplier moves with demand, so the charge at queue time can differ slightly.
  • Errors: 400 VIDEO_PARAM_REJECTED (a value the model's published enum excludes — carries details.issues) · 400 (Venice rejected a field we can't check) · 503 PRICE_UNAVAILABLE (Venice answered without a price — retry) · 502 VENICE_ERROR

POST /v1/video/queue

Start a job. Runs a balance check and refuses if you can't afford it.

  • Priced on Venice's own quote for this payload. Before anything is held, the operator asks Venice's /video/quote for the job — the model and its priced fields (duration, aspect_ratio, resolution, audio, the enhancement fields, video_url, reference_video_total_duration) — and charges that × the current multiplier when Venice accepts the job. A payload Venice refuses to price comes back here as its 4xx, before any hold; a job Venice cannot price right now is refused with 503 PRICE_UNAVAILABLE, nothing held — never billed on a guess.

  • 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/models carries a constraints block with its own durations, aspect_ratios, resolutions and prompt_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/14s on the 2-0 line, and every second up to 30s on 2-5, none of which appear below), they accept 21:9, and their resolutions differ per model — seedance-2-0* reaches 4k while seedance-2-0-fast*, -mini* and seedance-2-5* stop at 720p. Build from constraints.

    Model ids carry a tier suffix. Venice publishes the whole Seedance family as *-basic — seedance-2-5-reference-to-video-basic, not seedance-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.

  • duration is required. String with "s" suffix (e.g. "5s", not 5 or 5.0). Omitting it returns 400. The accepted enum is per-family — empirically validated via /v1/video/quote 2026-06-07 (seedance re-validated 2026-06-24 — the 2-0 line now accepts "15s"):

    FamilyAccepted duration
    sora-*"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 duration and aspect_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:

    FieldValuesBehaviour
    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 require reference_video_urls on queue, and reference_video_total_duration on quote; a source-matched duration bills ceil(reference_video_total_duration) seconds. The two are independent — match one without the other.

  • aspect_ratio is optional. "16:9" and "9:16" are accepted by every family. "1:1" works on most (rejected by sora-*, veo3*, longcat*, happyhorse-*). "4:3" / "3:4" accepted only by seedance*, 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 an https:// URL or a data: URI.
    • end_image_url (string) — the last frame, for first-last-frame interpolation models.
  • Reference conditioning (reference-to-video). A *-reference-to-video model 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 or data: 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 and on queue whenever reference clips are present: the job is priced on it. Required for a source-matched duration.

    • 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–91–30
      reference_video_urls≤ 3≤ 10
      reference_audio_urls≤ 3≤ 10
      per reference clip[2, 15] s[2, 30] s
      all clips combined≤ 15 s≤ 30 s
      output duration4–15 s4–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 — constraints covers durations / aspect_ratios / resolutions, not the conditioning fields. POST /v1/video/quote is 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 task or workflow field. seedance-*-reference-to-video-basic performs four different jobs and infers which one from the shape of your prompt:

    WorkflowWhat it doesPrompt shape
    Referencea new video, using your files as donors for subject / motion / style / voiceRefer to <Subject 1> in <Image 1> to generate …
    Editchange one thing in a clip; anything unnamed is preservedStrictly edit <Video 1>, changing its … to …
    Extendcontinue a clip forward or backwardExtend <Video 1>, generate …
    Stitchjoin 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 (@Element1 on Kling O3, @Image1 for scene_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.

  • 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 a duration, aspect_ratio or resolution that the model's own published constraints exclude, 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/quote and /v1/video/queue accept bodies up to 35 MB, matching the upstream — the rest of the API stays at 20 MB. Inline data: URIs for clips overshoot that fast (a 50 MB clip is ~67 MB of base64), so prefer https:// 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_limit carries 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/quote first with the exact payload — no charge, and a 400 names the field at fault. Cheaper than debugging in queue (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 *-basic models — 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 as UNSUPPORTED_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_text to 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 echo consent_version — it is server-set, and sending it back is a 400. 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 consents only 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) or queue_id (older shape) — use whichever is present when you call retrieve. Credit headers included.

  • Errors: 402 PAYMENT_REQUIRED · 400 INVALID_MODEL / BAD_REQUEST (prompt missing, or longer than this model's prompt_character_limit) · 400 VIDEO_PARAM_REJECTED (unsupported duration / aspect_ratio / resolution, refused before any hold is placed) · 4xx VENICE_ERROR (Venice refused to price the payload) · 413 (body over 35 MB) · 503 PRICE_UNAVAILABLE / NO_PROVIDERS / INSUFFICIENT_PROVIDER_CAPACITY / TEE_NOT_READY · 502 VENICE_ERROR

  • Rejected parameters come back structured. A VIDEO_PARAM_REJECTED body 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.issues carries its validator's own rows instead — { "path", "message", "expected"? }, where expected lists 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, so seed and camera_fixed are refused on some models while resolution and negative_prompt are fine) arrives as one row per key, with the key name in path.

    The same details.issues shape is used by /v1/video/quote, /v1/video/queue, /v1/video/retrieve and 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 (see NEEDS_CONSENT above).

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" }, plus details.issues when the upstream validator reported structured rows. This is terminal — stop polling. It is deliberately not a raw 5xx: a 5xx on a poll means the poll broke and should be retried; a dead job is an answer, not an outage.
  • A failed render Venice refunds is refunded to you. A video is charged when Venice accepts the job. When the render then fails and Venice gives the provider its credits back — its error says credits_refunded: true (e.g. a provider content-policy rejection), or a moderation rejection on a Grok Imagine -private model, which Venice does not bill — the poll that reports it gives your charge back: credits are credited back; a prepaid charge the operator has not pulled yet is cancelled (pulled, it comes back as credits); likewise on x402 (/v1/x402/video/retrieve), where a payment already pulled comes back as Carpe credits on the paying wallet. A failure Venice keeps the money for (the public Grok Imagine models' moderation rejections, for instance) is not refunded. Poll until the job ends: a failure nobody polls is never learnt.
  • Errors: 400 BAD_REQUEST (missing or malformed queue_id) · 404 VIDEO_JOB_NOT_FOUND (unknown/expired — an unknown queue_id answers this whatever model says) · 403 FORBIDDEN (queue_id belongs to another account) · 409 NEEDS_CONSENT (a gate to answer — see queue above) · 410 VIDEO_KEY_REVOKED (upstream provider key revoked mid-job — re-queue) · 502 VENICE_ERROR · plus status: "failed" with UNSUPPORTED_MEDIA for 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 in video_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 a 400. An empty array means the model takes no such parameter; null means 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_type is stripped — that is our routing discriminator, not a parameter you send.

    Beyond the three enums, constraints also carries prompt_character_limit, audio_input (the model takes reference audio), and — on models that condition on images — the media floors reference_image_min_short_side_pixels, reference_image_min_aspect_ratio and reference_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-play is a text model, topaz-video-upscale is video upscaling, etc.). Mapping:

    carpe_diem_typeEndpoint
    text, code/v1/chat/completions or /v1/messages
    embedding/v1/embeddings
    image/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/upscale
    tts/v1/audio/speech
    asr/v1/audio/transcriptions
    music/v1/audio/music/queue
    video, imageToVideo, referenceToVideo/v1/video/queue

    Send 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 on tts entries only, lists the model's accepted voice enum (passed through from Venice's model_spec.voices). Empty array means Venice didn't publish a catalog for that model — omit voice and let the model use its default.

GET /pricing

Per-model pricing + fixed (per-image / per-video) costs.

  • Auth: none · 200: { "models", "fixedCost", "updatedAt" }
  • Each models[] row: { "model", "tier", "inputBase", "outputBase", "inputPrice", "outputPrice", "multiplier", "requestCount", "traits", "beta" }, plus cacheInputBase/cacheInputPrice/cacheWriteBase/cacheWritePrice on a model that caches, and perThousandCharactersBase on a character-priced music model (the ElevenLabs voices): Venice's rate per 1 000 characters, before the multiplier. On a music row inputPrice is only a representative job (500 characters, 30 s, or the cheapest tier) — price a real one with the rules under Music.
  • Aliases are priced as the model they run on. Venice also accepts a model trait (default, default_reasoning, most_intelligent… — its /models/traits) or a compatibility name (gpt-4o, claude-3-5-sonnet-20241022… — its /models/compatibility_mapping) as model, and serves the model it maps to. They have no row here; a request naming one is billed at that model's row, refreshed hourly with the prices.

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: 503 BALANCE_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" }

GET /me/ledger

Chronological history of your money on Carpe Diem — deposits, card purchases and refunds, daily spend, on-chain settlements, credit migrations, provider rewards, Surplus payments received by your RewardSplitter and reward withdrawals — newest first.

  • Auth: JWT only (wallet or email session). API keys (cdm_) are refused with 403 API_KEY_NOT_ALLOWED: an agent key spends, it never reads your financial history.
  • Query: limit (1–200, default 50) · cursor (opaque, from the previous page) · kinds (comma-separated, see below) · since (ISO 8601) or days (1–3650); since wins if both are set.
  • 200:
    {
      "wallet": "0x…",
      "entries": [{ "id", "at", "kind", "flow", "amountUsd", "amountDiem", "txHash", "status", "meta" }],
      "totals": { "depositedUsd", "cardPurchasedUsd", "refundedUsd", "spentUsd", "earnedDiem", "earnedUsd", "earnedUsdc", "withdrawnDiem", "migratedUsd" },
      "nextCursor": "…" | null
    }
    
  • at is ISO 8601 UTC. Entries are sorted by at then id, descending. Daily aggregates (spend_day, reward_day, idle_rebate_day) are dated at the end of their UTC day; the day itself is in meta.day.
  • Kinds → flow: deposit → in · card_purchase → in · card_refund → out · spend_day → spend (meta.requests) · migration → out · prepaid_settlement, external_route_settlement → settlement (meta.rail) · reward_day, idle_rebate_day (paid only) → earn · splitter_income → earn (USDC) · reward_withdrawal → out (DIEM).
  • Spend is counted once. Every served request is in spend_day, whatever the rail. settlement entries show how that same spend was paid on-chain (with its txHash) and are never added to a total.
  • Totals cover your whole history, regardless of limit, cursor, kinds or days. migratedUsd counts settled migrations only. After an account deletion, an account opened again at the same address starts empty: nothing before the deletion is shown.
  • Only money provably yours is shown. Settlement entries cover payments debited from your wallet or from the smart account derived from it; reward entries cover your wallet and the canonical RewardSplitter the factory records for it (splitterOf). The wallet always comes from your session — there is no wallet parameter.
  • Rewards credited to your RewardSplitter rather than your wallet carry meta.via = "splitter" and meta.address; a splitter withdrawal is the gross amount pulled from the escrow (your 90 % is then claimed from the splitter). On-chain withdrawals are indexed every ~10 minutes.
  • splitter_income — one entry per USDC payment your RewardSplitter received (today: Surplus sales of your Venice capacity), dated at its block, with its txHash. amountUsd is your share: the payment net of the splitter's 10 % (meta.grossUsd, meta.feeUsd). meta.source names the payer (surplus, antseed, or other for an unnamed one whose payment was relayed — submitted by someone else on behalf of a wallet that signed for it, mostly x402 and Surplus buyers — which counts all the same). Transfers that are not income (from you or the owner of one of your keys, the treasury, another splitter…) are left out, and so are unnamed transfers that were not relayed — a wallet's own transfer, or a contract's such as a swap output (direct on /providers/:wallet/yield) — and only Base's canonical USDC counts — tokens airdropped to splitters by spammers never appear. totals.earnedUsdc sums these entries; earnedUsd stays the USD value of your DIEM earnings, so the two are never mixed. Indexed every ~10 minutes.
  • Errors: 401 AUTH_REQUIRED · 403 API_KEY_NOT_ALLOWED · 400 INVALID_KINDS / INVALID_SINCE / INVALID_DAYS / INVALID_CURSOR

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's depositWithSwap
  • Errors: 400 missing/invalid token or amount · 502 QUOTE_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.

RailSource of fundsSigningBest for
Creditspre-bought balance in the escrowone depositanyone
Prepaid accounta dedicated account you own (smart account or EOA)approve oncean isolated, withdrawable Carpe balance
Wallet (x402)your EOA directlypermit per sessionagents / 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" }
  • account must be your own wallet or the Carpe prepaid smart account derived from it — anything else is refused with 403 ACCOUNT_NOT_OWNED (the response carries expectedAccount). 503 ACCOUNT_VERIFY_UNAVAILABLE if ownership cannot be checked right now; retry.

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.

Same price as credits. Every x402 endpoint charges what its credit sibling charges: Venice's cost for the request × the current dynamic multiplier (GET /pricing) — media included (image, edit, upscale, video, music, transcription, web search and scrape), which were charged Venice's raw price before 2026-10-12. The 402 challenge's maxAmountRequired is that price (a ceiling for duration- or token-based jobs).

Coverage — every paid modality has an /v1/x402/… sibling (same permit flow; async ones mirror the queue/retrieve pattern of their credit counterpart):

Modalityx402 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. One exception: /v1/x402/audio/speech ignores streaming and always answers buffered (so does /router/audio/speech).

/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. It also prices the job on Venice's quote before the challenge, so maxAmountRequired is this render's price (503 PRICE_UNAVAILABLE when Venice cannot quote it — no payment is asked).

The permit flow (identical for every endpoint above)

  • Auth: none — the signed permit is the payment + identity.
  • No X-PAYMENT header → 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=USDC nonces(owner), deadline), then resend with:
    X-PAYMENT: base64( { "payload": { owner, spender, value, nonce, deadline, signature } } )
    
  • 200: the completion; response header X-Payment-Tx carries 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 } } — prepaidAccount is 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 the MigrateCredits(user, account, amount, deadline, nonce) typed data from the quote. account must equal the quote's prepaidAccount.
  • 200: { "ok": true, "txHash", "account", "migratedUsdc" } — the signature binds account+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) · 409 INSUFFICIENT_CREDITS (balance changed — re-quote) · 503 MIGRATION_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).

Names on the site: providers who sell through the surplus market see it as Carpe Reach; buyers see it as Surplus, like every market the router names. Ids, fields and headers keep surplus.

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 externalFeePct in GET /router/markets rather 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.

CapabilityRouter (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.
  • execute is the gate between a forceMarket pin and an actual external purchase. All of enabled, walletAllowlisted and floatAllowsExternal must be true, or the router serves Carpe and your pin is ignored. walletAllowlisted describes you, not the list, and is null on 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:
HeaderMeaning
X-Carpe-Route-Marketcarpe | surplus | antseed | openrouter | chutes — who actually served
X-Carpe-Route-Cost-Usdcwhat you were billed for this call
X-Carpe-Route-Saved-Usdcsavings vs the Carpe price (can be negative if a market was forced)
X-Carpe-Route-Reasonwhy this market served — the cascade's own words (carpe no capacity → surplus fallback, router float below floor — external routing paused, …)
X-Carpe-Route-Attemptsevery market the cascade reached, in order: antseed:failed(502):20013ms,carpe:ok:1650ms (skipped = refused as a fallback for price, uncovered = your rail could not pay it). On a stream, each leg's time to first token
Server-Timingpre (auth, quote, payment hold), upstream (the markets' own time), post (billing), total, in ms; on a stream, pre and ttft
X-Carpe-Route-Pinonly on a request with forceMarket: honored, ignored (the pinned market could not take the request; it was routed as if unpinned and the reason starts with <market> pin ignored (<why>)) or refused (strictMarket, see below)
X-Carpe-Route-Fallbackpresent when the cascade fell through: an earlier, cheaper market could not serve. Its value repeats the reason above
X-Carpe-Route-Stickyaffinity when the conversation was pinned to its incumbent market (cache/privacy)
  • Optional "forceMarket": "carpe"|"surplus"|"antseed"|"openrouter"|"chutes"|"venice"|"engy" — 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-Pin: ignored is set and X-Carpe-Route-Reason says why (no chutes price for <model>, external execution is off, privacy: openrouter is not a TEE market, private model: surplus may keep it, …).

  • Optional "privacy" — who may see the prompt:

    • absent (default): the cheapest market, except for a chat or embedding model Venice runs privately ("privacy": "private" in /v1/models). Such a model only goes to a market that keeps nothing of it:

      • on this route and /router/messages: Carpe and Venice direct, Chutes (confidential compute), Engy on its zero-retention models. Surplus, AntSeed and OpenRouter are skipped; a pin on one of them is ignored, or refused with a 409 under strictMarket;
      • on /router/completions and /router/embeddings, which compare Carpe and Surplus only: Carpe, even when Surplus is pinned;
      • on the x402 rail (/router/x402/chat/completions, /v1/x402/embeddings): Surplus is skipped the same way, and Carpe serves it.

      On the first two lines, when no such market can serve it, the request answers 503 PRIVATE_MODEL_NO_MARKET, and "privacy": false lifts it. On the x402 rail, Carpe's own answer stands. Images, voice and video have no such filter yet;

    • true: TEE markets only (Carpe, Chutes, Venice direct), for any chat or embedding model;

    • false: the cheapest market, even for a private model.

    privacy is a router field: it is never sent to the market that serves the request.

  • Optional "strictMarket": true (with forceMarket) — the pin or nothing: a pinned market that cannot take the request answers 409 PIN_UNDELIVERABLE ({ market, why }), with nothing held, served or billed; a pinned market that fails during the call returns its error, without falling back to Carpe. Without strictMarket, an external pin that fails still falls back to Carpe.

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.

EndpointNotes
POST /router/messagesAnthropic Messages shape (Claude Code, Cursor, Cline) — same market arbitration as /router/chat/completions, including streaming and the "privacy" field.
POST /router/completionsLegacy (non-chat) completions.
POST /router/embeddingsReturns the embedding vectors.
POST /router/audio/speechReturns the audio binary (mp3 by default).
POST /router/audio/transcriptionsmultipart/form-data in, transcription out.
POST /router/images/generationsOpenAI-shaped body; returns { "images": [...] } — or, for a queue-only model served on Carpe, 202 { "queue_id", "market": "carpe", "status": "pending" }.
POST /router/images/editsSame, with the source image. Every edit model is queue-only, so a Carpe-served edit always answers 202.
POST /router/images/retrieveBody { "queue_id" } → 200 JSON while pending, the image binary once ready.
POST /router/video/generationsAsync → { "queue_id", "market", "status" }.
POST /router/video/retrieveBody { "queue_id" } → the MP4 once ready.
POST /router/audio/music/queueAsync → { "queue_id", … }.
POST /router/audio/music/retrieveBody { "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), with rebate.escrow so you can verify it on Base. This is the authoritative figure: it accrues automatically on every external route, independently of anything this operator computes. null means unknown (older escrow, or the RPC was unreachable) — never treat it as 0.
    • rebate.pooledUsd / rebate.paidUsd — the operator's own daily computation: what was split among providers, and how much of it has actually been distributed.
    • armed vs paying. armed: the daily split is computed and recorded. paying: it is distributed on-chain. When armed && !paying the reserve is real and growing but has not been handed out — paidUsd is 0, and the pool must not be presented as provider income.

GET /stats/providers

  • Auth: none. What the network paid its providers, by revenue stream, and the providers' average yield. Aggregates only — no provider or splitter address. ?range=today|7d|30d|all (default 7d). Cached 5 minutes.
  • paid — over the range: { "carpe": { "diem", "usd", "providers" }, "splitter": { "grossUsdc", "netUsdc", "feeUsdc", "payments", "earningSplitters", "bySource": { "surplus", "antseed", "other", "self", "direct" } }, "rebate": { "paidDiem", "paidUsd", "accruedUsd", "distributed" }, "totalUsd" }
    • carpe — DIEM the escrow credited to providers for Carpe sales (USD at the time credited).
    • splitter — USDC paid into providers' RewardSplitters (Surplus sales), indexed from Base: grossUsdc received, feeUsdc the splitter's 10 % to the treasury, netUsdc the providers' share. A named payer (surplus, antseed) always counts. A payer that is not named counts if its payment was relayed: the transaction was sent by someone other than the USDC's sender (tx.from ≠ Transfer.from), and that sender is a key-controlled account (an EOA, EIP-7702-delegated included) or signed a transferWithAuthorization in that transaction — as when an x402 facilitator submits a buyer's signed authorization, or Surplus settles a buyer's delegated payment. That is other; a payment submitted by one of Surplus's (or AntSeed's) own settlement wallets is counted as that market's (surplus, antseed). Reported apart and in no total (not in payments nor earningSplitters either): bySource.self — USDC a provider sent from its own wallets (the beneficiary, or the owner of one of its keys) — and bySource.direct — unnamed USDC that was not relayed: a wallet's own transfer (tx.from = Transfer.from), which may be a provider paying itself from a wallet Carpe Diem does not know, or USDC a contract sent without a signed authorization, such as a DEX swap with the splitter as recipient, which anyone can trigger. A payment whose transaction could not be read yet is also direct until it is. This rule filters out a provider's plain transfers to itself; it does not prove a payment came from a real buyer — a determined provider can still route its own USDC through a relay, at the splitter's 10 %.
    • rebate — the idle-capacity rebate actually paid (accruedUsd: computed, paid or not).
    • totalUsd = carpe.usd + splitter.netUsdc + rebate.paidUsd. A block that could not be read is null and counts 0.
  • daily — the last 30 UTC days: [{ "day", "carpeUsd", "splitterNetUsdc", "rebatePaidUsd" }], empty days at 0.
  • network7d.onSold — the average APR on all DIEM sold over the same 7 days: { "soldDiem", "carpeSoldDiem", "surplusSoldDiem", "carpePct", "surplusPct", "rebatePct", "totalAprPct", "surplusMeasure", "surplusEarnedUsdc" } — Surplus measured through Carpe Diem's seller account (surplusMeasure: "seller") — every revenue (rebate accrued) ÷ the DIEM the providers sold on Carpe and on Surplus. null if nothing was sold.
  • network7d — the providers' average yield on their whole staked capacity over the last 7 complete UTC days, annualised: { "windowFrom", "windowTo", "coverageDays", "avgCapacityDiem", "capacityDiemDays", "diemPriceUsd", "carpe": { "diem", "aprPct" }, "external": { "netUsdc", "diemEq", "aprPct" }, "rebate": { "accruedDiem", "paidDiem", "aprPct", "distributed" }, "totalAprPct", "onSold" }. The base is the Venice capacity of every provider key (direct and Stake & provide) over the 7 days, in DIEM-days — the same base as each provider's own apr7dTotal. All three numerators count only providers whose capacity is in that base; splitter income excludes unidentified payers and is converted to DIEM at the current price (external.aprPct: null without a price). null unless all 7 days have their capacity. Past results — not a forecast.
  • splitters — { "registered", "v1", "v2", "factories": [{ "version", "address" }] }: verify every splitter on Base from its factory.
  • indexer — { "scannedToBlock", "lastScanAt", "lagSeconds" }: how fresh the splitter figures are (scanned every ~10 minutes).

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 — signature must recover to your provider wallet (the operator never sees the private key)
  • Body: { "apiKey", "message", "signature", "persist": true }
    • apiKey — your Venice inference key (must match VENICE_INFERENCE_KEY_<40-48 chars>, ≤ 200 chars).
    • message — the human-readable string you signed. The server only requires that it contain a line Timestamp: <ms-since-epoch> (rejected if older than 5 min). Recommended canonical format (matches the dashboard):
      Provision Venice API key
      Wallet: 0x<your-wallet>
      Timestamp: 1748345678901
      
    • signature — personal_sign (EIP-191) over message. The recovered address becomes the provider wallet.
    • persist (optional, default true) — set to false for an ephemeral key (RAM-only, lost on operator restart).
  • 200: { "status": "Provisioned", "provider": "0x…", "keyId": "<16-hex>", "walletKeyCount", "totalProviders", "totalKeys", "persist" }
  • Errors: 400 missing/invalid input · 403 INVALID_SIGNATURE · 400 REPLAY_REJECTED (timestamp expired/missing) · 400 INVALID_KEY_FORMAT · 409 DUPLICATE_KEY · 409 MAX_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", "rebate7dDiem", "rebateApr7d", "rebatePaidApr7d", "externalApr7d", "externalAprUnavailable", "apr7dTotal", "streams", "basis", "rewards24hDiem", "consumed24hDiem", "rewards7dDiem", "consumed7dDiem", "weightedAvgAvailable7dDiem", "currentDailyAllowanceDiem", "warmupRemainingHours", "rewardsError", "asOf" }
  • The base — your capacity. Every 7-day rate is measured against the Venice capacity of all your keys over the window: the 00:02 UTC reading of each key whose Carpe reward goes to you (keys you deposited directly and keys created through Stake & provide), deduped by Venice account, whichever market consumed it afterwards. Every rate divides by the capacity actually covered over the window, in DIEM-days (basis.capacityDiemDays — with a full week, average × 7), so a provider who joined two days ago is not diluted by days they did not exist. weightedAvgAvailable7dDiem is the average over the covered part; currentDailyAllowanceDiem is today's (= $/day). Rates need 24 h of known capacity (warmupRemainingHours counts down).
  • aprOnSold7d — the headline: every revenue on the DIEM you SOLD, annualised over the last 7 days: (Carpe DIEM + Surplus USDC at the DIEM price + idle rebate accrued) ÷ sold7d.totalDiem. sold7d = carpeDiem (allowance Carpe consumed on your keys) + surplusDiem (the DIEM your Surplus sales consumed). Surplus is followed through Carpe Diem's seller account on Surplus, which lists every user's Surplus key: what you earned there over the window (your payouts, paid or on their way, plus what has accrued) × the DIEM-per-USDC ratio of the seller's sales (Venice list price ÷ the seller's offer price, weighted by model — about 2 today, as Carpe sells at half Venice's price). sold7d.surplusMeasure is seller; it falls back to account_burn (your Stake & provide accounts' off-Carpe consumption) only when the seller's figures are unavailable. The Surplus part of the rate then uses those same earnings, net of the splitter's 10 % (streams.external.earned7dUsdc). Each stream's share is in streams.*.onSoldPct. Exists from the first sale (no capacity warm-up).
  • apr7dTotal — the same revenues on your whole stake (idle capacity included), annualised over the last 7 days. It is the exact sum of three terms on that same base:
    • apr7d — Carpe sales: DIEM credited to you by the escrow;
    • externalApr7d — what your RewardSplitter received from Surplus (or AntSeed), net of the splitter's 10 %, converted to DIEM at the current price. The only stream whose rate moves with DIEM's price. null with externalAprUnavailable: "diem_price" when the price is unknown — the USDC amount is still in streams.external. A named payer always counts (bySource.surplus, antseed); one that is not named counts if its payment was relayed — submitted by someone else (tx.from ≠ Transfer.from) on behalf of a key-controlled sender (EOA, EIP-7702 included) or one that signed a transferWithAuthorization, as an x402 facilitator does for a buyer (other); a payment submitted by a Surplus or AntSeed settlement wallet counts as that market's. Never counted, shown apart: USDC you send to your own splitter — from your wallet or the owner of one of your keys — is bySource.self (also self7dUsdc); unnamed USDC that was not relayed — a wallet's own transfer (tx.from = Transfer.from), or a contract's without a signed authorization, such as a swap output — is bySource.direct (also direct7dUsdc), since it may be a transfer of your own — as is a payment whose transaction could not be read yet, until it is;
    • rebateAccruedApr7d — the idle-capacity rebate accrued (computed daily; streams.rebate.distributed says whether it is paid out yet — rebatePaidApr7d is the paid part). apr7dTotal is null while apr7d is (warm-up, or rewardsError set).
  • streams — each term with its amounts: { carpe: { earned7dDiem, aprPct }, external: { net7dUsdc, gross7dUsdc, fee7dUsdc, diemEq7d, payments7d, lastPaymentAt, aprPct, bySource: { surplus, antseed, other, self, direct }, self7dUsdc, direct7dUsdc, unconfirmed7dUsdc }, rebate: { paid7dDiem, accrued7dDiem, aprPct, distributed } }. unconfirmed7dUsdc is kept for older clients and is always 0. basis: { capacityDiemDays, avgCapacityDiem, currentCapacityDiem, diemPriceUsd, windowStart, windowEnd }.
  • Carpe only, by construction: aprOnUsed7d and apr24h are per dollar of allowance Carpe consumed. The identity apr7d = aprOnUsed7d × utilization7d holds exactly — the yield on deployed capital is the yield on used capacity, scaled by the share of capacity Carpe actually used; the gap is idle capacity, never fees (every numerator is already net of fees).

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", "externalGrossUsdc", "externalNetUsdc", "externalCountedUsdc", "soldSurplusDiem", "rebateDiem", "aprPct", "allAprPct", "consumedUnavailable" }], "totals": { …same sums…, "aprPct", "allAprPct", "diemPriceUsd", "consumedDataFrom" }, "markupRate" }
  • allAprPct — every revenue on DIEM sold that day: (rewardDiem + externalCountedUsdc ÷ DIEM price + rebateDiem accrued) ÷ (consumedUsd + soldSurplusDiem) × 365 × 100.
  • Notes: consumption counts every key whose Carpe reward goes to you, Stake & provide keys included. aprPct = rewardDiem / consumedUsd × 365 × 100 — Carpe on the allowance Carpe consumed (DIEM-price-independent). externalNetUsdc is what your RewardSplitter received that day (Surplus sales), net of its 10 % (externalGrossUsdc before); it is paid in USDC and stays out of aprPct. consumedUnavailable: true marks days with reward but no consumption history (data predates the local ledger); the total aprPct is aligned to days that have consumption.
  • The fee waterfall: grossUsd is what buyers paid for the requests your keys served (Venice cost × dynamic multiplier); surchargeUsd is the protocol-surcharge slice inside it (markupRate × Venice cost, skimmed before the provider/protocol split). Your net share is (grossUsd − surchargeUsd) × providerBps/10000, with providerBps on the escrow contract — which is why the flat "65%" never applies to the gross: measured against grossUsd the 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.poolOnChainUsd in GET /stats/router to read it off Base. See GET /providers/:wallet/idle-rebate for your own share — and read its distributed flag: 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 in GET /router/markets); rebatePct of that fee is set aside for providers whose DIEM sat idle that day. No external routing on a given day → no fee → no pool.
  • armed vs distributed — read both. armed means the daily split is being computed and recorded. distributed means 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 case rebateDiem is an accrual held by the treasury, not DIEM you can claim. paid per row and paidUsd/paidDiem in 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.

HTTPcodeMeaningRetry?
400BAD_REQUEST · INVALID_MODEL · MODEL_ERROR · FST_ERR_VALIDATIONMalformed request / unknown model / bad params (/router/chat/completions applies the same body schema as /v1/chat/completions)No — fix it
401AUTH_REQUIRED · AUTH_FAILED · TOKEN_EXPIREDMissing/invalid key or expired sessionNo — re-auth
402PAYMENT_REQUIREDInsufficient creditsNo — buy credits
403OFAC_BLOCKED · FORBIDDEN · JWT_REQUIREDNot allowed for this callerNo
404NOT_FOUND · VIDEO_JOB_NOT_FOUNDUnknown / expired resourceNo
409NEEDS_CONSENTFace-bearing media awaiting the submitter's attestationYes — resubmit with consents
410VIDEO_KEY_REVOKEDProvider key revoked mid-jobRe-queue
413PAYLOAD_TOO_LARGERequest body too largeNo
429RATE_LIMITED · ENDPOINT_RATE_LIMITED · UPSTREAM_RATE_LIMITOperator throttle — 120/min per wallet overall; per route, 40/min for image generation and edits, 20/min for video queue — / Venice 429 — every response carries X-RateLimit-Limit / -Remaining / -ResetYes — back off
451—Geo-restricted regionNo
502VENICE_ERROR · QUOTE_FAILEDUpstream Venice / quote failureYes — transient
503NO_PROVIDERS · NO_PROVIDER_CAPACITY · INSUFFICIENT_PROVIDER_CAPACITY · MODEL_INFRA_SATURATED · UPSTREAM_UNAVAILABLE · TEE_NOT_READY · BALANCE_CHECK_FAILEDNo capacity / warming up / providers temporarily refusingYes — 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"? }. Returns degraded (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 — 0x23417CF66DF7cde16bE929576f6246cCa180b05F on 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 uint256 micros (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.