# Transkripsie API — Developer Reference The Transkripsie public API lets you list recordings, download transcripts, stream audio, and upload new files — over plain HTTPS, with a single `Authorization: Bearer …` header. It's the same data the web dashboard uses; you just point your scripts, agents, or third-party tools at `https://transkripsie.com/api/v1` instead of a browser session. Machine-readable spec (no auth): . Catalog: . > **Stability.** `v1` is stable. Breaking changes will ship under a new > path (`/api/v2`) so existing integrations keep working. --- ## Registration is closed Individual registration is closed. `POST /api/auth/signup` returns 403. Do not create an account, mint a key, or pay. The only unauthenticated action is submitting interest. `GET https://transkripsie.com/api/access/request` returns JSON with `creates_account` false, `registration` `"closed"`, `payment_required_for_paid_use` true, `method` `POST`, `url` (that same address), `content_types` `application/json` and `application/x-www-form-urlencoded`, and `body` where `email` is required and `name` and `reason` are optional. POST that URL with `{"email":"name@example.com"}` or with form field `email`. Either way the acknowledgement is: "Thanks. We'll email you when Transkripsie is available." The JSON field `message` is that sentence. ```bash curl -fsS https://transkripsie.com/api/access/request curl -fsS -X POST -H "Content-Type: application/json" \ -d '{"email":"name@example.com"}' \ https://transkripsie.com/api/access/request curl -fsS -X POST -d "email=name@example.com" \ https://transkripsie.com/api/access/request ``` That acknowledgement does not create an account, start a subscription, or reserve a price. Paid use still requires the account holder to pay when checkout is open. The limit is 3 requests per minute. A duplicate email gets the same acknowledgement. When access is open later, a human mints a key at after they activate the emailed invite and sign in. Keys are for scripts and coding agents that call this API. The remote MCP connector is `https://transkripsie.com/api/mcp`. Put the `tkp_live_` key in the connector's secret header (`Authorization: Bearer …`), not in the chat. --- ## 1. Quickstart If the account holder already has a key, export it and list recordings: ```bash export TRANSKRIPSIE_API_KEY="tkp_live_…" curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/recordings ``` Reads use that header. An upload also sends the recording-rights multipart fields on `POST /api/v1/recordings`. A missing field is HTTP 400. --- ## 2. Authentication ### Mint a key The account holder does this in the browser after they activate an invite and sign in. An API key cannot mint another key. The remote MCP connector is `https://transkripsie.com/api/mcp`. Put the key in the connector's secret header, not in the chat. `Settings → Developer API` → **Create new key**. Pick a name (e.g. `zapier-prod`), tick the scopes you actually need, optionally set an expiry, hit save. The raw key is shown **once** in the modal — copy it into your secret manager immediately. We hash it (SHA-256) before it hits the database; if you lose it we can't recover it, only revoke it. ### Key format ``` tkp_live_<43-char-base64url> ``` - Total length is ~52 characters. - The `tkp_live_` prefix is non-secret; it's there so secret-scanners (gitleaks, GitHub push protection, your own grep) can recognise a Transkripsie key on sight. Pattern is borrowed from Stripe. - A `tkp_test_` prefix is reserved for a future sandbox tier; it doesn't exist yet. ### Send the key Always as a Bearer token: ```http Authorization: Bearer tkp_live_… ``` Anything else (query string, custom header, basic auth) returns `401`. ### Scopes Scopes are comma-separated and additive. > **Breaking change (May 2026).** The default scope set for a new key > is now **empty (`[]`)**. You must select scopes explicitly at mint > time or the request returns `400 { "code": "no_scopes" }`. Existing > keys keep the scopes they were originally minted with — only new > mints are affected. | Scope | Grants | | ------------------- | ------------------------------------------------------------- | | `read:profile` | `GET /me` | | `read:recordings` | `GET /recordings`, `GET /recordings/{id}` (metadata only) | | `read:transcripts` | `GET /recordings/{id}` (with segments), exports, segments | | `read:audio` | `GET /recordings/{id}/audio` (stream the original media) | | `read:video` | `GET /meetings/{id}/video` (stream the meeting-bot MP4). Separate from `read:audio` because file size and privacy implications are bigger | | `read:meetings` | `GET /meetings`, `GET /meetings/{id}` | | `write:uploads` | `POST /recordings` (upload a new file for transcription) | | `manage:webhooks` | `POST /webhooks`, `GET /webhooks`, `DELETE /webhooks/{id}` | Asking for a scope you don't have returns: ```json { "detail": { "code": "insufficient_scope", "required_scopes": ["read:audio"], "granted_scopes": ["read:profile", "read:recordings"] } } ``` with HTTP `403`. ### What a key cannot do A `tkp_live_` bearer on `/api/v1` cannot create an account, mint, rotate, or revoke keys, send the meeting bot, delete or edit recordings, chat about a recording, or change billing. Key management stays on the session-only `/api/keys` routes, which reject API keys. There is no meeting-join route on `/api/v1`. ### Revocation Hit **Revoke** on the row in the dashboard. The key is dead the instant the request returns — every subsequent call gets `401`. Revoked rows stay in the dashboard so you can still see how many requests they served and from which IP, which is what you want during a "did this leaked key actually get used?" investigation. --- ## 3. Security model Keys are bearer credentials. The controls below are what we layer on top so a leaked or misused key has a small blast radius and a fast recovery path. Each piece is independent — pick the ones that match your threat model. ### Sensitive-action gate (re-auth) Minting, editing, and **rotating** keys (`POST /api/keys`, `PATCH /api/keys/{id}`, `POST /api/keys/{id}/rotate`) require that the user actually signed in (password + any email code, or a passkey) within the last **5 minutes**. The session JWT carries that time as `auth_time`; `POST /api/auth/refresh` keeps it unchanged, so a refreshed token does not count. If it is older, the server returns `403`: ```json { "detail": { "code": "reauth_required", "max_age_seconds": 300 } } ``` with a `WWW-Authenticate: Bearer error="insufficient_user_authentication"` header per [RFC 9470](https://www.rfc-editor.org/rfc/rfc9470). The client re-authenticates via `POST /api/auth/login` to mint a fresh JWT, then retries the original request. The session JWT is also bound to its sign-in session (claim `sid`). Once that session is revoked (sign-out, "sign out everywhere", a password reset, or refresh-token reuse detection) these routes answer `401 { "detail": "Session revoked; please sign in again" }` even while the token itself has not expired yet, so a stolen token cannot mint a key after the owner has cut its session off. A token without `sid` (issued before this rule) gets `403 reauth_required`. This is enforced on the session-only `/api/keys` surface only — regular `/api/v1/*` traffic with a `tkp_live_…` key is not affected. ### IP allowlists Each key can carry an `ip_allowlist` (comma-separated IPs or CIDRs). Requests from any other address get `403`: ```json { "detail": { "code": "ip_not_allowed" } } ``` `ip_allowlist` defaults to `null` (any IP), so existing integrations keep working. Set it on keys handed to known-static partners (Zapier egress ranges, your own CI workers) to shrink the leak surface. ### Key rotation with 7-day grace `POST /api/keys/{id}/rotate` mints a sibling key that inherits the original's scopes, recipient labels, IP allowlist, and daily upload budget. The **old** key keeps working for **7 days** after rotation so you can swap the new value into your secret manager, CI, and any running workers without a flap. Once `rotation_expires_at` passes, auth fails with `401`: ```json { "detail": { "code": "key_rotated", "rotated_to_prefix": "tkp_live_NeXtKey…" } } ``` The new key carries `rotated_from_id` pointing back at the old row, which is what the audit trail and the dashboard use to draw the rotation lineage. A key can be rotated **once**: rotating it again returns `409 { "detail": { "code": "already_rotated", ... } }` (it would otherwise restart the old key's grace window). Rotate the successor instead. ### Per-key rate limits + daily upload budgets Two independent throttles sit on top of the global limiter: - **`max_requests_per_minute`** — `null` resolves to the **120 req/min/key** default. Enforced per key across all IPs, on `/api/v1` and on the MCP connector (`/api/mcp`), up to a ceiling of **600 / minute** (larger stored values are treated as 600); the dashboard has no control for it yet (set it through `PATCH /api/keys/{id}`). - **`max_minutes_per_day`** — caps `write:uploads` traffic for a key. When the day's accepted upload minutes exceed it, the next upload returns `429`: ```json { "detail": { "code": "key_budget_exceeded" } } ``` Independent of the account-level monthly quota in [`docs/BILLING.md`](BILLING.md) §5 — the budget is per-key so you can hand a single integration a fraction of your monthly minutes without letting it spend the whole bucket. ### Email notifications The account owner receives an email when: - A key is **created** (with prefix, scopes, recipient label, and the IP it was minted from). - A key is used for the **first time** (one-time, fires after the first successful authenticated call — confirms the integration actually picked the key up). - A key is **revoked** (manual revoke, rotation grace expiry, or admin action). Any of those landing in your inbox when *you* didn't trigger them is a leaked-key signal — revoke immediately. ### CORS lockdown The `/api/v1/*` surface runs as its own sub-app with deliberately strict CORS: ``` Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: false Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Authorization, Content-Type, Accept, X-Transkripsie-Language ``` `Allow-Credentials: false` is the important bit: browser-side credentialed requests (cookies, basic auth) will fail by design. There is **no supported in-browser flow** for the public API — server-to-server, native apps, and trusted backends only. Never embed a `tkp_live_…` key in browser-accessible storage; treat it like a database password. ### DPoP (RFC 9449) — deferred Sender-constrained tokens via DPoP proof-of-possession are on the roadmap but **not yet implemented**. Until then, treat keys as plain bearer credentials: anyone holding the string can call the API as you. Store them server-side or in a sealed secret manager only. ### Remote MCP connector (`/api/mcp`) `https://transkripsie.com/api/mcp` is a read-only Model Context Protocol server for AI assistants (Claude, Grok, the Gemini CLI, Muse). Public card: . - **API key only.** Send `Authorization: Bearer tkp_live_…`. A session token (or any other bearer) gets `401` with `{ "detail": { "code": "api_key_required", … } }`, so every call is tied to one key you can scope and revoke. Revoked and expired keys get `401`, as on `/api/v1`. - **Protocol.** Streamable HTTP, MCP revision `2025-06-18`: `POST` only, one JSON-RPC message per request. A batch (JSON array) gets `400`. `GET` and `DELETE` get `405` with `Allow: POST` (no SSE stream, no session). - **Body.** At most **64 KiB** (`413` with `body_too_large` above that). Invalid JSON or invalid UTF-8 is JSON-RPC `-32700`. - **Origin.** A request with an `Origin` header from any site other than `https://transkripsie.com` gets `403` (`origin_not_allowed`). Hosted connectors call server-to-server and send no `Origin`. - **Tools.** `whoami`, `list_recordings`, `get_transcript`, `list_meetings` and `get_meeting`, all read-only (`readOnlyHint: true`). `tools/list` shows only the tools the key's scopes allow. Arguments that do not match a tool's `inputSchema` get JSON-RPC `-32602`. Output is a short projection (ids, titles, status, language, duration, dates): no meeting URLs, upload IPs or server file names. Transcripts are cut at 16,000 characters. Each `get_transcript` call is written to the security audit log with the tool name and the key prefix. - **Limits.** Every POST counts once against the key's `max_requests_per_minute`. `/api/mcp` also allows **120 requests / minute per account**, shared by all of the account's keys, instead of a per-IP limit: hosted assistants share their vendor's IP addresses. --- ## 4. Endpoints All paths are relative to `https://transkripsie.com`. Pagination uses `limit` (default 50, max 200) and `offset`. Timestamps are ISO-8601 UTC strings. Recording ids are strings; pass them through unchanged. User, speaker, segment, and project ids are integers. > **Two `/api` surfaces.** `/api/...` is the session-cookie API the web > app uses; `/api/v1/...` is the public API that accepts API keys (and > JWTs, so you can curl-test as yourself). Use `/api/v1` from anything > that isn't the dashboard. ### `GET /api/v1/me` Scope: `read:profile`. Returns the user the key belongs to plus a description of how the call was authenticated. ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/me ``` ```json { "user": { "id": 42, "email": "you@example.com", "name": "You", "created_at": "2026-01-12T08:14:55Z", "plan": "pro" }, "auth": { "kind": "api_key", "key_prefix": "tkp_live_AbCdEfGh", "scopes": ["read:profile", "read:recordings", "read:transcripts"] } } ``` --- ### `GET /api/v1/recordings` Scope: `read:recordings`. Paginated list of the caller's recordings. | Query param | Type | Default | Notes | | ----------- | ------- | ------- | ------------------------------------------------ | | `limit` | int | 50 | 1–200 | | `offset` | int | 0 | | | `status` | string | — | `uploading`, `converting`, `transcribing`, `completed`, `error` | ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ "https://transkripsie.com/api/v1/recordings?limit=20&status=completed" ``` ```json { "recordings": [ { "id": "901", "filename": "kickoff.m4a", "original_filename": "Kickoff Q3.m4a", "status": "completed", "language": "af", "duration_ms": 1843200, "ai_title": "Q3 Kickoff", "has_overview": true, "created_at": "2026-05-10T11:02:18Z", "speaker_list": [ { "name": "Cornell", "color": "#7c3aed" }, { "name": "Speaker 2", "color": "#0891b2" } ] } ], "total": 137, "limit": 20, "offset": 0 } ``` --- ### `GET /api/v1/recordings/{id}` Scope: `read:transcripts`. Full transcription including speakers and segments. See [§6 Recording schema](#6-recording-schema) for the complete shape. ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/recordings/901 ``` Returns `404` if the recording doesn't exist **or** isn't owned by the key's owner — we never reveal the existence of someone else's data. --- ### `GET /api/v1/recordings/{id}/segments` Scope: `read:transcripts`. Just the segments + speakers, none of the top-level metadata. Useful when you already have the metadata cached and only want to refresh the body. ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/recordings/901/segments ``` ```json { "speakers": [ { "id": 7701, "speaker_number": 1, "speaker_name": "Cornell", "color": "#7c3aed" } ], "segments": [ { "id": 30188, "speaker_number": 1, "text": "Hallo almal, welkom by die kickoff.", "start_ms": 0, "end_ms": 3120, "confidence": 0.97, "language": "af", "text_en": "Hi everyone, welcome to the kickoff.", "words": [ { "t": "Hallo", "s": 0, "e": 480 }, { "t": "almal,", "s": 480, "e": 1040 } ] } ] } ``` --- ### `GET /api/v1/recordings/{id}/audio` Scope: `read:audio`. Streams the original audio (decrypted on the fly). Supports HTTP range requests, so seekable players work. The `Content-Type` matches the original upload (`audio/mpeg`, `audio/mp4`, `audio/wav`, …). ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/recordings/901/audio \ -o kickoff.m4a ``` --- ### `GET /api/v1/recordings/{id}/export/{fmt}` Scope: `read:transcripts`. `fmt` is one of: | `fmt` | Content-Type | Use | | ------ | ----------------------- | -------------------------------- | | `txt` | `text/plain` | Plain transcript, no timestamps | | `srt` | `application/x-subrip` | SubRip subtitles for video tools | | `vtt` | `text/vtt` | WebVTT for HTML5 `` | | `json` | `application/json` | Same shape as `/recordings/{id}` | ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/recordings/901/export/srt \ -o kickoff.srt ``` --- ### `GET /api/v1/meetings` Scope: `read:meetings`. Paginated list of meeting-bot recordings (the ones started via Recall.ai, not direct uploads). A key can read these rows. It cannot send the meeting bot. | Query param | Type | Default | Notes | | ----------- | ---- | ------- | ---------------------------------------------- | | `limit` | int | 50 | 1–200 | | `offset` | int | 0 | | | `status` | str | — | `bot_sent`, `recording`, `completed`, `error` | ```json { "meetings": [ { "id": "mt_01HZ…", "meeting_url": "https://meet.google.com/abc-defg-hij", "platform": "google_meet", "status": "completed", "status_label": "Completed", "bot_name": "Transkripsie", "created_at": "2026-05-09T14:00:11Z", "ended_at": "2026-05-09T14:48:02Z", "transcription_id": 902, "has_video": true } ], "total": 14, "limit": 50, "offset": 0 } ``` The `transcription_id` field is the bridge to the transcript: pass it to `GET /api/v1/recordings/{id}` (with `read:transcripts`) to fetch the text. --- ### `GET /api/v1/meetings/{id}` Scope: `read:meetings`. Single meeting, same fields as above. ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/meetings/mt_01HZ… ``` --- ### `GET /api/v1/meetings/{id}/video` Scope: `read:video`. Streams the meeting bot's recorded MP4 (decrypted on the fly). Supports HTTP range requests, so seekable players work. The response is `video/mp4`; size and download time scale with meeting length, so prefer range requests over downloading in one shot. Only meetings with `has_video: true` have a video file. Anything else returns `404`. ```bash curl -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/v1/meetings/mt_01HZ…/video \ -o meeting.mp4 ``` The `read:video` scope is deliberately separate from `read:audio`: meeting videos are typically multi-GB and capture every participant's camera frame, so we want consent-by-scope to be explicit rather than "audio implies video". --- ### `POST /api/v1/recordings` Scope: `write:uploads`. Multipart upload of a single audio/video file. Returns `202 Accepted` with the freshly-created recording's ID and status; transcription happens asynchronously. Poll `GET /api/v1/recordings/{id}` until `status` is `completed`. A failure on that recording is `status` `error`. The account holder is acknowledging they have the right to process the recording. `recording_rights_acknowledged` must be the string `true`, and `recording_rights_acknowledgement_version` must be `2026-09-22`. A missing field is HTTP 400. | Form field | Type | Required | Notes | | ------------ | ------ | -------- | ----------------------------------------------------- | | `file` | file | yes | mp3, wav, m4a, mp4, webm, ogg, flac | | `language` | string | no | BCP-47 hint (e.g. `af`, `en`); auto-detected if omitted | | `recorded_at`| string | no | ISO-8601 timestamp of the original recording | | `recording_rights_acknowledged` | string | yes | `true`. The account holder has the right to process this recording | | `recording_rights_acknowledgement_version` | string | yes | `2026-09-22` | | `recording_rights_acknowledged_at` | string | no | Optional ISO-8601 client timestamp; server time is authoritative | `Idempotency-Key` is an optional header, not a form field. See below. ```bash UPLOAD_ID="kickoff-2026-09-22" # any name you choose for this file; reuse it on a retry curl -X POST \ -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ -H "Idempotency-Key: ${UPLOAD_ID}" \ -F "file=@kickoff.m4a" \ -F "language=af" \ -F "recording_rights_acknowledged=true" \ -F "recording_rights_acknowledgement_version=2026-09-22" \ https://transkripsie.com/api/v1/recordings ``` ```json { "id": "903", "status": "uploading" } ``` #### Idempotency-Key Optional header on this route. The value is 1–255 characters. Allowed characters are ASCII letters, digits, and `.`, `_`, `:`, `-`. The curl above includes one. Omit the header and behaviour is unchanged. The same key and the same file within 24 hours returns the original 202 body and does not create a second recording. The same file means the same filename, language, `recorded_at`, rights version, and file bytes. The same key and a different file returns `409` with `detail.code` `idempotency_conflict`. A duplicate that arrives while the first request is still running returns `409` with `detail.code` `idempotency_in_progress` and a `Retry-After` header. Wait for that delay, then send the same request again. Quota and per-file length caps are enforced before the upload is accepted; rejected uploads return `402` with `{"detail": {"code": "quota_exceeded" | "file_too_long"}}` (see [`docs/BILLING.md`](BILLING.md) §5). #### Recording-rights acknowledgement All upload and meeting-launch callers must send an explicit acknowledgement; the server never treats a missing field or continued meeting presence as consent. `POST /api/v1/recordings` sends the same multipart fields as the curl above. The account holder is acknowledging they have the right to process the recording. A missing field is HTTP 400. Web uploads send these multipart fields (batch uploads use the same fields once per request): | Field | Value | | --- | --- | | `recording_rights_acknowledged` | `true` (strict boolean string) | | `recording_rights_acknowledgement_version` | `2026-09-22` | | `recording_rights_acknowledged_at` | Optional ISO-8601 client timestamp; server time is authoritative | `POST /api/meetings/join` accepts the same names as JSON fields. That route is for a signed-in account holder. An API key cannot send the meeting bot. The accepted acknowledgement is stored in the security audit log with the authenticated user, resource ID, version, and server timestamp in the same transaction as the meeting or recording row. --- ### Webhooks Scope: `manage:webhooks`. Create, list, and revoke HTTPS callbacks for the account that owns the key. Call these routes from your server. A webhook receives the recording id and status of **every** recording in the account, not only the ones uploaded with this key. Give a key `manage:webhooks` only if its holder may know about all of the account's recordings. `POST /api/v1/webhooks` with JSON: ```json { "url": "https://example.com/hooks/transkripsie", "events": ["recording.completed", "recording.failed"] } ``` Returns `{id, url, events, secret}` once. `secret` is the HMAC key. Store it immediately; it is not shown again. Creating a webhook returns `503` if the server has no encryption key to store the secret. Create webhooks with an API key. A signed-in session token gets `403` with `detail.code` `api_key_required`. The webhook stays tied to the key that created it and stops delivering when that key is revoked, expires, or loses `manage:webhooks`, or when the account is disabled or deleted. Revoking the key also removes its webhooks from the list. Rotating the key moves its webhooks to the new key. The URL must be HTTPS on port 443. Redirects are rejected. Private, loopback, link-local, and metadata addresses are rejected. A malformed URL, or a host that does not resolve to a public address, returns `400` with `detail.code` `webhook_url_invalid`. ```bash curl -X POST \ -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/hooks/transkripsie","events":["recording.completed","recording.failed"]}' \ https://transkripsie.com/api/v1/webhooks ``` `GET /api/v1/webhooks` lists `id`, `url`, `events`, and `created_at`. It does not include `secret`. `DELETE /api/v1/webhooks/{id}` revokes that webhook. Listing and deleting also work with a session token. When a recording finishes or fails, Transkripsie POSTs: ```json { "id": "evt_...", "type": "recording.completed", "created_at": "ISO-8601", "data": { "recording_id": "...", "status": "completed" } } ``` `type` is `recording.completed` or `recording.failed`. `data.status` is `completed` or `error`. A failure uses `type` `recording.failed` and `data.status` `error`. The request header is: ```http Transkripsie-Signature: t=,v1= ``` `v1` is the hex HMAC-SHA256 of the timestamp, a dot, and the raw body (`{t}.{raw body}`), using the webhook `secret` as the key. Compare it in constant time (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node), never with `==`. Reject timestamps older than 5 minutes. ```python import hashlib, hmac, time def verify(secret: str, header: str, raw_body: bytes) -> bool: parts = dict(item.split("=", 1) for item in header.split(",")) signed = parts["t"].encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() fresh = abs(time.time() - int(parts["t"])) <= 300 return fresh and hmac.compare_digest(expected, parts["v1"]) ``` Answer with any `2xx` status within 20 seconds; the response body is ignored. Any other status, a timeout, or a connection error is a failed attempt. A delivery is tried up to 3 times: once, then about 10 seconds later, then about 60 seconds after that. Pending retries are lost if the server restarts, so poll for anything you must not miss. A retry sends the same event `id` with a new `t` and signature. Keep the event `id`s you have handled and ignore repeats. Clients with no public URL can poll `GET /api/v1/recordings/{id}` instead. On that GET, success is `status` `completed` and failure is `status` `error`, the same `error` value as webhook `data.status`. The webhook event name for a failure is `recording.failed`. --- ### Key management (session-only) These endpoints power the Settings → Developer API tab and are mounted at `/api/keys`, **not** `/api/v1`. They reject API-key auth on purpose — a leaked key must not be able to mint itself a fresh sibling. Use your session JWT (i.e. log in to the dashboard, then re-use the same bearer token) when calling them directly. | Method | Path | Description | | -------- | -------------------------- | --------------------------------------------------------------------------------- | | `GET` | `/api/keys` | List your keys | | `POST` | `/api/keys` | Mint a key. Returns the raw key once in `raw_key`. **Sensitive** — reauth-gated. | | `PATCH` | `/api/keys/{id}` | Rename, re-scope, change recipient labels, set expiry. **Sensitive** — reauth-gated. | | `POST` | `/api/keys/{id}/rotate` | Mint a sibling key; the old key keeps working for 7 days. **Sensitive** — reauth-gated. | | `DELETE` | `/api/keys/{id}` | Revoke (soft-delete; row stays for the audit trail). | "Sensitive — reauth-gated" means the route requires a sign-in from the last 5 minutes (a token refresh does not count) in a session that has not been revoked; see [§3 Security model](#3-security-model) for the re-auth flow. Key emails (created, rotated, changed, first use, revoked) always go to the account owner's own address; a `PATCH` that changes scopes, IP allowlist, expiry or rate limits sends a "changed" email (renames and label edits do not). `recipient_name` / `recipient_email` are labels only, and `recipient_email` must be a single email address. > **Organization SCIM tokens are separate.** A token minted with > `POST /api/compliance/organizations/{org_id}/scim-tokens` belongs to > the organization, not to a user: a user's password reset or "sign out > everywhere" does **not** revoke it. Minting one (and changing the > org's SSO / WorkOS configuration) needs the same 5-minute re-auth and > is emailed to the acting admin and every org owner. Revoke a SCIM > token explicitly with > `DELETE /api/compliance/organizations/{org_id}/scim-tokens/{token_id}` > (no re-auth needed). `GET /api/keys` returns rows of the shape below. New fields shipped in this release are marked **NEW**: ```jsonc { "id": 12, "name": "zapier-prod", "prefix": "tkp_live_AbCdEfGh", "scopes": ["read:recordings", "read:transcripts"], "recipient_name": "Zapier", "recipient_email": "billing@zapier.example", "created_at": "2026-05-09T11:02:18Z", "expires_at": null, "revoked_at": null, "last_used_at": "2026-05-16T07:14:22Z", "last_used_ip": "203.0.113.41", "request_count": 2814, "ip_allowlist": "203.0.113.0/24,198.51.100.7", // NEW; null = any IP "max_minutes_per_day": 60, // NEW; null = no per-key budget "max_requests_per_minute": null, // NEW; null = 120/min default "rotated_from_id": null, // NEW; set on the new key after a rotate "rotation_expires_at": null, // NEW; set on the OLD key after a rotate "last_used_country": null // NEW; alpha-2 ISO, reserved (NULL until GeoIP lands) } ``` #### `POST /api/keys/{id}/rotate` Issues a new key that inherits scopes, recipient labels, IP allowlist, and daily budget from `{id}`. The old key keeps working for 7 days; afterwards it fails with `401 { "code": "key_rotated" }`. The response shape matches `POST /api/keys` — `raw_key` is included exactly once. The route requires a JWT that's at most 5 minutes old; without one you get `403 { "code": "reauth_required", "max_age_seconds": 300 }`. Re-authenticate via `POST /api/auth/login` and retry. ```bash curl -X POST \ -H "Authorization: Bearer $TRANSKRIPSIE_API_KEY" \ https://transkripsie.com/api/keys/12/rotate ``` > `$TRANSKRIPSIE_API_KEY` here must hold a **session JWT**, not a > `tkp_live_…` key — the `/api/keys` surface rejects API-key auth. The returned `raw_key` is your new credential. Drop it into your secret manager, redeploy any workers that hold the old value, and let the 7-day grace window cover the rollout. --- ## 5. Recording Q&A (session API) These endpoints power **Ask about this recording** in the web app. They live on the session `/api/...` surface (JWT bearer or cookie session), **not** on `/api/v1` — API keys cannot call them. Answers are grounded in the recording's AI overview + full transcript. The server is stateless: pass prior turns in `history` so chat survives reloads. Model is configured server-side via `RECORDING_CHAT_MODEL` (OpenRouter id; default `anthropic/claude-opus-4.8`). ### `POST /api/transcriptions/{id}/chat` Auth: session JWT. Recording must belong to the caller and have `status == "completed"`. **Request** ```json { "message": "What acquisition targets did we discuss?", "conversation_id": "optional-opaque-id", "history": [ { "role": "user", "content": "Who spoke most?" }, { "role": "assistant", "content": "Cornell had the most airtime…" } ] } ``` | Field | Type | Required | Notes | | ------------------ | -------- | -------- | ------------------------------------------ | | `message` | string | yes | Current user question | | `conversation_id` | string | no | Echoed back unchanged (analytics hook) | | `history` | array | no | Up to ~16 prior turns (`user` / `assistant`) | **Response** `200` ```json { "answer": "They discussed PSG acquiring staff-heavy businesses…", "conversation_id": "optional-opaque-id", "ai_disclosure": { "…": "…" } } ``` Response includes `X-AI-Generated: true`. Errors: `404` (not found / not owned), `409` (still processing), `502`/`503` (LLM failure / misconfiguration). ### `POST /api/shared/{token}/chat` Same request/response shape as above, for anonymous viewers with a share link. No JWT. Password-protected shares require the `tkp_share_unlock` cookie from `POST /api/shared/{token}/unlock` first (`401` with `code: "password_required"` otherwise). Rate-limited per IP. --- ## 6. Recording schema This is the canonical shape returned by `GET /api/v1/recordings/{id}`. It mirrors `transcription_to_dict` in `backend/services/transcription.py` exactly — when that function grows a field, this section grows with it. > See [§4 Endpoints → Key management](#key-management-session-only) > for the `ApiKey` shape returned by `GET /api/keys`, including the > new `ip_allowlist`, `max_minutes_per_day`, `max_requests_per_minute`, > `rotated_from_id`, `rotation_expires_at`, and `last_used_country` > fields. ```jsonc { "id": "901", // string, primary key "filename": "kickoff.m4a", // server-side filename "original_filename": "Kickoff Q3.m4a", // what the user uploaded "status": "completed", // uploading | converting | transcribing | completed | error "created_at": "2026-05-10T11:02:18Z", "recorded_at": "2026-05-10T10:00:00Z", // when the audio was actually recorded; nullable "processing_started_at": "2026-05-10T11:02:20Z", // nullable "duration_ms": 1843200, // total length; nullable until processed "full_text": "Hallo almal, welkom …", // joined transcript (no timing); nullable "ai_title": "Q3 Kickoff", // generated 3-6 word title; nullable "has_overview": true, // is an AI summary available? "error_message": null, // only set when status == "error" "language": "af", // BCP-47; nullable "batch_id": null, // groups multi-file uploads; nullable "batch_index": null, // position within the batch; nullable "upload_source": "web", // web | macos-app | electron-app | api "upload_ip": "192.0.2.1", // nullable "file_size_bytes": 18912430, // nullable "project_id": 12, // nullable "project": { // nullable "id": 12, "name": "Q3 Planning", "color": "#7c3aed" }, "speaker_list": [ // always present (may be []) { "name": "Cornell", "color": "#7c3aed" }, { "name": "Speaker 2", "color": "#0891b2" } ], // The fields below are only present when calling // GET /api/v1/recordings/{id} (full detail). The list endpoint // omits them to keep responses small. "speakers": [ { "id": 7701, "speaker_number": 1, // 1-indexed; matches segments[*].speaker_number "speaker_name": "Cornell", "color": "#7c3aed", "match_score": 0.91, // voiceprint match confidence, 0-1; nullable "match_status": "auto", // auto | manual | unknown; nullable "match_candidates": [ // top-N voiceprint candidates; may be [] { "speaker_id": 5512, "name": "Cornell", "score": 0.91 } ] } ], "segments": [ { "id": 30188, "speaker_number": 1, "text": "Hallo almal, welkom by die kickoff.", "start_ms": 0, "end_ms": 3120, "confidence": 0.97, // 0-1 "language": "af", // per-segment lang for code-switched audio; nullable "text_en": "Hi everyone, welcome to the kickoff.", // nullable "words": [ // word-level timing; may be [] { "t": "Hallo", "s": 0, "e": 480 }, { "t": "almal,", "s": 480, "e": 1040 } ] } ] } ``` ### Field notes - **All times are integer milliseconds** from the start of the audio. Durations are the same units. Convert to seconds with `ms / 1000`. - **`speaker_number` is 1-indexed** and stable for the lifetime of the recording — use it as the join key between `segments` and `speakers`. - **`words[*].t`** preserves the original token (including trailing punctuation). Don't strip it client-side unless you have a reason to. - **`text_en`** is only populated for non-English segments when per-segment translation has been run. - **`language`** at the recording level is the dominant detected language; `segments[*].language` may differ on code-switched audio (very common for Afrikaans/English). --- ## 7. Errors | Status | Body shape | Cause | | ------ | -------------------------------------------------------------------------- | -------------------------------------------------- | | `400` | `{ "detail": "" }` or `{ "detail": { "code": "no_scopes" \| "recording_rights_acknowledgement_required" } }` | Malformed request, an upload missing the recording-rights fields, or a key-mint call that didn't specify any scopes (now required) | | `401` | `{ "detail": "" }` or `{ "detail": { "code": "key_rotated", "rotated_to_prefix": "..." } }` | Missing / invalid / expired / revoked key, or an old key past its 7-day rotation grace | | `402` | `{ "detail": { "code": "quota_exceeded" \| "file_too_long" \| "meeting_bot_not_allowed" } }` | Plan limit hit; upgrade or wait for the monthly reset | | `403` | `{ "detail": { "code": "insufficient_scope" \| "reauth_required" \| "ip_not_allowed" \| "api_key_required", ... } }` or a string detail | Missing scope, sensitive action without a fresh JWT, request from an IP outside the key's allowlist, `POST /api/v1/webhooks` with a session token instead of an API key, or `POST /api/auth/signup` while registration is closed | | `404` | `{ "detail": "" }` | Resource doesn't exist **or** isn't owned by the key — both look identical, on purpose | | `409` | `{ "detail": { "code": "idempotency_conflict" \| "idempotency_in_progress" \| "already_rotated" } }` | Same `Idempotency-Key` with a different upload, a duplicate upload while the first request is still running (`Retry-After` on `idempotency_in_progress`), or rotating a key that was already rotated | | `429` | `{ "error": "Rate limit exceeded: …" }`, `{ "detail": { "code": "key_rate_limited", "limit_per_minute": N } }` or `{ "detail": { "code": "key_budget_exceeded" } }` | Network/IP limiter (or the MCP per-account limit), the key's per-minute limit (with `Retry-After`), per-user upload 10/min, interest form 3/min, or the key's daily `max_minutes_per_day` budget hit | | `503` | `{ "detail": "" }` | `POST /api/v1/webhooks` when the server has no encryption key to store the secret | | `5xx` | `{ "detail": "" }` | Other server-side failures. Retry with exponential backoff. | `401` and re-auth `403` responses always include a `WWW-Authenticate: Bearer …` header so generic HTTP clients negotiate correctly. The re-auth variant uses `Bearer error="insufficient_user_authentication"` per [RFC 9470](https://www.rfc-editor.org/rfc/rfc9470). ### Rate limits Network/IP and per-user, enforced by `slowapi`: - **Network/IP default**: **120 requests / minute** per client IP on each route called with an API key (per user for a session JWT). The limiter counts only requests whose bearer was accepted: a missing, invalid, expired or revoked key gets `401` before any limit is checked and is not counted. A refusal is `429` with `{ "error": "Rate limit exceeded: 120 per 1 minute" }` and no `Retry-After` header; wait up to a minute. - **Per key**: each API key is limited to its `max_requests_per_minute` (default **120 / minute**, ceiling **600 / minute**) across all IPs, enforced after the key is validated, on `/api/v1` and on every `POST /api/mcp`. Exceeding it returns `429` with `{ "code": "key_rate_limited", "limit_per_minute": N }` and a `Retry-After` header. Session-JWT callers are limited per user instead. - **MCP connector** (`POST /api/mcp`): **120 / minute per account** (all of the account's keys together) instead of the per-IP limit, because hosted assistants share their vendor's IP addresses. - **Media** (`GET /recordings/{id}/audio`, `GET /meetings/{id}/video`): a bounded range request — one `bytes=a-b` (or `bytes=-n`) window of at most **4 MiB**, without `If-Range` — counts as **0.1** of a request against the key, so a seeking player does not exhaust it. Open-ended (`bytes=0-`), larger or multi-range requests and whole-file downloads count as 1. Each key can hold at most **4** media responses open at once (`429` while busy). The network/IP limit on these two routes is **600 / minute**. - **Uploads** (`POST /api/v1/recordings`): **10 / minute / user** across all of that user's keys — on top of the network/IP cap. - **Interest form** (`POST /api/access/request`): **3 / minute**. A duplicate email still receives the same acknowledgement. - **Daily upload budget**: cap `write:uploads` traffic on a specific key via `max_minutes_per_day`. Exceeding it returns `429` with `{ "code": "key_budget_exceeded" }`. - **Reads**: no per-route cap beyond the network/IP and per-key limits. Back off on any `429`. Quota (monthly minutes, max file length, meeting-bot allowance) is a **separate** layer; see [`docs/BILLING.md`](BILLING.md) §5. --- ## 8. Sharing keys with third parties Keys are tied to your account, but they don't have to live on your laptop. When handing one to a Zapier/n8n workflow, an external contractor, or a vendor's connector: 1. **Create a dedicated key per integration.** One key, one purpose. If you have to revoke it later, you don't take everything else down. 2. **Scope it minimally.** A read-only Zapier flow doesn't need `write:uploads`; a one-shot importer doesn't need `read:audio`. Ask for `manage:webhooks` only when the integration receives callbacks. 3. **Set an expiry** if the engagement is finite. Three months is a reasonable default for contractors. 4. **Name the recipient** in the dashboard's `recipient_name` / `recipient_email` fields so future-you remembers who has what. 5. **Never commit a key to git.** Our keys begin with `tkp_live_` so gitleaks / GitHub push protection / Trufflehog will flag them automatically; configure your secret scanner to recognise the prefix. 6. **Revoke when done.** Revocation is instant — there's no "stale cache" window where a revoked key still works. 7. **Keep the key in a script or coding agent that calls the HTTP API.** The remote MCP connector is `https://transkripsie.com/api/mcp`. Put the key in the connector's secret header, not in the chat. --- ## 9. Versioning Machine-readable spec (no auth): . Catalog: . `v1` is **stable**. We may add new fields, scopes, query parameters, or endpoints under `/api/v1` without notice — the contract is "your existing parser keeps working" (additive only). Any change that would remove a field, rename a field, change a type, or alter a status code ships under `/api/v2` instead, with `v1` kept alive for at least 12 months after `v2` is announced. If you depend on a field that isn't documented here, treat it as unsupported — it may move at any time.