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
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 -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.
{
"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-afteris 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-groupsPOST /v1/source-groups/:id/files/:fileId/completePOST /v1/cutsPOST /v1/cuts/:id/revisePOST /v1/cuts/:id/rendersPOST /v1/brand-kitsPOST /v1/brand-kits/:id/assetsPOST /v1/brand-kits/:id/assets/confirmPOST /v1/webhook-endpointsPATCH /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.
| Parameter | Type | Description |
|---|---|---|
limit | integer · default 50 · max 100 | Rows 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. |
cursor | string | The 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
| Field | Type | Description |
|---|---|---|
name | string · required | What the group is called in listings and in the dashboard. |
files | object[] · required · 1–8 | The files that belong together - the angles and audio tracks of one recording. Fewer than one or more than eight is refused. |
files[].filename | string | The 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_url | string | The pull path. An HTTPS URL the platform fetches. Resolved and checked against private address ranges before any row is written. |
files[].role | string · default "camera" | camera or audio. Camera files become the angles A–H the edit can cut between. |
files[].label | string | Your 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.
{
"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, orrole must be camera|audio. Validation covers the whole payload before the first row is written.422 unsupported_extension- Afilenameoutsidemp4 mov mkv wav mp3 m4a aac. Asource_urlis not extension-checked here - it is fetched and probed, so an undecodable source fails during ingest instead.422 ssrf_blocked- Asource_urlthat 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- Morecamerafiles 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. Carriesupgrade_url. Dormant unless the source-minutes gate is on.402 minutes_allowance_exhausted- The period's source-minute allowance is already used up. Carriesallowance,used,period_endandupgrade_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.
{
"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 uploadedand so on: the file is notpending, 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 stayspending, so the same call retries cleanly.
List source groups
GET/v1/source-groups
Scope read · Success 200 OK · Cursor-paginated
| Parameter | Type | Description |
|---|---|---|
limit | integer · default 50 · max 100 | Rows 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. |
cursor | string | The 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.
{
"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).
{
"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
| Parameter | Type | Description |
|---|---|---|
format | segments | 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. |
{
"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.
{ "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 stilluploadingdeletes 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
| Field | Type | Description |
|---|---|---|
prompt | string · required · 1–2000 | The editorial brief. Not blank - whitespace alone is refused - and measured against 2,000 characters as sent. |
source_group_ids | string[] · required · 1–4 | The footage the edit may draw on. Every id has to be a uuid you own and ready. |
settings | object · required | Shaping 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. |
aspects | string[] · 1–3 unique | 16:9, 1:1, 9:16. Optional here and required only when auto_render is true - the render request takes its own. |
crop_mode | bars | stack · default "bars" | How a wide frame is fitted into a 9:16 output. speaker parses and is then refused - see below. |
auto_render | boolean · default false | Render the first plan the moment it is committed. Fires on plan version 1 only; a revision never re-renders. |
brand_kit_id | string (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.
{
"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, orbrand_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 foundorbrand kit not found.422 group_not_ready-groups not ready: <id>- wait forsource.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
| Parameter | Type | Description |
|---|---|---|
limit | integer · default 50 · max 100 | Rows 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. |
cursor | string | The 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.
{
"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.
{
"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.
{
"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 foundorplan 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.
| Field | Type | Description |
|---|---|---|
remove | number[] | Entry numbers to drop. Applied first. |
reorder | number[] | The new running order. It must be a complete ordering of the numbers that survive remove - every one, exactly once. |
pin | number[] | Mark entries as settled, so a later revision leaves them alone. |
unpin | number[] | 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.
{
"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 stillplanning.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
| Field | Type | Description |
|---|---|---|
instruction | string · required · 1–500 | A 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.
{
"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 isplanningitself.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.
{ "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
| Field | Type | Description |
|---|---|---|
aspects | string[] · required · 1–3 unique | 16:9 (1920×1080), 1:1 (1080×1080), 9:16 (1080×1920). One render row per aspect, all from the same plan. |
plan_version | integer ≥ 1 | Pin the version to render. Omitted, the latest version is resolved and used - which a revision landing in between would change. |
crop_mode | bars | 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.
{
"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 foundorno plan exists for this cut.409 busy-cut is planning, not ready to render- only aplannedorcompletecut can be rendered - orcut is already renderingwhen another batch won the claim.422 invalid_request-aspects must be 1-3 unique values from 16:9|1:1|9:16orplan_version must be a positive integer.422 invalid_settings- Acrop_modeoutsidebars|stack, includingspeaker.429 concurrency_limit- The batch would exceed the account render ceiling. The test isin 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.
{
"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.
{ "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
| Field | Type | Description |
|---|---|---|
name | string · required · 1–100 | What the kit is called. The only validated field on this call. |
colors.primary_hex | string · default #FFFFFF | Caption fill, as #RRGGBB. |
colors.outline_hex | string · default #000000 | Caption outline, as #RRGGBB. |
caption_style.font_name | string | The face captions are set in. Falls back to a system face when no font asset has been uploaded. |
caption_style.size_pct | number · 2–12 · default 4.5 | Caption size as a percentage of frame height. |
caption_style.margin_v_pct | number · 0–30 · default 8 | How far the caption sits off the bottom edge. |
{
"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
| Parameter | Type | Description |
|---|---|---|
limit | integer · default 50 · max 100 | Rows 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. |
cursor | string | The 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.
{
"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
| Field | Type | Description |
|---|---|---|
kind | string · required | One of font, logo, end_card, end_card_16x9, end_card_1x1, end_card_9x16. One asset per kind per kit. |
filename | string · required | Only 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.
{
"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_9x16orfilename is required.422 unsupported_extension-unsupported extension for logo: gifand so on.
Confirm an asset
POST/v1/brand-kits/:id/assets/confirm
Scope admin · Success 200 OK · Honours idempotency-key
| Field | Type | Description |
|---|---|---|
kind | string · required | The same kind you minted the upload for. |
key | string · required | The 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.
{
"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 badkind, a missingkey, orkey 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.
{ "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.
{
"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
| Field | Type | Description |
|---|---|---|
names | object · required | A 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.
{
"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
| Field | Type | Description |
|---|---|---|
url | string · required | Where deliveries are POSTed. HTTPS, publicly resolvable, checked before the row is written. |
events | string[] · required · non-empty | Drawn from the closed set of six: source.ready, source.failed, cut.planned, cut.failed, render.completed, render.failed. One unrecognised value refuses the call. |
{
"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, orevents 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
| Parameter | Type | Description |
|---|---|---|
limit | integer · default 50 · max 100 | Rows 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. |
cursor | string | The 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.
{
"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.
{ "status": "deleted" }Usage#
Get usage
GET/v1/usage
Scope read · Success 200 OK · Carries the usage envelope
| Parameter | Type | Description |
|---|---|---|
from | string (ISO-8601) | Start of the window, inclusive. Omitted, the account history is summed. |
to | string (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.
{
"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.
