# 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.
