Anjin Media
Docs navigation

Docs

Quickstart

Seven steps from an API key to a finished, downloadable edit. The fastest route pulls one video from a URL you control, so there is no upload handshake to write before you see a result.

Everything below runs against https://anjin-media-api.fly.dev. Follow it in order: each step uses an id the previous one returned. Steps 2, 4 and 6 are shown in curl, TypeScript and Python; the reads in between are curl only, because they are the same call in every language.

1. Get a key#

Create an account at app.anjin.media, then open Settings → API keys and create one. Give it a name, tick the read and write scopes - this walkthrough needs both, and nothing here needs admin - and copy the key when it appears.

Put it in your environment; every example below reads it from there.

shell
export ANJIN_API_KEY=mk_live_9a4f1c7d0b3e5a628f41d90c7b25e3f814a6d5c2907b3e1f

Authentication covers scopes, revocation and rate limits in full.

2. Add your video#

POST/v1/source-groups

A source group is the unit you compose from: a name and one to eight files that belong together; how many of them may be camera files is a plan entitlement. Give each file either a filename - and get a signed upload URL back - or a source_url the platform pulls over HTTPS. The pull path is one call instead of three, so start there.

curl
curl -sS https://anjin-media-api.fly.dev/v1/source-groups \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: 7f6b0c9a-3c0d-4a5e-9f21-2b8d6e4a1c77" \
  -d '{
    "name": "Q3 product webinar",
    "files": [
      {
        "source_url": "https://files.example.com/webinars/q3-product.mp4",
        "role": "camera"
      }
    ]
  }'
TypeScript
const API = "https://anjin-media-api.fly.dev";
const KEY = process.env.ANJIN_API_KEY!;

const res = await fetch(`${API}/v1/source-groups`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${KEY}`,
    "content-type": "application/json",
    "idempotency-key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Q3 product webinar",
    files: [
      {
        source_url: "https://files.example.com/webinars/q3-product.mp4",
        role: "camera",
      },
    ],
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));

const group = (await res.json()) as { id: string };
console.log(group.id);
Python
import os, uuid, requests

API = "https://anjin-media-api.fly.dev"
HEADERS = {"authorization": f"Bearer {os.environ['ANJIN_API_KEY']}"}

res = requests.post(
    f"{API}/v1/source-groups",
    headers={**HEADERS, "idempotency-key": str(uuid.uuid4())},
    json={
        "name": "Q3 product webinar",
        "files": [
            {
                "source_url": "https://files.example.com/webinars/q3-product.mp4",
                "role": "camera",
            }
        ],
    },
)
res.raise_for_status()

group = res.json()
print(group["id"])
201 Created
{
  "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "files": [
    { "id": "3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915" }
  ],
  "minutes_plan": "plan_creator",
  "minutes_remaining": 150,
  "low_minutes": false
}

Keep the group id. A file created from a source_url needs no follow-up call - the group is already ingesting. Files created from a filename come back with an upload object instead and take two more calls; see Uploading video for that path, the accepted extensions and the size and duration limits.

3. Wait for ready#

GET/v1/source-groups/:id

Ingest transcodes each file, then transcribes and diarizes the group. The status walks uploadingingesting perceivingready. Poll until it says ready:

curl
GROUP_ID=9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340

curl -sS "https://anjin-media-api.fly.dev/v1/source-groups/$GROUP_ID" \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK · abridged
{
  "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "name": "Q3 product webinar",
  "status": "ready",
  "asr_status": "complete",
  "vad_status": "complete",
  "duration_ms": 3541200,
  "camera_count": 1,
  "total_duration_s": 3541.2
}

A cut can only draw on groups that are ready - asking earlier gets you 422 group_not_ready. If ingest fails, the status is failed and the group carries an error.

Or subscribe instead of polling

Register an HTTPS endpoint for the source.ready event and the platform tells you. That is one delivery instead of a polling loop, and it is the right shape once you are running this at any volume:

source.ready · one delivery
{
  "event": "source.ready",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "ids": { "group_id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340" },
  "summary": { "duration_ms": 3541200 },
  "occurred_at": "2026-08-17T14:03:21.482Z"
}

Deliveries are signed and retried; see Webhooks for registration and signature verification.

4. Ask for the edit#

POST/v1/cuts

This is the request that carries the editorial instruction. A cut is a brief plus the groups it may draw on plus the settings that shape it. prompt takes 1–2,000 characters, source_group_ids takes one to four groups, and settings.target_duration_s is required - a whole number of seconds between 10 and 600.

curl
curl -sS https://anjin-media-api.fly.dev/v1/cuts \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: 1c8e4b70-59a2-4d63-8e0f-73b6a2c41d95" \
  -d '{
    "prompt": "Cut the strongest answer Priya gives on data residency: open on the objection as the audience puts it, then her answer, then the worked example she uses straight afterwards. Leave out the pricing tangent.",
    "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
    "aspects": ["9:16"],
    "crop_mode": "bars",
    "settings": {
      "target_duration_s": 60,
      "duration_tolerance_pct": 15,
      "pace": "standard",
      "chronology": "flexible"
    }
  }'
TypeScript
const res = await fetch(`${API}/v1/cuts`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${KEY}`,
    "content-type": "application/json",
    "idempotency-key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    prompt:
      "Cut the strongest answer Priya gives on data residency: open on the " +
      "objection as the audience puts it, then her answer, then the worked " +
      "example she uses straight afterwards. Leave out the pricing tangent.",
    source_group_ids: [group.id],
    aspects: ["9:16"],
    crop_mode: "bars",
    settings: {
      target_duration_s: 60,
      duration_tolerance_pct: 15,
      pace: "standard",
      chronology: "flexible",
    },
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));

const cut = (await res.json()) as { id: string; status: string };
console.log(cut.id, cut.status); // … planning
Python
res = requests.post(
    f"{API}/v1/cuts",
    headers={**HEADERS, "idempotency-key": str(uuid.uuid4())},
    json={
        "prompt": (
            "Cut the strongest answer Priya gives on data residency: open on "
            "the objection as the audience puts it, then her answer, then the "
            "worked example she uses straight afterwards. Leave out the "
            "pricing tangent."
        ),
        "source_group_ids": [group["id"]],
        "aspects": ["9:16"],
        "crop_mode": "bars",
        "settings": {
            "target_duration_s": 60,
            "duration_tolerance_pct": 15,
            "pace": "standard",
            "chronology": "flexible",
        },
    },
)
res.raise_for_status()

cut = res.json()
print(cut["id"], cut["status"])  # … planning
202 Accepted
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "status": "planning",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 108.5,
  "low_minutes": false
}

Planning is asynchronous, which is why the response is a 202 and a status rather than a plan. Everything else in the body is optional: aspects (one to three of 16:9, 1:1, 9:16), crop_mode, brand_kit_id, and the rest of settings - tolerance, pace, chronology and pause handling. Setting auto_render to true sends the first plan straight to render and makes aspects required; leave it off if you want to read the plan first, as this walkthrough does.

5. Read the plan#

GET/v1/cuts/:id

Poll the cut until its status is planned, or subscribe to cut.planned. The status is the readiness test, not the plan field: plan is only null before a cut's first plan is committed, so once you start revising, a cut that is still planning hands back the previous version rather than nothing. The response is the cut plus the latest plan document, verbatim, and the version number it belongs to.

curl
CUT_ID=c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64

curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID" \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK · abridged
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "status": "planned",
  "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
  "plan_version": 1,
  "plan": {
    "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": [],
    "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
      },
      {
        "n": 2,
        "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": false
      }
    ]
  }
}

Each entry names its source group, the camera angle within that group and the exact millisecond window it was lifted from, alongside the words spoken there. That is the provenance record: every second of the finished file points back at a timecode in footage you supplied.

If the plan is not what you wanted, change it before rendering. PATCH /v1/cuts/:id/plan removes, reorders, pins and unpins entries synchronously - no planner call, and free. POST /v1/cuts/:id/revise takes a plain-language instruction of 1–500 characters and re-runs the planner for a new plan version. Composition results documents the plan document field by field.

6. Render it#

POST/v1/cuts/:id/renders

Rendering is a separate, deliberate call. Ask for one to three aspects and you get one render per aspect, queued immediately. Pin plan_version to the version you just read, so a revision landing in between cannot render something you have not seen.

curl
curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID/renders" \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: 5b2a9d14-7c60-4f38-91e2-0da8c36b47f1" \
  -d '{ "aspects": ["9:16", "16:9"], "plan_version": 1 }'
TypeScript
const res = await fetch(`${API}/v1/cuts/${cut.id}/renders`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${KEY}`,
    "content-type": "application/json",
    "idempotency-key": crypto.randomUUID(),
  },
  body: JSON.stringify({ aspects: ["9:16", "16:9"], plan_version: 1 }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));

const { render_ids } = (await res.json()) as { render_ids: string[] };
console.log(render_ids);
Python
res = requests.post(
    f"{API}/v1/cuts/{cut['id']}/renders",
    headers={**HEADERS, "idempotency-key": str(uuid.uuid4())},
    json={"aspects": ["9:16", "16:9"], "plan_version": 1},
)
res.raise_for_status()

print(res.json()["render_ids"])
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
}

A cut has to be planned or complete to accept a render batch; asking while it is still planning gets 409 busy.

7. Download#

GET/v1/cuts/:id/renders

The same path read back gives you every render on the cut with its status. Completed renders carry a signed download URL and signed URLs for their sidecar files: SRT captions, the styled ASS subtitles that were burned into the picture, an edit decision list as JSON and a QC report. The ass entry is present whenever the edit has speech to caption; the other three always are.

curl
curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID/renders" \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK
{
  "data": [
    {
      "id": "4b1f8c07-92ad-4e35-b6c1-7d0a3e58f214",
      "aspect": "9:16",
      "status": "complete",
      "plan_version": 1,
      "duration_ms": 58400,
      "url": "https://<storage-host>/media-renders/<key>?X-Amz-Expires=3600&X-Amz-Signature=<sig>",
      "sidecars": {
        "srt": "https://<storage-host>/media-renders/<key>.srt?X-Amz-Signature=<sig>",
        "ass": "https://<storage-host>/media-renders/<key>.ass?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>"
      }
    },
    {
      "id": "8e5c2a41-63f7-4d90-8b2e-05ca9146d738",
      "aspect": "16:9",
      "status": "processing",
      "plan_version": 1,
      "duration_ms": null
    }
  ]
}

Signed URLs are valid for one hour. They are minted fresh on every read, so fetch this endpoint again rather than storing a URL - and treat the URL itself as opaque.

That is your first composition: a brief in, a finished file out, with the plan that produced it still on record. Subscribe to render.completed and the whole sequence runs without a single polling loop.

Where to go next#

  • Uploading video - the signed upload path, multi-file groups, formats and limits.
  • Creating a composition - every field on POST /v1/cuts and what each one does to the edit.
  • Composition results - the plan document, render outputs and the provenance sidecars.
  • Webhooks - the six events, signature verification and delivery behaviour.
  • Errors & limits - the problem+json codes, rate limits and idempotency.
  • MCP & agents - the same workflow as thirteen tools an agent can call.

For the commercial picture rather than the mechanics, the API overview and pricing cover what this is for and what the plans cost.