Skip to content

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’s default mac_… key is minted at signup (mint more in the dashboard’s Keys console). Operator /admin provisioning 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 route GET /v1/videos/share/:token (the opaque ≥128-bit share token is the credential there — no api key). The /admin surface 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.

200 → { "ok": true, "service": "macaroni-gateway", "ts": "…" }


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 UNAVAILABLEPOST /v1/jobs returns 503 batch_unavailable (no worker drains the queue; MACARONI_BATCH_ENABLED off by default), no credits held. Use the interactive session for a delivered video today.

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.

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"
}
  • status vs a failed turn (read this). status:"failed" means the session could not open — the only cause today is insufficient credits, and then failure_reason is null. A turn that fails does not change status: the session stays open (or awaiting_gate for a budget failure) with failure_reason != null. So to detect a failed turn, read failure_reason / last_reply_stale, never status.
  • pending_gate is non-null only when status is awaiting_gate. It is { gate_id, stage, question, options, artifact_names, artifact_excerpt }, but be defensive: question and artifact_excerpt may be null and artifact_names may be []. Every name that is listed resolves via GET …/artifacts/:name (no phantoms), and artifact_excerpt (when present) is a ~2KB inline preview usually enough to decide with no extra call. Branch on stage/options: a stage:"budget" gate (options:["add_budget"]) is cleared with POST …/budget, not POST …/gates/:id; any other gate is resolved with POST …/gates/:id.
  • current_stage is an advisory, free-text label of the engine’s furthest checkpoint — pipeline-defined, may be null, and may include values like "budget". It is not a fixed enum; treat it as a human hint, not a control signal.
  • last_reply_stale is true when last_reply is from an earlier turn (e.g. the latest turn ended in a gate or failure); last_reply is 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 (a refund-type entry in your GET /v1/credits ledger).
    • 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 — only animated-explainer is certified for hosted delivery.
    • budget_exceeded (user) — the session budget ran out; top up with POST …/budget.
    • user_interrupted (user) — you stopped the turn with POST …/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_at is the executor’s liveness ping. It is null until the first ping — fall back to updated_at until then. Once flowing it advances while the engine works; a running turn whose heartbeat lapses for ~180s (3 min) is reaped by the server as failure_reason:"infra_error" (resend to retry).
  • video_url is 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 for status:"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 it null and reports failure_reason:"no_deliverable". While running, 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 comment and description tags read AI-generated video (macaroni.video) (the description also carries a parseable ai_generated=true token). 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 with ffprobe -v quiet -show_entries format_tags=comment,description -of default <file>.mp4.

Body (all optional; a session can also be steered entirely in-conversation):

fieldtypedefaultnotes
pipelineenumsee pipelines. Omit to decide in-conversation
briefstringthe 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_creditsintper-pipeline estimatecredits to hold; raise later with POST …/budget
formatenum— (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
byokboolfalseuse only your stored keys — no fallback (see BYOK)
autopilotboolfalsereserved / no-op — approvals are manual today; resolve gates yourself
webhook_urlstring(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.

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.

Method + pathPurpose
POST /v1/sessionsOpen a session — see above. 201 | 200 replay | 402 | 429.
GET /v1/sessionsList recent sessions.
POST /v1/sessions/:id/messagesSend 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/messagesThe readable conversation, paginated (after, limit).
POST /v1/sessions/:id/gates/:gateIdResolve 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/:nameThe full content of a named gate artifact. 404 if not present yet.
POST /v1/sessions/:id/budgetRaise 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/interruptStop 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/closeClose + release the unspent hold (video_url absolutized).
GET /v1/sessions/:id/eventsLive 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/videosGet 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/creditsBalance + 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):

LimitWhat it boundsDefaultEnv overridePer-account override (column)
Concurrent-turn capTurns/sandboxes running at once for the account3MACARONI_MAX_CONCURRENT_TURNSaccounts.max_concurrent_turns
Session-creation rateNew sessions per rolling minute10/minMACARONI_SESSIONS_PER_MINUTEaccounts.sessions_per_minute
Session-creation rateNew sessions per rolling hour100/hourMACARONI_SESSIONS_PER_HOURaccounts.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 to retry_after_seconds).
  • rate_limited = session-creation window tripped (POST /v1/sessions); retry_after_seconds counts 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 …/messages from open, or POST …/gates/:id). Wait for a running turn to finish (poll your open sessions), then retry. A 429 on gate-resolve leaves the gate pending (retry safe); an idempotent replay (same Idempotency-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 MCP submit_job tool) returns 503 { "error":"batch_unavailable", "hint":… } with no credits held — no worker drains the job queue and MACARONI_BATCH_ENABLED is off by default. For a delivered video now, use interactive sessions (above). Re-enabling needs a worker and MACARONI_BATCH_ENABLED=1. Everything below applies only when batch is enabled.

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? }.

Batch is CURRENTLY UNAVAILABLE. POST /v1/jobs returns 503 { "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 setting MACARONI_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:

fieldtypedefaultnotes
pipelineenumrequired; see pipelines
inputobject{}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)
autopilotbooltruekeep 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
byokboolfalseuse only the account’s stored provider keys — no fallback to platform keys (see BYOK)
webhook_urlstring(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-explainer pipeline 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.

Terminal window
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 its input key; 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": true
  • 402{ "error":"insufficient_credits", "required_credits", "available_credits", "job_id" }

200 → { "data": [ jobView, … ] } (newest first, up to 50).

200 → jobView · 404 → { "error":"not_found" }. Poll until status is terminal.

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.

Terminal window
curl -N https://api.macaroni.video/v1/jobs/$ID/events \
-H "authorization: Bearer $MAC_KEY"

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:true run 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" } (400 if 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. A mac_ API key here is rejected 401 { "error":"supabase_jwt_required" } (missing/garbage token → 401 { "error":"missing_token" } / 401 { "error":"invalid_token" }). The token is validated server-side via supabase.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_KEY and MACARONI_SIGNUP_ENABLED=1, read at call time) — all set in prod; were they unset it would return 503 { "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.
Method + pathPurpose
POST /v1/accountExchange (signup). First time → 201 with the raw key; thereafter → 200 replay.
POST /v1/account/keysMint an additional key (lost-key recovery). 201 with the raw key.
POST /v1/account/keys/:id/revokeRevoke 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:0
    • grant_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/revoke200 { 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 /admin route sits behind requireScope('admin'). A normal customer key (jobs:read/jobs:write) that reaches /admin gets 403 { "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-Key header; a keyless call is rejected 400 { "error":"idempotency_key_required" }. Reuse the same key to retry a request exactly once; use a new key for a genuinely separate operation.

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:

Terminal window
# 1) one-time: mint the real admin key using the bootstrap token
curl -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 token

The 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.)

Method + pathPurpose
POST /admin/accountsCreate an account. 201 { id, name, plan, status, created_at } (| 200 replayed:true).
POST /admin/keysMint an API key for account_id (optional scopes, default ["jobs:read","jobs:write"]).
POST /admin/credits/grantGrant credits to account_id. 200 { account_id, granted_credits, available_credits }.
POST /admin/credits/purchasePayments 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/revokeRevoke 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 keyed purchase:<source>:<external_ref> into the same idempotent credits_grant primitive, so a retried or duplicated webhook never double-credits (replayed:true, balance unchanged).
  • No Idempotency-Key header — the external_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 (credits must be a positive int; source a slug; external_ref non-empty).
  • Backed by grantPurchasedCredits(db, {accountId, credits, source, externalRef, usdAmount?}) in src/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.


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 with missing_required_input and no credits held (and while batch is off, batch_unavailable is 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. Only animated-explainer is 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 as failure_reason:"no_deliverable" and its captured credits are automatically refunded (you are not charged for a non-delivery). stability and hosted delivery are independent — a production engine pipeline (e.g. cinematic) can still be experimental for hosted delivery. For a proven end-to-end hosted video today, use animated-explainer.
PipelinerequiresstabilityHosted delivery
animated-explainerproductioncertified
screen-demoproductionexperimental
cinematicproductionexperimental
animationproductionexperimental
hybridproductionexperimental
avatar-spokespersonproductionexperimental
talking-headsource_urlbetaexperimental
clip-factorysource_urlbetaexperimental
podcast-repurposesource_urlbetaexperimental
localization-dubsource_urlbetaexperimental
documentary-montagebetaexperimental
character-animationbeta (v0.1, experimental)experimental
framework-smokebeta (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.

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": {…} } } (detail is the flattened Zod issue map — which field, what’s wrong).
  • Handler 400 (a semantic rejection past validation) → { "error": "<code>", … }, e.g. idempotency_key_required or missing_required_input (+missing).

Everything else is { "error": "<code>", … }:

StatusCodesNotes
400invalid_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
401missing_api_key (no bearer) · invalid_api_key (unknown/revoked key)over MCP this is JSON-RPC -32001
402insufficient_credits (+required_credits/available_credits)
403forbiddenwrong scope, e.g. a customer key on /admin
404not_found · account_not_found (admin)also the share route when a token is unknown or its render is not succeeded
409not_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)
416range_not_satisfiableshare route only — a malformed/unsatisfiable Range header
429rate_limited · concurrency_limit (+retry_after_seconds, Retry-After)see Rate & concurrency limits
503batch_unavailable (+hint)POST /v1/jobs while batch is disabled (the defaultMACARONI_BATCH_ENABLED off): returned first, no credits held; use interactive sessions. A worker + MACARONI_BATCH_ENABLED=1 re-enables batch