# QuillHub Developer Documentation Complete developer documentation for the QuillHub transcription API. This file is generated for LLMs — one Markdown document covering every endpoint, field, and usage pattern. Base URL: https://api.quillhub.ai Generated at: 2026-08-08 --- # Overview QuillHub is a transcription API for audio and video at scale. You submit a source — a YouTube or Instagram link, a direct audio/video URL, or an inline file — and receive plain text, speaker-labeled segments, a structured summary with chapters and highlights, and ready-to-use VTT/SRT subtitles. ## How it works Transcription is **asynchronous**. The flow is: 1. `POST /v1/transcriptions` with a URL or inline file. You get `202 Accepted` and a `Transcription` object with `status: "queued"` and an `id`. 2. The job progresses through `queued` → `processing` → one of the terminal states: `completed`, `failed`, or `cancelled`. 3. You either **poll** `GET /v1/transcriptions/{id}` until the status is terminal, or register a `webhook_url` on create and receive a POST when the job finishes. ## Base URL and auth ``` https://api.quillhub.ai Authorization: Bearer qai_live_... ``` All endpoints require a Bearer token. Create keys in the [Developers dashboard](https://quillhub.ai/developers). Keys are prefixed `qai_live_` (production) or `qai_test_` (read the `Authentication` section for the difference). ## Core resources - **Transcription** — the main object. Created by `POST /v1/transcriptions`, identified by `id` with `trs_` prefix. Carries `status`, `source`, `options`, `result`, `created_at`, `completed_at`, `points_spent`. - **Me** — account info returned by `GET /v1/me`: `id` (user id, `usr_` prefix), `available_points`, `subscription`. - **Error envelope** — every non-2xx response shares `{error: {type, code, message, request_id, param?}}`. ## Pricing Billing is measured in **points**, not minutes. Each completed transcription reports `points_spent` on the final object, and the account's remaining balance is on `GET /v1/me → available_points`. See https://quillhub.ai/pricing for the current rate card and plan options. ## Limits at a glance | Limit | Value | |---|---| | Max source duration | ~10 hours per job | | Max inline upload (REST) | ~4 GB base64-decoded | | Max inline upload (via MCP) | 25 MB base64-decoded | | Max `metadata` pairs | 16 | | Subtitle URL TTL | ~7 days | | Webhook delivery retries | Exponential, up to 24 h | ## SDK / integration options - **Direct REST** — any HTTP client. Examples in these docs use `curl`. - **MCP server** — `https://mcp.quillhub.ai/mcp` — call QuillHub tools from Claude Desktop, Cursor, Antigravity, or any MCP-aware agent. See the [MCP guide](https://quillhub.ai/docs/mcp). - **Agent skills** — self-describing markdown files published at `/.well-known/agent-skills/` that teach LLM agents how to use QuillHub well. Agents can verify them via the index's sha256 digests. --- # Quickstart Send your first transcription and read the result in under two minutes. No SDK — just `curl` and a key. ## 1. Get an API key Create a key at https://quillhub.ai/developers. Copy the value — it's shown once. ```bash export QAI_KEY=qai_live_a3f7...c2e9 ``` Keys start with `qai_live_` (production, counts against your point balance) or `qai_test_` (sandbox, doesn't consume points, returns deterministic dummy output). ## 2. Call the API ```bash curl -X POST https://api.quillhub.ai/v1/transcriptions \ -H "Authorization: Bearer $QAI_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://youtu.be/dQw4w9WgXcQ", "structure": true }' ``` Response (`202 Accepted`): ```json { "id": "trs_01HZX7K9E3M2N4P6Q8R0S2T4V6", "status": "queued", "source": {"type": "youtube", "url": "https://youtu.be/dQw4w9WgXcQ"}, "options": {"speaker_recognition": false, "structure": true}, "created_at": "2026-04-24T10:12:04Z" } ``` The `id` is your handle for the rest of the job's lifecycle. ## 3. Poll until done ```bash TRS_ID="trs_01HZX7K9E3M2N4P6Q8R0S2T4V6" while true; do RESP=$(curl -s "https://api.quillhub.ai/v1/transcriptions/$TRS_ID" \ -H "Authorization: Bearer $QAI_KEY") STATUS=$(echo "$RESP" | jq -r .status) echo "status: $STATUS" case "$STATUS" in completed|failed|cancelled) echo "$RESP" | jq .result; break ;; esac sleep 3 done ``` Typical short clip (< 30 min) completes in 30–90 seconds. Use a 3–5 second poll interval. ## 4. Read the result When `status` is `completed`, the `result` field is populated: - `result.text` — the full transcript as a single string. - `result.segments[]` — phrase-level chunks with `start`, `end`, `text` in seconds. - `result.structured.title`, `.summary`, `.chapters`, `.paragraphs`, `.highlights`, `.terms` — ready-to-render structured output. - `result.subtitles.vtt` / `.srt` — **presigned URLs**, not inline. `GET` them to fetch the subtitle file. ## Going further - **Webhooks instead of polling** — pass `webhook_url` on create. See [Webhooks](https://quillhub.ai/docs/webhooks). - **Long files** — upload to S3 or any public URL and pass it as `url`. Handle speaker labels. See the `transcribe-long-recording` agent skill. - **Native integration with AI assistants** — use the [MCP server](https://quillhub.ai/docs/mcp) instead of calling REST endpoints directly. --- # Authentication All QuillHub API requests are authenticated with a Bearer token. ```http Authorization: Bearer qai_live_a3f7...c2e9 ``` There is no OAuth flow. Each request carries the token; no session or cookie state. ## Key prefixes | Prefix | Environment | Points | Output | |---|---|---|---| | `qai_live_` | Production | Consumed from your balance | Real transcription | | `qai_test_` | Sandbox | Free | Deterministic dummy transcript | Use `qai_test_` in CI, local dev, and integration tests to avoid burning points. Both prefix styles work on the same endpoint (`https://api.quillhub.ai`) — the key itself selects the mode. ## Creating keys Create, rotate, and revoke keys at https://quillhub.ai/developers. On creation, the full key string is shown **once** — copy it to your secret manager immediately. The dashboard shows key metadata: creation time, last-used time, and status. You cannot retrieve the key string again after the create dialog closes. ## Rotation To rotate: 1. Create a new key in the dashboard. 2. Deploy the new key to your clients/servers. 3. Revoke the old key once you've confirmed all callers have switched over. There is no automatic expiry — keys live until you revoke them. Treat them like database passwords. ## Revocation Revoking a key takes effect immediately. Requests using the revoked key will return: ```json { "error": { "type": "authentication_error", "code": "key_revoked", "message": "This API key has been revoked.", "request_id": "req_..." } } ``` HTTP status is `401`. ## Error behavior | Situation | HTTP | `error.code` | |---|---|---| | Missing `Authorization` header | 401 | `missing_authorization` | | Malformed token (wrong prefix, bad format) | 401 | `invalid_api_key` | | Revoked key | 401 | `key_revoked` | | Valid key but account suspended | 403 | (permission_error) | | Valid key but out of points | 403 | `insufficient_points` | Never retry a 401. Fix the key; the same request will fail the same way. ## Secrets hygiene - Do not commit keys to git. Use `.env` with `.gitignore`, or your platform's secret storage (Vercel env vars, Railway variables, AWS Secrets Manager). - Do not ship keys to client-side JavaScript. All API calls must be server-side or from a trusted proxy. - Do not include keys in URL paths, query strings, or log bodies. - Use the `X-Request-Id` response header (or `error.request_id` in error envelopes) when reporting issues to support — we can pull the full trace from it without needing your key. ## Multi-user setups If you're building a platform where multiple of your own users issue transcriptions, **you are the only one with a QuillHub key**. Each of your users does not need their own. You: - Issue one QuillHub key per environment (prod, staging). - Call the API on behalf of your users. - Tag each job with your own user id via the `metadata` field so you can attribute and bill internally. ```json { "url": "https://...", "metadata": {"my_user_id": "user_42", "my_job_id": "job_9001"} } ``` QuillHub echoes `metadata` back unchanged on the `Transcription` object, on webhook deliveries, and on polled `GET` responses. --- # Transcriptions API The `Transcription` resource represents one transcription job and its result. ## Lifecycle ``` queued → processing → completed → failed → cancelled ``` Terminal states: `completed`, `failed`, `cancelled`. Once terminal, the object is immutable. ## `POST /v1/transcriptions` — create Submits a new job. Exactly one of `url` or `file` must be provided. ### Request body ```json { "url": "https://...", // XOR with `file` "file": { // XOR with `url` "filename": "meeting.mp3", "mime_type": "audio/mpeg", // optional; detected from filename "data": "SUQzAw..." // base64-encoded binary }, "language": "en", // optional; ISO-639-1; omit for auto-detect "speaker_recognition": false, // optional; default false "structure": true, // optional; default true "include_source_url": false, // optional; default false "webhook_url": "https://...", // optional; HTTPS URL for push delivery "metadata": { // optional; up to 16 string:string pairs "my_id": "job_42" } } ``` ### Response (`202 Accepted`) ```json { "id": "trs_01HZX7K9E3M2N4P6Q8R0S2T4V6", "status": "queued", "source": {"type": "youtube", "url": "https://..."}, "options": { "language": null, "speaker_recognition": false, "structure": true }, "created_at": "2026-04-24T10:12:04Z" } ``` ### Errors - `400 invalid_request_error / missing_source` — neither `url` nor `file` provided - `400 invalid_request_error / ambiguous_source` — both provided - `400 invalid_request_error / invalid_url` — malformed URL - `400 invalid_request_error / unsupported_source` — URL host not supported (private video, etc.) - `400 invalid_request_error / invalid_value` with `param: "language"` — not a valid ISO-639-1 code - `401 authentication_error` — missing/invalid/revoked key - `403 permission_error / insufficient_points` — balance too low to start the job ## `GET /v1/transcriptions/{id}` — fetch Returns the current state of a transcription in the requested format. Path params: `id` (string, required). Three forms are accepted interchangeably: - `trs_...` — the canonical public id returned from create/list. - a bare numeric id — the `request.id` you'd see in a web/desktop URL like `https://quillhub.ai/en/transcript/282769`. - `trs_legacy_` — the synthetic id `view=summary` / `view=full` list rows emit for pre-public-id rows. Visibility: you can always read your own transcriptions. If the row belongs to a workspace you're a member of and it's marked `workspace_visibility: 'visible'` (or you're an owner/admin of that workspace), you can read it too — otherwise the API returns `404` (never `403`, to avoid confirming existence). Query params: | Param | Type | Default | Notes | |---|---|---|---| | `format` | enum | `full` | `full` / `summary` / `text` / `segments` / `paragraphs` / `chapters` / `subtitles` / `dialog` | **Format payloads:** - `full` (default) — legacy bundled response with `text`, `segments`, `structured`, `subtitles` together. Largest. Kept for back-compat. - `summary` — `{id, status, language, duration_seconds, created_at, completed_at, title, summary, key_theses, highlights, action_items, decisions, topics, terms, participants, project, workspace, author, kind, source, available_formats}`. ~3-6 kB. Best default for agents. See [enriched summary fields](#enriched-summary-fields) below. - `text` — `{Meta, text}`. Plain transcript, no timestamps. - `segments` — `{Meta, segments: [{start, end, text, speaker?}]}`. Timestamped chunks. Speaker labels are backfilled from diarized utterances even for structurer-derived rows. - `paragraphs` — `{Meta, paragraphs: [{start_time, paragraph_text, ...}]}`. Readable paragraphs. - `chapters` — `{Meta, chapters: [{title, start_time, end_time, paragraphs}]}`. Chapter outline. - `subtitles` — `{Meta, vtt_url, srt_url}`. - `dialog` — `{Meta, dialog: [{speaker, start, text}]}`. Merged consecutive same-speaker utterances, with real participant names where known (falls back to `"Speaker A"`, `"Speaker B"`, ...). `start` is a whole-second offset; there's no `end` field. The token-cheapest way to read *who said what*. Returns `422 format_unavailable` if the transcription has no diarized utterances (e.g. speaker recognition wasn't enabled). `Meta` is `{id, status, language, duration_seconds, created_at, completed_at}` and is included in every non-`full` response. Example `format=dialog` response: ```json { "id": "trs_01HZX7K9E3M2N4P6Q8R0S2T4V6", "status": "completed", "language": "en", "duration_seconds": 612, "created_at": "2026-04-24T10:12:04Z", "completed_at": "2026-04-24T10:14:51Z", "dialog": [ { "speaker": "Alex Chen", "start": 0, "text": "Let's start with the migration timeline." }, { "speaker": "Speaker B", "start": 12, "text": "Sure — we're targeting end of next sprint." } ] } ``` If the requested format isn't available for this row (e.g. `chapters` on a row whose structurer didn't emit chapters, or `dialog` without diarization), the API responds `422 invalid_request_error / format_unavailable` with the list of available formats in the message. For non-completed rows, only `format=summary` succeeds (status fields without content); other formats return `format_unavailable`. Errors: - `401` — auth failed - `404 not_found_error` — no such transcription visible to you (owner, or workspace-visible) - `422 format_unavailable` — format not supported for this row ### Enriched summary fields Beyond the base summary fields, `format=summary` (and `view=summary` list rows) also carry: | Field | Type | Notes | |---|---|---| | `action_items` | array | `[{text, who?, due?}]` — extracted action items, newest structurer output only | | `decisions` | string[] | decisions called out in the meeting | | `topics` | string[] | topics discussed | | `participants` | string[] | resolved speaker display names (empty entries skipped) | | `project` | object? | `{id, name}` or `null` if not assigned to a project | | `workspace` | object? | `{id, name}` or `null` if not in a workspace | | `author` | object | `{tg_id, is_me}` — `is_me` is `true` when the caller authored the row | | `kind` | string? | `"dictation"` / `"meeting"` / `null` | ## `GET /v1/transcriptions` — list Cursor-paginated list of your transcriptions, newest first. ### Query params | Param | Type | Default | Range | |---|---|---|---| | `limit` | integer | 20 | 1–100 | | `cursor` | string | — | opaque; pass `next_cursor` from previous page | | `status` | enum | — | `queued` / `processing` / `completed` / `failed` / `cancelled` | | `view` | enum | `full` | `full` keeps the legacy bundled rows; `summary` returns ~6 kB rows with title/summary/key_theses/highlights | | `q` | string | — | substring search across title + summary + key_theses (case-insensitive, max 200 chars) | | `created_before` | ISO 8601 | — | exclusive upper bound on `created_at` | | `created_after` | ISO 8601 | — | inclusive lower bound on `created_at` | | `workspace_id` | integer | — | restrict to a workspace. Also brings in teammates' recordings shared with the workspace (`workspace_visibility: 'visible'`); owners/admins see every row in the workspace. Omit to see only transcriptions you authored, across all workspaces. Get ids from [`GET /v1/workspaces`](#get-v1workspaces-list-workspaces--projects). `403` if you're not a member. | | `project_id` | integer | — | restrict to one project inside `workspace_id`. `0` means the workspace **Inbox** (recordings not assigned to any project). | ### Response `view=full` (default) returns: ```json { "data": [ /* Transcription objects */ ], "next_cursor": "trs_01HZX..." // null on final page } ``` `view=summary` returns lightweight-but-rich rows (~6-8 kB each) with `available_formats` so an agent knows what to fetch in detail — see [enriched summary fields](#enriched-summary-fields) for the full field list: ```json { "data": [ { "id": "trs_...", "status": "completed", "title": "Architecture sync", "summary": "We discussed the migration plan ...", "key_theses": ["...", "..."], "highlights": ["...", "..."], "action_items": [{ "text": "Ship the migration script", "who": "Alex", "due": "Friday" }], "decisions": ["Move to the new schema next sprint"], "topics": ["migration", "scheduling"], "terms": ["blue-green deploy"], "participants": ["Alex Chen", "Jamie Lee"], "project": { "id": 12, "name": "Platform" }, "workspace": { "id": 4, "name": "Engineering" }, "author": { "tg_id": 9000000123, "is_me": true }, "kind": "meeting", "duration_seconds": 1843, "language": "ru", "source": { "type": "upload", "url": null }, "created_at": "2026-04-15T10:00:00Z", "completed_at": "2026-04-15T10:05:00Z", "available_formats": ["text", "segments", "paragraphs", "chapters", "subtitles", "dialog"] } ], "next_cursor": null } ``` Two-stage agent flow: call `view=summary` (optionally with `q`, `workspace_id`, `project_id`) to pick the right transcription by `id`, then call `GET /v1/transcriptions/{id}?format=...` with the format that fits the task. ### Pagination pattern ```bash cursor="" while :; do url="https://api.quillhub.ai/v1/transcriptions?limit=100" [ -n "$cursor" ] && url="$url&cursor=$cursor" page=$(curl -s "$url" -H "Authorization: Bearer $QAI_KEY") echo "$page" | jq '.data[]' cursor=$(echo "$page" | jq -r .next_cursor) [ "$cursor" = "null" ] && break done ``` ## `DELETE /v1/transcriptions/{id}` — cancel Cancels a transcription that has not yet reached a terminal state. - On `queued` / `processing`: transitions to `cancelled`, no further points charged. - On already-terminal: returns the current state unchanged (no error). Response (`200 OK`): the updated `Transcription` object (`status: "cancelled"` if the cancel landed in time). ## `GET /v1/workspaces` — list workspaces & projects The discovery endpoint for the `workspace_id` / `project_id` values used above and by other endpoints — there is no separate "list projects" endpoint; projects come back nested inside each workspace. Returns every workspace you own or are an active member of. Archived workspaces and projects are excluded. ### Response (`200 OK`) ```json { "workspaces": [ { "id": 4, "name": "Engineering", "type": "team", "role": "owner", "projects": [ { "id": 12, "name": "Platform", "description": "Backend + infra", "color": "#60a5fa", "recordings_count": 37 }, { "id": 13, "name": "Mobile", "description": null, "color": null, "recordings_count": 5 } ] }, { "id": 1, "name": "Personal", "type": "personal", "role": "owner", "projects": [] } ] } ``` `role` is your role in that workspace: `owner`, `admin`, or `member`. Pass `0` as `project_id` on `GET /v1/transcriptions` to see a workspace's Inbox (recordings not assigned to any of the listed projects). Errors: - `401` — auth failed ## `Transcription` fields | Field | Type | Notes | |---|---|---| | `id` | string | `trs_` prefix; stable identifier | | `status` | enum | `queued` / `processing` / `completed` / `failed` / `cancelled` | | `progress` | number? | 0–1 float, only while `processing`; `null` otherwise | | `source.type` | enum | `youtube` / `instagram` / `direct` / `upload` | | `source.url` | string? | normalized URL; `null` for inline uploads unless `include_source_url` was true | | `options.language` | string? | forced language or `null` for auto | | `options.speaker_recognition` | boolean | echoes request | | `options.structure` | boolean | echoes request | | `duration_seconds` | number? | detected after probe; `null` until then | | `language` | string? | **detected or forced** ISO-639-1; `null` until probed | | `points_spent` | number? | final charge; `null` until terminal | | `result` | object? | see [Structured output](https://quillhub.ai/docs/structured-output) | | `source_url` | string? | presigned URL to stored upload; present only when `include_source_url: true` was set for an inline upload | | `webhook_url` | string? | as submitted | | `error` | string? | human-readable failure reason when `status: "failed"` | | `metadata` | object? | string:string map you passed on create, echoed back | | `created_at` | string | ISO 8601 | | `completed_at` | string? | ISO 8601 at terminal transition; `null` otherwise | --- # Source types Every transcription is built from exactly one source. You pass either `url` or `file` on create — never both. ## `url` — external source The worker fetches the bytes from the URL you provide. Supported schemes and hosts: | Type | Example | `source.type` returned | |---|---|---| | YouTube | `https://youtu.be/ID`, `https://youtube.com/watch?v=ID`, `https://youtube.com/shorts/ID` | `youtube` | | Instagram | `https://instagram.com/p/SHORTCODE`, `https://instagram.com/reel/SHORTCODE` | `instagram` | | Direct audio/video | any reachable `https://` URL to `.mp3`, `.wav`, `.m4a`, `.mp4`, `.mov`, `.webm`, etc. | `direct` | | S3 reference | `s3://bucket-name/path/to/file.mp3` | `direct` | ### YouTube and Instagram Only **public** content is supported. Private, unlisted (in some regions), age-gated, or geo-blocked videos fail with `unsupported_source`. Livestreams work only as finished VODs. Shorts and Reels work identically to full-length content — pass the share URL. ### Direct URLs Any reachable HTTPS URL that serves an audio or video container QuillHub can decode. No size limit beyond what your host can serve. TTL of the URL must be long enough to complete the job (typically < 15 min for even long files). ### S3 Pass an `s3://bucket/key` reference. Your bucket must be publicly readable **or** accessible via credentials you've provided in the Developers dashboard. Server-side fetch, no transfer out of your AWS VPC. ## `file` — inline upload Inline base64. The object has three fields: ```json { "file": { "filename": "meeting.mp3", "mime_type": "audio/mpeg", "data": "SUQzAw..." } } ``` - `filename` — required, 1–255 chars. Used to detect MIME type if `mime_type` is omitted. - `mime_type` — optional, 3–127 chars. Pass it when the filename extension is ambiguous. - `data` — required, base64-encoded binary. Up to ~4 GB decoded via REST. On upload, `source.type` is `"upload"` and `source.url` is `null` unless you set `"include_source_url": true` on create — in that case, a short-lived presigned URL to the stored copy is returned on later `GET` responses. ### When to use inline upload - **Small files (< 25 MB)** — direct from user's browser to QuillHub with one call, no intermediate storage. - **Files you don't want to host publicly** — upload once, never again. ### When NOT to use inline upload - **Large files (> 100 MB)** — base64 inflates the payload ~33%, and the whole body must buffer server-side. Upload to S3, Wasabi, or any object store, then pass the URL. - **Retry scenarios** — if the create request times out mid-upload, you retransmit the full file. Much worse than a URL retry. - **Via MCP** — the MCP server caps inline base64 at 25 MB. Use `url` above that. ## Choosing between `url` and `file` ``` Do you have the audio accessible as a URL already? ├─ Yes → use `url` └─ No → is the file < 25 MB? ├─ Yes → use `file` (inline base64) └─ No → upload to object storage first, then use `url` ``` ## The `source` field on responses Every `Transcription` response carries: ```json { "source": { "type": "youtube" | "instagram" | "direct" | "upload", "url": "https://..." // null for upload unless include_source_url was set } } ``` Do not hardcode behavior by `source.type`. It's informational — all types produce the same `result` shape. ## Errors specific to sources | `error.code` | Cause | |---|---| | `invalid_url` | The `url` field isn't a parseable URL | | `unsupported_source` | URL host isn't supported, or video is private/geo-blocked/age-gated | | `duration_too_long` | Source exceeds ~10 hour per-job limit (detected after probe) | | `missing_source` | Neither `url` nor `file` provided | | `ambiguous_source` | Both `url` and `file` provided | | `missing_field` with `param: "filename"` | Inline upload without `filename` | | `payload_too_large` (MCP only) | Inline base64 exceeded 25 MB | --- # Structured output When a transcription reaches `status: "completed"`, the `result` field is populated. ```json { "result": { "text": "…full transcript…", "segments": [ /* phrase-level */ ], "structured": { /* see below */ }, "subtitles": { "vtt": "https://…presigned…", "srt": "https://…presigned…" } } } ``` When `structure: false` was requested on create, `result.structured` is `null` but `text`, `segments`, and `subtitles` are still present. ## `result.text` The full transcript as a single plain-text string. Newlines separate paragraphs. Safe to hand straight to a text area or a downstream summarizer. ## `result.segments` Array of phrase-level chunks: ```json { "start": 3.84, "end": 7.12, "text": "Welcome back to the channel.", "speaker": "Speaker 1" } ``` Fields: - `start`, `end` — seconds from the beginning of the audio (floats). - `text` — the chunk's text. - `speaker` — present only when `speaker_recognition: true` was set on create. Values are generic labels: `Speaker 1`, `Speaker 2`, … Use segments when you need millisecond-precise timestamps or want to build a turn-taking UI. For reading prose, use `structured.paragraphs` instead — segments are too dense. ## `result.structured` Present when `structure: true` (the default). Fields: | Field | Type | Description | |---|---|---| | `title` | string? | Suggested short label for the content. | | `summary` | string? | 2–4 sentence abstract. | | `language` | string? | Echoed detected language (same as top-level `language`). | | `toc` | object[] | Table of contents entries — each has `title` and `start_paragraph_number`. | | `chapters` | object[] | Chapter-level segmentation with `title`, `start_time`, `end_time`, and the list of paragraph indexes that belong to each. | | `paragraphs` | object[] | Readable paragraph view with `start_time`, `paragraph_text`, and the index matching back to `result.segments`. | | `highlights` | string[] | Notable quotes pulled from the transcript. | | `key_theses` | string[] | Main thesis statements — longer than highlights, typically 3–7 per hour. | | `terms` | string[] | Glossary of proper nouns, product names, and domain terms. | ### Using chapters `structured.chapters` is the most useful navigation primitive. Each entry: ```json { "title": "The problem with current approaches", "start_time": 132.4, "end_time": 287.9, "paragraphs": [3, 4, 5] } ``` Use `title` for the heading, `start_time` for deep-links into the audio, and the `paragraphs` index array to render the body: ```js for (const chapter of result.structured.chapters) { renderHeading(chapter.title, chapter.start_time); for (const i of chapter.paragraphs) { renderParagraph(result.structured.paragraphs[i].paragraph_text); } } ``` ### Using paragraphs directly If the file is short and has no chapters, just iterate paragraphs: ```js for (const p of result.structured.paragraphs) { renderParagraph(p.start_time, p.paragraph_text); } ``` ## `result.subtitles` ```json { "subtitles": { "vtt": "https://…", "srt": "https://…" } } ``` **Both values are presigned URLs, not inline strings.** Fetch them with a second `GET` if you want the file content. ```js const vttText = await fetch(result.subtitles.vtt).then(r => r.text()); ``` Use these for `` elements, OBS, captioning services, etc. **Do not regenerate SRT/VTT by stringifying `result.segments`** — the pre-built files use the right line wrapping (40–80 char cue lines, not one line per segment). TTL is approximately 7 days. After that the URL 403s — re-fetch the transcription to get a fresh presigned URL, or cache the file bytes on your side. ## Field presence matrix | `status` | `result` | `text` | `segments` | `structured` | `subtitles` | |---|---|---|---|---|---| | `queued` / `processing` | `null` | — | — | — | — | | `completed` (with `structure: true`) | ✅ | ✅ | ✅ | ✅ | ✅ | | `completed` (with `structure: false`) | ✅ | ✅ | ✅ | `null` | ✅ | | `failed` | `null` | — | — | — | — | | `cancelled` | `null` | — | — | — | — | Always null-check `result` before accessing nested fields — a `failed` or `cancelled` job won't have it. --- # Speaker recognition Also called diarization — identifying which person said which phrase and labeling the transcript accordingly. ## When to enable Set `"speaker_recognition": true` on create for: - Meetings, standups, interviews - Podcasts with multiple hosts or guests - Panels, debates, Q&A sessions - Any recording where turn-taking matters ```json POST /v1/transcriptions { "url": "https://storage.example.com/meeting.mp3", "speaker_recognition": true } ``` Cost: adds approximately **10–15% to processing time**. No extra points charged. Leave it off (default `false`) for: - Lectures, keynotes, audiobooks (single narrator) - Dictation / voice memos - Music with minimal speech ## Where the labels appear On `result.segments[]`, each segment carries a `speaker` field when diarization was enabled: ```json { "start": 3.84, "end": 7.12, "text": "Welcome back to the channel.", "speaker": "Speaker 1" } ``` When diarization is off, `speaker` is absent (not `null` — missing). ## Label semantics - Labels are **generic**: `Speaker 1`, `Speaker 2`, `Speaker 3`, … - The **same voice keeps the same label throughout one file**. Turn-taking across the file is stable. - Labels do **not** carry across different jobs. `Speaker 1` in `trs_abc` is not the same person as `Speaker 1` in `trs_def`. - QuillHub does not know who people are. If your app has that context (calendar attendees, Slack channel, etc.), map labels to real names on your side. ## Rendering a turn-taking view Group consecutive segments with the same speaker into "turns": ```js function toTurns(segments) { const turns = []; let current = null; for (const s of segments) { if (!current || current.speaker !== s.speaker) { if (current) turns.push(current); current = { speaker: s.speaker, start: s.start, texts: [s.text] }; } else { current.texts.push(s.text); } } if (current) turns.push(current); return turns.map(t => ({ speaker: t.speaker, start: t.start, text: t.texts.join(" ") })); } ``` Python with `itertools.groupby`: ```python from itertools import groupby turns = [ { "speaker": k, "start": g[0]["start"], "text": " ".join(s["text"] for s in g), } for k, grp in groupby(segments, key=lambda s: s.get("speaker")) for g in [list(grp)] ] ``` ## Known limitations - **Overlap**: two people speaking simultaneously are typically attributed to whichever voice dominates in that segment. - **Short utterances**: back-channel "mhm", "yeah", under ~300 ms may be merged into the previous speaker's segment. - **Voice swap**: if two speakers have very similar voices (same gender, similar pitch), they may occasionally be merged into one label. - **One-speaker files**: you may still see only `Speaker 1`. That's expected. ## Adding speakers after the fact Not supported. Once a job completes, you cannot retroactively enable diarization — the speaker embeddings are computed during transcription. Re-submit the source with `speaker_recognition: true`. ## When to combine with `structure: true` Structure + speakers is the common case for meeting notes: ```json { "url": "...", "speaker_recognition": true, "structure": true } ``` The structurer uses the speaker labels to build a coherent narrative — paragraphs don't straddle speaker boundaries, and chapters align with topic shifts rather than just time. ## UI pattern ``` [00:12] Alice │ Welcome everyone. Quick agenda check — we'll cover Q4 numbers first. [00:23] Bob │ Quick question before we start — is the metrics dashboard live? [00:27] Alice │ Yes, went out this morning. Link is in the channel. ``` The turns list from the grouping function above maps one-to-one to this view. --- # Languages QuillHub auto-detects the language of every audio file. You should almost never set `language` explicitly. ## The golden rule **Omit `language`. Let the detector work.** It's right in ~99% of cases, including heavily accented English, code-switched speech, and clips shorter than 30 seconds. ```json POST /v1/transcriptions {"url": "https://..."} // no language field ``` ## When to override Only set `"language": "xx"` (ISO-639-1 two-letter code) when **both** are true: 1. You already submitted the same audio and got the wrong language (check `top.language` and the text itself). 2. You know the correct one with certainty. Valid use cases: - **Ambiguous short clips** — a 4-second snippet of two English words that got tagged Dutch. - **Heavy code-switching** where the dominant language isn't the first-spoken one. - **Low-quality audio** that triggers a wrong guess. ```json {"url": "https://...", "language": "ru"} ``` ## Supported languages 130+ codes. Common ones: `en`, `ru`, `es`, `de`, `fr`, `pt`, `it`, `nl`, `pl`, `tr`, `ar`, `hi`, `ja`, `ko`, `zh`, `uk`, `cs`, `fi`, `sv`, `da`, `no`, `he`, `th`, `vi`, `id`, `ms`, `ro`, `el`, `hu`, `bg`. All codes are ISO-639-1 two-letter. Three-letter codes (`eng`, `rus`) are rejected with `invalid_value`. ## Reading detected language On any `Transcription` response: ```json { "language": "ru", // top-level: what was used — detected or forced "options": {"language": null}, // what the caller requested — null = auto "result": { "structured": {"language": "ru"} // echoed inside structured } } ``` Check `options.language`: - `null` — auto-detection ran; `top.language` is what the detector picked. - string — the caller forced it; `top.language` matches. ## Mixed-language recordings A single file with multiple languages (bilingual interview, international standup, subtitles over narration): - **Do not** force `language` — it makes the ASR transcribe the whole file as one language, mangling the other. - **Do** let auto-detect run. The transcript will be in the *dominant* language. Code-switched phrases in other languages are transliterated phonetically into the dominant language's alphabet (this is rarely useful; it's an artifact, not a feature). - If you need each language faithfully, **split the audio by language segment and submit each part separately**. Use `speaker_recognition: true` on the original to find the boundaries. ## Translation **QuillHub does not translate.** `result.text` is always in the source language. If the user asks "transcribe and translate to English": 1. Call QuillHub with no `language` — get the source transcript. 2. Pass `result.text` (or `result.structured.summary` for the short version) to a translation model on your side (GPT-4o, Claude, DeepL, Google Translate). 3. Return both. Do not try to trick QuillHub into translating by forcing `"language": "en"` on a Russian file. It produces garbage — phonetic English approximations of Russian words, not a translation. ## Quick reference | Situation | `language` field | Why | |---|---|---| | First attempt on any file | omit | trust auto-detect | | Auto-detect returned wrong language last time | set to correct ISO-639-1 | override | | Mixed-language file, want dominant language | omit | auto-detect finds the majority | | Mixed-language file, want each part faithfully | omit + split audio | force per-segment if needed | | User wants English translation of non-English audio | omit (then translate on your side) | QuillHub does transcription only | | Short clip (<10 s), ambiguous language | set explicitly if you know it | detector has less signal on short inputs | ## Errors - `400 invalid_request_error / invalid_value` with `param: "language"` — the code isn't ISO-639-1. Drop back to auto-detect or correct the code. --- # Webhooks Pass `webhook_url` on create and QuillHub POSTs the finished `Transcription` object to that URL when the job reaches a terminal state. Prefer this over polling for any job longer than ~1 minute — it's cheaper, more reliable, and agent-friendly. ## Registering ```json POST /v1/transcriptions { "url": "https://...", "webhook_url": "https://your-app.example.com/hooks/quillai", "metadata": {"my_job_id": "job_42"} } ``` `webhook_url` must be: - **HTTPS** — plain `http://` is rejected. - **Reachable from the public internet** — no `localhost`, no private CIDRs. For local dev, use ngrok or a tunnel. - **Idempotent** — see below. ## Payload The POST body is the full terminal `Transcription` object — identical to what `GET /v1/transcriptions/{id}` returns at the same moment. Example completed payload: ```json { "id": "trs_01HZX...", "status": "completed", "source": {"type": "youtube", "url": "..."}, "options": {"speaker_recognition": true, "structure": true}, "duration_seconds": 1843, "language": "en", "points_spent": 28, "result": { "text": "…", "segments": [ /* … */ ], "structured": { /* … */ }, "subtitles": {"vtt": "https://…", "srt": "https://…"} }, "metadata": {"my_job_id": "job_42"}, "created_at": "2026-04-24T10:12:04Z", "completed_at": "2026-04-24T10:18:47Z" } ``` Failed payloads carry `status: "failed"` and an `error` string explaining what went wrong: ```json { "id": "trs_01HZX...", "status": "failed", "error": "Source unreachable: 403 Forbidden on fetch", "metadata": {"my_job_id": "job_42"}, "created_at": "...", "completed_at": "..." } ``` Cancelled payloads (if you `DELETE` during processing) carry `status: "cancelled"`. ## Delivery semantics - **Exactly one POST per job** on the happy path. Your handler should still be idempotent — retries happen on delivery failure. - **Timeout**: your endpoint must respond within **10 seconds** with a 2xx status. - **Retries**: on 5xx or timeout, QuillHub retries with exponential backoff: 1s, 30s, 5m, 30m, 2h, 8h, up to approximately **24 hours** total. - **Final status**: on 4xx (except 408, 429), delivery is abandoned — we don't retry. You will not receive the webhook. - **Order**: not guaranteed across jobs. Do not assume `trs_A` arrives before `trs_B` just because it was created first. ## Verifying authenticity Webhooks are **unsigned**. Use an unguessable URL path as the shared secret: ``` https://your-app.example.com/hooks/quillai-9f3a2c1e8b4d ``` Store the secret path in your server's env vars; do not hardcode in source. Rotate by issuing a new path and updating the `webhook_url` on future create calls. If an attacker guesses your path, they can POST fake transcriptions to you. Treat that as low-severity (they get nothing; worst case they confuse your app) — but still rotate if you're worried. ## Idempotency Your webhook handler must be idempotent because: 1. Retries can cause duplicate deliveries. 2. Network issues can make QuillHub think it failed even if you received it. The `id` field is globally unique. Deduplicate on it: ```sql INSERT INTO processed_transcriptions (transcription_id, payload) VALUES ($1, $2) ON CONFLICT (transcription_id) DO NOTHING ``` If the insert was a no-op, skip your business logic and return 200. ## Local development - **ngrok**: `ngrok http 3000` gives you `https://xxxx.ngrok.io` — use that as `webhook_url`. - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:3000`. - **Vercel dev URL**: preview deploys have public HTTPS URLs — register one of those for testing. ## Error handling on your side Always return 200 unless you truly failed to process. If you 5xx, QuillHub retries for 24 hours, which is usually annoying. Acknowledge-and-queue pattern: ```ts // your handler app.post("/hooks/quillai-SECRET", async (req, res) => { await queue.publish("quillai.transcription", req.body); res.status(200).end(); }); // your worker queue.subscribe("quillai.transcription", async (payload) => { // all your actual business logic here // if this throws, re-queue — QuillHub doesn't need to know }); ``` ## Webhook vs polling — when to use which | | Polling | Webhooks | |---|---|---| | Best for | < 1 minute jobs, interactive UI | > 1 minute jobs, server-to-server | | Cost | GET per poll (you pay cents/hour) | one POST from QuillHub (free) | | Complexity | higher (polling loop, timeout) | lower (one endpoint) | | Network reliability | worse (you own retries) | better (QuillHub retries for you) | | Local dev | easy | needs a tunnel | MCP agents automatically pick polling via `wait_for_transcription` — that's fine for interactive use. Server jobs should use webhooks. --- # Errors Every QuillHub error uses the same envelope. Branch on `error.code`, not on `error.message` (messages can change). ## Envelope ```json { "error": { "type": "invalid_request_error", "code": "missing_field", "message": "`url` or `file` is required", "param": "url", "request_id": "req_01HZX5T9Y2R7Q8KMPF3VZABCDE" } } ``` - `type` — error family. Maps 1:1 to HTTP status. - `code` — machine-readable, stable, unique within the family. - `message` — human-readable; suitable for logs. - `param` — present only for `invalid_request_error`; names the offending field. - `request_id` — always present. Include it when reporting a problem; we can pull the trace in seconds. The `request_id` is also returned in the `X-Request-Id` response header on every request (success or failure), so you can log it globally. ## Types ↔ HTTP status | `type` | HTTP | Meaning | |---|---|---| | `invalid_request_error` | 400 | Malformed body, missing field, bad enum value | | `authentication_error` | 401 | Missing, invalid, or revoked bearer token | | `permission_error` | 403 | Key lacks required access, or account suspended, or out of points | | `not_found_error` | 404 | Resource doesn't exist under this account | | `rate_limit_error` | 429 | Reserved — not currently enforced | | `api_error` | 5xx | Bug on our side. Retryable. | ## Common codes | `code` | Family | Description | |---|---|---| | `missing_field` | invalid_request | Required field absent — see `param` | | `missing_source` | invalid_request | Neither `url` nor `file` provided | | `ambiguous_source` | invalid_request | Both `url` and `file` provided | | `invalid_value` | invalid_request | Field present but outside allowed range/enum/format | | `invalid_url` | invalid_request | `url` isn't a valid URL | | `unsupported_source` | invalid_request | URL host not supported, or content private/geo-blocked | | `duration_too_long` | invalid_request | Source exceeds ~10 h duration limit | | `payload_too_large` | invalid_request | Inline base64 body too large (MCP caps at 25 MB) | | `missing_authorization` | authentication | No `Authorization` header | | `invalid_api_key` | authentication | Malformed token or no matching key | | `key_revoked` | authentication | Key was revoked in the dashboard | | `insufficient_points` | permission | Account balance too low to start the job | | `not_found` | not_found | No resource with this id under this account | | `transcription_failed` | — | Appears as a string in `Transcription.error`, not an HTTP error | | `internal_error` | api | Unhandled server error — safe to retry with backoff | | `upstream_unreachable` | api | (MCP only) Worker couldn't reach `api.quillhub.ai` | ## Retry semantics ### Always retryable (with backoff) - **5xx** responses — use exponential backoff starting at 1 s: 1 → 2 → 4 → 8 → 16 → 30, cap at 30 s. Add jitter (e.g. ±25%). - **408 Request Timeout** — same backoff. ### Retry after a delay - **429** (not currently returned by QuillHub, but your client should handle it anyway) — honor the `Retry-After` header if present; otherwise back off at least 1 s. ### Never retry automatically - **4xx** (except 408/429) — the same request will fail the same way. Fix the input, then retry. - **401** — key is bad. Don't retry; fix the key. - **403** — account state problem. Don't retry until the underlying issue is resolved. ## Transcription-level failures Async failures don't surface as an HTTP error on the create call — that call returns 202 with a job ID. Instead, the job's final state is: ```json { "id": "trs_...", "status": "failed", "error": "Source unreachable: 403 Forbidden on fetch", "points_spent": 0, ... } ``` Handle them by polling or by subscribing to webhooks. On `status: "failed"`, show `error` to the user (it's human-readable and safe to surface). `points_spent` is `0` on failed jobs — you only pay for successful transcriptions. ## Example error-handling code TypeScript: ```ts async function createTranscription(body: unknown) { const res = await fetch("https://api.quillhub.ai/v1/transcriptions", { method: "POST", headers: { Authorization: `Bearer ${process.env.QAI_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (res.ok) return res.json(); const env = await res.json() as { error: { type: string; code: string; message: string; request_id: string } }; if (res.status >= 500) { // retry with backoff throw new RetryableError(env.error); } if (res.status === 401 || res.status === 403) { // fix credentials/account; never auto-retry throw new AuthError(env.error); } // 4xx client error — fix input throw new InvalidRequestError(env.error); } ``` ## Using `request_id` in support tickets When something goes wrong, the fastest way to get help is to include the `request_id`: - From HTTP responses: check the `X-Request-Id` header or `error.request_id`. - From webhooks: the payload doesn't include a per-delivery request id, but the `Transcription.id` is enough to look up. Email `support@quillhub.ai` (or use the in-app chat) with the `request_id` and a one-line description. We can pull the full trace in seconds without asking you for a reproduction. --- # MCP server QuillHub ships a Model Context Protocol (MCP) server so AI assistants can transcribe on your users' behalf without bespoke integration code. Endpoint: **`https://mcp.quillhub.ai/mcp`** Auth: same `Authorization: Bearer qai_live_...` you use for REST. ## Why MCP - **One config, every major client** — Antigravity, Claude Desktop, Cursor, Cline, ChatGPT all speak MCP. - **Conversational** — the assistant reads the tool schemas, picks the right one, fills fields. - **Same guarantees as REST** — identical auth, error envelope, point cost. The server is a thin protocol wrapper, not a separate product. ## Connecting QuillHub MCP supports two authentication methods: - **Sign in with Supabase (OAuth 2.1)** — recommended for interactive clients (Cursor, Claude Desktop, VS Code). The MCP client opens a browser, the user signs in, the client stores a short-lived access token. Zero copy-paste. - **API key (`qai_live_...`)** — recommended for headless / CI scripts. Long-lived bearer that goes straight into the config file. ### Sign in with Supabase (recommended for interactive clients) When you connect a modern MCP client (Cursor, Claude Desktop, VS Code) to `https://mcp.quillhub.ai/mcp` without an `Authorization` header, the server returns 401 with a `WWW-Authenticate` header pointing at `https://mcp.quillhub.ai/.well-known/oauth-protected-resource`. The client then: 1. Reads the metadata document, learns the authorization server is your Supabase project. 2. Performs OAuth 2.1 with PKCE: opens the browser, the user signs in to QuillHub, the client receives an access token. 3. Sends every subsequent request with `Authorization: Bearer `. To enable this on a client, point it at `https://mcp.quillhub.ai/mcp` *without* an `Authorization` header in the config: ```json { "mcpServers": { "quillhub": { "url": "https://mcp.quillhub.ai/mcp" } } } ``` Most MCP clients also accept the `mcp-remote` bridge format below without the `--header` argument; consult your client's docs. ### Connect via API key (recommended for scripts and CI) If your client doesn't speak OAuth, or you're running a non-interactive job, use a `qai_live_*` API key. Get one in the [Developers dashboard](/developers). All major MCP clients use the `mcp-remote` bridge for remote HTTP servers — drop this snippet into your client's MCP config. ### Antigravity Edit `mcp.json` (View raw config): ```json { "quillhub": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.quillhub.ai/mcp", "--header", "Authorization:Bearer qai_live_YOUR_KEY" ] } } ``` ### Claude Desktop Edit `claude_desktop_config.json` (on macOS: `~/Library/Application Support/Claude/`, on Windows: `%APPDATA%/Claude/`): ```json { "mcpServers": { "quillhub": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.quillhub.ai/mcp", "--header", "Authorization:Bearer qai_live_YOUR_KEY" ] } } } ``` Fully quit Claude (from the tray or dock — not just close the window) and reopen. ### Cursor / Cline / other standard MCP clients Same shape as Claude Desktop. Drop into the client's MCP config file. ### Critical detail: no space after the colon Write `Authorization:Bearer qai_live_...`, **not** `Authorization: Bearer qai_live_...`. Some versions of `mcp-remote` split args on spaces and lose the token. ## Available tools | Tool | Purpose | |---|---| | `get_account` | Fetch user id, available points, subscription. Cheap — liveness check. | | `create_transcription` | Create a transcription from a URL or inline base64 (≤25 MB). | | `get_transcription` | Fetch a single transcription by id in one of six formats: `summary` (default), `text`, `segments`, `paragraphs`, `chapters`, `subtitles`. Returns 422 `format_unavailable` if the row has no content for that format. | | `list_transcriptions` | Lightweight summaries of the key owner's jobs (always `view=summary` upstream). Each row has title, summary, key_theses, highlights, duration — NOT the full transcript. Filter with `q` (substring search), `created_before`, `created_after`. Limit cap 50. | | `cancel_transcription` | Cancel a queued or processing job. | | `wait_for_transcription` | Poll a job until terminal or timeout. Saves agents from writing polling loops. | | `get_developer_docs` | Return this documentation for agents that need to write integration code. | Schemas mirror the REST API exactly. If you've used the HTTP endpoints, the tools will feel familiar. ## Direct HTTP (no client) You can also call the MCP server directly with plain JSON-RPC: ```bash curl -X POST https://mcp.quillhub.ai/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer qai_live_YOUR_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "create_transcription", "arguments": {"url": "https://youtu.be/dQw4w9WgXcQ"} } }' ``` Useful for debugging, scripts, and custom agents that don't want the `mcp-remote` middleman. ## Limitations - Inline base64 uploads capped at **25 MB** via MCP. For larger files, upload to S3 and pass the URL. - No webhooks through MCP — agents can't receive inbound POSTs. Use REST webhooks for push delivery. - `wait_for_transcription` has a 300-second ceiling to stay within client timeouts. On timeout it returns the last-known non-terminal state (not an error), so the agent can simply call it again. ## Security The MCP server forwards your `Authorization` header verbatim to the REST API. It holds no state and no secrets. Treat the config file carefully: - Do not commit with a real key — use environment variables or secret storage where your client supports them. - Rotate via the Developers dashboard as usual; clients pick up the new value on next restart. ## Discovery The MCP Server Card is published at https://quillhub.ai/.well-known/mcp/server-card.json per SEP-1649, so compliant agents can discover the endpoint automatically.