Skip to main content

Overview

Transcripts are premium, diarized, entity-annotated, and community-correctable.
  • Base URL: https://api.arcmira.com, paths under /v1.
  • Auth: Authorization: Bearer arc_sk_... or x-api-key: arc_sk_.... Transcript access requires a paid plan.
  • Billing: 75 rows per 15-minute block of video, rounded up, minimum one block, debited from the shared monthly pool. Unlocks are permanent per account; re-reads free; a job that fails permanently auto-refunds (status refunded) and revokes the unlock it charged for. Corrections cost 0 rows.

Endpoints

GET    /v1/transcripts/{video_id}                read; ?unlock=true purchases in the same request
POST   /v1/transcriptions                        submit a video ({ url } or { videoId })
GET    /v1/transcriptions/{id}                   poll status (Retry-After header while in flight)
GET    /v1/transcriptions?video_id=              list your requests
POST   /v1/videos/{video_id}/corrections         unified corrections ingestion (all six kinds)
DELETE /v1/corrections/speaker-edits/{id}        withdraw pending speaker_reassign
DELETE /v1/corrections/entity-tags/{id}          withdraw pending entity_tag
POST   /v1/transcripts/{video_id}/edits          wrapper: line_edit           (DELETE .../edits/{id})
POST   /v1/transcripts/{video_id}/speakers       wrapper: speaker identify    (DELETE .../speakers/{id})
POST   /v1/transcripts/{video_id}/merges         video-level entity merge     (GET, DELETE .../merges/{id})
Prefer the unified corrections route; the wrappers are conveniences for single-kind clients.

Minimal working example

const BASE = 'https://api.arcmira.com';
const H = { Authorization: `Bearer ${process.env.ARCMIRA_API_KEY}` };

// Read; unlock if a premium transcript exists
let t = await fetch(`${BASE}/v1/transcripts/dQw4w9WgXcQ`, { headers: H }).then(r => r.json());
if (t.access === 'locked') {
  t = await fetch(`${BASE}/v1/transcripts/dQw4w9WgXcQ?unlock=true`, { headers: H }).then(r => r.json());
}
if (t.access === 'premium_pending' || t.access === 'not_transcribed') {
  const { request } = await fetch(`${BASE}/v1/transcriptions`, {
    method: 'POST',
    headers: { ...H, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({ videoId: 'dQw4w9WgXcQ' }),
  }).then(r => r.json());
  // poll request.id until status is terminal, sleeping on Retry-After
}
import os, time, uuid, httpx

BASE = 'https://api.arcmira.com'
H = {'Authorization': f"Bearer {os.environ['ARCMIRA_API_KEY']}"}

t = httpx.get(f'{BASE}/v1/transcripts/dQw4w9WgXcQ', headers=H).json()
if t['access'] == 'locked':
    t = httpx.get(f'{BASE}/v1/transcripts/dQw4w9WgXcQ', params={'unlock': 'true'}, headers=H).json()
elif t['access'] in ('premium_pending', 'not_transcribed'):
    req = httpx.post(
        f'{BASE}/v1/transcriptions',
        json={'videoId': 'dQw4w9WgXcQ'},
        headers={**H, 'Idempotency-Key': str(uuid.uuid4())},
    ).json()['request']
    while True:
        r = httpx.get(f"{BASE}/v1/transcriptions/{req['id']}", headers=H)
        if r.json()['status'] in ('complete', 'failed', 'refunded'):
            break
        time.sleep(int(r.headers.get('Retry-After', '30')))
curl 'https://api.arcmira.com/v1/transcripts/dQw4w9WgXcQ?unlock=true' \
  -H "Authorization: Bearer $ARCMIRA_API_KEY"

Request parameters

GET /v1/transcripts/{video_id}:
ParameterTypeDefaultDescription
video_idpathYouTube video ID (the 11-char form).
unlockbooleanfalseWhen access is locked, purchase the permanent unlock in this request. Ignored on premium_pending (nothing to unlock yet; the purchase is the generation).
POST /v1/transcriptions body:
FieldTypeDescription
urlstringYouTube watch URL. At least one of url / videoId is required.
videoIdstringYouTube video ID (11 characters). Prefer this form.
Constraints: public YouTube videos only, up to 12 hours, paid tiers only. Rows debited up front; unlock granted at submit time. Resubmitting an in-flight video returns the existing request with existing: true. If a premium transcript already exists, the request short-circuits to complete (unified pricing; quote.rows shows what was actually debited, 0 if you were already unlocked).

Response schema

Transcript read:
{
  "video": { "videoId": "dQw4w9WgXcQ", "title": "…", "channelName": "…", "durationSeconds": 3720 },
  "access": "unlocked",
  "segments": [
    { "index": 0, "start": 0.4, "end": 6.1, "text": "Welcome back to the show…", "speaker": 0 }
  ],
  "speakers": [
    { "id": 0, "label": "Speaker 0", "entity": { "id": 147403, "name": "Emad Mostaque", "slug": "emad-mostaque" } }
  ],
  "annotations": [
    { "segmentIndex": 12, "charStart": 34, "charEnd": 40, "entityId": 120034, "entityType": "organization", "name": "OpenAI", "slug": "openai" }
  ],
  "edits": [],
  "speakerIdentifications": [],
  "speakerEdits": [],
  "entityTags": [],
  "meta": { "diarized": true, "locked": false, "revision": "rwmksmk", "quote": { "quarters": 5, "rows": 375 } }
}
FieldTypeNotes
accessenumunlocked (full payload) · locked (~5 teaser segments + quote; buy with ?unlock=true) · premium_pending (entity-type counts only in detected, no text/timestamps; submit a transcription) · not_transcribed (submit) · unauthenticated (web-only; unreachable on /v1, which always requires a key). Gating is server-side; locked payloads lack the rest.
segments[].start / .endnumberSeconds, fractional allowed.
segments[].speakernumberDiarization speaker id; join against speakers[].
speakers[].entityobject | nullNull until the speaker is identified as a person.
annotations[]arrayCharacter spans (charStart/charEnd) within segments[segmentIndex].text. Note the camelCase; entityId is a raw integer (batch display metadata via /v1/entities/cards).
edits / speakerIdentifications / speakerEdits / entityTagsarraysYour own pending corrections, returned only to you, only when unlocked. Each row carries the id used to withdraw it.
meta.revisionstringOpaque transcript+corrections state id. Echo it in anchored corrections; a changed value means re-read before continuing.
meta.quoteobject{ quarters, rows }: the exact unlock price. Quoting is free.
Transcription request object. Three envelopes, one shape: submit wraps it as { "request": {...}, "existing": true? }, poll returns the object flat (no wrapper), and list returns { "requests": [...] } with an added nullable title per row.
{
  "id": "0b0e8f3a-…",
  "videoId": "dQw4w9WgXcQ",
  "status": "queued",
  "stage": "queued",
  "quote": { "quarters": 5, "rows": 375 },
  "etaSeconds": 678,
  "nextPollSeconds": 30
}
FieldTypeNotes
statusenumWalks queueddownloadingtranscribinganalyzingcomplete. Failures surface as refunded: rows returned, and the unlock revoked when this submission charged them. (failed is reserved in the enum but not currently written.)
etaSeconds / nextPollSecondsnumberPresent while in flight, alongside a Retry-After response header (seconds). Terminal statuses drop the header.
List rowsGET /v1/transcriptions returns { "requests": [...] } with title (nullable) per row; 20 rows unfiltered, 5 when filtered by video_id. Up to 5 in-flight rows are reconciled against live pipeline state per list call.
Polling loop: sleep on Retry-After, re-poll, stop on any terminal status (complete / refunded; treat failed as terminal too for forward compatibility). On complete, GET /v1/transcripts/{video_id} works immediately: you were unlocked at submission. Never busy-poll.

Corrections

All six kinds go through POST /v1/videos/{video_id}/corrections with a discriminated kind, optional seq, and (for anchored kinds) revision + anchor. Free, Idempotency-Key supported, pending review for everyone but you. Envelope:
{
  "kind": "line_edit",
  "seq": 4,
  "revision": "rwmksmk",
  "anchor": { "segmentIndex": 12, "contentHash": "1a2b3c" },
  "payload": { }
}

The six kinds, full payloads

line_edit (anchored):
{
  "kind": "line_edit",
  "revision": "rwmksmk",
  "anchor": { "segmentIndex": 12, "contentHash": "1a2b3c" },
  "payload": {
    "segmentIndex": 12,
    "originalText": "Anthropics new cloud 4.5",
    "correctedText": "Anthropic's new Claude 4.5"
  }
}
speaker_reassign (anchored; sub-line splits supported; target.kind is existing, new, or role with roles like clip / other / unidentified):
{
  "kind": "speaker_reassign",
  "revision": "rwmksmk",
  "anchor": { "segmentIndex": 18, "contentHash": "9zq1aa" },
  "payload": {
    "selection": { "startIndex": 18, "startChar": 0, "endIndex": 19, "endChar": 42 },
    "target": { "kind": "existing", "speakerId": 2 }
  }
}
speaker_identify (not anchored; creates a community-flagged appearance on the person’s page immediately; requires a numeric entityId. To propose a person by name, use add_person instead):
{
  "kind": "speaker_identify",
  "revision": "rwmksmk",
  "payload": { "speakerId": 2, "entityId": 147403 }
}
add_person (not anchored; proposes a person not in the index and links the speaker in one action):
{
  "kind": "add_person",
  "revision": "rwmksmk",
  "payload": { "speakerId": 3, "name": "Jordi Hays" }
}
entity_tag (anchored; existing entity via entityId, or propose one with proposedName + proposedType):
{
  "kind": "entity_tag",
  "revision": "rwmksmk",
  "anchor": { "segmentIndex": 12, "contentHash": "1a2b3c" },
  "payload": { "segmentIndex": 12, "charStart": 34, "charEnd": 40, "entityId": 120034 }
}
segment_rewrite (anchored; the structural primitive: replaces segments startIndex..endIndex inclusive with the replacements):
{
  "kind": "segment_rewrite",
  "revision": "rwmksmk",
  "anchor": { "segmentIndex": 12, "contentHash": "8kq2mf" },
  "payload": {
    "startIndex": 12,
    "endIndex": 14,
    "replacements": [
      { "text": "So Anthropic's Claude 4.5 is the new model they shipped.", "start": 754 },
      { "text": "It handles long-horizon coding tasks.", "speaker": 3 }
    ]
  }
}
  • Any N-to-M shape: merge mangled ASR lines into one clean segment, split one into several, or delete the range outright with "replacements": [].
  • Timestamps are repaired, optionally pinned: start/end (seconds) are optional pins per replacement and must be non-decreasing across the list. Every unpinned time is interpolated char-proportionally between the surrounding pins; with no pins at all, the outer bounds are the source time range. To fix a wrong timestamp, rewrite the segment with the same text and an explicit start.
  • speaker is optional per replacement; omitted, it inherits from the source segment the replacement’s repaired start time falls in.
  • startIndex must equal anchor.segmentIndex, and the contentHash covers startIndex..endIndex (joined with \n). Limits: 50 source segments, 50 replacements, 2000 chars per replacement.

Anchors and the content hash

anchor.contentHash is a djb2 hash of the covered segment text (segments joined with \n for multi-segment selections), base-36 encoded:
function djb2(input) {
  let hash = 5381;
  for (let i = 0; i < input.length; i++) {
    hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
  }
  return hash.toString(36);
}

seq and the queue-client contract

seq is an optional per-user, per-video monotonic counter for clients submitting streams of dependent corrections (sub-line splits re-index later segments, so order matters). One-off submissions can omit it. The error semantics are designed for at-least-once queue clients:
StatusMeaningWhat to do
201Stored. Body: { ok, kind, result } with the row id (use it to withdraw).Continue.
409Revision or anchor mismatch: the transcript changed underneath you. Body: { error, reason, currentRevision }. The sequence number was consumed.Drop or re-anchor this correction; continue the queue.
412Sequence mismatch. Body: { error, expectedSeq }.Refetch the transcript, rebase your counter, resend. Never cached by idempotency.
401Authentication expired.Pause the queue, re-authenticate, resume. Never drop.
429 / 5xxTransient.Retry the same event with backoff and the same Idempotency-Key.
Use the correction’s client-generated UUID as the Idempotency-Key: replays of final outcomes return the stored response with Idempotency-Replayed: true; non-final outcomes (412) are never cached.

Withdrawal

Every pending correction is deletable by its author. The POST response’s row id maps to:
DELETE /v1/transcripts/{video_id}/edits/{id}        line edits
DELETE /v1/transcripts/{video_id}/speakers/{id}     speaker identifications (also removes the community appearance)
DELETE /v1/corrections/speaker-edits/{id}           speaker reassigns/splits
DELETE /v1/corrections/entity-tags/{id}             entity tags
DELETE /v1/corrections/segment-rewrites/{id}        segment rewrites
DELETE /v1/transcripts/{video_id}/merges/{id}       video-level merges

Video-level entity merges

For misattributed name mentions within one video (“mentions of Imad in this video are Emad Mostaque”):
POST /v1/transcripts/{video_id}/merges
{ "sourceName": "Imad", "targetEntityId": 147403, "replaceWith": "Emad" }
replaceWith optionally respells the transcript text. Mentions of the same name in other videos are untouched.

Timestamps

Segment times are pipeline-derived and repaired on every structural correction: sub-line splits interpolate at the split point, segment_rewrite interpolates char-proportionally between pins. To set a time explicitly, pin it: rewrite the segment with the same text and an explicit start (or end). Dependent edits should share a seq stream so reviewers see them together.

Errors

CodeHTTPWhen
invalid_api_key401Bad key, or session expired mid-queue.
quota_exceeded402Monthly pool exhausted with on-demand off; standard envelope, fired before any handler (no quote).
Unlock/submit 402402Not enough rows for this specific purchase. Bare { error, quote } body, no envelope, no code: the refused price is in quote.
api_not_enabled403Your plan does not include API access.
Paid-plan gate403Transcript surfaces require a paid plan; bare { error } body (no envelope code).
invalid_body400Correction failed schema validation.
invalid_video_id400video_id must be the 11-character YouTube form.
idempotency_conflict409Same Idempotency-Key, different body.
Correction 409 / 412Anchor/revision and sequence semantics above: { error, reason, currentRevision } / { error, expectedSeq }, not the standard envelope.
rate_limit_exceeded429Honor Retry-After.

Rate limits & idempotency

Standard per-key limits (RateLimit-* headers). Idempotency-Key on every POST; for corrections, only final outcomes are cached, and a 412 never replays.

Community Review

Transcript corrections are the Community Review surface for transcript content: pending-review rows, attributed to your key, 0 rows, withdrawable, reputation-building. Anything that is not transcript content (wrong entity in search, a bad mention row that led you to this video) goes through POST /v1/feedback with the matching type. Catalog: Community Review.

Common mistakes

WrongCorrect
Expecting text when access is lockedPass ?unlock=true (quoted in meta.quote) or show the teaser
unlock=true on premium_pendingNothing to unlock yet; POST /v1/transcriptions runs the premium generation
Busy-polling /v1/transcriptions/{id}Sleep on Retry-After / nextPollSeconds; terminal statuses drop the header
Submitting anchored kinds without revision + anchor.contentHashline_edit, speaker_reassign, entity_tag, segment_rewrite require both; expect 409 on mismatch
Retrying a 409 correction verbatimThe transcript changed; re-anchor against the new revision. The seq was consumed
Treating 412 as fatalRebase to expectedSeq and resend; it is a sync signal, not an error
Empty correctedText to delete a lineUse segment_rewrite with "replacements": []
Expecting exact times without pinsUnpinned rewrite times are interpolated; pin start/end where the exact value matters
Charging users for correctionsCorrections are always 0 rows
SHA-256 for anchor.contentHashIt is djb2, base-36 encoded (function above)

Patterns and gotchas

  • Always read before you write: corrections anchor to the meta.revision you were served.
  • Batch dependent edits (splits then edits) under one seq stream, strict FIFO per video.
  • speaker_identify has visible side effects immediately (a community-flagged appearance on the person page); withdrawal removes it.
  • Refunds revoke unlocks: treat refunded as “you have neither the transcript nor the charge.”
  • Quote before you buy: meta.quote on a free read tells you the exact row cost; check /v1/me for budget.
  • existing: true on submit means an in-flight request already covers the video; poll that one.

Complete examples

Read-unlock-correct, end to end:
TypeScript
const BASE = 'https://api.arcmira.com';
const H = { Authorization: `Bearer ${process.env.ARCMIRA_API_KEY}` };

function djb2(input: string) {
  let hash = 5381;
  for (let i = 0; i < input.length; i++) hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
  return hash.toString(36);
}

const videoId = 'dQw4w9WgXcQ';
const t = await fetch(`${BASE}/v1/transcripts/${videoId}?unlock=true`, { headers: H }).then(r => r.json());
if (t.access !== 'unlocked') throw new Error(`not unlocked: ${t.access}`);

// Fix a mis-transcription in segment 12
const seg = t.segments[12];
const res = await fetch(`${BASE}/v1/videos/${videoId}/corrections`, {
  method: 'POST',
  headers: { ...H, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({
    kind: 'line_edit',
    revision: t.meta.revision,
    anchor: { segmentIndex: 12, contentHash: djb2(seg.text) },
    payload: { segmentIndex: 12, originalText: seg.text, correctedText: seg.text.replace('cloud', 'Claude') },
  }),
});
if (res.status === 409) {
  // transcript changed underneath us: refetch and re-anchor
} else if (res.ok) {
  const { result } = await res.json();
  console.log('pending edit id', result.id); // DELETE /v1/transcripts/{videoId}/edits/{id} to withdraw
}