API Reference
Metered REST API for AI video generation. Every job is asynchronous: submit → poll (or stream) → fetch the result URL. There’s also an MCP interface exposing the same capability to AI agents.
- Base URL (prod):
https://api.macaroni.video - Get a key: sign up self-serve at
https://app.macaroni.video(email magic link) — your account’sdefaultmac_…key is minted at signup (mint more in the dashboard’s Keys console). Operator/adminprovisioning remains a secondary path (see Get an API key / Admin). - Auth:
Authorization: Bearer <api-key>on every route except/health,/robots.txt, and the public share routeGET /v1/videos/share/:token(the opaque ≥128-bit share token is the credential there — no api key). The/adminsurface uses a different credential (see Admin). - Content type:
application/json. - Billing: integer credits. Submitting places a conservative hold; on
completion the hold is reconciled to actual usage.
insufficient_credits→ 402.
GET /health (no auth)
Section titled “GET /health (no auth)”200 → { "ok": true, "service": "macaroni-gateway", "ts": "…" }
Two ways to make a video
Section titled “Two ways to make a video”Start here: use interactive sessions. They are the only currently-working path to a delivered video; batch (/v1/jobs) is disabled. Each is summarized below, with the full session contract in v2 Interactive Sessions.
- Interactive session (
/v1/sessions, below): a video produced through a multi-turn conversation with approval gates — review the script/scenes, then approve or revise. This is the only currently-working path to a delivered video and the recommended one. REST ⇄ MCP are 1:1. - Fire-and-forget batch (
/v1/jobs, below): would produce a video end-to-end with no approval gates. CURRENTLY UNAVAILABLE —POST /v1/jobsreturns503 batch_unavailable(no worker drains the queue;MACARONI_BATCH_ENABLEDoff by default), no credits held. Use the interactive session for a delivered video today.
v2 Interactive Sessions
Section titled “v2 Interactive Sessions”Poll-first + async: POST /v1/sessions (hold a credit budget) → POST …/messages (returns a
turn_id, runs async) → poll GET /v1/sessions/:id until status leaves running. At
awaiting_gate, read pending_gate and POST …/gates/:gateId to resume.
GET /v1/sessions/:id — canonical poll
Section titled “GET /v1/sessions/:id — canonical poll”200 → sessionView:
{ "id": "uuid", "status": "open|running|awaiting_gate|closed|failed|expired", "pipeline": "animated-explainer", "format": "16:9|9:16 | null", "current_stage": "research|script|assets|edit|compose|… | null", "autopilot": false, "byok": false, "credits": { "budget": 500, "held": 460, "spent": 40 }, "pending_gate": { "gate_id": "uuid", "stage": "script", "question": "Approve the script? …", "options": ["approve", "revise"], "artifact_names": ["script"], "artifact_excerpt": "First ~2KB of the primary artifact… …[truncated]" }, "last_reply": "the engine's latest reply (truncated) | null", "last_reply_stale": false, "failure_reason": "infra_error|engine_error|no_deliverable|budget_exceeded|user_interrupted | null", "last_heartbeat_at": "2026-07-08T…Z | null", "video_url": "https://…/v1/videos/share/<token> | null", "created_at": "…Z", "updated_at": "…Z"}statusvs a failed turn (read this).status:"failed"means the session could not open — the only cause today is insufficient credits, and thenfailure_reasonisnull. A turn that fails does not changestatus: the session staysopen(orawaiting_gatefor a budget failure) withfailure_reason != null. So to detect a failed turn, readfailure_reason/last_reply_stale, neverstatus.pending_gateis non-null only whenstatusisawaiting_gate. It is{ gate_id, stage, question, options, artifact_names, artifact_excerpt }, but be defensive:questionandartifact_excerptmay benullandartifact_namesmay be[]. Every name that is listed resolves viaGET …/artifacts/:name(no phantoms), andartifact_excerpt(when present) is a ~2KB inline preview usually enough to decide with no extra call. Branch onstage/options: astage:"budget"gate (options:["add_budget"]) is cleared withPOST …/budget, notPOST …/gates/:id; any other gate is resolved withPOST …/gates/:id.current_stageis an advisory, free-text label of the engine’s furthest checkpoint — pipeline-defined, may benull, and may include values like"budget". It is not a fixed enum; treat it as a human hint, not a control signal.last_reply_staleistruewhenlast_replyis from an earlier turn (e.g. the latest turn ended in a gate or failure);last_replyis nulled on a failed turn so a failure and a stale success reply are never shown together.failure_reason(typed, latest turn only). Every reason has a class that decides billing: a system-caused failure (an internal error/non-delivery prevented finishing) has its captured credits automatically refunded; a user-caused failure is billed for real and never refunded. Emitted today:infra_error(system) — a transient infrastructure failure; the turn was not billed, so just resend your message to retry (this is also what a stalled-turn reap reports).engine_error(system) — the engine erred mid-turn; its captured credits are automatically refunded (arefund-type entry in yourGET /v1/creditsledger).no_deliverable(system) — the turn finished but produced no hosted, deliverable video; the turn’s captured credits are automatically refunded (you are not charged for a video you never received). Retry; if it persists the pipeline/brief may not be supported — onlyanimated-explaineris certified for hosted delivery.budget_exceeded(user) — the session budget ran out; top up withPOST …/budget.user_interrupted(user) — you stopped the turn withPOST …/interrupt(sandbox terminated, turn settled cleanly). Because you chose to stop, you are charged the turn’s actual model spend up to that point (metered from the proxy at the standard markup, capped at the remaining budget) — billed, not refunded.
last_heartbeat_atis the executor’s liveness ping. It isnulluntil the first ping — fall back toupdated_atuntil then. Once flowing it advances while the engine works; a running turn whose heartbeat lapses for ~180s (3 min) is reaped by the server asfailure_reason:"infra_error"(resend to retry).video_urlis resolved on read (an absolute, stable, no-index share link that streams the MP4 through the gateway) as soon as a hosted render succeeds — you don’t have to wait forstatus:"closed". It is non-null if and only if a gateway-resolvable video exists (never a bare or broken link); a turn that finished with no hosted render leaves itnulland reportsfailure_reason:"no_deliverable". Whilerunning, the poll stays cheap and does not resolve it.
AI-content disclosure. Every delivered MP4 is AI-generated and carries machine-readable AI-provenance metadata embedded in the file itself — the container
commentanddescriptiontags readAI-generated video (macaroni.video)(thedescriptionalso carries a parseableai_generated=truetoken). There is no visible watermark. This is a minimal, mandatory provenance label, not a platform disclosure: if you re-post the video to TikTok, Instagram, or YouTube, setting that platform’s own “AI-generated / altered content” flag is the poster’s responsibility (see INTEGRATION.md). Verify the embedded tag on a downloaded file withffprobe -v quiet -show_entries format_tags=comment,description -of default <file>.mp4.
Opening a session — POST /v1/sessions
Section titled “Opening a session — POST /v1/sessions”Body (all optional; a session can also be steered entirely in-conversation):
| field | type | default | notes |
|---|---|---|---|
pipeline | enum | — | see pipelines. Omit to decide in-conversation |
brief | string | — | the creative prompt. A session takes only free text (no structured input), so for a source-required pipeline put the source URL inside the brief (e.g. “Cut this talk: https://…/talk.mp4”) |
budget_credits | int | per-pipeline estimate | credits to hold; raise later with POST …/budget |
format | enum | — (16:9) | output aspect ratio, fixed for the whole session: "16:9" (landscape, default) or "9:16" (vertical — TikTok/Reels/Shorts). Only these two are certified today; omit for 16:9. Immutable once set |
byok | bool | false | use only your stored keys — no fallback (see BYOK) |
autopilot | bool | false | reserved / no-op — approvals are manual today; resolve gates yourself |
webhook_url | string(url) | — | reserved / no-op — stored, never delivered |
Header Idempotency-Key: <string> makes an open safe to retry (a replay returns the existing session, never rate-limited). 201 created · 200 replay · 402 insufficient credits · 429 rate limit.
Terminal sessions and expiry
Section titled “Terminal sessions and expiry”closed, failed, and expired are terminal. A mutation on a terminal session
(POST …/messages, …/gates/:id, …/budget) returns 409 { "error": "session_closed" | "session_expired" | "session_failed" } — open a new session to continue.
status:"expired" is set by a background sweeper after a session sits idle for 72h (no turn
activity). It is terminal, releases the unspent hold (no stranded credits), and is silent:
no failure_reason, no event — you simply observe status:"expired" on your next poll.
Other session endpoints
Section titled “Other session endpoints”| Method + path | Purpose |
|---|---|
POST /v1/sessions | Open a session — see above. 201 | 200 replay | 402 | 429. |
GET /v1/sessions | List recent sessions. |
POST /v1/sessions/:id/messages | Send a message → 202 { turn_id, status } (async). 409 gate_pending if a gate is unresolved; 409 session_* if terminal; 429 at the concurrent-turn cap; 400 invalid_request if message is empty or over the length cap (validation). |
GET /v1/sessions/:id/messages | The readable conversation, paginated (after, limit). |
POST /v1/sessions/:id/gates/:gateId | Resolve a gate: { decision: "approve"|"revise", feedback? } (feedback required for revise). RESUMES the session. 409 gate_not_pending if already resolved; 409 session_* if terminal; 429 at the concurrent-turn cap — the gate stays pending. (A stage:"budget" gate is cleared with …/budget, not here.) |
GET /v1/sessions/:id/artifacts/:name | The full content of a named gate artifact. 404 if not present yet. |
POST /v1/sessions/:id/budget | Raise the budget (clears a budget gate). Body: { "additional_credits": <int> }. Resume is NOT automatic: the session returns to open, but you must send the next message (e.g. {"message":"continue"}) to start a new turn — this applies after clearing a budget gate AND after a budget_exceeded turn failure. Requires an Idempotency-Key header — a keyless top-up is rejected 400 { error:"idempotency_key_required" }; reuse the same key to retry the same top-up once, a new key for a separate top-up. 402 if the account can’t cover it; 409 session_* if terminal. |
POST /v1/sessions/:id/interrupt | Stop button. Terminate the currently-running turn’s sandbox and settle that turn cleanly as failure_reason:"user_interrupted" (the session returns to open). Because you chose to stop, the turn is billed its actual model spend up to the stop (metered from the proxy at the standard markup, capped at the remaining budget) — a user-caused stop, never refunded; the rest of the budget hold is untouched (release it with close). Safe + idempotent: with no turn running it is a clean no-op. Returns the poll shape + interrupted (true iff a running turn was stopped). |
POST /v1/sessions/:id/close | Close + release the unspent hold (video_url absolutized). |
GET /v1/sessions/:id/events | Live SSE over session events (Last-Event-ID; 270s cap). Browser note: auth is header-only (Authorization: Bearer) — there is deliberately NO query-param key auth (keys in URLs leak into logs/history). Native EventSource cannot set headers, so browsers must consume SSE via fetch() streaming with the header (or just poll); server-side clients are unaffected. |
GET /v1/videos/:id · GET /v1/videos | Get a session’s video (delivery-on-read): { session_id, status, video_url, ready, format, width, height, aspect_ratio } — format is the ordered aspect; width/height/aspect_ratio are the actual delivered pixels (derived by gcd, e.g. "9:16"), all null until a hosted render succeeds — so you can route the file per platform without an ffprobe · list finished videos (each row carries format). |
GET /v1/credits | Balance + recent ledger: { available_credits, ledger:[{ id, type, operation, credits, available_after, reference_type, reference_id, description, created_at }] } (up to 20, newest first). type is one of grant|hold|capture|release|adjustment|reset|refund — a system-caused failure auto-refund (engine_error / no_deliverable) shows here as a refund entry. |
autopilot on open_session is reserved — accepted/recorded, but approvals are manual today
(no auto-approval); resolve gates yourself.
Rate & concurrency limits (429 + Retry-After)
Section titled “Rate & concurrency limits (429 + Retry-After)”Per-session credit holds bound money; these limits bound volume so one key can’t flood Modal/spine and exhaust quota (cost/DoS gate). Two per-account ceilings are enforced at the session-create / turn-start boundary (identically on REST and MCP — same code backs both):
| Limit | What it bounds | Default | Env override | Per-account override (column) |
|---|---|---|---|---|
| Concurrent-turn cap | Turns/sandboxes running at once for the account | 3 | MACARONI_MAX_CONCURRENT_TURNS | accounts.max_concurrent_turns |
| Session-creation rate | New sessions per rolling minute | 10/min | MACARONI_SESSIONS_PER_MINUTE | accounts.sessions_per_minute |
| Session-creation rate | New sessions per rolling hour | 100/hour | MACARONI_SESSIONS_PER_HOUR | accounts.sessions_per_hour |
A per-account override column (nullable; NULL = use the global default) wins over the env default.
The limits are DB-backed (counted from the turns / sessions tables), so they are exact and
correct even across multiple gateway instances — no per-instance in-memory counter. (The gateway
runs single-instance today; DB-backed is chosen anyway so a restart or future horizontal scale
can’t leak past the cap.)
The 429 contract (identical shape on REST + MCP):
{ "error": "rate_limited" | "concurrency_limit", "retry_after_seconds": 12, "hint": "…" }- REST also sets a
Retry-After: <seconds>header (equal toretry_after_seconds). rate_limited= session-creation window tripped (POST /v1/sessions);retry_after_secondscounts down until the oldest session in the window ages out.concurrency_limit= the account is already at its concurrent-turn cap when a new turn would start (POST …/messagesfromopen, orPOST …/gates/:id). Wait for a running turn to finish (poll your open sessions), then retry. A429on gate-resolve leaves the gate pending (retry safe); an idempotent replay (sameIdempotency-Key/ re-send while a turn already runs) is never limited — it returns the existing session/turn.
Firewall v1 (what’s enforced now, what’s deferred)
Section titled “Firewall v1 (what’s enforced now, what’s deferred)”Enforced now (the cheap, high-value slice): a length cap on every client-supplied free-text
field that enters a session — the open brief, a send_message message, and a resolve_gate
feedback — rejecting an obviously-abusive oversized payload at the boundary with a validation
400 { "error": "invalid_request", "detail": … } before any sandbox/turn is dispatched.
Default 16 000 characters (env MACARONI_MESSAGE_MAX_CHARS).
Combined with the 429 rate/concurrency ceilings above, this is firewall-v1’s per-account
rate/cost bound.
Deliberately deferred: semantic/content firewalling (prompt-injection screening, payload schema/policy classification of message content). Rationale: it needs a model-in-the-loop classifier and a policy corpus that don’t exist yet, its false-positive risk would harm the conversational product, and the concrete cost/DoS exposure this milestone targets is already closed by the volume ceilings + length cap. Tracked as a follow-up, not a launch blocker.
Batch jobs (/v1/jobs) — CURRENTLY UNAVAILABLE
Section titled “Batch jobs (/v1/jobs) — CURRENTLY UNAVAILABLE”Batch is disabled today — this whole section is documented for completeness only.
POST /v1/jobs(and the MCPsubmit_jobtool) returns503 { "error":"batch_unavailable", "hint":… }with no credits held — no worker drains the job queue andMACARONI_BATCH_ENABLEDis off by default. For a delivered video now, use interactive sessions (above). Re-enabling needs a worker andMACARONI_BATCH_ENABLED=1. Everything below applies only when batch is enabled.
Job object (jobView)
Section titled “Job object (jobView)”Returned by every job endpoint:
{ "id": "uuid", "status": "queued|running|awaiting_approval|succeeded|failed|canceled|expired", "stage": "research|compose|publish|… | null", "pipeline": "animated-explainer", "autopilot": true, "byok": false, "estimated_credits": 500, "actual_credits": 0, "result": { "url": "https://…/final.mp4", "credits": 480 }, "error": null, "approval": null, "created_at": "2026-06-30T…Z", "updated_at": "2026-06-30T…Z"}On succeeded, result.url is the rendered video (Supabase Storage). On failed,
error carries { message, reason? }.
POST /v1/jobs — submit a job
Section titled “POST /v1/jobs — submit a job”Batch is CURRENTLY UNAVAILABLE.
POST /v1/jobsreturns503 { "error": "batch_unavailable", "hint": … }and holds no credits — no worker drains the job queue today. For a delivered video now, use the interactive session API (POST /v1/sessions) — the only currently-working path to a video. Re-enabling batch requires standing up a worker and settingMACARONI_BATCH_ENABLED=1(the flag is off by default — a deliberate gate, not a bug). Everything documented below for this endpoint applies only when batch is enabled.
Body:
| field | type | default | notes |
|---|---|---|---|
pipeline | enum | — | required; see pipelines |
input | object | {} | the creative brief — { "brief": "…", …hints }. Source-required pipelines (talking-head, clip-factory, podcast-repurpose, localization-dub — see each pipeline’s requires) MUST include their key, e.g. input.source_url; a brief-only submit is rejected up-front with 400 missing_required_input and no credits held (and while batch is off, 503 batch_unavailable is returned first) |
autopilot | bool | true | keep true. false is not supported for batch: the job lands in awaiting_approval with no resume endpoint — a dead-end that holds credits. For a reviewable flow use an interactive session |
byok | bool | false | use only the account’s stored provider keys — no fallback to platform keys (see BYOK) |
webhook_url | string(url) | — | reserved / no-op — accepted and stored but never delivered today (Phase 2) |
Header Idempotency-Key: <string> makes retries safe (a replay returns the existing job).
Hosted delivery: only the
animated-explainerpipeline is certified to deliver a hosted, shareable video today. Other pipelines are experimental and may complete without producing a hosted result. Batch runs unattended only. For the proven, reviewable path, prefer interactive sessions.
curl -sX POST https://api.macaroni.video/v1/jobs \ -H "authorization: Bearer $MAC_KEY" -H "content-type: application/json" \ -H "idempotency-key: my-req-1" \ -d '{"pipeline":"animated-explainer","input":{"brief":"How photosynthesis works, 60s"}}'# → 503 { "error":"batch_unavailable", … } today (batch disabled, no credits held);# 202 + job id only when MACARONI_BATCH_ENABLED=1 and a worker is running.503→{ "error":"batch_unavailable", "hint":… }— the default today: batch is disabled, so this is returned first, before any other check, with no credits held. Use interactive sessions instead.
The outcomes below apply only when batch is enabled (MACARONI_BATCH_ENABLED=1 + a worker):
400→{ "error":"missing_required_input", "missing":[…], "hint":… }— a source-required pipeline (see pipelines) submitted without itsinputkey; rejected up-front, no credits held.202→ job created:{ …jobView, status:"queued", status_url, events_url, cancel_url }200→ idempotent replay: same body with"replayed": true402→{ "error":"insufficient_credits", "required_credits", "available_credits", "job_id" }
GET /v1/jobs — list recent jobs
Section titled “GET /v1/jobs — list recent jobs”200 → { "data": [ jobView, … ] } (newest first, up to 50).
GET /v1/jobs/:id — job status
Section titled “GET /v1/jobs/:id — job status”200 → jobView · 404 → { "error":"not_found" }. Poll until status is terminal.
POST /v1/jobs/:id/cancel
Section titled “POST /v1/jobs/:id/cancel”Cancels a queued/awaiting_approval job and releases its hold.
200 → jobView · 409 → { "error":"not_cancelable" }.
GET /v1/jobs/:id/events — live progress (SSE)
Section titled “GET /v1/jobs/:id/events — live progress (SSE)”text/event-stream. Event types: open, then job events (status, stage,
progress, approval, done, error), plus heartbeat every 2s. The stream
self-closes at a terminal status or after ~270s with event: timeout — reconnect
with Last-Event-ID: <last id> (or ?last_event_id=) to resume without gaps.
curl -N https://api.macaroni.video/v1/jobs/$ID/events \ -H "authorization: Bearer $MAC_KEY"BYOK — bring your own provider keys
Section titled “BYOK — bring your own provider keys”Store keys once (encrypted at rest, AES-256-GCM; never returned). Jobs/sessions submitted
with "byok": true use them instead of the platform keys.
No fallback. A
byok:truerun uses only the keys you stored here — there is no fallback to platform keys. Store a key for every provider the pipeline needs (see the run’s engine requirements) or the run fails. One exception, for the reasoning brain only: a BYOK run that stores no Anthropic key still runs the brain on the platform’s metered Anthropic key (through a per-run budget-capped proxy) — so the LLM line is still charged on that run. A BYOK run that does store its own Anthropic key dials Anthropic directly (your spend).
GET /v1/providers/keys→{ "data":[{provider,key_prefix,last4,updated_at}], "supported":[…] }POST /v1/providers/keys{ "provider":"fal", "key":"…" }→201 { provider, key_prefix, last4, status:"stored" }(400if unsupported)DELETE /v1/providers/keys/:provider→{ "status":"deleted" }·404
Get an API key (self-serve signup) — /v1/account
Section titled “Get an API key (self-serve signup) — /v1/account”A browser-facing namespace that exchanges a Supabase Auth access token for a Macaroni account
plus a mac_ API key — the primary, self-serve way to get a key (operator provisioning via
/admin remains a secondary path for concierge/special cases). The signup site is live at
https://app.macaroni.video; it signs the user in with Supabase Auth (email magic link) and
presents the resulting JWT:
Authorization: Bearer <supabase-access-token> # a JWT, NOT a mac_ key- JWT-authed, not
mac_-authed. Amac_API key here is rejected401 { "error":"supabase_jwt_required" }(missing/garbage token →401 { "error":"missing_token" }/401 { "error":"invalid_token" }). The token is validated server-side viasupabase.auth.getUser()(remote validation, inherits revocation). - REST-only — NO MCP twin. Like
/admin, this is a human/browser surface: agents don’t sign up. It is the second deliberate, documented exception to keep-api-mcp-in-sync (the first is/admin). - Live in production. The signup site (https://app.macaroni.video) is enabled today. The
namespace is gated by ops config (
SUPABASE_URL+SUPABASE_SERVICE_ROLE_KEYandMACARONI_SIGNUP_ENABLED=1, read at call time) — all set in prod; were they unset it would return503 { "error":"signup_unavailable" }. - Scoped CORS (
MACARONI_CORS_ORIGINS) and a per-IP rate limit (MACARONI_SIGNUP_PER_HOUR_PER_IP, default 20/hour →429 { "error":"rate_limited" }+Retry-After) apply only on this namespace.
Endpoints
Section titled “Endpoints”| Method + path | Purpose |
|---|---|
POST /v1/account | Exchange (signup). First time → 201 with the raw key; thereafter → 200 replay. |
POST /v1/account/keys | Mint an additional key (lost-key recovery). 201 with the raw key. |
POST /v1/account/keys/:id/revoke | Revoke one of your own keys. 200. |
POST /v1/account — the exchange. Serialized per auth user (advisory lock) + a one-account-per-auth-user
unique index, so it is idempotent for all time: exactly one account, never a second free grant.
// 201 (first time) — the raw api_key appears HERE and NOWHERE ELSE. The auto-provisioned first key// is named "default":{ "account_id":"…", "api_key":"mac_…", "name":"default", "key_prefix":"…", "last4":"…", "scopes":["jobs:read","jobs:write"], "granted_credits":0, "available_credits":0, "created":true }// (free grant configured but withheld: "granted_credits":0 + "grant_withheld":"unverified_or_disposable_email")
// 200 (already signed up) — metadata only, NO raw key (it is unrecoverable after mint):{ "account_id":"…", "created":false, "keys":[{ "id":"…","name":"default","key_prefix":"…","last4":"…","scopes":["jobs:read","jobs:write"],"status":"active","created_at":"…" }], "note":"raw keys are shown only at mint; POST /v1/account/keys to mint a new one" }- Scopes are hardcoded
["jobs:read","jobs:write"]— the request body can never influence them. - Free grant is a config, default 0 (off): signup always yields account+key (BYOK users are
productive immediately); free credits are granted only when the owner sets
MACARONI_SIGNUP_GRANT_CREDITS>0, and only for a confirmed, non-disposable email (else account+key still issue,granted_credits:0grant_withheld). The grant is exactly-once (credit-ledger idempotency), so a replay never re-grants.
POST /v1/account/keys — mint another key: 201 { id, account_id, name, key_prefix, last4, scopes, status, created_at, api_key }
(raw once). Optional body { "name": <string> } labels the key in the console — trimmed, capped at
64 chars, stored null when absent/empty; unknown fields are ignored. 404 { "error":"account_not_found" }
if you never exchanged; 403 { "error":"account_suspended" } if your account isn’t active;
409 { "error":"key_limit_reached" } at 5 active keys (revoke one first).
POST /v1/account/keys/:id/revoke — 200 { id, account_id, name, status:"revoked", revoked_at }. Owned-revoke:
a key id that isn’t yours (or is unknown / not a UUID) returns 404 — it never confirms a foreign key id exists.
Admin / provisioning API (/admin) — operators only
Section titled “Admin / provisioning API (/admin) — operators only”Create accounts, mint API keys, and grant credits (previously manual SQL). Since self-serve
signup (above) is now the primary way customers
get keys, /admin is a secondary, operator-only surface — for concierge provisioning, credit
grants, and special cases. It is an operator surface, not a customer/agent capability, so it is
deliberately:
admin-scoped. Every/adminroute sits behindrequireScope('admin'). A normal customer key (jobs:read/jobs:write) that reaches/admingets403 { "error":"forbidden" }— a valid key, just not authorized for this surface. A missing/unknown bearer →401.- REST-only — NO MCP twin. Unlike every customer capability (which is 1:1 REST ⇄ MCP), the admin API is intentionally excluded from MCP: agents drive sessions, they do not provision tenants. This is a deliberate, documented exception to the keep-api-mcp-in-sync rule.
- Idempotent by contract. Every admin mutation requires an
Idempotency-Keyheader; a keyless call is rejected400 { "error":"idempotency_key_required" }. Reuse the same key to retry a request exactly once; use a new key for a genuinely separate operation.
Bootstrapping the first admin key (ops)
Section titled “Bootstrapping the first admin key (ops)”Admin routes accept two credentials: an admin-scoped API key or a one-time env
bootstrap token. Set MACARONI_ADMIN_BOOTSTRAP_TOKEN (a long random secret) on the gateway
service; a request whose Authorization: Bearer <token> matches it (constant-time compare) is
granted admin scope. This solves the chicken-and-egg of minting the first key. Recommended flow:
# 1) one-time: mint the real admin key using the bootstrap tokencurl -sX POST $BASE/admin/keys -H "authorization: Bearer $MACARONI_ADMIN_BOOTSTRAP_TOKEN" \ -H "content-type: application/json" -H "idempotency-key: bootstrap-admin-1" \ -d '{"account_id":"<ops-account-uuid>","scopes":["admin"],"name":"ops-admin"}'# → 201 { …, "api_key":"mac_…"} ← store this; then RETIRE the bootstrap tokenThe bootstrap token is fails-closed: when MACARONI_ADMIN_BOOTSTRAP_TOKEN is unset the path is
inert (the token is just an unknown key → 401). Keep it ops-only; unset/rotate it after the admin
key exists. (Deploy note: add MACARONI_ADMIN_BOOTSTRAP_TOKEN to the gateway service env in
scripts/deploy.sh + .env.example.)
Endpoints
Section titled “Endpoints”| Method + path | Purpose |
|---|---|
POST /admin/accounts | Create an account. 201 { id, name, plan, status, created_at } (| 200 replayed:true). |
POST /admin/keys | Mint an API key for account_id (optional scopes, default ["jobs:read","jobs:write"]). |
POST /admin/credits/grant | Grant credits to account_id. 200 { account_id, granted_credits, available_credits }. |
POST /admin/credits/purchase | Payments seam — credit a completed purchase, exactly-once per (source, external_ref). 201 first, 200 replayed:true. No Idempotency-Key header (the external_ref is the idempotency). |
POST /admin/keys/:id/revoke | Revoke a key. 200 { id, status:"revoked", revoked_at } | 404. |
POST /admin/keys — raw-key-once contract. The raw secret is returned exactly once, on
first creation, as api_key — we persist only sha256(raw) plus key_prefix/last4/scopes.
A replay with the same Idempotency-Key returns the same key record without api_key
(replayed:true + a note). The raw is unrecoverable after first creation — if lost, mint a new key.
POST /admin/credits/grant — idempotent grants. The Idempotency-Key is threaded into the
credit ledger’s idempotent grant path (credit_ledger unique on account_id+idempotency_key), so
a retried grant never mints free credits twice — the same key twice yields one grant. 404 { "error":"account_not_found" } for an unknown account_id (same for /admin/keys).
POST /admin/credits/purchase — the payments seam (G3). The single, rail-agnostic endpoint
that turns a completed purchase into usable credits. A Stripe webhook handler, a manual ops top-up,
or a PIX/drpost hook all call it with their own source slug + external_ref:
// body — source is a slug ('stripe'|'manual'|'pix'|…); external_ref is that rail's own reference:{ "account_id":"…", "credits":1000, "source":"stripe", "external_ref":"evt_1…", "usd_amount":10 }// 201 (first landing) | 200 (replay):{ "account_id":"…", "source":"stripe", "external_ref":"evt_1…", "granted":1000, "available_credits":1000, "replayed":false }- Exactly-once for all time per
(source, external_ref): the grant is keyedpurchase:<source>:<external_ref>into the same idempotentcredits_grantprimitive, so a retried or duplicated webhook never double-credits (replayed:true, balance unchanged). - No
Idempotency-Keyheader — theexternal_ref(the rail’s event/charge id) is the idempotency, so this is the ONE admin mutation that doesn’t require the header. 404 { "error":"account_not_found" }unknown account ·400 { "error":"invalid_request" }malformed body (creditsmust be a positive int;sourcea slug;external_refnon-empty).- Backed by
grantPurchasedCredits(db, {accountId, credits, source, externalRef, usdAmount?})insrc/payments.ts— call it directly from a provider handler, or POST this endpoint. Wiring a real rail (Stripe Checkout → webhook) lands later against this seam; see MVP_PLAN Wave G3 + issue #6.
Idempotency semantics. Account and key creation are made idempotent without a schema column: the
row PK is derived deterministically from the Idempotency-Key, so a retry collides on the PK and
returns the original row. One Idempotency-Key identifies one logical creation — reusing it returns
the first record and ignores changed parameters (name/scopes). Revoke is a state-set (naturally
idempotent) but still requires the header for a uniform contract.
Pipelines
Section titled “Pipelines”Every pipeline is driven by input.brief. Two things vary and are advertised honestly per
pipeline (also on the MCP list_pipelines tool — the same catalog):
requires— input keys that are mandatory. A non-empty value (e.g.source_url) means the pipeline transforms existing media and cannot run brief-only; a brief-only submit is rejected up-front withmissing_required_inputand no credits held (and while batch is off,batch_unavailableis returned first). (In an interactive session, which takes only free text, put the source URL in the brief.)stability— the engine pipeline’s maturity (production|beta).- hosted delivery — whether it reliably returns a hosted, shareable
video_url. Onlyanimated-explaineris certified today (its Remotion composition is the one certified for the hosted render tier). Every other pipeline is experimental: it may finish its creative work yet produce no hosted video, in which case the turn settles asfailure_reason:"no_deliverable"and its captured credits are automatically refunded (you are not charged for a non-delivery).stabilityand hosted delivery are independent — aproductionengine pipeline (e.g.cinematic) can still be experimental for hosted delivery. For a proven end-to-end hosted video today, useanimated-explainer.
| Pipeline | requires | stability | Hosted delivery |
|---|---|---|---|
animated-explainer | — | production | certified |
screen-demo | — | production | experimental |
cinematic | — | production | experimental |
animation | — | production | experimental |
hybrid | — | production | experimental |
avatar-spokesperson | — | production | experimental |
talking-head | source_url | beta | experimental |
clip-factory | source_url | beta | experimental |
podcast-repurpose | source_url | beta | experimental |
localization-dub | source_url | beta | experimental |
documentary-montage | — | beta | experimental |
character-animation | — | beta (v0.1, experimental) | experimental |
framework-smoke | — | beta (test only — no real video) | experimental |
For per-pipeline descriptions + example inputs, call the MCP list_pipelines tool (see
MCP.md), or read PIPELINE_INFO in apps/gateway/src/schemas.ts.
Errors
Section titled “Errors”Two distinct 400 body shapes:
- Request-validation 400 (a malformed/invalid body, or a free-text field over the
length cap) →
{ "error": "invalid_request", "detail": { "formErrors": […], "fieldErrors": {…} } }(detailis the flattened Zod issue map — which field, what’s wrong). - Handler 400 (a semantic rejection past validation) →
{ "error": "<code>", … }, e.g.idempotency_key_requiredormissing_required_input(+missing).
Everything else is { "error": "<code>", … }:
| Status | Codes | Notes |
|---|---|---|
400 | invalid_request (+detail) · idempotency_key_required · missing_required_input (+missing) · unsupported_provider (+supported) | see the two shapes above; missing_required_input = a source-required batch pipeline submitted without its input key — rejected up-front, no credits held; unsupported_provider = POST /v1/providers/keys with a provider not in the supported list |
401 | missing_api_key (no bearer) · invalid_api_key (unknown/revoked key) | over MCP this is JSON-RPC -32001 |
402 | insufficient_credits (+required_credits/available_credits) | |
403 | forbidden | wrong scope, e.g. a customer key on /admin |
404 | not_found · account_not_found (admin) | also the share route when a token is unknown or its render is not succeeded |
409 | not_cancelable (jobs) · gate_pending · gate_not_pending · session_closed / session_expired / session_failed · idempotency_key_conflict (admin — a key reused for a different account) | recover per the code (resolve the gate / open a new session / use a fresh key) |
416 | range_not_satisfiable | share route only — a malformed/unsatisfiable Range header |
429 | rate_limited · concurrency_limit (+retry_after_seconds, Retry-After) | see Rate & concurrency limits |
503 | batch_unavailable (+hint) | POST /v1/jobs while batch is disabled (the default — MACARONI_BATCH_ENABLED off): returned first, no credits held; use interactive sessions. A worker + MACARONI_BATCH_ENABLED=1 re-enables batch |