# Sandchest docs The open-source speech-to-text API. Real word-level timestamps in milliseconds, an AssemblyAI-compatible surface, and a native TypeScript SDK. Sandchest turns audio into words. Every word comes back with its own start and end in milliseconds, its own confidence score, and the identifier of the model that actually produced it. This page tells you which door to walk through; the rest of the docs are linear, imperative and end with something you can check. ## What it is A speech-to-text API you can read the source of. Two HTTP surfaces sit on the same engine: an AssemblyAI-compatible one at `/v2/*`, and a native one at `/api/v1/*`. The hosted service runs at `https://stt-api.sandchest.com`; the same code runs on your own GPU or Apple Silicon machine. | | | | --- | --- | | Base URL | `https://stt-api.sandchest.com` | | Auth | `Authorization: ` (a `Bearer` prefix also works) | | Dashboard | [sandchest.com/dashboard](https://sandchest.com/dashboard) | | Source | [CapSoftware/Sandchest](https://github.com/CapSoftware/Sandchest) | | Machine-readable | [`/llms.txt`](/llms.txt) · [`/llms-full.txt`](/llms-full.txt) · [`/openapi.json`](/openapi.json) | ## Three ways in **Keep the AssemblyAI SDK you already have.** Change `apiKey` and `baseUrl`, change nothing else. Upload, create, poll, list, delete, sentences, paragraphs, word search, SRT and VTT all work. See [Using the AssemblyAI SDK](/docs/assemblyai). ```ts title="transcribe.ts" import { AssemblyAI } from "assemblyai"; const client = new AssemblyAI({ apiKey: process.env.SANDCHEST_API_KEY, baseUrl: "https://stt-api.sandchest.com", }); ``` **Use the native SDK.** `@sandchest/sdk` is small, fully typed, and cancels through uploads, requests and polling. See [TypeScript SDK](/docs/sdk). ```ts title="transcribe.ts" import { Sandchest } from "@sandchest/sdk"; const client = new Sandchest({ apiKey: process.env.SANDCHEST_API_KEY }); ``` **Call the HTTP API directly.** Ten endpoints, `{ "error": "..." }` on failure, no client library required. See the [API reference](/docs/api). ```bash curl https://stt-api.sandchest.com/v2/transcript/$ID \ -H "Authorization: $SANDCHEST_API_KEY" ``` ## Give this to your agent These docs are written to be handed to an AI coding agent. Paste this prompt into Claude Code, Cursor, Codex or anything else that can fetch a URL: ```text Add Sandchest speech-to-text to this project. Docs: https://sandchest.com/llms.txt (index) — fetch https://sandchest.com/llms-full.txt for everything. API key: read it from the SANDCHEST_API_KEY environment variable (never hardcode it). Base URL: https://stt-api.sandchest.com Use the official assemblyai package if it is already installed (change apiKey + baseUrl), otherwise @sandchest/sdk. Transcribe , print each word with its start/end in milliseconds, and add a small test. ``` Every page here is also served as plain Markdown at `/docs/.md`, and answers to `Accept: text/markdown`. [Set up with an AI agent](/docs/agents) has the details and a snippet for your `CLAUDE.md` or `AGENTS.md`. ## Everything else | Page | What it covers | | --- | --- | | [Quickstart](/docs/quickstart) | Key, environment variable, first transcript, three ways | | [Set up with an AI agent](/docs/agents) | The agent prompt, `llms.txt`, raw Markdown, editor rules | | [Authentication](/docs/authentication) | Keys, header forms, workspaces and roles, rotation | | [Using the AssemblyAI SDK](/docs/assemblyai) | The two-line swap, what is supported, what is rejected | | [Transcript options](/docs/transcripts) | Every request field, the transcript object, the word object | | [Languages](/docs/languages) | Detection, explicit codes, the supported list, models | | [Webhooks](/docs/webhooks) | Payload, auth header, retries, local testing | | [API reference](/docs/api) | Every endpoint, request, response and error | | [TypeScript SDK](/docs/sdk) | `@sandchest/sdk` in full | | [Errors and limits](/docs/errors) | Status codes, size limits, rate limits, deadlines | | [Billing](/docs/billing) | Prepaid credits, per-second metering, top-ups | | [Self-hosting](/docs/self-hosting) | Apple Silicon, Docker Compose, required environment | ## Verify You are ready to start when this prints your workspace's ten most recent transcripts (an empty list is a pass — it means the key authenticated): ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript?limit=10" \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```json title="expected shape" { "page_details": { "limit": 10, "result_count": 0, "current_url": "...", "prev_url": null, "next_url": null }, "transcripts": [] } ``` If you get `{"error":"A valid Sandchest API key is required."}`, the key is missing, misspelled or revoked. Start at the [Quickstart](/docs/quickstart). ## Next [Quickstart](/docs/quickstart) — get a key and your first transcript in about two minutes. --- # Quickstart Get an API key, set one environment variable, and turn a local audio file into word-level timestamps — with the AssemblyAI SDK, the native SDK, or curl. By the end of this page you will have a Sandchest API key in your environment and a completed transcript printed as words with millisecond timings. Pick one of the three paths in step 3; they all produce the same transcript. ## 1. Get an API key 1. Open [sandchest.com/dashboard](https://sandchest.com/dashboard) and sign in. Sign-in is a six-digit code sent to your email — there is no password. 2. Go to **API keys** and create a key. Give it a name you will recognise later (`local dev`, `staging worker`). 3. Copy it now. Keys start with `sc_live_` and are shown exactly once; Sandchest stores only a hash, so a lost key must be replaced rather than recovered. > [!WARNING] > An API key grants full access to your workspace's audio, transcripts and credits. > Keep it server-side. Never ship it to a browser, a mobile app or a public repository. ## 2. Put the key in your environment ```bash export SANDCHEST_API_KEY="sc_live_..." ``` For a project, put it in `.env.local` (or `.env`) and make sure that file is git-ignored: ```bash title=".env.local" SANDCHEST_API_KEY=sc_live_... ``` Check it is set: ```bash echo "${SANDCHEST_API_KEY:0:8}" ``` ```text title="expected output" sc_live_ ``` ## 3. Transcribe a file You need a local audio or video file. Anything FFmpeg can decode works — `.mp3`, `.m4a`, `.wav`, `.mp4`, `.webm`. The examples below use `meeting.mp3`. ### With the AssemblyAI SDK If your project already uses `assemblyai`, keep it. Change the key and the base URL. ```bash npm install assemblyai # or: bun add assemblyai ``` ```ts title="transcribe.ts" import { readFile } from "node:fs/promises"; import { AssemblyAI } from "assemblyai"; const client = new AssemblyAI({ apiKey: process.env.SANDCHEST_API_KEY as string, baseUrl: "https://stt-api.sandchest.com", }); const transcript = await client.transcripts.transcribe({ audio: await readFile("meeting.mp3"), }); if (transcript.status === "error") { throw new Error(transcript.error ?? "Transcription failed."); } console.log(transcript.text); for (const word of transcript.words ?? []) { console.log(`${word.start}\t${word.end}\t${word.text}`); } ``` `transcribe()` uploads the bytes, creates the transcript, and polls until it is finished. The AssemblyAI SDK owns the polling loop — Sandchest's `GET` returns the current state immediately rather than holding the connection open. ### With `@sandchest/sdk` ```bash bun add @sandchest/sdk # or: npm install @sandchest/sdk ``` ```ts title="transcribe.ts" import { readFile } from "node:fs/promises"; import { Sandchest } from "@sandchest/sdk"; const client = new Sandchest({ apiKey: process.env.SANDCHEST_API_KEY as string }); const transcript = await client.transcripts.transcribe({ audio: await readFile("meeting.mp3"), }); if (transcript.status === "error") { throw new Error(transcript.error ?? "Transcription failed."); } console.log(transcript.text); for (const word of transcript.words ?? []) { console.log(`${word.start}\t${word.end}\t${word.text}`); } ``` The base URL defaults to `https://stt-api.sandchest.com`; pass `baseUrl` only when you are pointing at your own deployment. ### With curl Three calls: upload the bytes, create the transcript, poll until it settles. ```bash title="transcribe.sh" API="https://stt-api.sandchest.com" # 1. Upload raw bytes. This is not a multipart form. UPLOAD_URL=$(curl -sS "$API/v2/upload" \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @meeting.mp3 | jq -r .upload_url) # 2. Create the transcript. ID=$(curl -sS "$API/v2/transcript" \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"audio_url\":\"$UPLOAD_URL\"}" | jq -r .id) # 3. Poll. GET returns the current state immediately, so you drive the loop. while true; do STATUS=$(curl -sS "$API/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" | jq -r .status) echo "$STATUS" [ "$STATUS" = completed ] || [ "$STATUS" = error ] && break sleep 2 done ``` > [!TIP] > `audio_url` also accepts any public HTTPS URL, so you can skip step 1 entirely if > your audio is already hosted somewhere reachable. ## 4. Read the words Every word carries `start` and `end` in **milliseconds** and a `confidence` between 0 and 1. ```bash curl -sS "$API/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" \ | jq -r '.words[] | "\(.start)\t\(.end)\t\(.text)"' | head ``` ```text title="expected output" 0 280 Okay 300 520 so, 980 1090 the 1100 1480 deploy 1500 1690 went ``` The full transcript object also carries `text`, `confidence`, `audio_duration` (in **seconds**), `language_code`, `language_confidence` and `speech_model_used`. See [Transcript options](/docs/transcripts) for every field. ## Verify Run this. It should print `completed` and a non-empty transcript id. ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" \ | jq '{id, status, language_code, audio_duration, speech_model_used, words: (.words | length)}' ``` ```json title="expected output" { "id": "tr_...", "status": "completed", "language_code": "en", "audio_duration": 4.8, "speech_model_used": "nvidia/parakeet-tdt-0.6b-v2", "words": 14 } ``` If `status` is `error`, read the `error` field — it says exactly what went wrong. [Errors and limits](/docs/errors) maps every failure to what to do about it. ## Next [Set up with an AI agent](/docs/agents) — hand these docs to a coding agent and let it wire Sandchest in for you. --- # Set up with an AI agent Hand these docs to a coding agent and let it integrate Sandchest unaided — with llms.txt, raw Markdown URLs, an OpenAPI document, and a rules snippet for your repo. Every page of these docs is plain Markdown behind the scenes, and every page is served as Markdown as well as HTML. An agent never has to scrape rendered pages or guess at an API. This page gives you the prompt, the URLs, and a snippet to drop into your repository so future agent sessions already know about Sandchest. ## The prompt Paste this into Claude Code, Cursor, Codex, or any agent that can fetch a URL. Replace `` with the audio you want transcribed. ```text Add Sandchest speech-to-text to this project. Docs: https://sandchest.com/llms.txt (index) — fetch https://sandchest.com/llms-full.txt for everything. API key: read it from the SANDCHEST_API_KEY environment variable (never hardcode it). Base URL: https://stt-api.sandchest.com Use the official assemblyai package if it is already installed (change apiKey + baseUrl), otherwise @sandchest/sdk. Transcribe , print each word with its start/end in milliseconds, and add a small test. ``` If your agent can only fetch one page, give it `https://sandchest.com/docs/quickstart.md`. It is self-contained. ## What is machine-readable | URL | Content type | What it is | | --- | --- | --- | | [`/llms.txt`](/llms.txt) | `text/plain` | The [llmstxt.org](https://llmstxt.org) index: one line per page with its title, Markdown URL and description | | [`/llms-full.txt`](/llms-full.txt) | `text/plain` | Every page's Markdown, concatenated in navigation order | | [`/openapi.json`](/openapi.json) | `application/json` | OpenAPI 3.1 generated from the live API contract, not written by hand | | `/docs/.md` | `text/markdown` | One page as Markdown, with its title and description at the top | | `/docs.md` | `text/markdown` | This site's index page as Markdown | Start with `/llms.txt`, then fetch only the pages you need. Reach for `/llms-full.txt` when you want the whole thing in one request. ```bash curl -sS https://sandchest.com/llms.txt curl -sS https://sandchest.com/docs/transcripts.md curl -sS https://sandchest.com/openapi.json | jq '.paths | keys' ``` ## Content negotiation Any `/docs/*` URL returns Markdown when you ask for it. Send an `Accept` header that includes `text/markdown` (or `text/plain`) and does not include `text/html`: ```bash curl -sS https://sandchest.com/docs/quickstart -H "Accept: text/markdown" ``` That returns the same bytes as `https://sandchest.com/docs/quickstart.md`. Browsers send `Accept: text/html,...` and keep getting the rendered page, so a single link works for both a person and an agent. > [!TIP] > Every rendered page also has a **Copy as Markdown** button and an **Open .md** link > in its header, plus **Copy for agent**, which copies a one-line prompt pointing at > that page's Markdown URL. ## Add it to your repository Drop this into `CLAUDE.md`, `AGENTS.md`, or `.cursorrules` so every future agent session already knows the shape of the integration. ```text title="CLAUDE.md" ## Speech-to-text: Sandchest - API base URL: https://stt-api.sandchest.com - Auth: `Authorization: $SANDCHEST_API_KEY` (a `Bearer ` prefix is also accepted). The key lives in the SANDCHEST_API_KEY environment variable. Never hardcode or log it. - Docs index: https://sandchest.com/llms.txt — fetch the page you need as Markdown, e.g. https://sandchest.com/docs/api.md. OpenAPI: https://sandchest.com/openapi.json - The API is AssemblyAI-compatible at /v2/*. If `assemblyai` is already a dependency, keep it and set `baseUrl`. Otherwise use `@sandchest/sdk`. - Word timings (`start`, `end`) are milliseconds. `audio_duration` is seconds. - GET /v2/transcript/:id returns the current state immediately. Poll it; do not expect the request to block until the transcript is ready. - Unknown request options are rejected with HTTP 400, not ignored. Speaker diarization (`speaker_labels`, `speakers_expected`) and word boosting (`word_boost`, `boost_param`) are not supported. - Errors are `{ "error": "..." }` with a meaningful status code. 402 means the workspace is out of credits; 429 means the per-minute rate limit was hit. ``` ## What an agent needs to know first If you are an agent reading this page, these are the five facts that prevent almost every mistake: 1. **Timings are milliseconds.** `word.start` and `word.end` are integer milliseconds. `audio_duration` on the transcript is in seconds. Do not mix them. 2. **`GET` does not block.** Creating a transcript returns immediately with `status: "queued"`. Poll `GET /v2/transcript/:id` until `status` is `completed` or `error`. Both SDKs already do this for you. 3. **Unknown options are a 400, not a no-op.** Sandchest refuses to acknowledge a request it has silently dropped fields from. Send only the options in [Transcript options](/docs/transcripts). 4. **The upload endpoint takes raw bytes.** `POST /v2/upload` with `Content-Type: application/octet-stream` and the file as the body. It is not a multipart form. It returns `{ "upload_url": "..." }`, which you pass as `audio_url`. 5. **Retries need `Idempotency-Key`.** Sending the same creation request twice without one creates two transcripts and bills twice. See [Errors and limits](/docs/errors#retrying-safely). ## Verify Confirm both representations of a page agree, and that the OpenAPI document is live: ```bash curl -sS https://sandchest.com/docs/quickstart.md | head -3 curl -sS https://sandchest.com/docs/quickstart -H "Accept: text/markdown" | head -3 curl -sS https://sandchest.com/openapi.json | jq -r '.info.title, (.paths | keys | length)' ``` ```text title="expected output" # Quickstart Get an API key, set one environment variable, and turn a local audio file into word-level timestamps — with the AssemblyAI SDK, the native SDK, or curl. # Quickstart Get an API key, set one environment variable, and turn a local audio file into word-level timestamps — with the AssemblyAI SDK, the native SDK, or curl. Sandchest API 10 ``` ## Next [Authentication](/docs/authentication) — where keys come from, and who in a workspace can create them. --- # Authentication How Sandchest API keys work, both header forms the API accepts, who in a workspace can create them, and how to rotate one without downtime. Every request to the Sandchest API carries one API key in the `Authorization` header. This page covers where keys come from, the two header forms that work, who can manage them, and how to rotate one safely. ## API keys A key looks like this: ```text sc_live_Xf9m2QvR7pKd3sTn1yBhLc8WgZaE0jUo5NrPiVxD4M ``` - Keys start with `sc_live_`. Anything that does not is rejected without a database lookup. - A key is shown **once**, when you create it. Sandchest stores only an HMAC-SHA256 digest of the key, so a lost key cannot be recovered — create a new one and revoke the old. - The dashboard lists a key's **prefix** (its first 16 characters), name, creation time and last-used time. That is enough to tell keys apart without exposing them. - A key belongs to a **workspace**, not to a person. It can read and write every transcript in that workspace and spend that workspace's credits. Create and revoke keys at [sandchest.com/dashboard/api-keys](https://sandchest.com/dashboard/api-keys). ## The header Both of these authenticate identically. Use whichever your HTTP client makes easy. ```http Authorization: sc_live_... ``` ```http Authorization: Bearer sc_live_... ``` The AssemblyAI SDKs send the raw form; most generic HTTP clients default to `Bearer`. Sandchest strips a case-insensitive `Bearer ` prefix and trims surrounding whitespace before comparing. ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript?limit=1" \ -H "Authorization: $SANDCHEST_API_KEY" ``` A missing, malformed, revoked or expired key returns: ```json title="401 Unauthorized" { "error": "A valid Sandchest API key is required." } ``` > [!NOTE] > There is no separate account id, project id or secret. One header, one key. ## Workspaces and roles A workspace owns its API keys, audio, transcripts, usage and credits. Users join workspaces as one of three roles: | Role | Can transcribe | Can manage API keys | Can manage members | Can manage billing | | --- | --- | --- | --- | --- | | `owner` | Yes | Yes | Yes | Yes | | `admin` | Yes | Yes | Yes | No | | `member` | Yes | No | No | No | A member who tries to create or revoke a key gets `{"error":"Only workspace admins can create keys."}` or `{"error":"Only workspace admins can revoke keys."}` with HTTP 403. Billing actions are owner-only. Keys are scoped to one workspace. A transcript created with a key from workspace A is invisible to every key in workspace B — a lookup for someone else's transcript id is indistinguishable from a lookup for an id that never existed. ## Rotating a key Revocation takes effect immediately, so rotate in this order: 1. Create the new key in the dashboard and copy it. 2. Deploy the new key to every process that talks to Sandchest. 3. Wait until the old key's **last used** timestamp stops moving. Sandchest refreshes it at most once every five minutes, so give it a little longer than that. 4. Revoke the old key. If a key has leaked, skip straight to revoking it. Requests using it start failing with 401 at once; in-flight transcripts continue and are still billed, because the work is already queued. ## Keeping keys out of client code - Call Sandchest from your server, a serverless function, or a background worker. Never from a browser, a mobile app, a desktop app, or anything else a user can open. - Give each deployment environment its own key so one can be revoked without affecting the others. - Never log the `Authorization` header. Sandchest's own error responses never echo it back, and neither should yours. - If you need to expose transcription to end users, put your own endpoint in front of Sandchest and authenticate your users there. ## Verify This should return `200` with a JSON body. Anything else means the key is wrong. ```bash curl -sS -o /dev/null -w '%{http_code}\n' \ "https://stt-api.sandchest.com/v2/transcript?limit=1" \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```text title="expected output" 200 ``` Then confirm the `Bearer` form works too: ```bash curl -sS -o /dev/null -w '%{http_code}\n' \ "https://stt-api.sandchest.com/v2/transcript?limit=1" \ -H "Authorization: Bearer $SANDCHEST_API_KEY" ``` ```text title="expected output" 200 ``` ## Next [Using the AssemblyAI SDK](/docs/assemblyai) — if you already have AssemblyAI code, this is the shortest path. --- # Using the AssemblyAI SDK Change apiKey and baseUrl and your existing AssemblyAI code runs on Sandchest. Here is exactly which endpoints and options are supported, and which are rejected. Sandchest speaks the AssemblyAI prerecorded API at `/v2/*`. If you already have AssemblyAI code, keep the official SDK and change two settings. This page shows the swap in TypeScript and Python, then lists precisely what works, what is refused, and where the behaviour differs. ## The swap ### TypeScript ```ts title="transcribe.ts" import { AssemblyAI } from "assemblyai"; const client = new AssemblyAI({ apiKey: process.env.SANDCHEST_API_KEY as string, baseUrl: "https://stt-api.sandchest.com", }); const transcript = await client.transcripts.transcribe({ audio: audioBuffer, disfluencies: true, }); console.log(transcript.speech_model_used); console.log(transcript.words); ``` ### Python ```python title="transcribe.py" import os import assemblyai as aai aai.settings.api_key = os.environ["SANDCHEST_API_KEY"] aai.settings.base_url = "https://stt-api.sandchest.com" transcript = aai.Transcriber().transcribe("meeting.mp3") print(transcript.text) for word in transcript.words: print(word.start, word.end, word.text) ``` That is the whole migration. Uploads, transcript creation, polling, listing, deletion, word search, sentences, paragraphs, SRT and VTT all go through unchanged. ## Supported endpoints | Method | Path | Notes | | --- | --- | --- | | `POST` | `/v2/upload` | Raw bytes in the body | | `POST` | `/v2/transcript` | `Idempotency-Key` accepted | | `GET` | `/v2/transcript/:id` | Returns the current state immediately | | `GET` | `/v2/transcript` | `limit`, `status`, `created_on`, `before_id`, `after_id` | | `DELETE` | `/v2/transcript/:id` | Erases words, text and source URLs | | `GET` | `/v2/transcript/:id/sentences` | Completed transcripts only | | `GET` | `/v2/transcript/:id/paragraphs` | Completed transcripts only | | `GET` | `/v2/transcript/:id/word-search` | `words=` required | | `GET` | `/v2/transcript/:id/srt` | `chars_per_caption` supported | | `GET` | `/v2/transcript/:id/vtt` | `chars_per_caption` supported | Those ten paths are the complete surface. Streaming/real-time transcription, LeMUR, and the AssemblyAI audio-intelligence endpoints are not implemented. Full details in the [API reference](/docs/api). ## Supported options `audio_url`, `speech_model`, `speech_models`, `language_code`, `language_detection`, `language_confidence_threshold`, `language_detection_options`, `punctuate`, `format_text`, `disfluencies`, `custom_spelling`, `multichannel`, `audio_start_from`, `audio_end_at`, `webhook_url`, `webhook_auth_header_name` and `webhook_auth_header_value`. Every field is documented in [Transcript options](/docs/transcripts). ## Options that are rejected Sandchest never acknowledges a request whose options it silently dropped. If it cannot do the thing you asked for, it says so with HTTP 400 and this exact body shape: | You sent | Response | | --- | --- | | `speaker_labels: true` or any `speakers_expected` | `{"error":"Speaker identification is not supported."}` | | a non-empty `word_boost`, or any `boost_param` | `{"error":"Custom word boosting is not supported."}` | | any option Sandchest does not implement | `{"error":"The request contains an unsupported option or an invalid option value."}` | That last row is the one to plan for. The AssemblyAI audio-intelligence options — `summarization`, `auto_chapters`, `auto_highlights`, `entity_detection`, `content_safety`, `iab_categories`, `sentiment_analysis`, `redact_pii`, `dual_channel`, `language_codes` and friends — are not in Sandchest's schema, so a request carrying them is refused rather than quietly stripped. Remove them before you switch a workload over. > [!NOTE] > `speaker_labels: false`, `speakers_expected: null` and an empty `word_boost: []` > are all accepted: they ask for nothing, so there is nothing to refuse. The language options have their own rules: | You sent | Response | | --- | --- | | no `language_code` and `language_detection: false` | `` {"error":"Either `language_detection` must be set to True, or one of `language_code` or `language_codes` must must be specified."} `` | | a `language_code` and `language_detection: true` | `` {"error":"`language_detection` is not available when `language_code` is specified."} `` | | `language_confidence_threshold` without detection | `{"error":"language_confidence_threshold requires language_detection."}` | Omit both and you get automatic detection, which is what most callers want. See [Languages](/docs/languages). ## Differences to expect **`GET` returns immediately.** AssemblyAI's `GET /v2/transcript/:id` and Sandchest's both return the current state without waiting. The SDK's `transcribe()` and `waitUntilReady()` own the polling loop, at their configured interval. Nothing to change — just do not write code that assumes the request blocks until completion. **`speech_model_used` tells the truth.** You can keep sending AssemblyAI model names in `speech_models` or `speech_model` for compatibility; they are accepted and echoed back in the response's `speech_models`. `speech_model_used` reports the model that genuinely produced the transcript, for example `nvidia/parakeet-tdt-0.6b-v2`. **`disfluencies` defaults to `false`.** New transcripts drop English filled pauses ("um", "uh") unless you ask for them. Send `disfluencies: true` for verbatim output. **Deletion is a tombstone.** `DELETE` erases words, text, source URLs, custom vocabulary and private error detail, then returns an object with `status: "completed"`, `text: "Deleted by user."` and `audio_url: "http://deleted_by_user"`. Usage and billing history survive. **Unknown transcript ids are a 400, not a 404.** The compatibility endpoints match AssemblyAI here: `{"error":"Transcript lookup error, transcript id not found"}` with HTTP 400. The native endpoint at `/api/v1/transcripts/:id` returns a plain 404 instead. **Non-English coverage is narrower than the code list.** The API accepts the full AssemblyAI language-code list, but the deployed model decodes a smaller set. A code outside it fails at inference time with a clear message on the transcript's `error` field. [Languages](/docs/languages) has the exact list. ## Verify Run your existing AssemblyAI code against Sandchest and check the model identifier — if it names a Parakeet model, you are on Sandchest and not on AssemblyAI. ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" \ | jq '{status, speech_model_used, speech_models}' ``` ```json title="expected output" { "status": "completed", "speech_model_used": "nvidia/parakeet-tdt-0.6b-v2", "speech_models": ["universal-2"] } ``` ## Next [Transcript options](/docs/transcripts) — every request field and every field on the object that comes back. --- # Transcript options Every field you can send when creating a transcript, what each one does, and every field on the transcript and word objects that come back. One JSON body creates a transcript, at `POST /v2/transcript` or `POST /api/v1/transcripts`. Only `audio_url` is required; everything else has a sensible default. This page is the complete field reference for both directions. ```bash curl -sS https://stt-api.sandchest.com/v2/transcript \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: import-2026-08-30-meeting-141" \ -d '{ "audio_url": "https://example.com/meeting.mp3", "language_detection": true, "disfluencies": true, "format_text": true }' ``` > [!IMPORTANT] > Unknown fields are rejected with HTTP 400 and > `{"error":"The request contains an unsupported option or an invalid option value."}`. > Sandchest will not accept a request it has silently dropped options from. Send only > the fields below. ## The audio | Name | Type | Default | Description | | --- | --- | --- | --- | | `audio_url` | string | — | **Required.** Either an `upload_url` from `POST /v2/upload`, or a public HTTP(S) URL. Credentials in the URL, private and loopback addresses are refused; in production the URL must be HTTPS. | | `audio_start_from` | integer \| null | `null` | Start transcribing this many **milliseconds** into the recording. Non-negative integer. | | `audio_end_at` | integer \| null | `null` | Stop transcribing at this **millisecond** offset. Non-negative integer. | | `multichannel` | boolean \| null | `null` | Transcribe each physical input channel independently. See [Multichannel](#multichannel). | An uploaded asset expires after the deployment's retention window (24 hours on the hosted service). Referring to an expired or deleted upload returns `{"error":"The uploaded audio asset was not found or has expired."}`. ## Language | Name | Type | Default | Description | | --- | --- | --- | --- | | `language_code` | string \| null | `null` | Transcribe as this language. One of the [supported codes](/docs/languages). Setting it turns detection off. | | `language_detection` | boolean \| null | `null` | Detect the language automatically. Defaults to on when `language_code` is absent. | | `language_confidence_threshold` | number \| null | `null` | Between 0 and 1. Fail the transcript when detection confidence falls below this. Requires detection. | | `language_detection_options.expected_languages` | string[] \| `["all"]` \| null | `null` | Restrict detection to these codes. | | `language_detection_options.fallback_language` | string \| `"auto"` \| null | `null` | Use this language when detection is not confident. | Rules the API enforces, with the exact error each one produces, are in [Languages](/docs/languages#the-rules). The short version: send `language_code`, or send `language_detection: true`, or send neither and get detection. `fallback_language` must appear in `expected_languages`, or be `"auto"`, or `expected_languages` must be `["all"]` — otherwise the request is rejected with `` {"error":"fallback_language must be in expected_languages or auto."} ``. When comparing, `en_au`, `en_uk` and `en_us` all fold to `en`; `de_ch` stays distinct from `de`. ## Text formatting | Name | Type | Default | Description | | --- | --- | --- | --- | | `punctuate` | boolean | `true` | Add punctuation and sentence casing. `false` lowercases the display text and strips edge punctuation *after* alignment, so word timings and confidences survive. | | `format_text` | boolean | `true` | Apply readability formatting — numbers, dates, currency written the way a person would. | | `disfluencies` | boolean | `false` | Keep filled pauses ("um", "uh"). The default removes them from English audio after alignment; retained words keep their original timings and confidence. Other languages keep the model's own output. | | `custom_spelling` | array \| null | `null` | Rewrite recognised words to a preferred spelling. See below. | ### custom_spelling An array of `{ "from": ..., "to": ... }` rules. `from` is a spoken word or phrase, or an array of them, matched case-insensitively. `to` is the single word to display. ```json { "audio_url": "https://example.com/meeting.mp3", "custom_spelling": [ { "from": ["sand chest", "sandchest"], "to": "Sandchest" }, { "from": "assembly ai", "to": "AssemblyAI" } ] } ``` Rules apply after recognition and alignment. They do not bias the acoustic model or improve accuracy — they rewrite what was already heard. When a multi-word `from` collapses into one word, the merged word keeps the first word's start, the last word's end, and the mean confidence. Constraints, and the exact error for each: | Problem | Response | | --- | --- | | not an array | `{"error":"custom_spelling must be an array of objects"}` | | an entry with keys other than `from` and `to` | `{"error":"custom_spelling must be an array of objects with only 'to' and 'from' keys"}` | | a missing or empty `from` | `{"error":"custom_spelling 'from' fields cannot be empty or null"}` | | an empty string inside `from` | `{"error":"custom_spelling 'from' values cannot be empty"}` | | a `to` that is not exactly one word | `{"error":"custom_spelling 'to' fields must contain only one word"}` | | a wrong type anywhere | `{"error":"Invalid endpoint schema, please refer to documentation for examples."}` | `null`, `false`, `0`, `""`, `[]` and `{}` all mean "no rules" and are accepted. ## Models | Name | Type | Default | Description | | --- | --- | --- | --- | | `speech_models` | string[] \| null | `null` | Accepted for AssemblyAI compatibility and echoed back. Provider model names such as `universal-2` are fine. | | `speech_model` | string \| null | `null` | The singular form, treated the same way. | Sandchest routes to its own configured models — English audio to Parakeet TDT v2, automatic and non-English requests to the multilingual Parakeet TDT v3. The response field `speech_model_used` reports the model that genuinely produced the transcript, so you always know what ran. See [Languages](/docs/languages#models). ## Webhooks | Name | Type | Default | Description | | --- | --- | --- | --- | | `webhook_url` | string \| null | `null` | Publicly routable `http`/`https` URL, at most 8192 characters, no credentials and no fragment. | | `webhook_auth_header_name` | string \| null | `null` | 1–1000 characters from `A-Z a-z 0-9 _ -`, and not a reserved HTTP header. | | `webhook_auth_header_value` | string \| null | `null` | At most 1000 characters. | Both auth fields must be sent together with a `webhook_url`, or all three omitted. Anything else returns `{"error":"Invalid webhook URL or authentication parameters."}`. Full behaviour in [Webhooks](/docs/webhooks). ## Idempotency `Idempotency-Key` is a request **header**, not a body field. At most 200 characters. ```http POST /v2/transcript HTTP/1.1 Authorization: sc_live_... Idempotency-Key: import-2026-08-30-meeting-141 Content-Type: application/json ``` - Same key, same request → the original transcript, not a second one, and no second charge. - Same key, a genuinely different request → HTTP 409 and `{"error":"This Idempotency-Key was already used for a different transcription request."}`. - Same key, but the transcript was deleted → HTTP 409 and `{"error":"This Idempotency-Key belongs to a deleted transcript."}`. Keys stay reserved after deletion; they never produce a fresh job. - Longer than 200 characters → HTTP 400 and `{"error":"Idempotency-Key must contain at most 200 characters."}`. Keys are scoped to your workspace and do not expire. Re-uploading the identical bytes and retrying with the same key still counts as the same request: Sandchest compares the stored SHA-256 of the audio, not the URL. ## Multichannel `multichannel: true` recognises each physical input channel on its own, so a two-track call recording gives you both sides with independent timings. Up to 32 channels. When it is on, the completed transcript adds: - `audio_channels` — how many channels were decoded. - `utterances` — per-channel spans, each with `speaker`, `channel`, `text`, `start`, `end`, `confidence` and `words`. - `speaker` and `channel` on every word, both set to the channel label: `"1"`, `"2"`, and so on. Channel labels identify input channels. They are **not** inferred speaker identities — Sandchest does not do diarization, and `speaker_labels: true` is rejected. ## The transcript object Returned by create, get, list-item lookups and delete. | Field | Type | Description | | --- | --- | --- | | `id` | string | The transcript id, `tr_...` | | `status` | string | `queued`, `processing`, `completed` or `error` | | `audio_url` | string | The URL the audio was read from | | `text` | string \| null | The full transcript; `null` until it completes | | `words` | Word[] \| null | Every word with its own timing — see below | | `utterances` | Utterance[] \| null | Per-channel spans; `null` unless `multichannel` | | `multichannel` | boolean \| null | `true` when requested, otherwise `null` | | `audio_channels` | number \| null | Present only when `multichannel` was requested | | `confidence` | number \| null | Overall confidence, 0–1. `0` for a completed empty transcript | | `audio_duration` | number \| null | Measured length in **seconds** | | `audio_start_from` | number \| null | Echoed **milliseconds**; `null` when unset or `0` | | `audio_end_at` | number \| null | Echoed **milliseconds**; `null` when unset or `0` | | `language_code` | string \| null | Detected or requested language | | `language_confidence` | number \| null | 0–1, from genuine detection | | `speech_model_used` | string \| null | The model that actually ran | | `speech_models` | string[] \| null | The model names you requested | | `punctuate` | boolean | Effective setting | | `format_text` | boolean | Effective setting | | `disfluencies` | boolean | Effective setting | | `custom_spelling` | Rule[] \| null | Normalised rules, `from` lowercased | | `language_detection` | boolean | Whether detection ran | | `speaker_labels` | boolean | Always `false` — diarization is not supported | | `webhook_url` | string \| null | The callback URL, if one was set | | `webhook_status_code` | number \| null | HTTP status of the most recent delivery attempt | | `webhook_auth` | boolean | Whether a custom auth header was configured | | `webhook_auth_header_name` | string \| null | The header name, never its value | | `error` | string \| null | Why it failed, when `status` is `error` | | `created` | string | ISO 8601 timestamp | ## The word object ```json { "text": "deploy", "start": 1100, "end": 1480, "confidence": 0.99, "speaker": null, "channel": null } ``` | Field | Type | Description | | --- | --- | --- | | `text` | string | The word, with the punctuation the model produced | | `start` | number | Start offset in **milliseconds** | | `end` | number | End offset in **milliseconds** | | `confidence` | number | 0–1, for this word alone | | `speaker` | string \| null | The channel label under `multichannel`, else `null` | | `channel` | string \| null | The channel label under `multichannel`, else `null` | Timings are acoustic, from the model's own alignment. Every derived resource — sentences, paragraphs, SRT, VTT, word search — is built from these same word boundaries, so a caption never disagrees with a word. ## Verify Create a transcript with a couple of options and check they round-trip: ```bash curl -sS https://stt-api.sandchest.com/v2/transcript \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -d '{"audio_url":"'"$UPLOAD_URL"'","disfluencies":true,"language_code":"en"}' \ | jq '{status, disfluencies, language_detection, language_code}' ``` ```json title="expected output" { "status": "queued", "disfluencies": true, "language_detection": false, "language_code": null } ``` `language_code` is `null` until the transcript completes — the response echoes what was *detected*, not what was requested. Poll the same id and it fills in. ## Next [Languages](/docs/languages) — automatic detection, the supported codes, and which model handles which. --- # Languages Automatic language detection, explicit language codes, the rules the API enforces, the full list of accepted codes, and which model transcribes what. Sandchest detects the language of your audio by default. You can also pin it, hint it, or require a confidence floor. This page covers each mode, the exact validation rules, and the codes the API accepts. ## Detection is the default Omit both `language_code` and `language_detection` and Sandchest detects the language: ```json { "audio_url": "https://example.com/meeting.mp3" } ``` The completed transcript reports what it found: ```json { "language_code": "de", "language_confidence": 0.97, "language_detection": true } ``` `language_confidence` is between 0 and 1 and comes from the genuine transcript — Sandchest never fabricates a language for audio it could not read. Detection needs speech: a file with no spoken audio fails with `{"error":"language_detection cannot be performed on files with no spoken audio."}` on the transcript, rather than guessing. ## Pinning a language When you already know the language, say so. It skips detection and avoids a wrong guess on short or noisy clips. ```json { "audio_url": "https://example.com/meeting.mp3", "language_code": "es" } ``` The response then carries `language_detection: false` and the `language_code` you asked for. ## Hinting and fallbacks `language_detection_options` narrows the search or names a safety net. ```json { "audio_url": "https://example.com/support-call.mp3", "language_detection": true, "language_confidence_threshold": 0.6, "language_detection_options": { "expected_languages": ["en", "fr", "de"], "fallback_language": "en" } } ``` | Field | What it does | | --- | --- | | `expected_languages` | Restrict detection to these codes. `["all"]` means no restriction. | | `fallback_language` | Use this code when detection is not confident. `"auto"` leaves it unrestricted. | | `language_confidence_threshold` | 0–1. Fail rather than proceed below this confidence. | ## The rules The API enforces four rules and gives you the exact reason each time. | Situation | HTTP | Body | | --- | --- | --- | | No `language_code`, and `language_detection: false` | 400 | `` {"error":"Either `language_detection` must be set to True, or one of `language_code` or `language_codes` must must be specified."} `` | | A `language_code` **and** `language_detection: true` | 400 | `` {"error":"`language_detection` is not available when `language_code` is specified."} `` | | `language_confidence_threshold` with detection off | 400 | `{"error":"language_confidence_threshold requires language_detection."}` | | `fallback_language` not in `expected_languages` | 400 | `` {"error":"fallback_language must be in expected_languages or auto."} `` | That last rule is satisfied when any of these is true: `expected_languages` is absent, `expected_languages` is `["all"]`, `fallback_language` is `"auto"`, or `expected_languages` contains the fallback. When it is omitted, the fallback is compared as `en`. English locale codes fold together for this comparison — `en_au`, `en_uk` and `en_us` all count as `en` — while `de_ch` stays distinct from `de`. ## Models | Model | Covers | When it runs | | --- | --- | --- | | `nvidia/parakeet-tdt-0.6b-v2` | English | Explicit English requests, and a refinement pass over uncertain detected-English audio | | `nvidia/parakeet-tdt-0.6b-v3` | 25 languages, listed below | Automatic detection and every non-English request | Both models stay warm, so neither routing decision costs you a cold start. The transcript's `speech_model_used` names whichever one produced it. Self-hosted deployments choose their own models with `SANDCHEST_MODEL` and `SANDCHEST_MULTILINGUAL_MODEL` — see [Self-hosting](/docs/self-hosting). The multilingual model decodes these 25 languages: | Code | Language | Code | Language | | --- | --- | --- | --- | | `bg` | Bulgarian | `hr` | Croatian | | `cs` | Czech | `da` | Danish | | `nl` | Dutch | `en` | English | | `et` | Estonian | `fi` | Finnish | | `fr` | French | `de` | German | | `el` | Greek | `hu` | Hungarian | | `it` | Italian | `lv` | Latvian | | `lt` | Lithuanian | `mt` | Maltese | | `pl` | Polish | `pt` | Portuguese | | `ro` | Romanian | `sk` | Slovak | | `sl` | Slovenian | `es` | Spanish | | `sv` | Swedish | `ru` | Russian | | `uk` | Ukrainian | | | > [!WARNING] > The API accepts the full AssemblyAI-compatible code list below so that existing > code validates, but a request for a language outside the table above cannot be > transcribed by the Parakeet engine. It is refused at inference time and the > transcript finishes with `status: "error"` and an `error` such as > `Parakeet does not support the requested language: ja`. Check the table above > before pinning a language. Self-hosted deployments have a second option: the worker can be built with an optional Whisper engine and started with `SANDCHEST_INFERENCE_ENGINE=whisper`, which covers the whole accepted code list below. Parakeet is the default engine and the one behind the hosted service. See [Self-hosting](/docs/self-hosting). ## Accepted language codes These 103 codes pass validation on `language_code` and inside `language_detection_options.expected_languages`. Anything else is rejected with `{"error":"The request contains an unsupported option or an invalid option value."}`. | Code | Language | Code | Language | | --- | --- | --- | --- | | `en` | English | `es` | Spanish | | `fr` | French | `de` | German | | `it` | Italian | `pt` | Portuguese | | `nl` | Dutch | `hi` | Hindi | | `ja` | Japanese | `zh` | Chinese | | `fi` | Finnish | `ko` | Korean | | `pl` | Polish | `ru` | Russian | | `tr` | Turkish | `uk` | Ukrainian | | `vi` | Vietnamese | `af` | Afrikaans | | `sq` | Albanian | `am` | Amharic | | `ar` | Arabic | `hy` | Armenian | | `as` | Assamese | `az` | Azerbaijani | | `ba` | Bashkir | `eu` | Basque | | `be` | Belarusian | `bn` | Bangla | | `bs` | Bosnian | `br` | Breton | | `bg` | Bulgarian | `my` | Burmese | | `ca` | Catalan | `hr` | Croatian | | `cs` | Czech | `da` | Danish | | `et` | Estonian | `fo` | Faroese | | `gl` | Galician | `ka` | Georgian | | `el` | Greek | `gu` | Gujarati | | `ht` | Haitian Creole | `ha` | Hausa | | `haw` | Hawaiian | `he` | Hebrew | | `hu` | Hungarian | `is` | Icelandic | | `id` | Indonesian | `jw` | Javanese | | `kn` | Kannada | `kk` | Kazakh | | `km` | Khmer | `lo` | Lao | | `la` | Latin | `lv` | Latvian | | `ln` | Lingala | `lt` | Lithuanian | | `lb` | Luxembourgish | `mk` | Macedonian | | `mg` | Malagasy | `ms` | Malay | | `ml` | Malayalam | `mt` | Maltese | | `mi` | Māori | `mr` | Marathi | | `mn` | Mongolian | `ne` | Nepali | | `no` | Norwegian | `nn` | Norwegian Nynorsk | | `oc` | Occitan | `pa` | Punjabi | | `ps` | Pashto | `fa` | Persian | | `ro` | Romanian | `sa` | Sanskrit | | `sr` | Serbian | `sn` | Shona | | `sd` | Sindhi | `si` | Sinhala | | `sk` | Slovak | `sl` | Slovenian | | `so` | Somali | `su` | Sundanese | | `sw` | Swahili | `sv` | Swedish | | `tl` | Tagalog | `tg` | Tajik | | `ta` | Tamil | `tt` | Tatar | | `te` | Telugu | `th` | Thai | | `bo` | Tibetan | `tk` | Turkmen | | `ur` | Urdu | `uz` | Uzbek | | `cy` | Welsh | `yi` | Yiddish | | `yo` | Yoruba | `en_au` | Australian English | | `en_uk` | English (United Kingdom) | `en_us` | American English | | `de_ch` | Swiss High German | | | ## Verify Detect the language of a file and read back what was found: ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" \ | jq '{status, language_detection, language_code, language_confidence, speech_model_used}' ``` ```json title="expected output" { "status": "completed", "language_detection": true, "language_code": "en", "language_confidence": 0.99, "speech_model_used": "nvidia/parakeet-tdt-0.6b-v3" } ``` Detected-English audio may report either model: detection runs on the multilingual model, and an uncertain English result is refined on the English one. Whichever finished the job is the one named in `speech_model_used`. ## Next [Webhooks](/docs/webhooks) — stop polling and get told when a transcript is done. --- # Webhooks Get a POST when a transcript finishes instead of polling for it — the payload, the optional auth header, the retry schedule, and how to test locally. Set `webhook_url` when you create a transcript and Sandchest posts to it once the transcript reaches `completed` or `error`. The payload is deliberately tiny: it tells you *which* transcript changed, and you fetch the rest. This page covers the payload, authentication, retries, and testing against a local server. ## Set it up ```bash curl -sS https://stt-api.sandchest.com/v2/transcript \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "audio_url": "https://example.com/meeting.mp3", "webhook_url": "https://api.example.com/hooks/sandchest", "webhook_auth_header_name": "X-Sandchest-Token", "webhook_auth_header_value": "a-long-random-secret" }' ``` | Field | Rules | | --- | --- | | `webhook_url` | `http` or `https`, at most 8192 characters. No embedded credentials, no `#fragment`. The host may not be `localhost`, a `.localhost` name, or an IP literal in a loopback, link-local, private or otherwise non-routable range. | | `webhook_auth_header_name` | 1–1000 characters from `A-Z a-z 0-9 _ -`. Cannot be a reserved header (see below). | | `webhook_auth_header_value` | At most 1000 characters. | Send both auth fields together with a URL, or none of them. Any other combination — and any URL that fails the checks above — returns HTTP 400 with `{"error":"Invalid webhook URL or authentication parameters."}`. ### Creation time vs delivery time Those checks read the URL as written. They do **not** resolve the hostname, so a name that points at a private address is accepted at creation and returns 201. Where the host actually resolves is enforced at *delivery*. Sandchest resolves the hostname on every attempt and pins the resulting addresses to the socket; if any of them is loopback, link-local, private or otherwise non-routable, the delivery fails **permanently**. It is recorded as `unsafe_destination`, it is not retried, and no 400 was ever returned to warn you — the transcript itself still completes normally. So `https://webhook.internal.example.com` (resolving to `10.0.0.7`) passes creation and then never delivers. The symptom is a `webhook_status_code` that stays `null` long after the transcript reached `completed`: nothing was ever sent, so there is no status to record. Reserved header names, which `webhook_auth_header_name` may not be: `host`, `content-length`, `transfer-encoding`, `connection`, `trailer`, `te`, `upgrade`, `expect`, `content-type`, `content-encoding`, `accept-encoding`, `proxy-authorization`, `proxy-connection`, `keep-alive`. ## The payload Sandchest sends `POST` with `Content-Type: application/json`, a `User-Agent: Sandchest-Webhook/1.0`, and exactly this body: ```json { "transcript_id": "tr_9k2mQvR7pKd3sTn1yBhL", "status": "completed" } ``` `status` is `"completed"` or `"error"`. There are no other fields, and there never will be a transcript body in the payload — the callback tells you to go and fetch it. Your handler should look like this: ```ts title="app/hooks/sandchest/route.ts" export async function POST(request: Request) { if (request.headers.get("x-sandchest-token") !== process.env.SANDCHEST_WEBHOOK_SECRET) { return new Response("forbidden", { status: 403 }); } const { transcript_id, status } = (await request.json()) as { transcript_id: string; status: "completed" | "error"; }; // Acknowledge fast, then do the work out of band. void handleTranscript(transcript_id, status); return new Response("ok", { status: 200 }); } ``` ## Verifying a delivery There is no HMAC signature. Verify a callback in one of two ways, and preferably both: 1. **The auth header.** Set `webhook_auth_header_name` and `webhook_auth_header_value` to a long random secret and compare it on arrival, as above. Sandchest stores the value encrypted and never returns it — the transcript only exposes `webhook_auth: true` and `webhook_auth_header_name`. 2. **Re-fetch the transcript.** The payload carries no content, so the authoritative answer is always `GET /v2/transcript/:id` with your API key. A forged callback cannot make a transcript exist. ```ts const transcript = await client.transcripts.get(transcript_id); if (transcript.status !== "completed") return; ``` ## Retries | Behaviour | Value | | --- | --- | | Attempts | Up to 10 | | Retry spacing | About 10 seconds between attempts | | Request timeout | 10 seconds per attempt | | Success | Any `2xx` | | Permanent failure | Any `4xx`, or a destination that resolves to a non-public address (`unsafe_destination`) — Sandchest stops retrying | | Retried | `5xx`, timeouts, connection errors | | Redirects | Never followed | Delivery is **at least once**. A crash after your server accepted but before Sandchest recorded the acknowledgement can redeliver the same `transcript_id`. Make your handler idempotent — deduplicate on `transcript_id`. The transcript records the outcome. `webhook_status_code` is the HTTP status of the most recent attempt, `null` if nothing has been attempted yet: ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" \ | jq '{webhook_url, webhook_status_code, webhook_auth, webhook_auth_header_name}' ``` ```json title="expected output" { "webhook_url": "https://api.example.com/hooks/sandchest", "webhook_status_code": 200, "webhook_auth": true, "webhook_auth_header_name": "X-Sandchest-Token" } ``` > [!NOTE] > Deleting a transcript clears its webhook configuration along with its content, so > `webhook_url` on a deleted transcript reads `null`. ## Testing locally The hosted service refuses `localhost` and private addresses, so a tunnel is the simplest path: ```bash # any tunnel works — cloudflared, ngrok, tailscale funnel cloudflared tunnel --url http://localhost:3000 ``` Then pass the public URL the tunnel prints as `webhook_url`. If you are running Sandchest yourself, you can allow private destinations instead — in development only: ```bash title=".env.local" SANDCHEST_ALLOW_PRIVATE_WEBHOOK_URLS=true ``` That flag is ignored when `NODE_ENV=production`, so it cannot weaken a real deployment. ## Verify Point a webhook at a request-capture service, transcribe a short file, and confirm the delivery was recorded: ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID" \ -H "Authorization: $SANDCHEST_API_KEY" | jq .webhook_status_code ``` ```text title="expected output" 200 ``` A `null` means nothing has been delivered yet — the transcript is probably still `queued` or `processing`. A `null` on a transcript that finished long ago means no request was ever made: the host resolved to a non-public address and the delivery was dropped as `unsafe_destination`. A `4xx` means your endpoint rejected the callback and Sandchest has stopped retrying. ## Next [API reference](/docs/api) — every endpoint, request and response in one place. --- # API reference Every Sandchest endpoint — method, path, request, response and errors — for both the AssemblyAI-compatible surface and the native API. Ten endpoints, one header, JSON in and JSON out. This page documents each one with a real request and a real response. A machine-readable OpenAPI 3.1 document, generated from the same contract the server runs, is at [`/openapi.json`](/openapi.json). | | | | --- | --- | | Base URL | `https://stt-api.sandchest.com` | | Auth | `Authorization: sc_live_...` on every endpoint (`Bearer` prefix optional) | | Errors | `{ "error": "..." }` with a meaningful status code | ## POST /v2/upload Store audio and get a URL you can pass as `audio_url`. The body is the **raw file bytes** — not a multipart form. | | | | --- | --- | | Auth | Required | | Body | Raw bytes, `Content-Type: application/octet-stream` | | Headers | `X-File-Name` (optional) records the original filename | | Limit | 100 MiB on the hosted service | ```bash curl -sS https://stt-api.sandchest.com/v2/upload \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/octet-stream" \ -H "X-File-Name: meeting.mp3" \ --data-binary @meeting.mp3 ``` ```json title="200 OK" { "upload_url": "https://stt-api.sandchest.com/api/v1/uploads/aud_7pKd3sTn1yBhLc8WgZaE" } ``` Uploads are private to your workspace and expire after the deployment's retention window — 24 hours on the hosted service. Pass the `upload_url` straight through as `audio_url`; do not try to fetch it yourself. | Status | When | | --- | --- | | 400 | `{"error":"The uploaded audio file is empty."}` | | 413 | `{"error":"The uploaded audio file exceeds the configured size limit."}` | | 429 | Rate limited. This endpoint sets a `retry-after` header in seconds. | ## POST /v2/transcript Create a transcript. Returns immediately with `status: "queued"`. | | | | --- | --- | | Auth | Required | | Body | JSON, at most 64 KB | | Headers | `Idempotency-Key` (optional, ≤ 200 characters) | ```bash curl -sS https://stt-api.sandchest.com/v2/transcript \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: import-meeting-141" \ -d '{"audio_url":"https://example.com/meeting.mp3","disfluencies":true}' ``` ```json title="200 OK" { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "status": "queued", "audio_url": "https://example.com/meeting.mp3", "text": null, "words": null, "utterances": null, "multichannel": null, "confidence": null, "audio_duration": null, "audio_start_from": null, "audio_end_at": null, "language_code": null, "language_confidence": null, "speech_model_used": null, "speech_models": [], "punctuate": true, "format_text": true, "disfluencies": true, "custom_spelling": null, "language_detection": true, "speaker_labels": false, "webhook_url": null, "webhook_status_code": null, "webhook_auth": false, "webhook_auth_header_name": null, "error": null, "created": "2026-08-30T09:14:22.001Z" } ``` Every request field is documented in [Transcript options](/docs/transcripts). | Status | When | | --- | --- | | 400 | Unknown option, invalid value, unsupported feature, or an over-long `Idempotency-Key` | | 402 | `{"error":"Your credit balance is exhausted. Add credits to continue transcribing."}` | | 409 | The `Idempotency-Key` was used for a different request, or belongs to a deleted transcript | | 413 | `{"error":"The transcription request exceeds the 64 KB size limit."}` | | 429 | Rate limited | | 503 | Billing, inference or storage temporarily unavailable | ## GET /v2/transcript/:id Read the current state. This returns immediately — it does not wait for the transcript to finish. Poll it. ```bash curl -sS https://stt-api.sandchest.com/v2/transcript/tr_9k2mQvR7pKd3sTn1yBhL \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```json title="200 OK" { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "status": "completed", "audio_url": "https://example.com/meeting.mp3", "text": "Okay so, um, the deploy went out at four and, uh, honestly, nothing broke.", "words": [ { "text": "Okay", "start": 0, "end": 280, "confidence": 0.99, "speaker": null, "channel": null }, { "text": "so,", "start": 300, "end": 520, "confidence": 0.98, "speaker": null, "channel": null } ], "utterances": null, "confidence": 0.98, "audio_duration": 4.8, "language_code": "en", "language_confidence": 0.99, "speech_model_used": "nvidia/parakeet-tdt-0.6b-v2", "speech_models": [], "punctuate": true, "format_text": true, "disfluencies": true, "custom_spelling": null, "language_detection": true, "speaker_labels": false, "error": null, "created": "2026-08-30T09:14:22.001Z" } ``` | Status | When | | --- | --- | | 400 | `{"error":"Transcript lookup error, transcript id not found"}` — matches AssemblyAI, which uses 400 rather than 404 here. A transcript belonging to another workspace is indistinguishable from one that never existed. | ## GET /v2/transcript List your workspace's transcripts, newest first. Every list request is floored at 90 days. Transcripts created more than 90 days ago are never listed, whatever parameters you send — the floor is not only a `created_on` rule. It applies to `before_id` and `after_id` paging too, so walking back through `prev_url` stops silently at the boundary: you get a page with `"result_count": 0` and no error. Older transcripts are still readable by id with `GET /v2/transcript/:id`, so keep the ids you care about. | Parameter | Type | Default | Rules | | --- | --- | --- | --- | | `limit` | integer | `10` | 1–200 | | `status` | string | — | `queued`, `processing`, `completed` or `error` | | `created_on` | string | — | `yyyy-MM-dd`, not in the future, at most 90 days ago | | `before_id` | string | — | Transcripts older than this id | | `after_id` | string | — | Transcripts newer than this id | | `throttled_only` | string | — | Deprecated. Accepted and ignored. | ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript?limit=2&status=completed" \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```json title="200 OK" { "page_details": { "limit": 2, "result_count": 2, "current_url": "https://stt-api.sandchest.com/v2/transcript?limit=2&status=completed", "prev_url": "https://stt-api.sandchest.com/v2/transcript?limit=2&status=completed&before_id=tr_3sTn1yBhLc8WgZaE0jUo", "next_url": "https://stt-api.sandchest.com/v2/transcript?limit=2&status=completed&after_id=tr_9k2mQvR7pKd3sTn1yBhL" }, "transcripts": [ { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "resource_url": "https://stt-api.sandchest.com/v2/transcript/tr_9k2mQvR7pKd3sTn1yBhL", "status": "completed", "created": "2026-08-30T09:14:22.001Z", "completed": "2026-08-30T09:14:29.412Z", "audio_url": "https://example.com/meeting.mp3", "error": null } ] } ``` Page with the absolute URLs the response gives you. `prev_url` walks into older transcripts, `next_url` back toward newer ones; both are `null` when the page is empty. | Status | When | | --- | --- | | 400 | `{"error":"'limit' must be an integer"}` | | 400 | `{"error":"'limit' must be greater than or equal to 1"}` | | 400 | `{"error":"'limit' must be less than or equal to 200"}` | | 400 | `` {"error":"'status' must be one of ['queued', 'processing', 'completed', 'error']."} `` | | 400 | `{"error":"'created_on' is not a valid date. Use 'yyyy-MM-dd'."}` | | 400 | `{"error":"'created_on' cannot be more than 90 days in the past"}` | | 400 | `{"error":"'created_on' cannot be a future date"}` | | 400 | `{"error":"'before_id' is not a valid transcript id"}` | ## DELETE /v2/transcript/:id Erase a transcript's content. Words, text, source URL, custom vocabulary, detected language and private error detail are all removed. Usage and billing history survive, and so does the id. ```bash curl -sS -X DELETE https://stt-api.sandchest.com/v2/transcript/tr_9k2mQvR7pKd3sTn1yBhL \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```json title="200 OK" { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "status": "completed", "audio_url": "http://deleted_by_user", "text": "Deleted by user.", "words": null, "confidence": null, "audio_duration": 4.8, "language_code": null, "language_confidence": null, "speech_model_used": null, "speech_models": null, "punctuate": false, "format_text": false, "disfluencies": false, "language_detection": false, "speaker_labels": false, "error": null, "created": "2026-08-30T09:14:22.001Z" } ``` The uploaded audio goes too: an upload used by no other live transcript is deleted from object storage immediately. An `Idempotency-Key` that pointed at a deleted transcript stays reserved and returns 409 forever — it never creates a fresh job. | Status | When | | --- | --- | | 400 | `{"error":"Transcript lookup error, transcript id not found"}` | | 503 | `{"error":"Transcript deletion is temporarily unavailable. Please retry."}` | ## GET /v2/transcript/:id/sentences Sentence-level spans, built from the same word boundaries as the transcript. Requires `status: "completed"`. ```bash curl -sS https://stt-api.sandchest.com/v2/transcript/$ID/sentences \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```json title="200 OK" { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "confidence": 0.98, "audio_duration": 4.8, "speech_model_used": "nvidia/parakeet-tdt-0.6b-v2", "sentences": [ { "text": "Okay so, um, the deploy went out at four and, uh, honestly, nothing broke.", "start": 0, "end": 4520, "confidence": 0.99, "words": [{ "text": "Okay", "start": 0, "end": 280, "confidence": 0.99 }] } ] } ``` Sentence boundaries come from ICU segmentation in the transcript's own language, so punctuation inside URLs and decimals does not split a sentence. With `punctuate: false` and no custom spelling there is nothing to segment on, so the whole transcript comes back as one sentence. ## GET /v2/transcript/:id/paragraphs The same shape with a `paragraphs` array instead. A paragraph ends after five sentences, after a pause longer than two seconds, or when the channel changes. ```json title="200 OK" { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "confidence": 0.98, "audio_duration": 4.8, "speech_model_used": "nvidia/parakeet-tdt-0.6b-v2", "paragraphs": [ { "text": "Okay so, um, the deploy went out at four and, uh, honestly, nothing broke.", "start": 0, "end": 4520, "confidence": 0.99, "words": [{ "text": "Okay", "start": 0, "end": 280, "confidence": 0.99 }] } ] } ``` ## GET /v2/transcript/:id/word-search Find words and short phrases with their timings. | Parameter | Type | Rules | | --- | --- | --- | | `words` | string | **Required.** Comma-separated. Each phrase is at most five words. | Matching ignores case and punctuation: `COUNTRY`, `country!` and `country` are the same term. ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID/word-search?words=deploy,nothing%20broke" \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```json title="200 OK" { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "total_count": 2, "matches": [ { "text": "deploy", "count": 1, "timestamps": [[1100, 1480]], "indexes": [4] }, { "text": "nothing broke", "count": 1, "timestamps": [[3700, 4520]], "indexes": [12] } ] } ``` `indexes` are positions in the transcript's `words` array. `timestamps` are `[start, end]` pairs in milliseconds. | Status | When | | --- | --- | | 400 | `` {"error":"`words` is a required query parameter"} `` | | 400 | `` {"error":"`words` entries must not be empty"} `` | | 400 | `{"error":"Phrases are currently restricted to five words at most."}` | | 400 | `{"error":"The transcript has been deleted."}` | ## GET /v2/transcript/:id/srt and /vtt Captions as `text/plain`, cut on real word boundaries. | Parameter | Type | Default | Rules | | --- | --- | --- | --- | | `chars_per_caption` | integer | `42` | Maximum characters per caption | ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript/$ID/srt?chars_per_caption=32" \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```text title="200 OK — srt" 1 00:00:00,000 --> 00:00:01,860 Okay so, um, the deploy went 2 00:00:01,880 --> 00:00:04,520 out at four and, uh, honestly, ``` ```text title="200 OK — vtt" WEBVTT 00:00.000 --> 00:01.860 Okay so, um, the deploy went ``` | Status | When | | --- | --- | | 400 | `{"error":"'chars_per_caption' must be an integer value."}` | | 400 | A single word is longer than `chars_per_caption`; the message names the value and asks you to raise it | | 400 | The transcript was deleted, so there is no text to caption | ## Native endpoints The native API is the same engine with plainer semantics. `@sandchest/sdk` uses it. ### POST /api/v1/transcripts Identical request body, identical response, identical errors to `POST /v2/transcript`, including `Idempotency-Key`. ```bash curl -sS https://stt-api.sandchest.com/api/v1/transcripts \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -d '{"audio_url":"https://example.com/meeting.mp3"}' ``` ### GET /api/v1/transcripts/:id The same transcript object as `GET /v2/transcript/:id`, with one difference: an unknown id is a proper **404** here. ```json title="404 Not Found" { "error": "Transcript not found." } ``` ## Resource preconditions `sentences`, `paragraphs`, `word-search`, `srt` and `vtt` all require a completed transcript. Asking earlier returns HTTP 400 naming the current status and the resource you asked for: ```json title="400 Bad Request" { "error": "This transcript has a status of 'processing'. Transcripts must have a status of 'completed' before requesting sentences." } ``` The resource name in that message is one of `sentences`, `paragraphs`, `word search` or `captions`. ## Status codes | Code | Meaning | | --- | --- | | 200 | Success | | 400 | Invalid request: unknown option, bad value, unsupported feature, or an unknown transcript id on the `/v2` endpoints | | 401 | Missing, malformed, revoked or expired API key | | 402 | Out of credits | | 403 | Declared by the API contract; not currently returned by any endpoint | | 404 | Unknown transcript id on the native `/api/v1` endpoints | | 409 | `Idempotency-Key` conflict | | 413 | Body too large: 100 MiB for uploads, 64 KB for transcript creation | | 429 | Rate limited — 120 requests per minute per workspace by default | | 503 | A dependency (inference, billing, storage, rate limiting) is temporarily unavailable | Every error body is `{ "error": "..." }`. See [Errors and limits](/docs/errors) for what to do about each one. ## OpenAPI ```bash curl -sS https://sandchest.com/openapi.json | jq '.paths | keys' ``` ```json title="expected output" [ "/api/v1/transcripts", "/api/v1/transcripts/{id}", "/v2/transcript", "/v2/transcript/{id}", "/v2/transcript/{id}/paragraphs", "/v2/transcript/{id}/sentences", "/v2/transcript/{id}/srt", "/v2/transcript/{id}/vtt", "/v2/transcript/{id}/word-search", "/v2/upload" ] ``` ## Verify Walk the whole surface with one file: ```bash title="walk-the-api.sh" API="https://stt-api.sandchest.com" AUTH="Authorization: $SANDCHEST_API_KEY" UPLOAD_URL=$(curl -sS "$API/v2/upload" -H "$AUTH" \ -H "Content-Type: application/octet-stream" --data-binary @meeting.mp3 | jq -r .upload_url) ID=$(curl -sS "$API/v2/transcript" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"audio_url\":\"$UPLOAD_URL\"}" | jq -r .id) while true; do STATUS=$(curl -sS "$API/v2/transcript/$ID" -H "$AUTH" | jq -r .status) [ "$STATUS" = completed ] || [ "$STATUS" = error ] && break sleep 2 done echo "status: $STATUS" curl -sS "$API/v2/transcript/$ID/sentences" -H "$AUTH" | jq '.sentences | length' curl -sS "$API/v2/transcript/$ID/srt" -H "$AUTH" | head -2 ``` You are looking for `status: completed`, a sentence count of at least `1`, and an SRT block that starts with `1` and a `00:00:00,000 --> ` timing line: ```text title="expected output" status: completed 1 1 ``` ## Next [TypeScript SDK](/docs/sdk) — the same API with types, cancellation and polling already handled. --- # TypeScript SDK Install @sandchest/sdk, transcribe a file in three lines, and use cancellation, idempotent retries and polling controls when you need them. `@sandchest/sdk` is the native client: fully typed, no runtime dependencies, and it propagates cancellation through uploads, requests and polling. It talks to the native endpoints (`/api/v1/transcripts`). This page is its complete API. ## Install ```bash bun add @sandchest/sdk ``` ```bash npm install @sandchest/sdk ``` Requires Node 20 or newer, Bun, or any runtime with `fetch`, `AbortSignal` and `crypto.subtle`. ## The three-line version ```ts title="transcribe.ts" import { readFile } from "node:fs/promises"; import { Sandchest } from "@sandchest/sdk"; const client = new Sandchest({ apiKey: process.env.SANDCHEST_API_KEY as string }); const transcript = await client.transcripts.transcribe({ audio: await readFile("meeting.mp3"), }); for (const word of transcript.words ?? []) { console.log(word.start, word.end, word.text); } ``` `transcribe()` uploads the bytes, creates the transcript, and polls until it is `completed` or `error`. It resolves with the transcript in either state — check `status` before trusting `words`. ## The client ```ts new Sandchest({ apiKey, baseUrl, fetch }); ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `apiKey` | string | — | **Required.** Sent as the `Authorization` header. A blank key throws immediately. | | `baseUrl` | string | `https://stt-api.sandchest.com` | Point this at your own deployment when self-hosting. A trailing slash is trimmed. | | `fetch` | function | `globalThis.fetch` | Swap in your own `fetch` for testing, tracing or proxying. | ## client.transcripts ### transcribe(options, waitOptions?) Submit and wait. Equivalent to `submit()` followed by `waitUntilReady()`. ```ts const transcript = await client.transcripts.transcribe( { audio: bytes, disfluencies: true }, { pollingInterval: 500, signal: controller.signal }, ); ``` ### submit(options, requestOptions?) Upload if needed and create the transcript. Returns as soon as the job is queued. ```ts const { id } = await client.transcripts.submit({ audio: bytes }); ``` ### get(id, requestOptions?) Fetch the current state. Returns immediately; it does not wait. ```ts const transcript = await client.transcripts.get(id); ``` ### waitUntilReady(id, waitOptions?) Poll an existing transcript until it settles. ```ts const transcript = await client.transcripts.waitUntilReady(id, { pollingTimeout: 120_000 }); ``` ## client.files ### upload(audio, requestOptions?) Store bytes and get back a URL string, when you want to manage the two steps yourself. ```ts const audioUrl = await client.files.upload(bytes); const transcript = await client.transcripts.transcribe({ audio: audioUrl }); ``` ## TranscriptionOptions | Name | Type | Description | | --- | --- | --- | | `audio` | `Blob \| Uint8Array \| ArrayBuffer \| string` | **Required.** Binary audio is uploaded for you; a string is used as `audio_url` directly. | | `idempotencyKey` | string | Sent as the `Idempotency-Key` header, and makes the upload idempotent too. | | `language_code` | string | Pin the language. See [Languages](/docs/languages). | | `language_detection` | boolean | Detect the language. Defaults to on when `language_code` is absent. | | `language_detection_options` | `{ expected_languages?, fallback_language? }` | Narrow detection or name a fallback. | | `speech_models` | string[] | Accepted for compatibility and echoed back. | | `punctuate` | boolean | Punctuation and casing. Default `true`. | | `format_text` | boolean | Readable numbers, dates and currency. Default `true`. | | `disfluencies` | boolean | Keep "um" and "uh". Default `false`. | | `custom_spelling` | `Array<{ from: string[] \| string; to: string }> \| null` | Rewrite recognised words. | | `multichannel` | `boolean \| null` | Transcribe each input channel separately. | | `audio_start_from` | `number \| null` | Start offset in **milliseconds**. | | `audio_end_at` | `number \| null` | End offset in **milliseconds**. | ## WaitOptions | Name | Type | Default | Description | | --- | --- | --- | --- | | `pollingInterval` | number | `75` | Milliseconds between polls. | | `pollingTimeout` | number | `1800000` | Give up after this many milliseconds — 30 minutes. | | `signal` | `AbortSignal` | — | Cancel the wait, and the in-flight request with it. | ## Cancellation Every method takes an `AbortSignal`, and it reaches all the way down: the upload, the create request, the poll loop and the request in flight when you abort. ```ts const controller = new AbortController(); setTimeout(() => controller.abort(), 30_000); try { const transcript = await client.transcripts.transcribe( { audio: bytes }, { signal: controller.signal }, ); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { console.log("Gave up waiting."); } } ``` Aborting stops your client from waiting. It does not cancel the transcript — the job keeps running and is still billed on completion. Keep the id if you may want the result later. ## Idempotent retries Pass `idempotencyKey` and a retry is free of duplicates. Sandchest returns the original transcript instead of creating a second one, and the SDK caches the upload so the same bytes are not sent twice. ```ts title="retry.ts" import { SandchestError } from "@sandchest/sdk"; async function transcribeWithRetry(bytes: Uint8Array, key: string) { for (let attempt = 0; attempt < 3; attempt++) { try { return await client.transcripts.transcribe({ audio: bytes, idempotencyKey: key }); } catch (error) { if (error instanceof SandchestError && error.status >= 500 && attempt < 2) continue; throw error; } } throw new Error("unreachable"); } ``` Use a key derived from *your* domain — a job id, a recording id, a content hash — not a random value, or a retry after a crash will not match. Keys are at most 200 characters, scoped to your workspace, and never expire. The upload cache holds at most 128 entries for five minutes each. After it expires, re-uploading the identical bytes still matches: Sandchest compares a persistent SHA-256 fingerprint of the audio, not the URL. Genuinely different bytes under the same key are a 409. ## Errors Every non-2xx response becomes a `SandchestError` carrying the server's message and status code. ```ts import { Sandchest, SandchestError } from "@sandchest/sdk"; try { await client.transcripts.transcribe({ audio: bytes }); } catch (error) { if (error instanceof SandchestError) { console.error(error.status, error.message); if (error.status === 402) await topUpCredits(); if (error.status === 429) await backOff(); } else { throw error; } } ``` | `error.status` | Meaning | | --- | --- | | 400 | Bad request — an unknown option, an invalid value, or an unsupported feature | | 401 | The API key is missing, malformed, revoked or expired | | 402 | Out of credits | | 409 | `idempotencyKey` conflict | | 413 | The audio or the request body is too large | | 429 | Rate limited | | 408 | `waitUntilReady` hit `pollingTimeout` — `"The transcription polling deadline expired."` | | 503 | A dependency is temporarily unavailable; safe to retry | The full table, with what to do about each, is in [Errors and limits](/docs/errors). > [!IMPORTANT] > A transcript that fails during inference is **not** an exception. It resolves with > `status: "error"` and a populated `error` field. Always check `status` before > reading `words`. ## Types ```ts import type { Transcript, TranscriptWord, TranscriptUtterance, TranscriptStatus, TranscriptionOptions, WaitOptions, AudioInput, } from "@sandchest/sdk"; ``` `TranscriptWord` is `{ text, start, end, confidence, speaker, channel }` with `start` and `end` in milliseconds. The full transcript shape is documented in [Transcript options](/docs/transcripts#the-transcript-object). ## Verify ```ts title="verify.ts" import { Sandchest } from "@sandchest/sdk"; const client = new Sandchest({ apiKey: process.env.SANDCHEST_API_KEY as string }); const transcript = await client.transcripts.transcribe({ audio: await Bun.file("meeting.mp3").bytes(), }); console.log(transcript.status, transcript.words?.length, transcript.speech_model_used); ``` ```bash bun run verify.ts ``` ```text title="expected output" completed 14 nvidia/parakeet-tdt-0.6b-v2 ``` ## Next [Errors and limits](/docs/errors) — every status code, every limit, and what to do when you hit one. --- # Errors and limits Every status code Sandchest returns and what to do about it, plus the size limits, the rate limit, and the deadlines you should design around. Every failure is the same shape — `{ "error": "..." }` with a status code that means something. This page maps each code to a cause and a fix, then lists the limits worth knowing before you hit them. ## The error shape ```json { "error": "Your credit balance is exhausted. Add credits to continue transcribing." } ``` That is the whole body. Messages are stable and safe to match on, and they never echo your request back — no URLs, no vocabulary, no credentials. ## Status codes | Code | Meaning | What to do | | --- | --- | --- | | `400` | Invalid request: an unknown option, an invalid value, an unsupported feature, or an unknown transcript id on a `/v2` endpoint | Read the message — it names the problem. Do not retry unchanged. | | `401` | The API key is missing, malformed, revoked or expired | Check the `Authorization` header. Rotate the key if it was revoked. | | `402` | The workspace is out of credits | Add credits, or enable auto top-up. See [Billing](/docs/billing). | | `403` | Declared by the API contract; no endpoint currently returns it | Nothing. Handle it as a permanent failure if you are being thorough. | | `404` | Unknown transcript id — native `/api/v1` endpoints only | Check the id. The `/v2` endpoints return 400 in the same situation. | | `409` | `Idempotency-Key` conflict | The key was used for a different request, or belongs to a deleted transcript. Use a new key. | | `413` | The body exceeds a size limit | 100 MiB for uploads, 64 KB for transcript creation. Split or shrink. | | `429` | Rate limited | Back off and retry. Windows are one minute long. | | `503` | Inference, billing, storage or rate limiting is temporarily unavailable | Retry with backoff. This is transient, and safe to retry with an `Idempotency-Key`. | ## Errors that are not HTTP errors A transcript can fail *after* it was accepted. The create call returns `200`, and the failure shows up on the object: ```json { "id": "tr_9k2mQvR7pKd3sTn1yBhL", "status": "error", "error": "Parakeet does not support the requested language: ja", "words": null, "text": null } ``` Always branch on `status` before reading `words`. Common causes: | `error` says | Cause | | --- | --- | | `Parakeet does not support the requested language: …` | The code is accepted by the API but outside the deployed model's coverage. See [Languages](/docs/languages#models). | | `language_detection cannot be performed on files with no spoken audio.` | Silent or music-only audio with detection on. Pin a `language_code` instead. | Failed transcripts are **not billed** — metering only counts completed audio. ## Size limits | Limit | Value | Response when exceeded | | --- | --- | --- | | Upload body | 100 MiB (`SANDCHEST_MAX_UPLOAD_BYTES`) | 413, `{"error":"The uploaded audio file exceeds the configured size limit."}` | | Transcript request body | 64 KB | 413, `{"error":"The transcription request exceeds the 64 KB size limit."}` | | `Idempotency-Key` | 200 characters | 400, `{"error":"Idempotency-Key must contain at most 200 characters."}` | | `webhook_url` | 8192 characters | 400, `{"error":"Invalid webhook URL or authentication parameters."}` | | Webhook auth header value | 1000 characters | 400, same message | | Word-search phrase | 5 words | 400, `{"error":"Phrases are currently restricted to five words at most."}` | | List `limit` | 1–200 | 400, `{"error":"'limit' must be less than or equal to 200"}` | | List `created_on` | Within the last 90 days | 400, `{"error":"'created_on' cannot be more than 90 days in the past"}` | | Multichannel channels | 32 | The transcript fails with a channel-count error | An empty upload body is rejected too: 400 with `{"error":"The uploaded audio file is empty."}`. Self-hosted deployments set their own upload limit — see [Self-hosting](/docs/self-hosting). ## Rate limits **120 requests per minute per workspace** by default, counted in fixed one-minute windows across every endpoint. Over the limit: ```json title="429 Too Many Requests" { "error": "Too many requests. Please retry shortly." } ``` `POST /v2/upload` also sets a `retry-after` header with the number of seconds until the current window resets. Other endpoints do not, but the window is always at most 60 seconds, so waiting a minute always clears it. ```ts async function withRateLimitRetry(call: () => Promise): Promise { for (let attempt = 0; ; attempt++) { try { return await call(); } catch (error) { if (!(error instanceof SandchestError) || error.status !== 429 || attempt >= 5) throw error; await new Promise((resolve) => setTimeout(resolve, Math.min(60_000, 2 ** attempt * 1000))); } } } ``` The limit counts requests, not audio. Polling a long transcript every 75 milliseconds will hit it long before your uploads do — poll every second or two, or use a [webhook](/docs/webhooks) and stop polling entirely. Self-hosted deployments change it with `SANDCHEST_RATE_LIMIT_PER_MINUTE`. ## Deadlines | Deadline | Value | | --- | --- | | SDK polling timeout | 30 minutes (`pollingTimeout`), then `SandchestError` with status `408` | | SDK polling interval | 75 ms by default; raise it for long files | | Webhook attempt timeout | 10 seconds per attempt | | Webhook attempts | Up to 10, roughly 10 seconds apart | | Uploaded audio retention | 24 hours (`SANDCHEST_AUDIO_RETENTION_HOURS`) | The SDK's polling timeout is a client-side deadline. When it expires the transcript keeps going — fetch it later by id. ## Retrying safely Retries are only safe when they cannot create a second transcript. Send an `Idempotency-Key` on every creation request you might retry: ```bash curl -sS https://stt-api.sandchest.com/v2/transcript \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: job-8842" \ -d '{"audio_url":"'"$UPLOAD_URL"'"}' ``` - Same key, same request → the original transcript, and no second charge. - Same key, a different request → 409. - No key → a second transcript, and a second charge. Derive the key from something stable in your own system — a job id, a recording id, a content hash. Not a random value, or a retry after a crash will not match. | Code | Retry? | | --- | --- | | `400`, `402`, `403`, `404`, `409`, `413` | No. Fix the request first. | | `401` | No, unless you have just rotated the key. | | `429` | Yes, after the window resets. | | `503` | Yes, with exponential backoff. | | Network error or timeout | Yes — with an `Idempotency-Key`. | ## Verify Provoke each class of failure and check you get the code you expect: ```bash API="https://stt-api.sandchest.com" # 401 — no key curl -sS -o /dev/null -w '%{http_code}\n' "$API/v2/transcript?limit=1" # 400 — an option that does not exist curl -sS -o /dev/null -w '%{http_code}\n' "$API/v2/transcript" \ -H "Authorization: $SANDCHEST_API_KEY" -H "Content-Type: application/json" \ -d '{"audio_url":"https://example.com/a.mp3","not_a_real_option":true}' # 400 — an unknown transcript id on the compatibility endpoint curl -sS -o /dev/null -w '%{http_code}\n' "$API/v2/transcript/tr_does_not_exist" \ -H "Authorization: $SANDCHEST_API_KEY" # 404 — the same id on the native endpoint curl -sS -o /dev/null -w '%{http_code}\n' "$API/api/v1/transcripts/tr_does_not_exist" \ -H "Authorization: $SANDCHEST_API_KEY" ``` ```text title="expected output" 401 400 400 404 ``` ## Next [Billing](/docs/billing) — what a second of audio costs and how credits work. --- # Billing Prepaid credits in US dollars, metered by the second of completed audio, with no charge for failures — plus top-ups, automatic top-ups, and where to watch usage. Sandchest is prepaid. You buy credits in US dollars, and transcription draws them down by the second of audio it actually completed. No seats, no subscription, no monthly minimum. This page covers how metering works, how to add credits, and where to see what you have spent. ## Prepaid credits Your workspace holds a US-dollar credit balance. Every completed transcript subtracts the cost of its measured audio duration. When the balance runs out, new transcription requests are refused: ```json title="402 Payment Required" { "error": "Your credit balance is exhausted. Add credits to continue transcribing." } ``` Existing transcripts keep processing — only new requests are held back. Add credits and everything resumes at once. The dashboard shows your current price per audio hour, formatted like `$0.21 / audio hour`, alongside your balance. ## What gets metered | | | | --- | --- | | Unit | One second of audio | | Measured from | The decoded media duration, not the file size or the wall-clock time | | Charged when | The transcript reaches `status: "completed"` | | Not charged | Anything that ends in `status: "error"` | | Not charged | Uploads, polling, listing, deletion, captions, sentences, paragraphs, word search | Only the audio that finished counts. A transcript that fails during inference — an unsupported language, undecodable media, a worker fault — costs nothing. Storage, API calls and every derived resource are free; you pay for transcription. `audio_start_from` and `audio_end_at` trim what is transcribed, and therefore what is billed. > [!NOTE] > Metering is exactly-once. Each completed transcript produces one usage event, > keyed by its transcript id, and a retry or a redelivery can never double-charge. ## Adding credits Go to [sandchest.com/dashboard/billing](https://sandchest.com/dashboard/billing) and choose an amount. | | | | --- | --- | | Minimum | $5 | | Maximum | $1,000 | | Increments | Whole dollars only | Anything outside that returns `Choose a whole-dollar credit amount between $5 and $1,000.` Credits do not expire and are not refundable to a card; they stay on the workspace. Only a workspace **owner** can buy credits or change billing settings. Admins can manage API keys and members; members can transcribe. See [Authentication](/docs/authentication#workspaces-and-roles). ## Automatic top-ups Turn on auto top-up and Sandchest buys more credits before you run dry. | Setting | Rules | | --- | --- | | Threshold | A whole number of dollars, at least `$1` and **below** the top-up amount | | Amount | The same $5–$1,000 whole-dollar range as a manual top-up | | Monthly limit | Between 1 and 100 top-ups per month — a hard ceiling on spend | Two prerequisites, each with its own message if you skip it: - A saved payment method — `Add a payment method before enabling automatic top-ups.` - At least one completed manual purchase — `Purchase credits once before enabling automatic top-ups.` Turning auto top-up **off** never depends on any of that. Disabling it keeps your stored settings and simply stops future charges, so you can always stop spending. ## Watching usage [sandchest.com/dashboard/usage](https://sandchest.com/dashboard/usage) shows audio hours transcribed and what they cost. The billing page shows your remaining balance, plus anything already used but not yet reconciled with the payment provider — so the number you see is what you can actually still spend, not an optimistic one. Per transcript, `audio_duration` is the billable length in **seconds**: ```bash curl -sS "https://stt-api.sandchest.com/v2/transcript?limit=100&status=completed" \ -H "Authorization: $SANDCHEST_API_KEY" | jq '.transcripts | length' ``` ## Self-hosting There is no billing when you run Sandchest yourself. Set `SANDCHEST_SELF_HOSTED=true` and the entitlement check is bypassed entirely — no credits, no metering, no payment provider. Your costs are your own GPU, storage and bandwidth. See [Self-hosting](/docs/self-hosting). ## Verify Confirm your workspace can transcribe by creating a transcript. A `402` means you are out of credits; anything else means billing is not what is blocking you. ```bash curl -sS -o /dev/null -w '%{http_code}\n' https://stt-api.sandchest.com/v2/transcript \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/json" \ -d '{"audio_url":"'"$UPLOAD_URL"'"}' ``` ```text title="expected output" 200 ``` ## Next [Self-hosting](/docs/self-hosting) — run the whole thing on your own hardware. --- # Self-hosting Run the whole Sandchest stack yourself — natively on Apple Silicon, or with Docker Compose and an NVIDIA GPU — then point any SDK at your own host. Sandchest is open source end to end: the API, the queue, the inference worker and the dashboard. This page gets a working install running, lists the environment it needs, and shows how to point a client at it. The full operator guide, including AWS infrastructure and cost modelling, is in [SELFHOST.md](https://github.com/CapSoftware/Sandchest/blob/main/SELFHOST.md). ## What runs Three processes and two stores. | Process | What it does | | --- | --- | | Web (`next start`) | The API, the dashboard, and authentication | | Queue consumer (`bun run worker`) | Claims durable jobs, retries, retention, webhook delivery | | Inference worker (`bun run worker:inference`) | The warm Parakeet models, on Metal or CUDA | | Postgres | Tenancy, hashed API keys, jobs, transcripts, usage | | Object storage | Retained audio — the local filesystem, or anything S3-compatible | All three processes must be running. Without the queue consumer the API accepts jobs and never finishes them. ## Apple Silicon, natively Install Bun 1.4, Rust (edition 2024), the Xcode command-line tools, and FFmpeg. ```bash git clone https://github.com/CapSoftware/Sandchest cd Sandchest bun install bun run db:migrate bun run worker:inference ``` In a second terminal: ```bash bun run dev ``` Open `http://localhost:3000`. With no `DATABASE_URL`, development uses a durable embedded Postgres under `.sandchest/postgres` and stores objects in `.sandchest/storage`; both are git-ignored. Job processing runs inside the web process, so **do not** start a separate queue consumer in this mode — two processes cannot open the embedded database. The first inference start-up builds the Rust worker and downloads the pinned model weights into the Hugging Face cache. No Python runtime is involved. If Resend is not configured, six-digit sign-in codes are printed to the server console — in local development only. > [!IMPORTANT] > When `DATABASE_URL` points at external Postgres, inline processing turns itself > off. Start `bun run worker` in a third terminal with the same database, storage, > inference URL and token settings as the web process. ## Docker Compose with an NVIDIA GPU Install Docker, Docker Compose, current NVIDIA drivers and the NVIDIA Container Toolkit, then verify GPU passthrough works. Put five independent secrets in a git-ignored `.env`: ```bash title=".env" BETTER_AUTH_SECRET=at-least-32-random-characters SANDCHEST_API_KEY_PEPPER=a-different-32-or-more-random-characters SANDCHEST_INFERENCE_TOKEN=a-third-independent-random-secret POSTGRES_PASSWORD=a-strong-private-database-password MINIO_ROOT_PASSWORD=a-strong-private-object-store-password RESEND_API_KEY=your-resend-key EMAIL_FROM=Sandchest NEXT_PUBLIC_APP_URL=https://speech.your-domain.example SANDCHEST_SELF_HOSTED=true ``` ```bash docker compose config --quiet docker compose up --build ``` Compose brings up Postgres 17, private MinIO storage with bucket initialisation, the CUDA Parakeet worker, the Next.js control plane and an independent queue consumer. It refuses to start when any required signing, database, object-storage or inference secret is missing. The application binds to localhost by default. Set `SANDCHEST_BIND_ADDRESS` only when an intentional public bind sits behind a properly configured HTTPS reverse proxy. > [!WARNING] > Docker on Apple Silicon cannot pass through an NVIDIA GPU. Use the native MLX path > on macOS. The CUDA image targets A10/SM86 and still needs validation on your own > NVIDIA hardware before a production rollout. ## Environment The complete contract is [`.env.example`](https://github.com/CapSoftware/Sandchest/blob/main/.env.example). The variables that matter most: | Variable | Default | Purpose | | --- | --- | --- | | `NEXT_PUBLIC_APP_URL` | `http://localhost:3000` | Public origin. Upload and pagination URLs are built from it. | | `BETTER_AUTH_SECRET` | — | Session signing. At least 32 characters, required in production. | | `SANDCHEST_API_KEY_PEPPER` | — | HMAC key for hashing API keys. At least 32 characters, independent of the above. | | `DATABASE_URL` | embedded | Postgres connection string. Omit for embedded development Postgres. | | `SANDCHEST_INFERENCE_URL` | `http://127.0.0.1:8765` | Where the warm model worker listens. | | `SANDCHEST_INFERENCE_TOKEN` | — | Authenticates web and queue requests to the worker. | | `SANDCHEST_INFERENCE_ENGINE` | `parakeet` | `parakeet`, or `whisper` when the worker was built with the optional Whisper feature. Whisper covers every accepted language code. | | `SANDCHEST_MODEL` | `nvidia/parakeet-tdt-0.6b-v2` | English model. | | `SANDCHEST_MULTILINGUAL_MODEL` | `nvidia/parakeet-tdt-0.6b-v3` | Model for detection and non-English audio. | | `SANDCHEST_DEVICE` | `auto` | `auto`, `metal`, `cuda` or explicit `cpu`. `auto` requires the platform GPU. | | `SANDCHEST_ENGLISH_REFINEMENT` | `true` | Refine uncertain detected-English transcripts on the English model. | | `SANDCHEST_STORAGE_DRIVER` | `local` | `local` or `s3`. | | `SANDCHEST_STORAGE_PATH` | `.sandchest/storage` | Where local objects live. | | `SANDCHEST_S3_BUCKET` | — | Private bucket, when the driver is `s3`. | | `SANDCHEST_S3_ENDPOINT` | — | For MinIO, R2 and other S3-compatible stores. | | `SANDCHEST_AUDIO_RETENTION_HOURS` | `24` | How long uploaded audio is kept. | | `SANDCHEST_MAX_UPLOAD_BYTES` | `104857600` | Upload limit in bytes — 100 MiB. | | `SANDCHEST_RATE_LIMIT_PER_MINUTE` | `120` | Requests per minute per workspace. | | `SANDCHEST_QUEUE_CONCURRENCY` | `4` | Parallel durable-job lanes. | | `SANDCHEST_MAX_JOB_ATTEMPTS` | `3` | Retries before a job is failed. | | `SANDCHEST_SELF_HOSTED` | `false` | `true` bypasses billing entitlement checks entirely. | | `RESEND_API_KEY` | — | Email delivery for sign-in codes. Required for real logins. | | `SANDCHEST_SQS_QUEUE_URL` | — | Optional AWS wake-up queue. Postgres stays authoritative. | Production fails closed: missing database, cryptographic or billing configuration stops the process rather than starting in a weakened state. ## Health | Endpoint | Checks | | --- | --- | | `/api/health/live` | The web process and the database. Independent of the models, so the dashboard and login stay reachable while models warm up. | | `/api/health` | Everything, including genuine inference-model readiness and the loaded model identifiers. | | `:8765/health` | The inference worker itself. | | `:8765/ready` | HTTP 503 until the model worker is usable. | ```bash curl -fsS http://localhost:3000/api/health/live curl -fsS http://localhost:3000/api/health ``` ```json title="expected output" {"status":"ok","database":"ok"} ``` Use `/api/health/live` as your load-balancer probe and `/api/health` for readiness gating, so a warming model does not take the dashboard offline. ## Pointing a client at your host Every client takes a base URL. Nothing else changes. ```ts title="@sandchest/sdk" const client = new Sandchest({ apiKey: process.env.SANDCHEST_API_KEY as string, baseUrl: "https://speech.your-domain.example", }); ``` ```ts title="assemblyai" const client = new AssemblyAI({ apiKey: process.env.SANDCHEST_API_KEY as string, baseUrl: "https://speech.your-domain.example", }); ``` ```python title="assemblyai (python)" aai.settings.base_url = "https://speech.your-domain.example" aai.settings.api_key = os.environ["SANDCHEST_API_KEY"] ``` ```bash title="curl" curl -sS "https://speech.your-domain.example/v2/transcript?limit=1" \ -H "Authorization: $SANDCHEST_API_KEY" ``` Keys are created in your own dashboard at `/dashboard/api-keys`, exactly as on the hosted service. ## Verify Bring the stack up, then run the whole path end to end: ```bash curl -fsS http://localhost:3000/api/health ``` ```json title="expected output" { "status": "ok", "database": "ok", "inference": "ready", "model": "nvidia/parakeet-tdt-0.6b-v2", "accepting_new_jobs": true, "response_ms": 12 } ``` `/api/health` returns HTTP 503 with `"status": "degraded"` until the database is reachable and the model worker is genuinely ready. Then sign in at `http://localhost:3000`, create an API key, and transcribe something: ```bash curl -sS http://localhost:3000/v2/upload \ -H "Authorization: $SANDCHEST_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @meeting.mp3 ``` ```json title="expected output" { "upload_url": "http://localhost:3000/api/v1/uploads/aud_7pKd3sTn1yBhLc8WgZaE" } ``` The repository also ships a full local verifier, which signs in with a genuine email code, creates a workspace and key, uploads audio, transcribes through the unchanged AssemblyAI SDK, and checks metering: ```bash bun run verify:local ``` ## Next Back to the [docs index](/docs), or read the source at [CapSoftware/Sandchest](https://github.com/CapSoftware/Sandchest).