# REST API v1

Throughline's external API exposes a production's graph — scenes, assets,
schedule, approvals, deliveries, people, activity — to tools that live
around the production rather than inside the app: pipeline scripts,
reporting dashboards, delivery trackers, and similar integrations.

It is a **read-mostly, write-carefully** surface. The read side mirrors what
crew see in the app, scoped to one production. The write side is
deliberately narrow: create an asset, attach a note, decide an approval —
because nothing in Throughline moves without an accountable person (or a key
acting on one's behalf) behind it.

Most of what's below is **one resource, one endpoint** — the ordinary REST
shape. A newer part of the surface — [bundles, turnovers, media and purchase
orders](#one-round-trip-clamped-to-the-caller) — is built the other way: one
call that answers a whole question, assembled server-side and clamped to
whatever the calling key may see. Both shapes share the same keys, the same
scopes, and the same [MCP door](mcp.html).

## Base URL

```
https://throughline-backend-idkf.onrender.com/api/v1
```

This host is provisional for the pilot. All requests and JSON bodies use
`Content-Type: application/json`.

## Authentication

Every request carries a **Throughline access key** as a bearer token:

```
Authorization: Bearer tl_your_access_key_here
```

A key is minted by a production's admin, from inside Throughline, for a
specific person or integration's use — one key, one label, one production.
The full token is shown once, at creation; after that only a recognizable
prefix is kept. There is no cross-production surface: a key minted on one
production sees that production and nothing else.

Each key carries one or more **scopes**:

| Scope | Grants |
|---|---|
| `read` | Every `GET` endpoint below except `/v1/purchase-orders` |
| `write` | Every `POST` endpoint below except `/v1/turnovers/{id}/submissions` |
| `submit:turnover` | `POST /v1/turnovers/{id}/submissions` — submitting work back on a delivery |
| `read:money` | `GET /v1/purchase-orders` — a vendor's own purchase orders and cost-report line |

A key can hold any combination. `read`/`write` are the default when a key is
minted with no scopes named; `submit:turnover` and `read:money` are never on
by default — they're for the [engagement-bound](#two-shapes-of-key) shape
below and have to be asked for explicitly.

### Two shapes of key

**Production-wide** is the ordinary shape, and everything above this point
describes it: it reads and writes across the whole production, scoped only
by `read`/`write`.

**Engagement-bound** is the other shape — minted for one vendor
organization's own automation, bound to that vendor's engagement on this
production. It:

- reaches **only** the [bundles, turnovers, media and purchase-order
  surface](#one-round-trip-clamped-to-the-caller) below — every endpoint
  above this point refuses it outright with a `403`, regardless of which
  scopes it holds;
- on that surface, is clamped to exactly what its engagement has been
  granted — the identical reach a signed-in person seated on that same
  engagement would get, computed the same way;
- is what a vendor's own pipeline uses instead of a person's login. There is
  no other way for an outside organization's automation to reach Throughline
  at all.

A production-wide key also works on the bundle/turnover/media/purchase-order
surface — unclamped, the same production-scoped read it already has
everywhere else. The engagement clamp only ever narrows; it is never a wider
door than the key already has.

### Auth errors

| Status | Meaning |
|---|---|
| `401` | Missing or malformed `Authorization` header, or an unknown/revoked key |
| `403` | The key is valid but lacks the scope the endpoint needs |
| `403` | An engagement-bound key called an endpoint outside the bundle/turnover/media/purchase-order surface |

Revoking a key from Settings takes effect immediately.

## Read endpoints

All of the following require the `read` scope.

### GET /production

The production the key belongs to, with headline counts.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/production \
  -H "Authorization: Bearer $TL_KEY"
```

```json
{
  "id": 1,
  "title": "Example Production",
  "logline": "One line describing the show.",
  "status": "active",
  "phase": "production",
  "accent": "sea-glass",
  "format": "single",
  "stats": {
    "departments": 12,
    "open_approvals": 3,
    "deliveries_in_flight": 2,
    "scenes": 44,
    "nodes": 210
  }
}
```

An episodic production also carries `"format": "episodic"` and a
`season_number`. `accent` is a key into a small curated palette, never a raw
color — never document one you didn't see returned.

### GET /departments

Every department on the production.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/departments \
  -H "Authorization: Bearer $TL_KEY"
```

```json
[
  {
    "id": 7,
    "slug": "props",
    "name": "Props",
    "cluster": "art",
    "phases": ["prep", "production"],
    "mission": "Every object an actor touches.",
    "key_roles": ["Props Master", "Props Assistant"],
    "privacy": "open",
    "unveiled": true
  }
]
```

### GET /departments/{slug}

One department, with its people, its registry, and an asset count.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/departments/props \
  -H "Authorization: Bearer $TL_KEY"
```

```json
{
  "id": 7,
  "slug": "props",
  "name": "Props",
  "cluster": "art",
  "registry": {},
  "people": [
    { "id": 14, "name": "<person name>", "role": "Props Master", "is_hod": true }
  ],
  "assets": 38
}
```

### GET /scenes

The current script breakdown, in script order.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/scenes \
  -H "Authorization: Bearer $TL_KEY"
```

```json
[
  {
    "node_id": 41,
    "number": "12",
    "slugline": "EXT. LIGHTHOUSE GALLERY - NIGHT",
    "synopsis": "One line describing the scene.",
    "status": "active",
    "revision_color": "Blue",
    "elements": { "props": ["Brass compass"], "cast": ["KEEPER"] }
  }
]
```

On an episodic production, every scene also carries `episode_id` and
`episode_code` (e.g. `"S2E7"`); a single-output production's response omits
both keys entirely rather than sending them as `null`.

### GET /assets

Non-scene graph nodes — props, locations, characters, shots, and so on.
Every optional query parameter narrows the list:

| Parameter | Meaning |
|---|---|
| `department` | Department slug, e.g. `props` |
| `kind` | Node kind, e.g. `asset`, `character`, `location` |
| `status` | One of `planned`, `active`, `blocked`, `done` |

```bash
curl -s "https://throughline-backend-idkf.onrender.com/api/v1/assets?department=props&status=active" \
  -H "Authorization: Bearer $TL_KEY"
```

```json
[
  {
    "node_id": 112,
    "label": "Brass compass",
    "kind": "asset",
    "status": "active",
    "department": "props",
    "versions": 3,
    "updated_at": "2026-07-09T17:42:11Z"
  }
]
```

Rows are logistical — labels, statuses, version counts — never media.

### GET /assets/{node_id}

One asset in full. Released versions carry media; anything not yet released
comes back as a locked stub, the same rule the app applies to anyone outside
the owning department.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/assets/112 \
  -H "Authorization: Bearer $TL_KEY"
```

### GET /schedule

Shoot days with their scenes.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/schedule \
  -H "Authorization: Bearer $TL_KEY"
```

```json
[
  {
    "id": 3,
    "day_number": 3,
    "date": "2026-07-10",
    "general_call": "06:30",
    "status": "scheduled",
    "scenes": [
      { "node_id": 41, "number": "12", "slugline": "EXT. LIGHTHOUSE GALLERY - NIGHT" }
    ]
  }
]
```

`episode_id` is a repeatable query parameter (`?episode_id=1&episode_id=2`)
on episodic productions. It marks matching strips rather than dropping
anything from the response, and has no effect on a single-output
production.

### GET /deliveries

Every delivery — inter-department and external — with its gating approval,
if it has one.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/deliveries \
  -H "Authorization: Bearer $TL_KEY"
```

### GET /approvals

Approval chains. Filter with `?status=pending|approved|changes_requested|rejected`.

```bash
curl -s "https://throughline-backend-idkf.onrender.com/api/v1/approvals?status=pending" \
  -H "Authorization: Bearer $TL_KEY"
```

### GET /script-changes

Detected script-draft differences awaiting a decision. Defaults to
`?status=pending`; also accepts `approved`, `dismissed`, or `all`. On an episodic
production, `?episode_id=` scopes to one episode and `?all_episodes=true`
widens to every episode at once.

### GET /activity

The production activity feed, newest first. `?limit=` defaults to 50, capped
at 200.

### GET /people

The crew directory — name, role, department, and HOD/admin flags. No
contact details.

### GET /jobs

Recent generation jobs (image/video renders). `?limit=` as for `/activity`.

## Write endpoints

All of the following require the `write` scope.

### POST /assets

Create a new graph node.

```bash
curl -s -X POST https://throughline-backend-idkf.onrender.com/api/v1/assets \
  -H "Authorization: Bearer $TL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label": "Hero lantern", "kind": "asset", "department": "props", "status": "active"}'
```

`label` is required. `kind` defaults to `"asset"`; scenes cannot be created
here — they come from script breakdown. `status` must be one of `planned`,
`active`, `blocked`, `done`.

### POST /assets/{node_id}/notes

Attach a note to an asset. It lands on the asset's newest version if it has
one, or as an activity entry if it doesn't — either way it's on the record.

```bash
curl -s -X POST https://throughline-backend-idkf.onrender.com/api/v1/assets/112/notes \
  -H "Authorization: Bearer $TL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Approved for hero use.", "author": "Editorial pipeline"}'
```

`text` is required. `author` is free text attributed to your integration
if omitted.

### POST /approvals/{approval_id}/decide

Advance the current step of an approval chain.

```bash
curl -s -X POST https://throughline-backend-idkf.onrender.com/api/v1/approvals/12/decide \
  -H "Authorization: Bearer $TL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"decision": "approved", "decided_by": "Dana Whitfield", "note": "Signed off via external review tool."}'
```

`decision` must be `"approved"` or `"changes"`. `decided_by` names the person
making the call, and a write-scoped key is not a signature — if `decided_by`
isn't the person whose step it actually is, the request is refused rather
than silently recorded under someone else's name. Decision-mode approvals
refuse an anonymous decision outright; multi-step chains permit omitting it.

## One round trip, clamped to the caller

Seven endpoints, in four families — bundles, turnovers, media and purchase
orders — built for the tools that don't want a resource each: they
want one call that answers a whole question, assembled on the server and
clamped to whatever the calling key may see. A [production-wide
key](#two-shapes-of-key) gets the unclamped answer it already gets
everywhere else; an [engagement-bound key](#two-shapes-of-key) gets exactly
its own engagement's slice, and nothing here is a wider door than the
resource-by-resource endpoints above already open.

### GET /bundles/scene/{node_id}

Everything about one scene in a single call: its script state, its linked
assets and their released versions, its shoot days, its approvals, its
department notes. Requires `read`.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/bundles/scene/41 \
  -H "Authorization: Bearer $TL_KEY"
```

```json
{
  "version": 1,
  "scene": { "node_id": 41, "number": "12", "slugline": "EXT. LIGHTHOUSE GALLERY - NIGHT",
             "status": "active", "revision_color": "Blue", "elements": {} },
  "assets": [
    { "node_id": 112, "label": "Brass compass", "kind": "asset", "status": "active",
      "released_versions": [ { "id": 88, "version": 3, "variant": "A", "released": true } ] }
  ],
  "shoot_days": [ { "id": 3, "day_number": 3, "date": "2026-07-10", "status": "scheduled", "general_call": "06:30" } ],
  "approvals": [ { "id": 12, "title": "Compass hero build", "status": "pending", "mode": "chain", "due": "2026-07-12" } ],
  "department_notes": [ { "id": 210, "summary": "Approved for hero use.", "created_at": "2026-07-09T18:03:27Z" } ],
  "etag": "\"3f9c1a…\""
}
```

`assets` carries released versions only — the same rule every other read on
this API follows. The response carries an `ETag`; send it back as
`If-None-Match` on your next request and get a bare `304` when nothing has
changed, rather than re-fetching the whole document.

### GET /bundles/character/{node_id}

A character's costumes, plus the ingredient files that fed them — the
relationship a pipeline can't reconstruct itself from a resource list.
Requires `read`.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/bundles/character/57 \
  -H "Authorization: Bearer $TL_KEY"
```

```json
{
  "version": 1,
  "character": { "node_id": 57, "label": "Mara", "status": "active" },
  "costumes": [
    { "node_id": 88, "label": "Storm slicker", "status": "approved",
      "released_versions": [ { "id": 201, "version": 1, "variant": "A", "released": true } ],
      "ingredients": [ { "id": 40, "kind": "swatch", "label": "Oilskin swatch" } ] }
  ],
  "etag": "\"a01e2f…\""
}
```

### GET /turnovers

The deliveries this key's engagement holds a grant against — `read` or
`submit`, either counts. Requires `read`; empty (never an error) for a
production-wide key or an ungranted engagement.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/turnovers \
  -H "Authorization: Bearer $TL_VENDOR_KEY"
```

```json
[
  { "id": 71, "title": "Costume turnover — batch 1", "status": "sent",
    "due": "2026-07-11", "external_to": "Meridian FX", "spec": [],
    "sent_at": "2026-07-01T09:00:00Z", "can_submit": true }
]
```

`can_submit` is whether this engagement specifically holds a `submit` grant
on that delivery — a `read`-only grant lists the delivery here but answers
`false`.

### POST /turnovers/{id}/submissions

Work coming back. Requires `submit:turnover` **and** a live `submit` grant
on this one delivery — either alone is refused. Multipart, not JSON:

```bash
curl -s -X POST https://throughline-backend-idkf.onrender.com/api/v1/turnovers/71/submissions \
  -H "Authorization: Bearer $TL_VENDOR_KEY" \
  -F "title=Batch 1 comps" \
  -F "note=Two shots, first pass" \
  -F "files=@shot_010.mov"
```

```json
{
  "id": 14,
  "delivery_id": 71,
  "status": "submitted",
  "title": "Batch 1 comps",
  "note": "Two shots, first pass",
  "media": [ { "id": 501, "label": "shot_010.mov", "kind": "submission" } ],
  "created_at": "2026-07-02T14:20:00Z"
}
```

Every uploaded file becomes its own media object, below — never a raw path
handed back. Nothing in the request names where a file lands on disk.

### GET /media/{id} and GET /media/{id}/content

A piece of media, read by id rather than by guessing a path. `/media/{id}`
returns its metadata; `/media/{id}/content` streams the bytes. Requires
`read`.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/media/501/content \
  -H "Authorization: Bearer $TL_VENDOR_KEY" -o shot_010.mov
```

Visible to a production-wide key unconditionally. For an engagement-bound
key, work submitted on a turnover is visible only to the engagement that
uploaded it — never through the delivery it was submitted against, even to
another engagement holding a `read` grant on that same delivery. Vendors in
film and TV are frequently competitors; isolation between them is the safe
default, not an oversight.

### GET /purchase-orders

A vendor's own purchase orders and its own cost-report line — nothing
else money-shaped. Requires `read:money` **and** a live grant on the
engagement. A key without the scope is refused with a `403`, the same as
any other missing scope; a key that holds the scope but has no engagement,
or an engagement with no money grant, answers with an empty result rather
than an error.

```bash
curl -s https://throughline-backend-idkf.onrender.com/api/v1/purchase-orders \
  -H "Authorization: Bearer $TL_VENDOR_KEY"
```

```json
{
  "purchase_orders": [
    { "id": 90, "department_id": 12, "account": "1100", "vendor": "Meridian FX",
      "description": "VFX turnover batch 1", "amount": 40000.0, "status": "open", "date": "2026-06-01" }
  ],
  "line": [ { "account": "1100", "budget": 100000.0 } ],
  "week_of": "2026-06-01"
}
```

Never a production's whole topsheet, and never another vendor's rows.

### OpenAPI

A hand-maintained [OpenAPI 3.1 document](openapi.v1.json) covers this
round-trip surface — the seven bundle, turnover, media, and purchase-order
endpoints above, bearer-key auth only. It is scoped to exactly what's public;
it does not describe the pilot's internal access gate.

## Not yet in v1

The full budget topsheet across every department, and the assistant's chat
interface, are part of the in-app surface today and are not yet exposed to
external API keys — a vendor's own purchase orders above are the one slice
of money data that is. If your integration needs the rest, talk to your
Throughline contact.

## MCP

Most of this page is also reachable over [MCP](mcp.html) — the same keys,
the same scopes, the same clamped answers, a different transport. Fifteen
tools cover the read surface, both bundles, turnovers and turnover
submissions. What has no tool yet: `/script-changes`, `/people`, `/jobs`,
the two `/media` reads, and the three [write
endpoints](#write-endpoints). Use REST for those.

## Versioning

The path prefix is `/api/v1`. Changes within it are additive only — new
endpoints, new optional fields, new query parameters. Nothing is renamed or
removed inside v1; a breaking change would ship as `/api/v2`.

Timestamps are UTC ISO-8601. IDs are integers, stable within a production.
Unknown fields may appear in any response at any time — parse leniently.
