Anjin Media
Docs navigation

Docs

API reference

Every endpoint the platform exposes: twenty-eight routes under /v1 across seven groups, plus a health check and the MCP mount. Each one carries its scope, its success status, the fields it accepts, the shape it returns and the refusals it can produce.

This is the whole surface. There is no second API, no undocumented route and no versioning scheme beyond the /v1 prefix in every path below. The task-shaped guides - Quickstart, Uploading video, Creating a composition - explain why you would call these in a given order. This page answers what exactly each one does.

Conventions#

Seven things are true of every endpoint on this page. They are stated once here rather than twenty-eight times below.

Base URL

base url
https://anjin-media-api.fly.dev

Every path below is relative to it. Requests and responses are application/json, with two exceptions: ?format=srt and ?format=vtt on the transcript endpoint return the caption file as plain text, and GET /healthz returns the string ok.

Authentication

A bearer key on every route except /healthz. Keys are minted in the dashboard, shown once, and prefixed mk_live_.

curl
curl -sS https://anjin-media-api.fly.dev/v1/source-groups \
  -H "authorization: Bearer $ANJIN_API_KEY"

Each endpoint requires exactly one of three scopes - read, write or admin - named in its fact strip below. A key whose scope list is blank is grandfathered to every scope, so a key created without ticking anything is unrestricted rather than useless. A key that does not carry the required scope is 403 insufficient_scope; a key that does not resolve at all is 401 invalid_api_key. Full detail on Authentication.

Errors

Every refusal is an RFC 9457 problem document - application/problem+json, with five members. Branch on code; detail is for humans and logs.

422 Unprocessable Entity · application/problem+json
{
  "type": "https://anjin.media/errors/group_not_ready",
  "title": "Group Not Ready",
  "status": 422,
  "detail": "groups not ready: 9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "code": "group_not_ready"
}

Five of these can come back from any authenticated endpoint. The sixth, capacity_paused, is raised only by the admission gate, so it reaches just the five calls that start work: creating a source group, confirming a file, creating a cut, revising a plan and requesting renders.

  • 401 invalid_api_key - The bearer token is missing, malformed or revoked.
  • 403 insufficient_scope - The key resolved but lacks the scope this route needs.
  • 429 rate_limited - Over 60 requests a minute on this key.
  • 429 account_rate_limited - Over 120 requests a minute across the account.
  • 503 capacity_paused - New work is not being accepted right now. retry-after is 5, 60, or the remainder of the window, depending on which stop fired - see Errors & limits. Nothing was started, so a retry after the wait is safe.
  • 500 internal - An unhandled failure. Nothing about the request is wrong; retry.

Every code in the catalogue, what each one means and how to recover from it is on Errors & limits. Only the endpoint-specific refusals are repeated below.

404, not 403

On the read, get, patch and list paths, a resource belonging to another account is answered 404 not_found - identically to one that never existed. Your code cannot tell the two apart, which is the intent, so never treat a 404 as proof something was deleted.

Idempotency

Send an idempotency-key header on a mutation and a retry cannot duplicate the work. The key identifies the whole request - method, path and a hash of the body - and a stored 2xx replays verbatim with idempotency-replayed: true on the response. Reusing a key with a different body is 422 idempotency_conflict; a duplicate arriving while the first is still in flight waits up to two seconds and then gets 409 idempotency_in_progress. Only 2xx responses are stored, so a refused request releases its key and a corrected retry runs.

Ten routes honour the header:

  • POST /v1/source-groups
  • POST /v1/source-groups/:id/files/:fileId/complete
  • POST /v1/cuts
  • POST /v1/cuts/:id/revise
  • POST /v1/cuts/:id/renders
  • POST /v1/brand-kits
  • POST /v1/brand-kits/:id/assets
  • POST /v1/brand-kits/:id/assets/confirm
  • POST /v1/webhook-endpoints
  • PATCH /v1/source-groups/:id/speakers

PATCH /v1/cuts/:id/plan is the one mutation that does not - it runs synchronously and has nothing to duplicate. The stored replay of a webhook-endpoint create has its secret stripped, so a replay returns the same shape without it.

Pagination

Four list endpoints are keyset-paginated, newest first: /v1/source-groups, /v1/cuts, /v1/brand-kits and /v1/webhook-endpoints. They take the same two parameters and return { data, next_cursor }, with next_cursor null on the last page.

ParameterTypeDescription
limitinteger · default 50 · max 100Rows per page. A value that is not a positive integer is ignored rather than refused, and you get the default; anything above 100 is clamped.
cursorstringThe next_cursor from the previous page. Opaque - do not parse or construct one. A malformed cursor is treated as no cursor, so you get the first page rather than an error.

GET /v1/cuts/:id/renders is deliberately not paginated: it returns every render a cut has, across every batch, oldest first.

Response headers

Three, and no others. x-ratelimit-remaining rides every authenticated response, success or refusal, and reports the lower of the two buckets - 60 a minute per key, 120 a minute per account. retry-after appears on 429 and 503. idempotency-replayed appears on a replay. There are no request ids and no tracing headers.

The usage envelope

Six responses carry additive usage fields on top of their own body. An account on a published plan, an Enterprise contract or a live trial gets minutes_plan (the account's plan id, null when it has none), minutes_remaining (source minutes left in the current period, to one decimal, null when the allowance is custom) and low_minutes (true at ten percent of the allowance or less). A legacy credit account gets credits_remaining and low_credits instead. They ride POST /v1/source-groups, the file /complete call, POST /v1/cuts, POST /v1/cuts/:id/revise, POST /v1/cuts/:id/renders and GET /v1/usage - and are marked in the fact strips below. Source minutes are metered once, when a group becomes ready; creating, revising and rendering a cut never move minutes_remaining. The render quote, POST /v1/cuts/:id/render-quote, carries the same figures in a minutes block - allowance, used, remaining, period_end and the account's plan - and that block is null when the allowance could not be read in time: unknown, never zero. The full contract for the quote lives in the platform's API reference, docs/API.md.

Source groups#

A source group is one recording: a name and one to eight files that belong together. Eight is the request limit; how many of them may be camera files is a plan entitlement - one on Creator and Pro, two on Studio, four on Enterprise. Six routes create one, get bytes into it, read it back and remove it. Ingest behaviour, the format allowlist and the ceilings are on Uploading video.

Create a source group

POST/v1/source-groups

Scope write · Success 201 Created · Honours idempotency-key · Carries the usage envelope

FieldTypeDescription
namestring · requiredWhat the group is called in listings and in the dashboard.
filesobject[] · required · 1–8The files that belong together - the angles and audio tracks of one recording. Fewer than one or more than eight is refused.
files[].filenamestringThe upload path. Its extension is checked against the allowlist at request time, and the response carries a signed upload URL for it. Mutually exclusive with source_url.
files[].source_urlstringThe pull path. An HTTPS URL the platform fetches. Resolved and checked against private address ranges before any row is written.
files[].rolestring · default "camera"camera or audio. Camera files become the angles A–H the edit can cut between.
files[].labelstringYour own name for the file. Defaults to file-1, file-2 and so on, by position.

Each file takes either a filename or a source_url - one or the other, never both, never neither. The response returns one entry per file in the order you sent them: an upload-path file carries a signed upload object, and a pull-path file carries its id alone, because there is nothing for you to upload.

201 Created
{
  "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "files": [
    {
      "id": "3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915",
      "upload": {
        "url": "https://<storage-host>/<signed-upload-path>?token=<token>",
        "token": "<token>",
        "key": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215/9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340/3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915/original.mp4"
      }
    },
    { "id": "0c6d84b2-1e57-4a39-90f8-5b7c2e416d03" }
  ],
  "minutes_plan": "plan_creator",
  "minutes_remaining": 150,
  "low_minutes": false
}
  • 400 invalid_json - The body did not parse.
  • 422 invalid_request - name and 1-8 files required, each file needs filename or source_url, provide filename or source_url, not both, or role must be camera|audio. Validation covers the whole payload before the first row is written.
  • 422 unsupported_extension - A filename outside mp4 mov mkv wav mp3 m4a aac. A source_url is not extension-checked here - it is fetched and probed, so an undecodable source fails during ingest instead.
  • 422 ssrf_blocked - A source_url that is not HTTPS or does not resolve to a public address.
  • 429 concurrency_limit - Too many ingests already running for the account.
  • 402 multicam_not_entitled - More camera files than the plan's entitlement: one on Creator and Pro, two on Studio, four on Enterprise. Dormant unless billing enforcement is on.
  • 402 minutes_plan_required - No plan and no live trial on the account. Carries upgrade_url. Dormant unless the source-minutes gate is on.
  • 402 minutes_allowance_exhausted - The period's source-minute allowance is already used up. Carries allowance, used, period_end and upgrade_url. Dormant unless the source-minutes gate is on.

Confirm an uploaded file

POST/v1/source-groups/:id/files/:fileId/complete

Scope write · Success 202 Accepted · Honours idempotency-key · Carries the usage envelope

No request body. Call it once the bytes are in the signed URL. The server probes storage for the key it minted rather than taking your word for it, then flips the group to ingesting and queues the work. An upload-path file starts no work until this call succeeds; a source_url file begins ingest at create time and has no confirmation step.

202 Accepted
{
  "status": "queued",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 150,
  "low_minutes": false
}
  • 404 not_found - file not found - wrong file id, wrong group, or not yours.
  • 409 conflict - file is uploaded and so on: the file is not pending, usually because it was already confirmed.
  • 409 upload_incomplete - object not found in storage - no bytes landed. Re-upload and call again.
  • 429 concurrency_limit - Too many ingests already running. The file stays pending, so the same call retries cleanly.

List source groups

GET/v1/source-groups

Scope read · Success 200 OK · Cursor-paginated

ParameterTypeDescription
limitinteger · default 50 · max 100Rows per page. A value that is not a positive integer is ignored rather than refused, and you get the default; anything above 100 is clamped.
cursorstringThe next_cursor from the previous page. Opaque - do not parse or construct one. A malformed cursor is treated as no cursor, so you get the first page rather than an error.

Rows carry two derived signals the detail route also returns: camera_count, the number of files with the camera role, and total_duration_s, the duration of the group's reference angle - not the sum of its files. The file list itself is not included here.

200 OK
{
  "data": [
    {
      "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
      "name": "Q3 product webinar",
      "status": "ready",
      "duration_ms": 3541200,
      "created_at": "2026-08-16T09:12:44.610Z",
      "camera_count": 1,
      "total_duration_s": 3541.2
    },
    {
      "id": "1c7e5a94-3b28-4f60-9d15-8a2f6c30e7b1",
      "name": "Customer panel - March",
      "status": "ready",
      "duration_ms": 5218900,
      "created_at": "2026-03-04T15:40:02.118Z",
      "camera_count": 3,
      "total_duration_s": 5218.9
    }
  ],
  "next_cursor": "MjAyNi0wMy0wNFQxNTo0MDowMi4xMThafDFjN2U1YTk0LTNiMjgtNGY2MC05ZDE1LThhMmY2YzMwZTdiMQ"
}

Get a source group

GET/v1/source-groups/:id

Scope read · Success 200 OK

No request body. The full group row, its files, and the same two signals. Three separate perception stages are reported, so a group sitting in perceiving tells you which one it is waiting on: asr_status (transcription and diarization), vad_status (speech and silence) and sync_status (multi-camera alignment, skipped on a single-camera group).

200 OK · abridged file rows
{
  "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "name": "Q3 product webinar",
  "status": "ready",
  "asr_status": "complete",
  "vad_status": "complete",
  "sync_status": "skipped",
  "duration_ms": 3541200,
  "reference_file_id": "3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915",
  "error": null,
  "created_at": "2026-08-16T09:12:44.610Z",
  "updated_at": "2026-08-16T09:41:07.332Z",
  "source_files": [
    {
      "id": "3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915",
      "role": "camera",
      "label": "file-1",
      "status": "ready",
      "source_url": null
    }
  ],
  "camera_count": 1,
  "total_duration_s": 3541.2
}
  • 404 not_found - source group not found.

Get a transcript

GET/v1/source-groups/:id/transcript

Scope read · Success 200 OK

ParameterTypeDescription
formatsegments | words | srt | vtt · default "segments"segments and words return JSON; srt and vtt return the caption file as plain text. Any other value is 422 invalid_format.
format=segments · 200 OK · one segment shown
{
  "segments": [
    {
      "idx": 412,
      "speaker": "B",
      "start_ms": 1412000,
      "end_ms": 1425600,
      "text": "So the question we get every single time is: where does our footage actually sit?",
      "word_start_idx": 3980,
      "word_end_idx": 3995
    }
  ]
}

format=words returns { language, words[] }, each word with its own timing, confidence, speaker label and filler flag. format=srt and format=vtt return caption text rather than JSON. Speaker values are diarization labels; give them names with the speakers endpoint.

  • 404 not_found - source group not found.
  • 409 not_ready - transcript is processing - the detail names the stage transcription is at.
  • 422 invalid_format - format must be segments|words|srt|vtt.

Delete a source group

DELETE/v1/source-groups/:id

Scope admin · Success 202 Accepted

No request body. Removes the group and its files and purges the stored bytes - originals, mezzanines, audio tracks and proxies. Cuts that drew on the group keep their plan documents; there is simply no footage left to render them from.

202 Accepted
{ "status": "deleted" }
  • 404 not_found - source group not found.
  • 409 conflict - group is ingesting or perceiving - wait for it to finish or fail. A group still uploading deletes cleanly.

Cuts#

A cut is a brief plus the footage it may draw on. Seven routes create one, read its plan and every version of it, change that plan two different ways, and remove it. The field-by-field contract for creating one lives on Creating a composition; the plan document itself is documented on Composition results.

Create a cut

POST/v1/cuts

Scope write · Success 202 Accepted · Honours idempotency-key · Carries the usage envelope

FieldTypeDescription
promptstring · required · 1–2000The editorial brief. Not blank - whitespace alone is refused - and measured against 2,000 characters as sent.
source_group_idsstring[] · required · 1–4The footage the edit may draw on. Every id has to be a uuid you own and ready.
settingsobject · requiredShaping controls. Carries the one required setting, target_duration_s (integer, 10–600), so the object cannot be omitted. Every key is listed on Creating a composition.
aspectsstring[] · 1–3 unique16:9, 1:1, 9:16. Optional here and required only when auto_render is true - the render request takes its own.
crop_modebars | stack · default "bars"How a wide frame is fitted into a 9:16 output. speaker parses and is then refused - see below.
auto_renderboolean · default falseRender the first plan the moment it is committed. Fires on plan version 1 only; a revision never re-renders.
brand_kit_idstring (uuid)A kit you own. It is a property of the cut, so it applies to every render this cut ever produces.

aspects, crop_mode and auto_render are top-level fields that are folded into the stored settings object. They are also accepted nested inside settings; if you send both, the top-level one wins.

202 Accepted
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "status": "planning",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 108.5,
  "low_minutes": false
}
  • 400 invalid_json - The body did not parse.
  • 422 invalid_request - prompt must be 1-2000 non-blank chars, source_group_ids must be 1-4 uuids, or brand_kit_id must be a uuid.
  • 422 invalid_settings - A settings value is out of range or not an allowed string. The detail names the key first, e.g. target_duration_s: required integer in [10, 600].
  • 404 not_found - one or more source groups not found or brand kit not found.
  • 422 group_not_ready - groups not ready: <id> - wait for source.ready.
  • 429 concurrency_limit - Too many planner runs already in flight for the account.

List cuts

GET/v1/cuts

Scope read · Success 200 OK · Cursor-paginated

ParameterTypeDescription
limitinteger · default 50 · max 100Rows per page. A value that is not a positive integer is ignored rather than refused, and you get the default; anything above 100 is clamped.
cursorstringThe next_cursor from the previous page. Opaque - do not parse or construct one. A malformed cursor is treated as no cursor, so you get the first page rather than an error.

Four fields per cut, newest first. Neither the plan nor the settings appear here - read the cut for those.

200 OK
{
  "data": [
    {
      "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
      "prompt": "Cut a 60 second answer to the data residency question.",
      "status": "planned",
      "created_at": "2026-08-17T14:06:11.204Z"
    },
    {
      "id": "2b8e4f01-9d63-4a57-b1c0-8e5a37d64f92",
      "prompt": "Ninety seconds on why the migration took nine months.",
      "status": "complete",
      "created_at": "2026-08-16T09:41:55.017Z"
    }
  ],
  "next_cursor": "MjAyNi0wOC0xNlQwOTo0MTo1NS4wMTdafDJiOGU0ZjAxLTlkNjMtNGE1Ny1iMWMwLThlNWEzN2Q2NGY5Mg"
}

Get a cut

GET/v1/cuts/:id

Scope read · Success 200 OK

No request body. The cut row, the latest plan document verbatim, and the version number that plan belongs to.

200 OK · plan abridged
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "prompt": "Cut a 60 second answer to the data residency question.",
  "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
  "settings": {
    "targetDurationS": 60,
    "durationTolerancePct": 15,
    "minSegmentDurationS": 3,
    "pace": "standard",
    "chronology": "flexible",
    "shrinkPauses": true,
    "maxPauseMs": 1500,
    "pauseFloorMs": 350,
    "snapWindowMs": 400,
    "minSilenceMs": 150,
    "plannerModelTier": "default",
    "autoRender": false,
    "aspects": ["9:16"],
    "economy": false,
    "fillerRemoval": false,
    "cropMode": "bars"
  },
  "status": "planned",
  "brand_kit_id": null,
  "error": null,
  "created_at": "2026-08-17T14:06:11.204Z",
  "updated_at": "2026-08-17T14:07:02.881Z",
  "plan": { "…": "the full plan document, verbatim" },
  "plan_version": 2
}
  • 404 not_found - cut not found.

Get a plan version

GET/v1/cuts/:id/plans/:version

Scope read · Success 200 OK

No request body. Every plan a cut has ever had is kept. The response is that version's document on its own, with no cut row wrapped around it.

200 OK · one entry shown
{
  "version": 1,
  "estimated_duration_s": 58.4,
  "title": "Where your data actually lives",
  "rationale": "Opens on the objection as asked, answers it, then lands the worked example.",
  "warnings": [],
  "settings": {
    "targetDurationS": 60,
    "durationTolerancePct": 15,
    "pace": "standard",
    "chronology": "flexible"
  },
  "entries": [
    {
      "n": 1,
      "role": "hook",
      "source_group": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
      "camera": "A",
      "source_start_ms": 1412000,
      "source_end_ms": 1425600,
      "text": "So the question we get every single time is: where does our footage actually sit?",
      "speaker": "B",
      "tightening": [],
      "pinned": false
    }
  ]
}
  • 404 not_found - cut not found or plan version not found.
  • 422 invalid_request - version must be a positive integer.

Edit a plan

PATCH/v1/cuts/:id/plan

Scope write · Success 200 OK

Structural changes you already know the answer to. Synchronous, no planner call, and free. This is the one mutation that does not honour idempotency-key, because there is nothing asynchronous to duplicate.

FieldTypeDescription
removenumber[]Entry numbers to drop. Applied first.
reordernumber[]The new running order. It must be a complete ordering of the numbers that survive remove - every one, exactly once.
pinnumber[]Mark entries as settled, so a later revision leaves them alone.
unpinnumber[]Clear the pin.

At least one of the four has to be present and non-empty. Operations apply in a fixed order - remove, then reorder, then pin and unpin - so a reorder alongside a remove lists the numbers that survive it. The response is the new plan document: entries renumbered 1..N, estimated_duration_s recomputed, and manually edited appended to warnings.

200 OK · one entry shown
{
  "version": 3,
  "estimated_duration_s": 50.6,
  "title": "Where your data actually lives",
  "rationale": "Opens on the objection as asked, answers it, then lands the worked example.",
  "warnings": ["manually edited"],
  "entries": [
    {
      "n": 1,
      "role": "body",
      "source_group": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
      "camera": "A",
      "source_start_ms": 1426400,
      "source_end_ms": 1458900,
      "text": "It sits in the region you pick, and it never leaves it.",
      "speaker": "A",
      "speaker_name": "Priya Raman",
      "tightening": [
        { "op": "pause_shrink", "at_ms": 1441200, "removed_ms": 620 }
      ],
      "pinned": true
    }
  ]
}
  • 400 invalid_json - The body did not parse.
  • 404 not_found - cut not found.
  • 409 busy - plan is being generated - the cut is still planning.
  • 409 no_plan - no plan to edit yet.
  • 409 conflict - plan changed concurrently - re-fetch and retry.
  • 422 invalid_request - at least one edit required, or the reorder is not a complete ordering of the remaining entry numbers.

Revise a plan

POST/v1/cuts/:id/revise

Scope write · Success 202 Accepted · Honours idempotency-key · Carries the usage envelope

FieldTypeDescription
instructionstring · required · 1–500A plain-language note about the plan the planner just wrote. Not blank. The planner re-runs against the same footage and settings and writes a new version.

The move into planning is a single atomic claim, so two revisions of one cut cannot both run: the second is told the cut is busy rather than queued behind the first. The response carries no id - you already have one - just the status the cut has been moved into.

202 Accepted
{
  "status": "planning",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 108.5,
  "low_minutes": false
}
  • 400 invalid_json - The body did not parse.
  • 404 not_found - cut not found.
  • 409 busy - plan is being generated. The only status that refuses a revise is planning itself.
  • 422 invalid_request - instruction must be 1-500 non-blank chars.
  • 429 concurrency_limit - Too many planner runs already in flight for the account.

Delete a cut

DELETE/v1/cuts/:id

Scope admin · Success 202 Accepted

No request body. Removes the cut, every plan version and every render row, and purges the render outputs and their sidecars from storage. The source groups are untouched.

202 Accepted
{ "status": "deleted" }
  • 404 not_found - cut not found.
  • 409 conflict - a render of this cut is queued or processing - wait for it to finish.

Renders#

Rendering is the deliberate second step: a plan you have read, turned into files. Three routes request a batch, read it back and remove a finished one.

Request renders

POST/v1/cuts/:id/renders

Scope write · Success 202 Accepted · Honours idempotency-key · Carries the usage envelope

FieldTypeDescription
aspectsstring[] · required · 1–3 unique16:9 (1920×1080), 1:1 (1080×1080), 9:16 (1080×1920). One render row per aspect, all from the same plan.
plan_versioninteger ≥ 1Pin the version to render. Omitted, the latest version is resolved and used - which a revision landing in between would change.
crop_modebars | stack · default "bars"This batch's crop policy, validated by the same checker the create call uses. Only changes anything on a 9:16 row.

One render row is created per aspect and the cut is claimed into rendering as a single atomic step, so two concurrent batches cannot both start. The batch is admitted whole or not at all.

202 Accepted
{
  "render_ids": [
    "4b1f8c07-92ad-4e35-b6c1-7d0a3e58f214",
    "8e5c2a41-63f7-4d90-8b2e-05ca9146d738"
  ],
  "minutes_plan": "plan_creator",
  "minutes_remaining": 108.5,
  "low_minutes": false
}
  • 400 invalid_json - The body did not parse.
  • 404 not_found - cut not found, plan version not found or no plan exists for this cut.
  • 409 busy - cut is planning, not ready to render - only a planned or complete cut can be rendered - or cut is already rendering when another batch won the claim.
  • 422 invalid_request - aspects must be 1-3 unique values from 16:9|1:1|9:16 or plan_version must be a positive integer.
  • 422 invalid_settings - A crop_mode outside bars|stack, including speaker.
  • 429 concurrency_limit - The batch would exceed the account render ceiling. The test is in flight + requested > limit.

List renders

GET/v1/cuts/:id/renders

Scope read · Success 200 OK

No request body, and no pagination: every render the cut has, across every batch, oldest first. A row that is not complete carries no url and no sidecars at all - the keys are absent rather than null, so status is the reliable test.

200 OK
{
  "data": [
    {
      "id": "4b1f8c07-92ad-4e35-b6c1-7d0a3e58f214",
      "aspect": "9:16",
      "status": "complete",
      "plan_version": 2,
      "duration_ms": 58400,
      "url": "https://<storage-host>/media-renders/<key>.mp4?X-Amz-Expires=3600&X-Amz-Signature=<sig>",
      "sidecars": {
        "srt": "https://<storage-host>/media-renders/<key>.srt?X-Amz-Signature=<sig>",
        "vtt": "https://<storage-host>/media-renders/<key>.vtt?X-Amz-Signature=<sig>",
        "edl": "https://<storage-host>/media-renders/<key>.edl.json?X-Amz-Signature=<sig>",
        "qc": "https://<storage-host>/media-renders/<key>.qc.json?X-Amz-Signature=<sig>",
        "ass": "https://<storage-host>/media-renders/<key>.ass?X-Amz-Signature=<sig>"
      }
    },
    {
      "id": "8e5c2a41-63f7-4d90-8b2e-05ca9146d738",
      "aspect": "16:9",
      "status": "processing",
      "plan_version": 2,
      "duration_ms": null
    }
  ]
}

A finished render ships four sidecars always - srt and vtt captions, an edl edit decision list and a qc report - and a fifth, ass, whenever the edit had speech to caption.

  • 404 not_found - cut not found.

Delete a render

DELETE/v1/renders/:id

Scope admin · Success 202 Accepted

No request body. Purges the output and every sidecar from storage. The plan is untouched, so the same version can be rendered again.

202 Accepted
{ "status": "deleted" }
  • 404 not_found - render not found.
  • 409 conflict - render is queued or processing - wait for it to finish. Only a terminal render can be deleted.

Brand kits#

A kit is caption styling plus up to six uploaded assets. It is attached to a cut with brand_kit_id, not to a render request, so it applies to everything that cut produces. At render time it styles the burned-in captions, places a logo watermark and appends an end card - and nothing else: no intro cards, lower thirds, music, transitions or colour grade. Six routes.

Create a brand kit

POST/v1/brand-kits

Scope admin · Success 201 Created · Honours idempotency-key

FieldTypeDescription
namestring · required · 1–100What the kit is called. The only validated field on this call.
colors.primary_hexstring · default #FFFFFFCaption fill, as #RRGGBB.
colors.outline_hexstring · default #000000Caption outline, as #RRGGBB.
caption_style.font_namestringThe face captions are set in. Falls back to a system face when no font asset has been uploaded.
caption_style.size_pctnumber · 2–12 · default 4.5Caption size as a percentage of frame height.
caption_style.margin_v_pctnumber · 0–30 · default 8How far the caption sits off the bottom edge.
201 Created
{
  "id": "6d0a58b3-7c41-4e29-b5f8-3a19e07c2d64",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "name": "House style",
  "colors": { "primary_hex": "#FFFFFF", "outline_hex": "#101014" },
  "caption_style": { "font_name": "Matter", "size_pct": 4.5, "margin_v_pct": 8 },
  "font_key": null,
  "logo_key": null,
  "end_card_key": null,
  "created_at": "2026-08-17T11:02:38.914Z",
  "end_card_16x9_key": null,
  "end_card_1x1_key": null,
  "end_card_9x16_key": null
}
  • 400 invalid_json - The body did not parse.
  • 422 invalid_request - name must be 1-100 chars.

List brand kits

GET/v1/brand-kits

Scope read · Success 200 OK · Cursor-paginated

ParameterTypeDescription
limitinteger · default 50 · max 100Rows per page. A value that is not a positive integer is ignored rather than refused, and you get the default; anything above 100 is clamped.
cursorstringThe next_cursor from the previous page. Opaque - do not parse or construct one. A malformed cursor is treated as no cursor, so you get the first page rather than an error.

Full kit rows, newest first. An asset key that is non-null is an asset that has been uploaded and confirmed.

200 OK
{
  "data": [
    {
      "id": "6d0a58b3-7c41-4e29-b5f8-3a19e07c2d64",
      "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
      "name": "House style",
      "colors": { "primary_hex": "#FFFFFF", "outline_hex": "#101014" },
      "caption_style": { "font_name": "Matter", "size_pct": 4.5, "margin_v_pct": 8 },
      "font_key": null,
      "logo_key": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215/6d0a58b3-7c41-4e29-b5f8-3a19e07c2d64/logo.png",
      "end_card_key": null,
      "created_at": "2026-08-17T11:02:38.914Z",
      "end_card_16x9_key": null,
      "end_card_1x1_key": null,
      "end_card_9x16_key": null
    }
  ],
  "next_cursor": null
}

Get a brand kit

GET/v1/brand-kits/:id

Scope read · Success 200 OK

No request body. The same row shape as the create response.

  • 404 not_found - brand kit not found.

Mint an asset upload

POST/v1/brand-kits/:id/assets

Scope admin · Success 200 OK · Honours idempotency-key

FieldTypeDescription
kindstring · requiredOne of font, logo, end_card, end_card_16x9, end_card_1x1, end_card_9x16. One asset per kind per kit.
filenamestring · requiredOnly the extension is used, and it has to match the kind: ttf or otf for font, png, jpg or jpeg for every image kind.

Returns a signed upload URL and the deterministic key it belongs to. PUT the bytes there, then confirm - uploading alone does not attach the asset to the kit.

200 OK
{
  "upload": {
    "url": "https://<storage-host>/<signed-upload-path>?token=<token>",
    "token": "<token>",
    "key": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215/6d0a58b3-7c41-4e29-b5f8-3a19e07c2d64/logo.png"
  }
}
  • 400 invalid_json - The body did not parse.
  • 404 not_found - brand kit not found.
  • 422 invalid_request - kind must be font|logo|end_card|end_card_16x9|end_card_1x1|end_card_9x16 or filename is required.
  • 422 unsupported_extension - unsupported extension for logo: gif and so on.

Confirm an asset

POST/v1/brand-kits/:id/assets/confirm

Scope admin · Success 200 OK · Honours idempotency-key

FieldTypeDescription
kindstring · requiredThe same kind you minted the upload for.
keystring · requiredThe upload.key exactly as it was issued. It is checked against the key this account, kit and kind would have been given - a key that does not match is refused rather than trusted.

The server checks the object is genuinely in storage before writing the key onto the kit. The response is the updated kit row.

200 OK
{
  "id": "6d0a58b3-7c41-4e29-b5f8-3a19e07c2d64",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "name": "House style",
  "colors": { "primary_hex": "#FFFFFF", "outline_hex": "#101014" },
  "caption_style": { "font_name": "Matter", "size_pct": 4.5, "margin_v_pct": 8 },
  "font_key": null,
  "logo_key": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215/6d0a58b3-7c41-4e29-b5f8-3a19e07c2d64/logo.png",
  "end_card_key": null,
  "created_at": "2026-08-17T11:02:38.914Z",
  "end_card_16x9_key": null,
  "end_card_1x1_key": null,
  "end_card_9x16_key": null
}
  • 400 invalid_json - The body did not parse.
  • 404 not_found - brand kit not found.
  • 422 invalid_request - A bad kind, a missing key, or key does not match the expected format - the key has to be exactly the one this account, kit and kind was issued.
  • 409 upload_incomplete - object not found in storage - the bytes never landed.

Delete a brand kit

DELETE/v1/brand-kits/:id

Scope admin · Success 202 Accepted

No request body. The delete is scoped to your account, and the response is 202 whether or not a kit matched - no row is read first, so there is no 404 on this route for a wrong id, an id you do not own, or one you already deleted. Confirm by listing the kits back.

202 Accepted
{ "status": "deleted" }

Cuts that reference a deleted kit keep their brand_kit_id. Uploaded kit assets are not purged from storage by this call.

Speakers#

Perception diarizes a group into labelled speakers. Two routes read them and give them names - and the names travel into plan documents, so an edit says who is talking rather than A and B.

Get speakers

GET/v1/source-groups/:id/speakers

Scope read · Success 200 OK

No request body. Every distinct label the group's segments carry, sorted by label, with talk time, segment count and the two longest things each one said - enough to tell people apart without opening the video.

200 OK
{
  "speakers": [
    {
      "label": "A",
      "display_name": "Priya Raman",
      "talk_time_ms": 1842300,
      "segment_count": 214,
      "sample_quotes": [
        "It sits in the region you pick, and it never leaves it, and that is the whole answer to the question people are really asking.",
        "We moved the whole archive in nine months, and the part that took longest was not the engineering."
      ]
    },
    {
      "label": "B",
      "display_name": null,
      "talk_time_ms": 604800,
      "segment_count": 88,
      "sample_quotes": [
        "So the question we get every single time is: where does our footage actually sit?",
        "Let me push back on that, because our procurement team asks it differently."
      ]
    }
  ]
}
  • 404 not_found - source group not found.

Name speakers

PATCH/v1/source-groups/:id/speakers

Scope write · Success 200 OK · Honours idempotency-key

FieldTypeDescription
namesobject · requiredA non-empty map of diarization label to display name, e.g. { "A": "Priya Raman" }. Each name is 1–60 characters, and each label has to be one this group's segments actually carry.

Names are upserted per label, so naming one speaker leaves the others as they were. The response is the same shape as the read above, with the new names in place.

200 OK · same shape as the read
{
  "speakers": [
    {
      "label": "A",
      "display_name": "Priya Raman",
      "talk_time_ms": 1842300,
      "segment_count": 214,
      "sample_quotes": [
        "It sits in the region you pick, and it never leaves it, and that is the whole answer to the question people are really asking.",
        "We moved the whole archive in nine months, and the part that took longest was not the engineering."
      ]
    },
    {
      "label": "B",
      "display_name": null,
      "talk_time_ms": 604800,
      "segment_count": 88,
      "sample_quotes": [
        "So the question we get every single time is: where does our footage actually sit?",
        "Let me push back on that, because our procurement team asks it differently."
      ]
    }
  ]
}
  • 400 invalid_json - The body did not parse.
  • 404 not_found - source group not found.
  • 422 invalid_request - names must be a non-empty object of {diar_label: display_name}, or a name outside 1–60 characters.
  • 422 unknown_label - unknown speaker label(s) for this group: C - a label this group's segments do not carry. Read the speakers first rather than guessing.

Webhook endpoints#

Three routes, all admin - including the list, which is the one GET in the API that is not read. Payload shapes, signature verification and delivery behaviour are on Webhooks.

Register an endpoint

POST/v1/webhook-endpoints

Scope admin · Success 201 Created · Honours idempotency-key

FieldTypeDescription
urlstring · requiredWhere deliveries are POSTed. HTTPS, publicly resolvable, checked before the row is written.
eventsstring[] · required · non-emptyDrawn from the closed set of six: source.ready, source.failed, cut.planned, cut.failed, render.completed, render.failed. One unrecognised value refuses the call.
201 Created
{
  "id": "e58a3c17-0d64-4b92-8f31-97ae2c5b6041",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "url": "https://hooks.example.com/anjin",
  "events": ["cut.planned", "render.completed", "render.failed"],
  "failure_count": 0,
  "disabled_at": null,
  "created_at": "2026-08-17T14:02:48.119Z",
  "secret": "3b7f1e0c9a45d268e3fa17c04b95d8e6720af31c6d4bb95e08c27ad3f61e4b82"
}
  • 400 invalid_json - The body did not parse.
  • 422 invalid_request - url is required, or events must be a non-empty array drawn from: … with the six names listed.
  • 422 ssrf_blocked - The URL is not HTTPS or does not resolve to a public address.

List endpoints

GET/v1/webhook-endpoints

Scope admin · Success 200 OK · Cursor-paginated

ParameterTypeDescription
limitinteger · default 50 · max 100Rows per page. A value that is not a positive integer is ignored rather than refused, and you get the default; anything above 100 is clamped.
cursorstringThe next_cursor from the previous page. Opaque - do not parse or construct one. A malformed cursor is treated as no cursor, so you get the first page rather than an error.

failure_count counts consecutive exhausted deliveries and resets on any 2xx. disabled_at is non-null once an endpoint has been switched off after twenty of them - a disabled endpoint is still listed, and still receives nothing.

200 OK
{
  "data": [
    {
      "id": "e58a3c17-0d64-4b92-8f31-97ae2c5b6041",
      "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
      "url": "https://hooks.example.com/anjin",
      "events": ["cut.planned", "render.completed", "render.failed"],
      "failure_count": 0,
      "disabled_at": null,
      "created_at": "2026-08-17T14:02:48.119Z"
    }
  ],
  "next_cursor": null
}

Delete an endpoint

DELETE/v1/webhook-endpoints/:id

Scope admin · Success 202 Accepted

No request body. Like the brand-kit delete, this is an account-scoped delete that returns 202 whether or not a row matched - there is no 404 on this route. List the endpoints back to confirm. Deleting is also how you rotate: register a new endpoint, which mints a new secret, then delete the old one.

202 Accepted
{ "status": "deleted" }

Usage#

Get usage

GET/v1/usage

Scope read · Success 200 OK · Carries the usage envelope

ParameterTypeDescription
fromstring (ISO-8601)Start of the window, inclusive. Omitted, the account history is summed.
tostring (ISO-8601)End of the window, exclusive. Consecutive windows therefore never double-count the instant they share.

Seven meters for the window, and no money: there are no cost estimates in this response, deliberately. What the plans cost is on pricing.

200 OK
{
  "camera_hours_ingested": 41.6,
  "render_angle_hours": 12.4,
  "plan_transcript_hours": 38.2,
  "plan_count": 27,
  "output_minutes_rendered": 63.5,
  "egress_gb": 74.8,
  "stored_gb_months": 218.4,
  "minutes_plan": "plan_enterprise",
  "minutes_remaining": null,
  "low_minutes": false
}
  • camera_hours_ingested - footage taken in, counted per camera file rather than per group. This is the meter the source-minute allowance reads, in hours.
  • render_angle_hours - angle-hours assembled by the renderer.
  • plan_transcript_hours - transcript hours the planner read, summed over every plan version it wrote.
  • plan_count - how many plan versions were committed in the window.
  • output_minutes_rendered - finished output length, reported for context.
  • egress_gb - bytes served out of storage, including the audio handed to transcription.
  • stored_gb_months - the library footprint, recorded once per account per month across sources, derivatives and renders.

Health and MCP#

Health check

GET/healthz

The only unauthenticated route. It returns the literal string ok as text/plain, and it reports that the API process is up - not that ingest, planning or rendering are healthy. Use it for a load-balancer probe, not as a status page.

The MCP mount

The same platform speaks MCP at /mcp on this host: streamable HTTP, stateless, the same bearer key, scopes checked per tool rather than per route. Its thirteen tools delegate to the same services as the REST routes above, so behaviour is identical. JSON-RPC batch arrays are refused with 400 and send one request per call. Everything about connecting and the tool list is on MCP & agents.

Paths under /internal/ are the platform's own callbacks - they authenticate differently, are not part of this API, and are listed here only so you know what they are if you see one.

From here: Errors & limits for the full code catalogue and the retry rules, Security & data handling for where the media goes, and Quickstart if you would rather see these calls in order than one at a time.