# REST API-referentie

> Zoek parameters en voorbeelden voor elk /v1-endpoint op. De OpenAPI-specificatie is beschikbaar als JSON en YAML.

Canonical: https://transcript1.com/nl/docs/api-reference
Language: nl
Last updated: 2026-10-19

## Inleiding

Gebruik `https://api.transcript1.com/v1` als basis-URL voor je requests.

- Je verstuurt en ontvangt JSON, met veldnamen in `snake_case`.
- Datums en tijdstempels gebruiken ISO 8601 in UTC, zoals `2026-10-13T09:30:00.000Z`.
- De duur van een opname en de tijden in een transcript zijn in seconden.
- Foutresponses hebben een vaste opbouw. Die vind je onder [Fouten](https://transcript1.com/nl/docs/api-reference#errors).
- Elke response op een geauthenticeerd request bevat `X-RateLimit-Limit` en `X-RateLimit-Remaining`. Lees meer over [minuten, abonnementen en limieten](https://transcript1.com/nl/docs/billing-and-limits).
- Met `source=auto` proberen we op YouTube, X, TikTok, Bluesky, Dailymotion en LinkedIn eerst bruikbare ondertitels in de oorspronkelijke taal. Zijn die er niet, dan gebruiken we speech-to-text. Niet elke video heeft ondertitels. Met `captions` (alleen ondertitels) halen we nooit audio op en schakelen we niet over op STT. Zonder bruikbaar spoor krijg je `no_captions`. Met `source=stt` of sprekerherkenning kies je meteen voor audio. In [Bronnen en instellingen](https://transcript1.com/nl/docs/sources-and-options) lees je welke beperkingen gelden en wanneer je tijdstempels per segment of per woord krijgt.

Wil je de API-specificatie in je eigen tooling gebruiken? Download OpenAPI als:

- [JSON](https://api.transcript1.com/v1/openapi.json)
- [YAML](https://api.transcript1.com/v1/openapi.yaml)

## Authenticatie

Geef je API-sleutel of token mee in de `Authorization`-header, als `Authorization: Bearer <token>`.

- **API-sleutel:** begint met `t1_` en geeft toegang tot alle endpoints. Maak een sleutel aan op [“API keys”](https://transcript1.com/dashboard/account/tokens).
- **OAuth-toegangstoken:** begint met `t1o_`. Clients zoals Claude en ChatGPT krijgen dit wanneer je ze via MCP koppelt. Het token gebruikt de scopes `transcriptions:read` en `transcriptions:write`.

| Scope | Toegang tot |
|---|---|
| `transcriptions:read` | `GET /v1/transcriptions`, `GET /v1/transcriptions/:id`, `GET /v1/transcriptions/:id/upload` en `GET /v1/account` |
| `transcriptions:write` | `POST /v1/transcriptions`, `DELETE /v1/transcriptions/:id` en `POST /v1/transcriptions/:id/upload/complete` |

- Mist het token de scope die je nodig hebt, dan krijg je `403 insufficient_scope`.
- Ontbreekt de sleutel of het token, of is die ongeldig, dan krijg je `401 unauthorized`.

Voor het versturen van chunks gebruik je een ander token. Geef bij `PUT /v1/transcriptions/:id/upload/chunks/:index` de header `X-Upload-Token` mee. Je krijgt dat token bij het aanmaken van de upload. De stappen staan bij [Bestanden uploaden](https://transcript1.com/nl/docs/uploads).

```bash
curl https://api.transcript1.com/v1/account \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1({ apiKey: process.env.TRANSCRIPT1_API_KEY });
const account = await t1.account();
```

```python
import os
import requests

response = requests.get(
    "https://api.transcript1.com/v1/account",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
print(response.json())
```

## Idempotentie

Geef bij `POST /v1/transcriptions` een `Idempotency-Key`-header mee. Dan kun je het request veilig herhalen.

Kies een waarde van 1 tot 255 tekens en stuur die als header, niet als queryparameter.

- Herhaal je het request met dezelfde sleutel en parameters, dan krijg je de oorspronkelijke transcriptie terug en betaal je niet opnieuw.
- Gebruik je dezelfde sleutel met andere parameters, dan krijg je `409 idempotency_conflict`.
- Maak je een upload in chunks aan, dan levert hetzelfde request met dezelfde sleutel een nieuw uploadtoken op.

```bash
curl -X POST https://api.transcript1.com/v1/transcriptions \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
  -H "Idempotency-Key: 5f1c2b0e-8d4a-4c3e-9b7a-2e6f1d0c9a8b" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
const transcription = await t1.transcriptions.create('https://www.youtube.com/watch?v=dQw4w9WgXcQ', {
  idempotencyKey: '5f1c2b0e-8d4a-4c3e-9b7a-2e6f1d0c9a8b',
});
```

```python
import os
import requests

response = requests.post(
    "https://api.transcript1.com/v1/transcriptions",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}", "Idempotency-Key": "5f1c2b0e-8d4a-4c3e-9b7a-2e6f1d0c9a8b"},
    json={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
)
print(response.json())
```

## Foutmeldingen

- Een fout bij een request betekent dat de API je aanroep meteen weigert.
- Een fout tijdens de transcriptie ontstaat pas nadat de API die heeft geaccepteerd. Dan staat `status` op `failed`.

### Zo ziet een foutresponse eruit

Bij een geweigerd `/v1`-request krijg je een `error`-object met `code`, `message` en `retryable`.

- Laat je code reageren op `code`.
- Gebruik `message` voor de uitleg aan de gebruiker. De tekst kan veranderen.
- Staat `retryable` op `true`, dan kan hetzelfde request later wel slagen.

Afhankelijk van de fout krijg je naast `retryable` extra velden:

| Veld | Foutcode | Wat staat erin? |
|---|---|---|
| `action_url` | `insufficient_minutes` | De link om een abonnement te nemen of minuten bij te kopen. |
| `minutes_needed`, `minutes_remaining` | `insufficient_minutes` | Het aantal benodigde minuten en je huidige saldo. Het benodigde aantal is `null` zolang de duur nog onbekend is. |
| `supported_platforms` | `unsupported_platform` | De toegestane platforms: `["youtube", "x", "tiktok", "instagram", "facebook", "bluesky", "twitch", "dailymotion", "pinterest", "linkedin", "snapchat"]`. |
| `missing_chunks` | `upload_incomplete` | De indexen van de chunks die de server nog mist. |
| `max_bytes` | `media_too_large` | Jouw maximale bestandsgrootte in bytes. |
| `params` | `invalid_request` | Bij ongeldige body- of queryvelden een object dat per veld uitlegt wat je moet aanpassen. |

### Wanneer de API je request weigert

| HTTP-status | `code` | Opnieuw proberen? | Oorzaak |
|---|---|---|---|
| 400, 422 | `invalid_request` | Nee | Een parameter of bodyveld ontbreekt of is ongeldig. Dit geldt ook voor ongeldige JSON en combinaties zoals `diarize` met `source: "captions"` of `reuse_recent` met `webhook_url`. |
| 422 | `invalid_url` | Nee | De link is niet geldig of komt niet overeen met `platform`. Gebruik YouTube, X, TikTok, Instagram, Facebook, Bluesky of een ander platform uit [Bronnen en instellingen](https://transcript1.com/nl/docs/sources-and-options). |
| 422 | `unsupported_platform` | Nee | De opgegeven waarde voor `platform` is niet toegestaan. |
| 422 | `unsupported_media` | Nee | De bestandsextensie wordt niet ondersteund. |
| 401 | `unauthorized` | Nee | Je API-sleutel of token ontbreekt, is ongeldig, verlopen of ingetrokken. Ook een ongeldig of verlopen uploadtoken geeft deze fout. |
| 403 | `insufficient_scope` | Nee | Het OAuth-token heeft geen `transcriptions:read` of `transcriptions:write` waar die scope nodig is. |
| 403 | `email_not_verified` | Nee | Je moet eerst je e-mailadres bevestigen om transcripties te kunnen starten. |
| 402 | `insufficient_minutes` | Nee | Je beschikbare saldo is 0 of je hebt te weinig minuten voor deze transcriptie. |
| 402 | `no_active_plan` | Nee | Je wilt minuten bijkopen, maar hebt geen actief betaald abonnement. Alleen het billing-endpoint van het dashboard geeft deze fout, niet de transcriptie-endpoints. |
| 404 | `not_found` | Nee | De transcriptie bestaat niet of is van een ander team. |
| 409 | `upload_incomplete` | Nee | De server mist nog chunks. Verstuur die en rond de upload daarna opnieuw af. |
| 409 | `invalid_state` | Nee | De transcriptie wacht niet meer op een upload. Je kunt geen chunks meer sturen en de upload niet meer afronden. |
| 409 | `idempotency_conflict` | Nee | Je hebt deze `Idempotency-Key` eerder met andere parameters gebruikt. |
| 413 | `media_too_large` | Nee | Het bestand overschrijdt je uploadlimiet. Bekijk [Bestanden uploaden](https://transcript1.com/nl/docs/uploads). |
| 413 | `invalid_request` | Nee | De JSON-body is groter dan 1 MB. Bij te grote bestanden krijg je `media_too_large`. |
| 429 | `rate_limited` | Ja | Je stuurt te veel requests per minuut. Wacht het aantal seconden uit `Retry-After`. |
| 429 | `too_many_active_jobs` | Ja | Er lopen te veel transcripties of er staan te veel uploads open. Wacht het aantal seconden uit `Retry-After`. |
| 500 | `internal_error` | Ja | Er gaat iets mis bij transcript1. Probeer het na een korte pauze opnieuw. |
| 503 | `service_unavailable` | Ja | transcript1 is even niet beschikbaar. Probeer het na een korte pauze opnieuw. |

Bij browserrequests met een inlogcookie controleren we ook vanaf welke site het request komt. Een andere site krijgt `403 forbidden_origin`.

Voor API-requests met een `Authorization`-header geldt die controle niet.

In elke `429`-response staat `Retry-After`: het aantal seconden dat je moet wachten. De limieten zelf vind je bij [Minuten, abonnementen en limieten](https://transcript1.com/nl/docs/billing-and-limits).

### Wanneer stuur je een request opnieuw?

- Herhaal alleen requests met `retryable` op `true`: `rate_limited`, `too_many_active_jobs`, `internal_error` en `service_unavailable`.
- Houd de wachttijd uit `Retry-After` aan. Ontbreekt die header, wacht dan bij elke poging langer.
- Gebruik bij het herhalen van `POST /v1/transcriptions` dezelfde `Idempotency-Key`-header. Zo betaal je niet dubbel als het eerste request toch is gelukt.
- Pas bij alle andere codes eerst je request aan voordat je het nog eens verstuurt.

### Als een transcriptie later mislukt

Loopt een transcriptie vast, dan staat `status` op `failed`. In het `error`-object staan `code` en `message`.

Ook na een geslaagd request kan de transcriptie nog mislukken. Controleer `error` dus tijdens het pollen of bij een [webhook](https://transcript1.com/nl/docs/api-reference#webhooks) van het type `transcription.failed`.

| `error.code` | Wat is er mis? | Wat kun je doen? |
|---|---|---|
| `media_unavailable` | De video bestaat niet of is niet meer beschikbaar. | Niet opnieuw proberen. Controleer de URL. |
| `media_private` | De video is afgeschermd. | Gebruik een openbare post of een openbare of verborgen YouTube-video. Hetzelfde request herhalen helpt niet. |
| `login_required` | Je moet op het platform ingelogd zijn om de video te bekijken. | Niet opnieuw proberen. Deze fout is definitief; we proberen het niet automatisch opnieuw. |
| `no_audio` | Er zit geen audiospoor in de video. | Niet opnieuw proberen. Deze fout is definitief; we proberen het niet automatisch opnieuw. |
| `age_restricted` | De video heeft een leeftijdsbeperking. | Hetzelfde request herhalen helpt niet. Upload het bestand. |
| `live_stream` | De uitzending loopt nog of is nog niet begonnen. | Probeer het na afloop van de uitzending. |
| `no_captions` | Je zette `source` op `captions`, maar er is geen bruikbaar ondertitelspoor. | Kies `source: "auto"` of `"stt"`. |
| `media_too_long` | De opname duurt langer dan de 5 uur die speech-to-text aankan. | Splits het bestand. Hetzelfde request herhalen helpt niet. |
| `unsupported_media` | Het bestand is niet leesbaar of heeft geen audiospoor. | Stuur een ondersteund bestand; herhaal niet hetzelfde request. |
| `no_speech` | Geen spraak gedetecteerd. | Niet opnieuw proberen. |
| `unsupported_language` | We ondersteunen de taal van deze opname niet. | Kies voor `language` een ondersteunde code of `"auto"`; probeer niet opnieuw met dezelfde instelling. |
| `insufficient_minutes` | Je saldo was op voordat we de minuten konden afschrijven. | Koop eerst minuten bij en probeer het dan opnieuw. |
| `fetch_failed` | Ook na meerdere pogingen konden we de opname niet ophalen. | Probeer het later opnieuw. |
| `transcription_failed` | Ook na meerdere pogingen lukte speech-to-text niet. | Probeer het later opnieuw. |

Mislukt een transcriptie of annuleer je die, dan betaal je niets. Hadden we al minuten afgeschreven, dan krijg je die terug.

Example: 402 Payment Required

```json
{
  "error": {
    "code": "insufficient_minutes",
    "message": "Not enough minutes",
    "retryable": false,
    "action_url": "https://transcript1.com/dashboard/billing",
    "minutes_needed": 20,
    "minutes_remaining": 3
  }
}
```

## The Transcription object

### The Transcription object

#### Attributes

- `id` (string, required)
- `object` (string, required, one of `transcription`)
- `status` (string, required, one of `awaiting_upload`, `queued`, `fetching`, `transcribing`, `completed`, `failed`, `canceled`)
- `source` (object, required)
  - With `url`
    - `platform` (string, required)
    - `url` (string, required, nullable)
    - `media_id` (string, required, nullable)
  - With `filename`
    - `platform` (string, required, one of `upload`)
    - `filename` (string, required, nullable)
    - `size` (number, required, nullable)
- `media` (object, required, nullable)
  - `title` (string, optional, nullable)
  - `author` (string, optional, nullable)
  - `author_url` (string, optional, nullable)
  - `thumbnail_url` (string, optional, nullable)
  - `filename` (string, optional)
  - `mime_type` (string, optional)
  - `size` (number, optional)
  - `duration_seconds` (number, optional, nullable)
- `options` (object, required)
  - `source` (string, required, one of `auto`, `captions`, `stt`)
  - `language` (string, required)
  - `diarize` (boolean, required)
- `transcript_source` (string, required, nullable, one of `youtube_captions`, `x_captions`, `speech_to_text`)
- `caption_kind` (string, required, nullable, one of `manual`, `auto_generated`)
- `language` (string, required, nullable)
- `billed_minutes` (integer, required, nullable)
- `cached` (boolean, required)
- `error` (object, required, nullable)
  - `code` (string, required)
  - `message` (string, required, nullable)
- `created_at` (string, date-time, required, nullable)
- `completed_at` (string, date-time, required, nullable)
- `deleted_at` (string, date-time, required, nullable): When the transcription was deleted. Always `null` unless the request includes `include_deleted=true`.
- `transcript` (object, optional): Included in `GET` responses for completed transcriptions.
  - `text` (string, required)
  - `speakers` (array of strings, required)
  - `segments` (array of objects, required)
    - `start` (number, required)
    - `end` (number, required)
    - `speaker` (string, required, nullable)
    - `language` (string, required, nullable)
    - `text` (string, required)
    - `words` (array of objects, optional, nullable)
      - `start` (number, required)
      - `end` (number, required)
      - `text` (string, required)
      - `confidence` (number, optional, nullable)
      - `speaker` (string, required, nullable)

#### Example

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "completed",
  "source": {
    "platform": "youtube",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "media_id": "dQw4w9WgXcQ"
  },
  "media": {
    "title": "Example video",
    "author": "Example channel",
    "author_url": "https://www.youtube.com/@examplechannel",
    "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
    "duration_seconds": 212
  },
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": "youtube_captions",
  "caption_kind": "auto_generated",
  "language": "en",
  "billed_minutes": 4,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": "2026-10-13T09:30:04.000Z",
  "deleted_at": null,
  "transcript": {
    "text": "Welcome back to the channel. Today we are looking at how transcripts are made.",
    "speakers": [],
    "segments": [
      {
        "start": 0,
        "end": 4.2,
        "speaker": null,
        "language": "en",
        "text": "Welcome back to the channel."
      },
      {
        "start": 4.2,
        "end": 9.8,
        "speaker": null,
        "language": "en",
        "text": "Today we are looking at how transcripts are made."
      }
    ]
  }
}
```

## Transcriptions

Create, read, list and delete transcriptions of video links and uploaded files.

### Create a transcription

`POST /v1/transcriptions`

Transcribes an uploaded file or a YouTube, X, TikTok, Instagram, Facebook, Bluesky, Twitch, Dailymotion, Pinterest, LinkedIn, Snapchat link. Social posts must be public; only the first video is used.

Twitch accepts clips and completed VODs; Pinterest accepts native video pins; LinkedIn accepts public video posts; Snapchat accepts Spotlight. New platforms require public recorded native media without supplied account cookies; no live streams or external embeds.

`platform` is optional and detected from the link. Captions are YouTube/X-only. All sources use the same per-minute price.

Returns `200` for a final result, `201` when awaiting an upload and `202` while processing. Pass `wait` to wait up to 60 seconds for a final status.

Pass `reuse_recent=true` with a platform URL to reuse a transcription of the same video and options from the last 24 hours. The response includes `reused: true`, with no additional charge. Short links may not be matched. An `Idempotency-Key` replay takes precedence.

#### Query

- `wait` (integer, optional, nullable, default 0): Seconds to wait for a final status before responding, up to 60. Defaults to 0.
- `reuse_recent` (string, optional, default false, one of `true`, `false`): For platform URLs only. Cannot be combined with `webhook_url`. Returns the team's most recent transcription of the same video with the same `source`, `language` and `diarize`. It must have been created in the last 24 hours and must not be `failed` or `canceled`. Reuses that transcription without creating or charging for a new one.

#### Headers

- `Idempotency-Key` (string, optional): Returns the original transcription when you repeat a request with the same key and parameters.

#### Body

- With `url`
  - `platform` (string, optional, one of `youtube`, `x`, `tiktok`, `instagram`, `facebook`, `bluesky`, `twitch`, `dailymotion`, `pinterest`, `linkedin`, `snapchat`): Optional; detected from the link. If supplied, must match the link.
  - `url` (string, required): YouTube, X, TikTok, Instagram, Facebook, Bluesky, Twitch, Dailymotion, Pinterest, LinkedIn, Snapchat video link. Social posts must be public; only the first video is used. New platforms support public recorded native video without supplied account cookies, not live streams or external embeds.
  - `source` (string, optional, one of `auto`, `captions`, `stt`): "auto" uses YouTube or X captions when available, otherwise speech-to-text. "captions" is YouTube/X-only. "stt" always uses speech-to-text.
  - `language` (string, optional)
  - `diarize` (boolean, optional)
  - `webhook_url` (string, uri, optional)
- With `upload`
  - `upload` (object, required)
    - `filename` (string, required)
    - `size` (integer, required)
    - `mime_type` (string, optional)
  - `source` (string, optional, one of `auto`, `stt`)
  - `language` (string, optional)
  - `diarize` (boolean, optional)
  - `webhook_url` (string, uri, optional)
- multipart/form-data
  - `file` (string, binary, required)
  - `source` (string, optional, one of `auto`, `stt`)
  - `language` (string, optional)
  - `diarize` (boolean, optional)
  - `webhook_url` (string, uri, optional)

#### Returns

- 200 OK: The transcription reached a final status
- 201 Created: The transcription is awaiting a chunked upload. `upload` describes where to send each chunk.
- 202 Accepted: The transcription is processing
- 400 Bad Request: The JSON request body is malformed
- 401 Unauthorized: Missing or invalid credential
- 402 Payment Required: Not enough minutes. Applies to every request body format.
- 403 Forbidden: Insufficient scope, unverified email, cross-site request or a single-member team plan
- 409 Conflict: `Idempotency-Key` was reused with different parameters.
- 413 Content Too Large: The upload or the JSON request body is too large
- 422 Unprocessable Content: Invalid request, URL, platform or file type
- 429 Too Many Requests: Too many requests or too many active transcriptions. Wait for the number of seconds in `Retry-After`.

```bash
curl -X POST https://api.transcript1.com/v1/transcriptions \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
const transcription = await t1.transcriptions.create('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
```

```python
import os
import requests

response = requests.post(
    "https://api.transcript1.com/v1/transcriptions",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
    json={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
)
print(response.json())
```

Response 202 Accepted:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "queued",
  "source": {
    "platform": "youtube",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "media_id": "dQw4w9WgXcQ"
  },
  "media": null,
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": null,
  "caption_kind": null,
  "language": null,
  "billed_minutes": null,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": null,
  "deleted_at": null,
  "reused": false
}
```

Response 200 OK:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "completed",
  "source": {
    "platform": "youtube",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "media_id": "dQw4w9WgXcQ"
  },
  "media": {
    "title": "Example video",
    "author": "Example channel",
    "author_url": "https://www.youtube.com/@examplechannel",
    "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
    "duration_seconds": 212
  },
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": "youtube_captions",
  "caption_kind": "auto_generated",
  "language": "en",
  "billed_minutes": 4,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": "2026-10-13T09:30:04.000Z",
  "deleted_at": null,
  "transcript": {
    "text": "Welcome back to the channel. Today we are looking at how transcripts are made.",
    "speakers": [],
    "segments": [
      {
        "start": 0,
        "end": 4.2,
        "speaker": null,
        "language": "en",
        "text": "Welcome back to the channel."
      },
      {
        "start": 4.2,
        "end": 9.8,
        "speaker": null,
        "language": "en",
        "text": "Today we are looking at how transcripts are made."
      }
    ]
  },
  "reused": false
}
```

Response 201 Created:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "awaiting_upload",
  "source": {
    "platform": "upload",
    "filename": "interview.m4a",
    "size": 48210334
  },
  "media": {
    "filename": "interview.m4a",
    "mime_type": "audio/mp4",
    "size": 48210334
  },
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": null,
  "caption_kind": null,
  "language": null,
  "billed_minutes": null,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": null,
  "deleted_at": null,
  "reused": false,
  "upload": {
    "chunk_url_template": "https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload/chunks/{index}",
    "headers": {
      "X-Upload-Token": "t1u_Zk3vYb8Nw1ZrT5xLpD0aHcE7sUj9GfMiO2yXtVnR4Bw"
    },
    "expires_at": "2026-10-13T10:30:00.000Z",
    "chunks": [
      {
        "index": 0,
        "offset": 0,
        "size": 838860
      },
      {
        "index": 1,
        "offset": 838860,
        "size": 838860
      },
      {
        "index": 2,
        "offset": 1677720,
        "size": 838860
      },
      {
        "index": 3,
        "offset": 2516580,
        "size": 838860
      },
      {
        "index": 4,
        "offset": 3355440,
        "size": 838860
      },
      {
        "index": 5,
        "offset": 4194300,
        "size": 8388608
      },
      {
        "index": 6,
        "offset": 12582908,
        "size": 8388608
      },
      {
        "index": 7,
        "offset": 20971516,
        "size": 8388608
      },
      {
        "index": 8,
        "offset": 29360124,
        "size": 8388608
      },
      {
        "index": 9,
        "offset": 37748732,
        "size": 8388608
      },
      {
        "index": 10,
        "offset": 46137340,
        "size": 2072994
      }
    ]
  }
}
```

Response 400 · invalid_request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Request body is not valid JSON",
    "retryable": false
  }
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 402 · insufficient_minutes:

```json
{
  "error": {
    "code": "insufficient_minutes",
    "message": "Not enough minutes",
    "retryable": false,
    "action_url": "https://transcript1.com/dashboard/billing",
    "minutes_needed": 12,
    "minutes_remaining": 3
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · email_not_verified:

```json
{
  "error": {
    "code": "email_not_verified",
    "message": "Verify your email address to create transcriptions",
    "retryable": false
  }
}
```

Response 403 · forbidden_origin:

```json
{
  "error": {
    "code": "forbidden_origin",
    "message": "Cross-site request rejected",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 409 · idempotency_conflict:

```json
{
  "error": {
    "code": "idempotency_conflict",
    "message": "Idempotency-Key was used with different parameters",
    "retryable": false
  }
}
```

Response 413 · media_too_large:

```json
{
  "error": {
    "code": "media_too_large",
    "message": "File exceeds the maximum size",
    "retryable": false,
    "max_bytes": 95000000
  }
}
```

Response 413 · invalid_request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Request body is too large (max 1 MB)",
    "retryable": false
  }
}
```

Response 422 · invalid_request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Request validation failed",
    "retryable": false,
    "params": {
      "wait": "Number must be less than or equal to 60"
    }
  }
}
```

Response 422 · invalid_url:

```json
{
  "error": {
    "code": "invalid_url",
    "message": "Not a supported link. Use a YouTube, X, TikTok, Instagram, Facebook, Bluesky, Twitch, Dailymotion, Pinterest, LinkedIn, Snapchat link.",
    "retryable": false
  }
}
```

Response 422 · unsupported_platform:

```json
{
  "error": {
    "code": "unsupported_platform",
    "message": "Platform \"vimeo\" is not supported",
    "retryable": false,
    "supported_platforms": [
      "youtube",
      "x",
      "tiktok",
      "instagram",
      "facebook",
      "bluesky",
      "twitch",
      "dailymotion",
      "pinterest",
      "linkedin",
      "snapchat"
    ]
  }
}
```

Response 422 · unsupported_media:

```json
{
  "error": {
    "code": "unsupported_media",
    "message": "Unsupported file type",
    "retryable": false
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

Response 429 · too_many_active_jobs:

```json
{
  "error": {
    "code": "too_many_active_jobs",
    "message": "Too many active transcriptions (limit 2)",
    "retryable": true
  }
}
```

### Get a transcription

`GET /v1/transcriptions/{id}`

Returns the transcription object. Completed transcriptions include the transcript. Use `format` to render it as text, SRT or WebVTT.

If you request a rendered format before completion, the response is JSON. It returns `202` while processing or `200` if the transcription failed or was canceled.

With `include_deleted=true`, a deleted transcription is returned as a record without a transcript. For deleted records, `wait` is ignored and rendered formats return `404`.

#### Path

- `id` (string, required)

#### Query

- `wait` (integer, optional, nullable, default 0): Seconds to wait for a final status before responding, up to 60. Defaults to 0.
- `format` (string, optional, default json, one of `json`, `text`, `srt`, `vtt`)
- `include` (string, optional, one of `words`)
- `timestamps` (string, optional, default false, one of `true`, `false`)
- `include_deleted` (string, optional, default false, one of `true`, `false`): Also returns deleted transcriptions as read-only records without a transcript. Their `deleted_at` is set. Defaults to `false`.

#### Returns

- 200 OK: The transcription, or the rendered transcript
- 202 Accepted: A rendered format was requested while the transcription is still processing
- 401 Unauthorized: Missing or invalid credential
- 403 Forbidden: Insufficient scope or a single-member team plan
- 404 Not Found: Transcription not found
- 422 Unprocessable Content: Invalid query parameters
- 429 Too Many Requests: Too many requests. Wait for the number of seconds in `Retry-After`.

```bash
curl https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
const transcription = await t1.transcriptions.get('tr_4fHq8ZbN2xLm0PqRsTuVwX');
```

```python
import os
import requests

response = requests.get(
    "https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
print(response.json())
```

Response 200 · Completed:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "completed",
  "source": {
    "platform": "youtube",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "media_id": "dQw4w9WgXcQ"
  },
  "media": {
    "title": "Example video",
    "author": "Example channel",
    "author_url": "https://www.youtube.com/@examplechannel",
    "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
    "duration_seconds": 212
  },
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": "youtube_captions",
  "caption_kind": "auto_generated",
  "language": "en",
  "billed_minutes": 4,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": "2026-10-13T09:30:04.000Z",
  "deleted_at": null,
  "transcript": {
    "text": "Welcome back to the channel. Today we are looking at how transcripts are made.",
    "speakers": [],
    "segments": [
      {
        "start": 0,
        "end": 4.2,
        "speaker": null,
        "language": "en",
        "text": "Welcome back to the channel."
      },
      {
        "start": 4.2,
        "end": 9.8,
        "speaker": null,
        "language": "en",
        "text": "Today we are looking at how transcripts are made."
      }
    ]
  }
}
```

Response 200 · Failed:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "failed",
  "source": {
    "platform": "instagram",
    "url": "https://www.instagram.com/reel/C8xYz12abCd/",
    "media_id": "C8xYz12abCd"
  },
  "media": null,
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": null,
  "caption_kind": null,
  "language": null,
  "billed_minutes": null,
  "cached": false,
  "error": {
    "code": "media_private",
    "message": "Private posts are not supported"
  },
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": "2026-10-13T09:30:03.000Z",
  "deleted_at": null
}
```

Response 202 Accepted:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "queued",
  "source": {
    "platform": "youtube",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "media_id": "dQw4w9WgXcQ"
  },
  "media": null,
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": null,
  "caption_kind": null,
  "language": null,
  "billed_minutes": null,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": null,
  "deleted_at": null
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 404 · not_found:

```json
{
  "error": {
    "code": "not_found",
    "message": "Transcription not found",
    "retryable": false
  }
}
```

Response 422 · invalid_request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Request validation failed",
    "retryable": false,
    "params": {
      "wait": "Number must be less than or equal to 60"
    }
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

### List transcriptions

`GET /v1/transcriptions`

Lists your team's transcriptions, newest first, without transcripts. Pass `next_cursor` as `cursor` to get the next page.

Deleted transcriptions are hidden unless you pass `include_deleted=true`.

#### Query

- `limit` (integer, optional, default 20)
- `cursor` (string, optional)
- `status` (string, optional, one of `awaiting_upload`, `queued`, `fetching`, `transcribing`, `completed`, `failed`, `canceled`)
- `platform` (string, optional)
- `include_deleted` (string, optional, default false, one of `true`, `false`): Also returns deleted transcriptions as read-only records without a transcript. Their `deleted_at` is set. Defaults to `false`.

#### Returns

- 200 OK: A page of transcriptions
- 401 Unauthorized: Missing or invalid credential
- 403 Forbidden: Insufficient scope or a single-member team plan
- 422 Unprocessable Content: Invalid query parameters or cursor
- 429 Too Many Requests: Too many requests. Wait for the number of seconds in `Retry-After`.

```bash
curl https://api.transcript1.com/v1/transcriptions \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
for await (const transcription of t1.transcriptions.list({ limit: 20 })) {
  console.log(transcription.id, transcription.status);
}
```

```python
import os
import requests

response = requests.get(
    "https://api.transcript1.com/v1/transcriptions",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
print(response.json())
```

Response 200 OK:

```json
{
  "object": "list",
  "data": [
    {
      "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
      "object": "transcription",
      "status": "completed",
      "source": {
        "platform": "youtube",
        "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
        "media_id": "dQw4w9WgXcQ"
      },
      "media": {
        "title": "Example video",
        "author": "Example channel",
        "author_url": "https://www.youtube.com/@examplechannel",
        "thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
        "duration_seconds": 212
      },
      "options": {
        "source": "auto",
        "language": "auto",
        "diarize": false
      },
      "transcript_source": "youtube_captions",
      "caption_kind": "auto_generated",
      "language": "en",
      "billed_minutes": 4,
      "cached": false,
      "error": null,
      "created_at": "2026-10-13T09:30:00.000Z",
      "completed_at": "2026-10-13T09:30:04.000Z",
      "deleted_at": null
    }
  ],
  "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEwLTEzVDA5OjMwOjAwLjAwMFoiLCJpZCI6IjViMGU2YzFjLTBkNmYtNGMxZi05YjVlLThmM2Y0ZDBhN2ExMSJ9"
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 422 · invalid_request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Invalid cursor",
    "retryable": false
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

### Cancel or delete a transcription

`DELETE /v1/transcriptions/{id}`

Cancels an active transcription or one awaiting an upload. Any charged minutes are refunded.

Deletes a completed, failed or canceled transcription together with its stored transcript.

#### Path

- `id` (string, required)

#### Returns

- 204 No Content: Canceled or deleted
- 401 Unauthorized: Missing or invalid credential
- 403 Forbidden: Insufficient scope, cross-site request or a single-member team plan
- 404 Not Found: Transcription not found
- 429 Too Many Requests: Too many requests. Wait for the number of seconds in `Retry-After`.

```bash
curl -X DELETE https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
await t1.transcriptions.delete('tr_4fHq8ZbN2xLm0PqRsTuVwX');
```

```python
import os
import requests

response = requests.delete(
    "https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
response.raise_for_status()
```

Response 204 No Content:

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · forbidden_origin:

```json
{
  "error": {
    "code": "forbidden_origin",
    "message": "Cross-site request rejected",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 404 · not_found:

```json
{
  "error": {
    "code": "not_found",
    "message": "Transcription not found",
    "retryable": false
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

## Uploads

Send a large file in chunks. Start with Create a transcription using an `upload` body. Send each chunk, then complete the upload.

### Upload one chunk

`PUT /v1/transcriptions/{id}/upload/chunks/{index}`

Sends one chunk of a chunked upload as `application/octet-stream`.

Authenticate with the `X-Upload-Token` from the create response. This endpoint does not use a bearer credential.

The body must match the planned chunk size exactly. Sending a chunk again replaces it.

#### Path

- `id` (string, required)
- `index` (integer, optional, nullable): Zero-based chunk number from the upload plan

#### Headers

- `X-Upload-Token` (string, required)

#### Body

- `(raw body)` (string, binary, required)

#### Returns

- 200 OK: The chunk was stored
- 401 Unauthorized: Missing, invalid or expired upload token
- 404 Not Found: Transcription not found
- 409 Conflict: The transcription is no longer awaiting an upload
- 422 Unprocessable Content: Index outside the chunk plan or wrong body size

```bash
curl -X PUT https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload/chunks/0 \
  -H "X-Upload-Token: t1u_Zk3vYb8Nw1ZrT5xLpD0aHcE7sUj9GfMiO2yXtVnR4Bw" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @chunk-0.bin
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
// the SDK uploads the file in chunks and completes the upload
const transcription = await t1.transcriptions.create('./interview.m4a');
```

```python
import requests

with open("chunk-0.bin", "rb") as f:
    response = requests.put(
        "https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload/chunks/0",
        headers={"X-Upload-Token": "t1u_Zk3vYb8Nw1ZrT5xLpD0aHcE7sUj9GfMiO2yXtVnR4Bw", "Content-Type": "application/octet-stream"},
        data=f,
    )
print(response.json())
```

Response 200 OK:

```json
{
  "object": "upload_chunk",
  "index": 0,
  "size": 838860
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid or expired upload token",
    "retryable": false
  }
}
```

Response 404 · not_found:

```json
{
  "error": {
    "code": "not_found",
    "message": "Transcription not found",
    "retryable": false
  }
}
```

Response 409 · invalid_state:

```json
{
  "error": {
    "code": "invalid_state",
    "message": "Transcription is not awaiting an upload",
    "retryable": false
  }
}
```

Response 422 · invalid_request:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Chunk 0 must be exactly 838860 bytes",
    "retryable": false
  }
}
```

### Get upload progress

`GET /v1/transcriptions/{id}/upload`

Lists the received chunks of a chunked upload so an interrupted upload can resume.

#### Path

- `id` (string, required)

#### Returns

- 200 OK: Upload progress
- 401 Unauthorized: Missing or invalid credential
- 403 Forbidden: Insufficient scope or a single-member team plan
- 404 Not Found: Transcription or upload not found
- 429 Too Many Requests: Too many requests. Wait for the number of seconds in `Retry-After`.

```bash
curl https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
// the SDK uploads the file in chunks and completes the upload
const transcription = await t1.transcriptions.create('./interview.m4a');
```

```python
import os
import requests

response = requests.get(
    "https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
print(response.json())
```

Response 200 OK:

```json
{
  "object": "upload",
  "status": "awaiting_upload",
  "total_bytes": 48210334,
  "uploaded_bytes": 4194300,
  "received_chunks": [
    0,
    1,
    2,
    3,
    4
  ],
  "chunks": [
    {
      "index": 0,
      "offset": 0,
      "size": 838860
    },
    {
      "index": 1,
      "offset": 838860,
      "size": 838860
    },
    {
      "index": 2,
      "offset": 1677720,
      "size": 838860
    },
    {
      "index": 3,
      "offset": 2516580,
      "size": 838860
    },
    {
      "index": 4,
      "offset": 3355440,
      "size": 838860
    },
    {
      "index": 5,
      "offset": 4194300,
      "size": 8388608
    },
    {
      "index": 6,
      "offset": 12582908,
      "size": 8388608
    },
    {
      "index": 7,
      "offset": 20971516,
      "size": 8388608
    },
    {
      "index": 8,
      "offset": 29360124,
      "size": 8388608
    },
    {
      "index": 9,
      "offset": 37748732,
      "size": 8388608
    },
    {
      "index": 10,
      "offset": 46137340,
      "size": 2072994
    }
  ],
  "expires_at": "2026-10-13T10:30:00.000Z"
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 404 · not_found:

```json
{
  "error": {
    "code": "not_found",
    "message": "Transcription not found",
    "retryable": false
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

### Complete a chunked upload

`POST /v1/transcriptions/{id}/upload/complete`

Checks that every planned chunk arrived with the planned size. Then assembles the file and queues the transcription.

#### Path

- `id` (string, required)

#### Returns

- 202 Accepted: The transcription is queued
- 401 Unauthorized: Missing or invalid credential
- 402 Payment Required: Not enough minutes to queue the transcription. The upload stays awaiting completion.
- 403 Forbidden: Insufficient scope, cross-site request or a single-member team plan
- 404 Not Found: Transcription or upload not found
- 409 Conflict: `upload_incomplete` means chunks are missing. `missing_chunks` lists them. `invalid_state` means the transcription is not awaiting an upload.
- 429 Too Many Requests: Too many requests or too many active transcriptions. Wait for the number of seconds in `Retry-After`.

```bash
curl -X POST https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload/complete \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
// the SDK uploads the file in chunks and completes the upload
const transcription = await t1.transcriptions.create('./interview.m4a');
```

```python
import os
import requests

response = requests.post(
    "https://api.transcript1.com/v1/transcriptions/tr_4fHq8ZbN2xLm0PqRsTuVwX/upload/complete",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
print(response.json())
```

Response 202 Accepted:

```json
{
  "id": "tr_4fHq8ZbN2xLm0PqRsTuVwX",
  "object": "transcription",
  "status": "queued",
  "source": {
    "platform": "upload",
    "filename": "interview.m4a",
    "size": 48210334
  },
  "media": {
    "filename": "interview.m4a",
    "mime_type": "audio/mp4",
    "size": 48210334
  },
  "options": {
    "source": "auto",
    "language": "auto",
    "diarize": false
  },
  "transcript_source": null,
  "caption_kind": null,
  "language": null,
  "billed_minutes": null,
  "cached": false,
  "error": null,
  "created_at": "2026-10-13T09:30:00.000Z",
  "completed_at": null,
  "deleted_at": null
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 402 · insufficient_minutes:

```json
{
  "error": {
    "code": "insufficient_minutes",
    "message": "Not enough minutes",
    "retryable": false,
    "action_url": "https://transcript1.com/dashboard/billing",
    "minutes_needed": null,
    "minutes_remaining": 0
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · forbidden_origin:

```json
{
  "error": {
    "code": "forbidden_origin",
    "message": "Cross-site request rejected",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 404 · not_found:

```json
{
  "error": {
    "code": "not_found",
    "message": "Transcription not found",
    "retryable": false
  }
}
```

Response 409 · upload_incomplete:

```json
{
  "error": {
    "code": "upload_incomplete",
    "message": "Missing chunks",
    "retryable": false,
    "missing_chunks": [
      3,
      7
    ]
  }
}
```

Response 409 · invalid_state:

```json
{
  "error": {
    "code": "invalid_state",
    "message": "Transcription is not awaiting an upload",
    "retryable": false
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

Response 429 · too_many_active_jobs:

```json
{
  "error": {
    "code": "too_many_active_jobs",
    "message": "Too many active transcriptions (limit 2)",
    "retryable": true
  }
}
```

## Account

Your plan, remaining minutes and rate limits.

### The Account object

#### Attributes

- `object` (string, required, one of `account`)
- `user` (object, required)
  - `id` (string, required)
  - `email` (string, required)
- `plan` (object, required, nullable)
  - `id` (string, required)
  - `name` (string, required)
- `minutes` (object, required)
  - `remaining` (number, required)
  - `cycle` (number, required)
  - `topup` (number, required)
  - `topup_usable` (boolean, required)
  - `resets_at` (string, date-time, required, nullable)
- `renews_at` (string, date-time, required, nullable)
- `limits` (object, required)
  - `requests_per_minute` (number, required)
  - `max_active_jobs` (number, required)

#### Example

```json
{
  "object": "account",
  "user": {
    "id": "5b0e6c1c-0d6f-4c1f-9b5e-8f3f4d0a7a11",
    "email": "you@example.com"
  },
  "plan": {
    "id": "9d4e0c0a-2b8a-4f0c-8d63-6a8d3c8a1f22",
    "name": "Monthly"
  },
  "minutes": {
    "remaining": 1180,
    "cycle": 980,
    "topup": 200,
    "topup_usable": true,
    "resets_at": "2026-11-13T09:30:00.000Z"
  },
  "renews_at": "2026-11-13T09:30:00.000Z",
  "limits": {
    "requests_per_minute": 200,
    "max_active_jobs": 10
  }
}
```

### Get your account

`GET /v1/account`

Returns the authenticated user, current plan and remaining transcription minutes.

#### Returns

- 200 OK: The authenticated user's account
- 401 Unauthorized: Missing or invalid credential
- 403 Forbidden: Insufficient scope or a single-member team plan
- 404 Not Found: No account found
- 429 Too Many Requests: Too many requests. Wait for the number of seconds in `Retry-After`.

```bash
curl https://api.transcript1.com/v1/account \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```javascript
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1();
const account = await t1.account();
```

```python
import os
import requests

response = requests.get(
    "https://api.transcript1.com/v1/account",
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPT1_API_KEY']}"},
)
print(response.json())
```

Response 200 OK:

```json
{
  "object": "account",
  "user": {
    "id": "5b0e6c1c-0d6f-4c1f-9b5e-8f3f4d0a7a11",
    "email": "you@example.com"
  },
  "plan": {
    "id": "9d4e0c0a-2b8a-4f0c-8d63-6a8d3c8a1f22",
    "name": "Monthly"
  },
  "minutes": {
    "remaining": 1180,
    "cycle": 980,
    "topup": 200,
    "topup_usable": true,
    "resets_at": "2026-11-13T09:30:00.000Z"
  },
  "renews_at": "2026-11-13T09:30:00.000Z",
  "limits": {
    "requests_per_minute": 200,
    "max_active_jobs": 10
  }
}
```

Response 401 · unauthorized:

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Invalid session",
    "retryable": false
  }
}
```

Response 403 · insufficient_scope:

```json
{
  "error": {
    "code": "insufficient_scope",
    "message": "OAuth scope is insufficient",
    "retryable": false
  }
}
```

Response 403 · plan_single_member:

```json
{
  "error": {
    "code": "plan_single_member",
    "message": "This team plan only supports one active member. Contact your team admin to upgrade.",
    "retryable": false
  }
}
```

Response 404 · not_found:

```json
{
  "error": {
    "code": "not_found",
    "message": "No account found for this credential",
    "retryable": false
  }
}
```

Response 429 · rate_limited:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests",
    "retryable": true
  }
}
```

## Webhooks

Met `webhook_url` laat je je server weten wanneer de transcriptie klaar is. Geef die URL mee bij het aanmaken. Bij een geslaagde of mislukte transcriptie stuurt transcript1 daar een ondertekend `POST`-request naartoe.

Je kunt webhooks alleen via de REST API instellen. De [MCP-server](https://transcript1.com/nl/docs/mcp) heeft hiervoor geen parameter.

### Geef je webhook-URL mee

Bij `POST /v1/transcriptions` geef je `webhook_url` op deze manier mee:

- Bij een ondersteunde videolink of het aanmaken van een upload in chunks: in de JSON-body.
- Bij een bestand dat je in één request uploadt: als formulierveld.

Gebruik een URL die:

- HTTPS gebruikt.
- Geen gebruikersnaam of wachtwoord bevat.
- Uitkomt op een openbaar adres.

Verwijst de URL naar een privéadres, loopback-adres, link-local-adres of een ander niet-openbaar adres, dan krijg je `422 invalid_request`. We controleren het adres bij het aanmaken en opnieuw bij elke poging om de webhook te versturen.

Je kunt `webhook_url` niet samen met `reuse_recent=true` gebruiken.

### Welke events ontvang je?

| `type` | Wanneer ontvang je dit? |
|---|---|
| `transcription.completed` | Zodra de status `completed` is. |
| `transcription.failed` | Zodra de status `failed` is. In `data.error` staan de `code` en `message`. |

Annuleer je een transcriptie, dan sturen we geen webhook.

### Wat staat er in het request?

De webhook heeft een JSON-body met deze velden:

- In `created_at` staat wanneer het event is aangemaakt.
- In `data` staat het transcriptieobject, zonder het veld `transcript`.

Haal de tekst op met `GET /v1/transcriptions/:id`. Gebruik daarvoor de `id` uit `data`.

Deze headers komen mee:

- `Content-Type: application/json`
- `User-Agent: transcript1-webhooks/1.0`
- `X-Transcript1-Signature`; hieronder lees je hoe je die controleert.

### Controleer de handtekening

In elk webhookrequest staat `X-Transcript1-Signature`, met de waarde `t=<unix seconds>,v1=<hex digest>`.

- `t` geeft in Unix-seconden aan wanneer we het request hebben ondertekend.
- `v1` bevat de HMAC-SHA256 als hexwaarde. Bereken die over `"<t>.<raw body>"`: `t`, een punt en de body zoals je die hebt ontvangen. Gebruik je volledige geheime sleutel, dus inclusief `whsec_`.

Controleer de handtekening op de oorspronkelijke bytes, vóór je de JSON parset. Zet je geparste JSON weer om naar tekst, dan kunnen spaties of de veldvolgorde anders zijn. De handtekening klopt dan niet meer.

- Vergelijk de handtekeningen in constante tijd.
- Weiger requests waarvan `t` te ver van de huidige tijd ligt. De voorbeelden staan een verschil van 5 minuten toe.
- Laat Express de body als tekst inlezen, zonder die eerst te parsen.
- Geef bij Flask `request.get_data(as_text=True)` door als `raw_body`.

### Je geheime sleutel beheren

Je team heeft één geheime sleutel die met `whsec_` begint. We gebruiken die voor de webhooks van alle transcripties die je team aanmaakt.

Je kunt de sleutel bekijken of vervangen op [“Webhooks”](https://transcript1.com/dashboard/webhooks).

Een vervanging gaat direct in. Elke webhook die we daarna versturen, gebruikt de nieuwe sleutel.

### Aflevering en nieuwe pogingen

- Antwoordt je server met `2xx`, dan beschouwen we de webhook als afgeleverd.
- Een andere statuscode, een verbindingsfout of geen antwoord binnen 10 seconden telt als een mislukte poging.
- We proberen het maximaal 6 keer in totaal. Tussen de pogingen loopt de wachttijd exponentieel op: 2, 4, 8, 16 en 32 minuten. De laatste poging is ongeveer een uur na de eerste.
- Geef meteen de uiteindelijke URL op. transcript1 volgt geen redirects; ook `3xx` telt als mislukt.
- Dezelfde webhook kan vaker aankomen, bijvoorbeeld als je server pas na de time-out antwoordt. Voorkom dubbele verwerking met `data.id` en `type`.

```javascript
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';

// header: X-Transcript1-Signature, rawBody: the request body exactly as received,
// secret: your full signing secret including the whsec_ prefix.
export const verifySignature = (secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) => {
  const fields = Object.fromEntries(header.split(',').map(part => part.trim().split('=')));
  const t = Number(fields.t);

  if (!Number.isInteger(t) || !fields.v1 || Math.abs(nowSeconds - t) > 300) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');

  return fields.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(fields.v1), Buffer.from(expected));
};
```

```python
import hashlib
import hmac
import time


def verify_signature(secret, header, raw_body, now=None):
    # header: the X-Transcript1-Signature header, raw_body: the request body exactly as received,
    # secret: your full signing secret including the whsec_ prefix.
    fields = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        t = int(fields["t"])
    except (KeyError, ValueError):
        return False

    now = int(time.time()) if now is None else now
    if abs(now - t) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(fields.get("v1", ""), expected)
```

Example: transcription.completed

```json
{
  "type": "transcription.completed",
  "created_at": "2026-10-13T09:30:04.000Z",
  "data": {
    "id": "tr_8fK2Lm0PqRsTuVwXyZaBcD",
    "object": "transcription",
    "status": "completed",
    "transcript_source": "youtube_captions",
    "billed_minutes": 4,
    "error": null
  }
}
```
