# API 레퍼런스

> 모든 /v1 엔드포인트의 파라미터와 호출 예제를 확인하세요. OpenAPI 명세도 JSON 또는 YAML로 다운로드할 수 있어요.

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

## API 소개

API를 호출할 때는 `https://api.transcript1.com/v1`을 기본 주소로 사용하세요.

- 요청·응답 본문은 JSON이며, 필드 이름은 `snake_case`예요.
- 날짜와 시각은 `2026-10-13T09:30:00.000Z`처럼 UTC 기준 ISO 8601 형식으로 표시해요.
- 재생 시간과 스크립트의 타임스탬프는 초 단위예요.
- 오류 응답은 모두 같은 형식이에요. 필드와 코드별 의미는 [오류 처리](https://transcript1.com/ko/docs/api-reference#errors)에서 확인하세요.
- 인증을 거친 요청의 응답에는 항상 `X-RateLimit-Limit`, `X-RateLimit-Remaining` 헤더가 있어요. 자세한 기준은 [요금제와 사용 한도](https://transcript1.com/ko/docs/billing-and-limits)를 확인하세요.
- `source=auto`에서는 YouTube, X, TikTok, Bluesky, Dailymotion, LinkedIn의 영상 원본 언어 자막을 먼저 확인해요. 사용할 자막이 없으면 음성 텍스트 변환(STT)을 실행해요. `source=captions`인 `captions`(자막만) 모드는 오디오를 다운로드하거나 STT로 전환하지 않아요. 사용할 트랙이 없으면 `no_captions`로 실패해요. `source=stt`를 지정하거나 화자 분리를 켜면 처음부터 자막을 건너뛰어요. 플랫폼별 자막 지원 범위와 세그먼트·단어별 타임스탬프의 차이는 [지원 링크와 변환 옵션](https://transcript1.com/ko/docs/sources-and-options)에 정리돼 있어요.

도구에서 사용할 OpenAPI 명세는 아래 형식으로 다운로드할 수 있어요.

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

## 인증

API 키나 OAuth 토큰을 `Authorization` 헤더에 넣으세요. 형식은 `Authorization: Bearer <token>`이에요.

- API 키는 `t1_`로 시작하며 모든 엔드포인트를 호출할 수 있어요. 대시보드의 [“API keys” 페이지](https://transcript1.com/dashboard/account/tokens)에서 발급받으세요.
- OAuth 액세스 토큰은 `t1o_`로 시작해요. Claude나 ChatGPT 같은 MCP 클라이언트를 연결하면 발급돼요. 권한 범위는 `transcriptions:read`, `transcriptions:write`로 나눠요.

| 권한 범위 | 호출할 수 있는 엔드포인트 |
|---|---|
| `transcriptions:read` | `GET /v1/transcriptions`, `GET /v1/transcriptions/:id`, `GET /v1/transcriptions/:id/upload`, `GET /v1/account` |
| `transcriptions:write` | `POST /v1/transcriptions`, `DELETE /v1/transcriptions/:id`, `POST /v1/transcriptions/:id/upload/complete` |

- 토큰에 호출에 필요한 권한이 없으면 `403 insufficient_scope`를 반환해요.
- 키나 토큰이 없거나 유효하지 않으면 `401 unauthorized`를 반환해요.

청크를 보내는 `PUT /v1/transcriptions/:id/upload/chunks/:index`는 `X-Upload-Token`으로 인증해요. 업로드 예약 시 받은 토큰을 사용하세요. 자세한 흐름은 [파일 업로드](https://transcript1.com/ko/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())
```

## 멱등성

`POST /v1/transcriptions`를 다시 보내도 작업이 중복되지 않게 하려면 `Idempotency-Key` 헤더를 사용하세요.

키는 1~255자로 지정하세요. 쿼리 파라미터에 넣지 말고 요청 헤더로 보내야 해요.

- 같은 키에 같은 파라미터를 보내면 기존 작업을 반환해요. 추가로 크레딧을 차감하지 않아요.
- 이미 사용한 키에 다른 파라미터를 보내면 `409 idempotency_conflict`를 반환해요.
- 청크 업로드 생성 요청을 같은 키로 다시 보내면 새 업로드 토큰을 받을 수 있어요.

```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())
```

## 오류

- 요청 오류가 나면 API가 호출을 받아들이지 않은 거예요.
- 작업 실패는 요청을 접수한 뒤 변환 도중에 생긴 오류예요. 작업의 `status`가 `failed`로 바뀌어요.

### 오류 응답 읽기

`/v1` API의 요청 오류는 `error` 객체로 반환해요. 이 객체에는 `code`, `message`, `retryable`이 있어요.

- 오류를 분기할 때는 `code`를 사용하세요.
- `message`는 사용자에게 보여 줄 설명이에요. 문구가 바뀔 수 있으니 분기 조건으로 쓰지 마세요.
- `retryable`이 `true`이면 잠시 후 같은 요청을 다시 시도할 수 있어요.

오류 종류에 따라 `retryable` 외에 다음 필드를 함께 반환해요.

| 필드 | 포함되는 오류 | 설명 |
|---|---|---|
| `action_url` | `insufficient_minutes` | 요금제를 구매하거나 분량을 충전하는 페이지 주소 |
| `minutes_needed`, `minutes_remaining` | `insufficient_minutes` | 요청에 필요한 분량과 남은 분량. 필요한 분량을 아직 모르면 `null`이에요. |
| `supported_platforms` | `unsupported_platform` | 지원 플랫폼 목록: `["youtube", "x", "tiktok", "instagram", "facebook", "bluesky", "twitch", "dailymotion", "pinterest", "linkedin", "snapchat"]` |
| `missing_chunks` | `upload_incomplete` | 아직 받지 못한 청크의 인덱스 목록 |
| `max_bytes` | `media_too_large` | 현재 허용되는 최대 파일 크기. 바이트 단위예요. |
| `params` | `invalid_request` | 본문·쿼리 검증에서 문제가 된 필드와 이유를 담은 객체 |

### 요청 단계에서 발생하는 오류

| HTTP 상태 | `code` | 그대로 재시도 | 원인과 대응 |
|---|---|---|---|
| 400, 422 | `invalid_request` | 불가 | 필수 파라미터나 본문 필드가 빠졌거나 값이 잘못됐어요. `diarize`와 `source: "captions"`, 또는 `reuse_recent`와 `webhook_url`을 함께 보낸 경우도 해당해요. 본문이 올바른 JSON인지도 확인하세요. |
| 422 | `invalid_url` | 불가 | YouTube, X, TikTok, Instagram, Facebook, Bluesky 또는 [지원 목록](https://transcript1.com/ko/docs/sources-and-options)에 있는 플랫폼의 영상 링크인지 확인하세요. 지정한 `platform`과 링크가 달라도 이 오류가 나요. |
| 422 | `unsupported_platform` | 불가 | 지정한 `platform`을 지원하지 않아요. |
| 422 | `unsupported_media` | 불가 | 파일 확장자가 지원 목록에 없어요. |
| 401 | `unauthorized` | 불가 | 인증 정보가 없거나 잘못됐거나 만료·폐기됐어요. 업로드 토큰이 잘못됐거나 만료된 경우도 해당해요. |
| 403 | `insufficient_scope` | 불가 | OAuth 토큰에 필요한 `transcriptions:read` 또는 `transcriptions:write` 권한이 없어요. |
| 403 | `email_not_verified` | 불가 | 변환 작업을 시작하려면 먼저 이메일 인증을 마쳐야 해요. |
| 402 | `insufficient_minutes` | 불가 | 사용 가능한 분량이 0이거나 이번 변환에 필요한 분량보다 적어요. |
| 402 | `no_active_plan` | 불가 | 이용 중인 유료 요금제 없이 충전을 시도했어요. 변환 API가 아니라 대시보드 결제 엔드포인트에서 반환하는 오류예요. |
| 404 | `not_found` | 불가 | 해당 작업이 없거나 다른 팀에 속해 있어요. |
| 409 | `upload_incomplete` | 불가 | 누락된 청크를 보낸 뒤 업로드 완료 요청을 다시 보내세요. |
| 409 | `invalid_state` | 불가 | 업로드 대기 상태가 아니에요. 더 이상 청크를 전송하거나 업로드를 완료할 수 없어요. |
| 409 | `idempotency_conflict` | 불가 | 이미 사용한 `Idempotency-Key`에 다른 파라미터를 보냈어요. |
| 413 | `media_too_large` | 불가 | 파일이 허용 크기를 넘었어요. [파일 업로드](https://transcript1.com/ko/docs/uploads)에서 한도를 확인하세요. |
| 413 | `invalid_request` | 불가 | JSON 본문이 1MB를 넘었어요. 파일 크기 초과는 `media_too_large`로 구분해요. |
| 429 | `rate_limited` | 가능 | 분당 요청 한도를 넘었어요. `Retry-After`에 지정된 초만큼 기다리세요. |
| 429 | `too_many_active_jobs` | 가능 | 진행 중인 변환 작업이나 완료 전 업로드가 한도를 넘었어요. `Retry-After`에 지정된 초만큼 기다리세요. |
| 500 | `internal_error` | 가능 | transcript1 서버에서 오류가 났어요. 잠시 후 다시 요청하세요. |
| 503 | `service_unavailable` | 가능 | transcript1에 일시적인 장애가 있어요. 잠시 후 다시 요청하세요. |

브라우저에서 로그인 쿠키로 인증하면 요청 출처도 검사해요. 다른 사이트에서 보낸 요청에는 `403 forbidden_origin`을 반환해요.

`Authorization` 헤더로 인증하는 API 호출에는 이 검사를 적용하지 않아요.

`429` 응답에는 항상 `Retry-After` 헤더가 있어요. 값은 다시 요청하기까지 기다릴 시간이며 초 단위예요. 실제 한도는 [요금제와 사용 한도](https://transcript1.com/ko/docs/billing-and-limits)에서 확인하세요.

### 안전하게 재시도하기

- 같은 요청을 그대로 다시 보낼 때는 `retryable`이 `true`인지 확인하세요. `rate_limited`, `too_many_active_jobs`, `internal_error`, `service_unavailable`이 해당해요.
- `Retry-After`가 있으면 지정한 시간만큼 기다리고, 없으면 재시도 간격을 늘리세요.
- `POST /v1/transcriptions`를 다시 보낼 때는 처음과 같은 `Idempotency-Key` 헤더를 사용하세요. 첫 요청이 이미 성공했더라도 중복 차감을 막을 수 있어요.
- 다른 오류는 원인을 해결하고 요청을 수정한 뒤 다시 보내세요.

### 접수된 작업이 실패한 경우

작업의 `status`가 `failed`이면 `error` 객체의 `code`와 `message`로 원인을 확인하세요.

생성 요청에 응답한 뒤에도 작업이 실패할 수 있어요. 상태를 조회하거나 `transcription.failed` [웹훅](https://transcript1.com/ko/docs/api-reference#webhooks)을 받았을 때 `error`를 확인하세요.

| `error.code` | 원인 | 다시 시도하려면 |
|---|---|---|
| `media_unavailable` | 영상이 없거나 더 이상 접근할 수 없어요. | 그대로 재시도하지 말고 URL을 확인하세요. |
| `media_private` | 비공개 영상이에요. | 공개 게시물이나 공개·일부 공개 YouTube 영상으로 바꾸세요. |
| `login_required` | 영상을 보려면 플랫폼 로그인이 필요해요. | 자동 재시도하지 않는 영구 오류예요. |
| `no_audio` | 영상에 오디오 트랙이 없어요. | 자동 재시도하지 않는 영구 오류예요. |
| `age_restricted` | 연령 제한이 걸려 있어요. | 링크 대신 파일을 업로드하세요. |
| `live_stream` | 방송 중이거나 아직 시작하지 않은 라이브예요. | 방송이 끝난 뒤 다시 시도하세요. |
| `no_captions` | `source`가 `captions`인데 사용할 자막 트랙이 없어요. | `source: "auto"` 또는 `"stt"`로 바꾸세요. |
| `media_too_long` | 음성 텍스트 변환(STT)의 최대 길이인 5시간을 넘었어요. | 파일을 나눠서 보내세요. |
| `unsupported_media` | 파일을 읽지 못했거나 오디오 트랙이 없어요. | 지원 형식의 파일로 바꿔 보내세요. |
| `no_speech` | 음성이 감지되지 않았어요. | 그대로 재시도하지 마세요. |
| `unsupported_language` | 지원하지 않는 언어예요. | 그대로 재시도하지 말고 `language`를 지원하는 코드나 `"auto"`로 바꾸세요. |
| `insufficient_minutes` | 차감 시점에 사용 가능한 분량이 부족해졌어요. | 분량을 충전한 뒤 다시 시도하세요. |
| `fetch_failed` | 미디어 다운로드를 여러 번 시도했지만 실패했어요. | 잠시 후 다시 시도할 수 있어요. |
| `transcription_failed` | 음성 텍스트 변환(STT)을 여러 번 시도했지만 실패했어요. | 잠시 후 다시 시도할 수 있어요. |

작업이 실패하거나 취소되면 비용이 들지 않아요. 이미 차감한 분량은 돌려드려요.

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
  }
}
```

## 웹훅

완료 알림을 받으려면 작업 생성 시 `webhook_url`을 지정하세요. 변환이 완료되거나 실패하면 transcript1이 해당 주소로 서명된 `POST` 요청을 보내요.

웹훅은 REST API 전용 기능이에요. [MCP 서버](https://transcript1.com/ko/docs/mcp)에서는 웹훅 파라미터를 지원하지 않아요.

### 알림을 받을 URL 지정하기

`POST /v1/transcriptions`를 호출할 때 다음 위치에 `webhook_url`을 넣으세요.

- 영상 링크를 보내거나 청크 업로드를 예약할 때는 JSON 본문에 넣으세요.
- 파일을 요청 한 번으로 업로드할 때는 폼 필드로 보내세요.

웹훅 URL에는 다음 조건이 적용돼요.

- HTTPS URL이어야 해요.
- URL 안에 사용자 이름이나 비밀번호를 넣으면 안 돼요.
- 도메인을 조회하면 공개 IP 주소로 연결돼야 해요.

사설, 루프백, 링크 로컬 등 비공개 주소를 가리키면 `422 invalid_request`를 반환해요. 작업 생성 시 확인하고, 웹훅을 전송할 때마다 다시 검사해요.

`reuse_recent=true`를 사용할 때는 `webhook_url`을 지정할 수 없어요.

### 알림이 오는 시점

| `type` | 발생 조건 |
|---|---|
| `transcription.completed` | 작업이 `completed` 상태로 끝났을 때 |
| `transcription.failed` | 작업이 `failed` 상태로 끝났을 때. 원인은 `data.error`의 `code`, `message`에서 확인해요. |

작업을 취소한 경우에는 웹훅을 보내지 않아요.

### 웹훅에 담기는 데이터

웹훅 본문은 JSON이며 다음 정보를 포함해요.

- `created_at`: 이벤트가 발생한 시각이에요.
- `data`: 변환 작업 객체예요. `transcript` 본문은 제외해요.

스크립트는 `data`에 있는 `id`로 `GET /v1/transcriptions/:id`를 호출해 가져오세요.

함께 보내는 요청 헤더예요.

- `Content-Type: application/json`
- `User-Agent: transcript1-webhooks/1.0`
- 서명 검증에 쓰는 `X-Transcript1-Signature`

### 요청 서명 검증하기

웹훅을 보낼 때마다 `X-Transcript1-Signature` 헤더를 넣어요. 값의 형식은 `t=<unix seconds>,v1=<hex digest>`예요.

- `t`: 요청에 서명한 시각을 Unix 시간의 초 단위로 나타내요.
- `v1`: `"<t>.<raw body>"`를 HMAC-SHA256으로 서명한 16진수 값이에요. `t` 값 뒤에 점을 붙이고, 그 뒤에 받은 요청 본문을 그대로 이어 붙여 계산해요. 키에는 `whsec_` 접두어까지 포함한 서명 시크릿 전체를 사용하세요.

서명은 JSON을 파싱하기 전에 받은 본문 바이트 그대로 검증하세요. 파싱 후 다시 JSON으로 만들면 공백이나 키 순서가 달라져 검증에 실패할 수 있어요.

- 서명값은 상수 시간으로 비교하세요.
- `t`가 현재 시각에서 너무 벗어난 요청은 거부하세요. 예제에서는 5분 이내만 허용해요.
- Express에서는 JSON 파서보다 먼저 원문 텍스트를 읽도록 설정하세요.
- Flask를 쓴다면 `request.get_data(as_text=True)`로 읽은 값을 `raw_body`에 넘기세요.

### 서명 시크릿 확인과 교체

팀마다 `whsec_`로 시작하는 서명 시크릿이 있어요. 해당 팀에서 만든 모든 변환 작업의 웹훅에 같은 시크릿을 사용해요.

[“Webhooks” 페이지](https://transcript1.com/dashboard/webhooks)에서 시크릿을 확인하거나 새로 발급받을 수 있어요.

교체는 즉시 적용돼요. 이후 전송하는 웹훅부터 새 시크릿으로 서명해요.

### 전송과 재시도

- 서버가 `2xx`로 응답하면 전송 성공으로 처리해요.
- 다른 상태 코드, 연결 오류, 10초 내 응답 없음은 전송 실패로 처리해요.
- 실패하면 대기 시간을 늘려 다시 보내요. 첫 전송을 포함해 총 6번까지 시도하며, 각 시도 사이는 2, 4, 8, 16, 32분이에요. 마지막 시도는 첫 전송에서 약 한 시간 뒤예요.
- 리다이렉트는 따라가지 않아요. `3xx`도 전송 실패이므로 최종 도착 URL을 직접 지정하세요.
- 서버가 제한 시간 이후에 응답하는 경우처럼 같은 알림을 여러 번 받을 수 있어요. `data.id`와 `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
  }
}
```
