# REST API

The REST API is for apps and custom integrations. Most users should prefer
[MCP](/guide/mcp) or the [CLI](/guide/cli), which wrap this.

## Base URL

```
https://api.framehood.ai
```

`api.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:

```sh
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:

```json
{ "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." } }
```

::: info 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.

```json
{
  "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": "…" }`) |

::: warning 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 of
  `url_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 on `error`.

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

::: warning Actors temporarily disabled
Actor endpoints are part of the actor feature, which is **temporarily disabled**
while we rework it — see [Actor models](/reference/models/actors).
:::

| 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](/reference/tools) without managing
tokens — your client handles auth.
:::
