REST API
The REST API is for apps and custom integrations. Most users should prefer MCP or the CLI, which wrap this.
Base URL
https://api.framehood.aiapi.framehood.ai is the primary REST host. worker.framehood.ai is an equivalent legacy alias kept during the api.* transition — it serves the exact same backend and handlers, so every path below works identically on either host. Prefer api.framehood.ai for new integrations.
Authentication
Send a bearer token on every request:
Authorization: Bearer <token>A token is either:
- a session token (what the web console uses), or
- an API key — create one in the console (Settings → API keys) or via
POST /api-keys.
Job lifecycle
Generation is asynchronous. Submit a job, then poll it until it reaches a terminal status (succeeded / failed / cancelled).
Submit a job
POST /v1/jobs/{kind}{kind} is a model kind (list them with GET /v1/models). The body carries the model inputs:
curl -X POST https://api.framehood.ai/v1/jobs/flux_pro \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"inputs":{"prompt":"a red fox in the snow","out":"fox.jpg"}}'Add ?wait=25 to block up to 25s for fast jobs and return the finished result inline instead of a job_id.
A queued response:
{ "job_id": "job_…", "kind": "flux_pro", "status": "queued",
"status_url": "/v1/jobs/job_…",
"next_step": { "tool": "get_status", "args": { "job_id": "job_…" },
"why": "Poll this job with get_status until done=true; the result URL will then be in outputs." } }REST vs MCP: next_step
Job responses on both REST and MCP now carry a machine-readable next_step object (#223) — the REST API previously returned a prose string here. REST emits {tool, args, why} (the poll step is always tool: "get_status"); the MCP NextStep type additionally allows an optional action? for other worker-side steps, which REST job responses don't use. In-flight (queued/running) responses include next_step; terminal responses omit it.
Poll a job
GET /v1/jobs/{job_id}When status is succeeded, the result URLs are in outputs (image_url / video_url / audio_url). When failed, see error.
Poll many jobs at once
GET /v1/jobs/batch?ids=job_a,job_b,…Up to 50 comma-separated ids per call — the parallel-submit pattern: fire all your submits, collect the ids, then batch-poll every 30–60s until summary.running + summary.queued is 0.
{
"summary": { "total": 3, "queued": 0, "running": 1, "succeeded": 1,
"failed": 0, "cancelled": 0, "not_found": 1 },
"jobs": [
{ "job_id": "job_a", "status": "succeeded", "done": true,
"outputs": { "image_url": "…" }, "credits": 12, "…": "…" },
{ "job_id": "job_b", "status": "running", "done": false, "…": "…" },
{ "job_id": "job_c", "status": "not_found" }
]
}Each entry mirrors the single-job compact shape (done is true once the job is terminal; failed entries carry the structured error {code, message}). An id that doesn't exist — or that belongs to another account — comes back as status: "not_found"; it never fails the whole request. More than 50 ids is a 400.
Other job endpoints
| Method & path | Purpose |
|---|---|
GET /v1/jobs | list your recent jobs (filters: kind, status, since — RFC3339 or unix seconds, limit, cursor) |
POST /v1/jobs/{job_id}/cancel | cancel a job — a queued job cancels immediately, no charge (200). A running job's cancel is a best-effort request (202): if the model doesn't support cancellation at all it stays running (409); otherwise the request is accepted but the job may still complete and be charged if the model doesn't honor a mid-run stop — poll status for the outcome |
Models & guidance
| Method & path | Purpose |
|---|---|
GET /v1/models | list available model kinds |
GET /v1/models/{kind} | input schema for a kind |
GET /v1/models/{kind}/prompt-guide | prompting guide for a kind |
GET /v1/models/{kind}/skill | agent skill (usage guidance) for a kind |
GET /v1/skill | entry-point skill (how to drive the API) |
GET /v1/workflows | list available multi-step workflows |
GET /v1/workflows/{name}/skill | skill for a named workflow |
GET /v1/health | liveness ({ "ok": true }) — no auth |
Files
| Method & path | Purpose |
|---|---|
GET /files | list your stored outputs |
GET /files/{key} | download a file |
DELETE /files/{key} | delete a file |
POST /files/{key}/publish | make a file public |
POST /files/{key}/unpublish | make a published file private again |
POST /files/{key}/import-to-library | add an uploaded file to your library so it can be assigned to a project (optional JSON body { "name": "…", "project_id": "…" }; returns { ok, asset_id, url, type, size, project_id }) |
GET /files/public/{userId}/{key} | public file (no auth) |
import-to-library mints a library asset for a file you already uploaded (PUT /upload, POST /upload-from-url, or the files MCP tool) — it references the existing file (no copy) and is free. Image/video/audio files only. It resolves your OWN private uploads only: a published (public/) key returns 404, and a teammate's file is not reachable (unlike publish/unpublish/delete). Re-importing the same file mints a new library asset each time.
Uploads
Bring your own input (a reference image, audio, etc.) into your private storage, then reference the returned url in a job's inputs.
| Method & path | Purpose |
|---|---|
PUT /upload?key={key} | stream a file body directly to storage |
POST /upload-from-url | fetch a remote file into storage ({ "source_url": "…", "key": "…" }) |
Breaking change: /upload-from-url error responses and accepted sources
POST /upload-from-url now enforces the same rules as the files(import_remote) MCP action above:
- Only
https://source URLs are accepted (http://is rejected). - Only image, video, and audio files are accepted — a PDF, SVG, or other non-media file that previously stored successfully is now rejected.
- Error responses changed shape. They used to be a free-text
{ "error": "<sentence>" }. They are now a structured code:{ "error": "<code>", "message": "<detail>" }, where<code>is one ofurl_not_allowed(400),unsupported_media_type(415),too_large(413),external_file_expired(502),fetch_failed(502). If your integration matched on the old error text, update it to match onerror.
The success response shape ({ ok, key, r2_key, url, content_type, size }) and the request body are unchanged.
Billing
Read endpoints are available to any org member. The endpoints that change a subscription, card, or plan are owner-only (enforced server-side; a member gets 403 forbidden).
| Method & path | Purpose |
|---|---|
GET /billing/balance | current credit balance |
GET /billing/transactions | credit ledger |
GET /billing/subscription | current subscription (status, allowance, balance, role) |
GET /billing/plans | available credit packages |
GET /billing/manage | owner: card on file, cancel state, recent invoices (in-app billing) |
POST /billing/checkout | owner: start a Stripe Checkout for a package ({ "package": "…" }) → { url } |
POST /billing/change | owner: switch the active subscription to another package, prorated ({ "package": "…" }) |
POST /billing/preview | owner: preview the prorated cost + credits of a switch ({ "package": "…" }) |
POST /billing/cancel | owner: cancel at period end, or { "reactivate": true } to resume |
POST /billing/topup | owner: buy a one-off batch of extra credits now ({ "amount_eur": 20 }, min €20; credits at the extra-usage rate) → a hosted invoice { url } (an owner with a saved card is charged automatically) |
GET / PUT /billing/extra-usage | owner: view / configure automatic overflow top-ups (Extra usage) |
GET /billing/extra-usage/charges | Extra-usage charge history (date, amount, credits, receipt link) |
POST /billing/card | owner: open the Stripe card-entry page → { url } |
POST /billing/card-finalize | owner: set the just-added card as default ({ "session": "…" }) |
POST /billing/portal | owner: open the Stripe customer portal → { url } |
Library & projects
| Method & path | Purpose |
|---|---|
GET /library | search assets (q, type, project, limit, offset) |
GET /library/trash | list trashed assets (auto-purged after 10 days) |
POST /library/{id}/trash, POST /library/{id}/restore | trash / restore an asset |
POST /library/{id}/project | assign to a project ({ "project_id": "…|null" }) |
GET / POST /projects | list / create projects |
GET /projects/active | get your active (default) project |
PUT / POST /projects/active | set your active (default) project ({ "project_id": "…|null" }) |
PATCH / DELETE /projects/{id} | rename·revisibility / delete (owner) |
Keys, actors, orgs
Actors temporarily disabled
Actor endpoints are part of the actor feature, which is temporarily disabled while we rework it — see Actor models.
| Method & path | Purpose |
|---|---|
GET / POST /api-keys, DELETE /api-keys/{key} | manage API keys (POST body { name?, expires_in_days? } — expires_in_days an optional integer 1–3650 day lifetime, omitted = never expires; an expired key stops working) |
GET /actors | list your actors |
GET /orgs, /orgs/members, /orgs/spend, /orgs/spend/trend | organization info + daily spend |
POST /orgs/invites | owner: invite by email; returns the join link ({ "email": "…", "role": "member|owner" }) |
POST /orgs/invites/accept | accept an invite ({ "token": "…" }) |
PATCH /orgs/members/{userId} | change role / suspend ({ "role": "admin" } or { "suspended": true }) |
DELETE /orgs/members/{userId} | remove a member (owner) |
TIP
The same operations are available over MCP without managing tokens — your client handles auth.