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