# HTTP API リファレンス

この API は、あなたの**サーバー側のコード**から呼び、セッションを探す・短期のセッショントークンを発行する・文字起こしを書き出す・利用量を見るために使います。ベース URL は `https://moji.kaitoranma.com` です。機械可読な版は [openapi.yaml](openapi.yaml)（OpenAPI 3.1）にあります。文字おこしくんが文字起こしの開始と終了をあなたの URL に知らせる仕組み（Webhook）は、[Webhook リファレンス](webhooks.md)にあります。

## エンドポイント一覧

| メソッド・パス | 認証 | 内容 |
|---|---|---|
| [GET `/healthz`](#getHealth) | なし | 文字おこしくんが動いているかを確かめる |
| [GET `/v1/sessions`](#listSessions) | API キー（`transcripts:read`） | セッションを一覧する |
| [POST `/v1/sessions`](#createSession) | API キー（`ingest:write`） | セッションを作る |
| [GET `/v1/sessions/{sessionId}`](#getSession) | API キー（`transcripts:read`） | セッションの状態を見る |
| [DELETE `/v1/sessions/{sessionId}`](#deleteSession) | API キー（`transcripts:delete`） | セッションを削除する |
| [POST `/v1/sessions/{sessionId}/end`](#endSession) | API キー（`ingest:write`） | セッションを終える |
| [POST `/v1/sessions/{sessionId}/tokens`](#createSessionToken) | API キー（`transcripts:read`） / API キー（`ingest:write`） | セッショントークンを発行する |
| [GET `/v1/sessions/{sessionId}/transcript`](#exportTranscript) | API キー（`transcripts:read`） / セッショントークン（`transcript:read`） / クエリの `token`（`transcript:download`） | 文字起こしを書き出す |
| [POST `/v1/sessions/{sessionId}/downloads`](#createTranscriptDownloads) | API キー（`transcripts:read`） | 書き出しのリンクを作る |
| [GET `/v1/sessions/{sessionId}/chatgpt`](#openTranscriptInChatGpt) | クエリの `token`（`transcript:download`） | 記録を ChatGPT に渡す |
| [GET `/v1/usage`](#getUsage) | API キー（`usage:read`） | 利用量を見る |
| [GET `/v1/ingest`](#ingestWebSocket) | 接続後の `hello` でセッショントークン | WebSocket の Ingest（音声を送る）に接続する |
| [GET `/v1/subscribe`](#subscribeWebSocket) | 接続後の `hello` でセッショントークン | WebSocket の Subscribe（結果を受け取る）に接続する |

## 認証

```text
Authorization: Bearer <資格情報>
```

| 資格情報 | 形 | 使える操作 | ブラウザ・OBS に置いてよいか |
|---|---|---|---|
| API キー | `mjk_live_` + 38 文字（base62） | キーのスコープが許す操作（スコープの表）。API キーは、API キーのテナント（Discord サーバー）のセッションと利用量だけを扱える | **置かない**（流出したら管理画面で取り消す） |
| セッショントークン（`transcript:read`） | `base64url.base64url` | そのセッションの[書き出し](#exportTranscript)（と WebSocket の Subscribe） | 置いてよい（使えるセッションも権限も 1 つだけで、期限がある） |
| 書き出しのリンクのトークン（`transcript:download`） | `base64url.base64url` | そのセッションの[書き出し](#exportTranscript)と[ChatGPT への転送](#openTranscriptInChatGpt)だけ。`Authorization` ではなく、クエリの `token` で送る（[書き出しのリンク](#createTranscriptDownloads)に入っている） | 置いてよい。チャットに貼ってもよい（書き出しにしか使えず、期限がある） |

API キーは、ボットを入れた Discord サーバーの管理者が管理画面で作ります（[クイックスタート](quickstart.md#from-api-key)）。作るときに、管理者はスコープと期限を選びます。管理画面は作ったキーをその 1 度だけ表示します。キーの形は `mjk_live_` + 32 文字の乱数 + 6 文字のチェックサム（base62）で、シークレットスキャナーは `mjk_live_[0-9A-Za-z]{38}` でキーを見つけられます。

| スコープ | 使える操作 |
|---|---|
| `transcripts:read` | [GET `/v1/sessions`](#listSessions)、[GET `/v1/sessions/{sessionId}`](#getSession)、[POST `/v1/sessions/{sessionId}/tokens`](#createSessionToken)、[GET `/v1/sessions/{sessionId}/transcript`](#exportTranscript)、[POST `/v1/sessions/{sessionId}/downloads`](#createTranscriptDownloads) |
| `transcripts:delete` | [DELETE `/v1/sessions/{sessionId}`](#deleteSession) |
| `usage:read` | [GET `/v1/usage`](#getUsage) |
| `ingest:write` | [POST `/v1/sessions`](#createSession)、[POST `/v1/sessions/{sessionId}/end`](#endSession)、[POST `/v1/sessions/{sessionId}/tokens`](#createSessionToken) |

- キーが無いか、無効か、失効していれば、文字おこしくんは `401 unauthorized` を返します（`WWW-Authenticate: Bearer realm="mojiokoshi"` 付き）。
- スコープが足りなければ、文字おこしくんは `403 insufficient_scope` を返します（`requiredScope` と `WWW-Authenticate: Bearer …, error="insufficient_scope"` 付き）。
- API キーで別のテナントのセッションを指定すると、文字おこしくんは `404 session_not_found` を返します（存在を漏らさないため）。

API キーで発行するセッショントークンの有効期間は[レート制限と上限](limits.md#セッショントークンの有効期間)、期限の後の取り直しは[再接続とエラーの扱い](reconnection.md#token-expiry)にあります。

## エラー（problem details）

エラーの本文は [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) の problem details（`Content-Type: application/problem+json; charset=utf-8`）です。**`code` で分岐してください。** `error` と `message` は、以前の形との後方互換のためのメンバーで、`code` / `detail` と同じ値を持ちます。

```json
{
  "type": "https://moji.kaitoranma.com/docs/errors/#rate_limited",
  "title": "要求が多すぎます",
  "status": 429,
  "detail": "要求が多すぎます。しばらくしてから、要求をやり直してください",
  "code": "rate_limited",
  "error": "rate_limited",
  "message": "要求が多すぎます。しばらくしてから、要求をやり直してください",
  "retryAfterSeconds": 1
}
```

| メンバー | 型 | 説明 |
|---|---|---|
| `type` | string（URI） | `https://moji.kaitoranma.com/docs/errors/#<code>`（コードごとに固定の識別子） |
| `title` | string | コードごとに固定の短い説明（日本語。文言は変わることがある） |
| `status` | integer | HTTP ステータス（応答と同じ） |
| `detail` / `message` | string | この要求で起きたエラーの説明（日本語。文言は変わることがある） |
| `code` / `error` | string | 機械向けのコード |
| `retryAfterSeconds` | integer | `rate_limited`・`quota_exceeded`: 待つ秒数（`Retry-After` と同じ） |
| `quota` | object | `quota_exceeded`: `tenantId`・`dailyAudioSeconds`・`usedAudioSeconds`・`resetsAt`（次の UTC 0 時） |
| `requiredScope` | string | `insufficient_scope`: 足りないスコープ |

- `429`・`5xx` を受け取ったら、待ってから要求をやり直してください（`Retry-After` があれば、その秒数だけ待ちます）。それ以外の `4xx` を受け取ったら、要求を直すまでやり直さないでください。
- コードごとの意味と対処は[エラーコード](errors/index.md)にあります。

## 共通の決まり

- パスはすべて `/v1` から始まります（`/healthz` を除く）。
- **あなたのサーバー側のコードから呼んでください。** 文字おこしくんの HTTP API は CORS のヘッダーを付けていないので、ブラウザ（別のオリジン）からはこの API を呼べません。ブラウザの画面では WebSocket（Subscribe）を使い、トークンはあなたのサーバーで発行してブラウザに渡します。
- 要求の本文は JSON（UTF-8）にし、16 KiB までにします（超えると、文字おこしくんは `413 payload_too_large` を返します）。JSON の応答には `Content-Type: application/json; charset=utf-8` が付きます。
- 時刻はすべて**エポックミリ秒の数値**（UTC）で表します。日付（`from` / `to`）は UTC の `YYYY-MM-DD` で表します。利用量の「1 日」は UTC の暦日で、日本時間の 9:00 に切り替わります。
- API の応答には `Cache-Control: no-store` が付きます。
- **互換性**: 応答のフィールド・エラーコード・任意の引数は予告なく増えることがあります。知らないフィールドは無視し、知らないコードはステータスで扱ってください。文字おこしくんは、互換性の無い変更をするときに、パスの `/v1` を上げます（[変更履歴](changelog.md)）。

## レート制限

文字おこしくんは、API キーでの要求を、API キーごとに 1 分あたり 120 要求までに制限します。認証の無い要求・セッショントークンでの書き出しの要求・認証に失敗した要求は、送信元 IP ごとに 1 分あたり 120 要求までに制限します。制限はトークンバケットの方式で、使える要求の数は、毎秒 2 ずつ戻り、最大 120 までたまります。レート制限の対象の応答には、成功・失敗のどちらにも、IETF の草案（draft-ietf-httpapi-ratelimit-headers）の形のヘッダーが付きます。

```text
RateLimit-Policy: "api-key";q=120;w=60
RateLimit: "api-key";r=117;t=2
```

- `q` はためられる要求の数を、`w` は空から満杯に戻るまでの秒数を、`r` は残りの要求数を、`t` は満杯に戻るまでの秒数を表します。ポリシー名は `"api-key"` か `"ip"` です。
- レート制限を超えると、文字おこしくんは `429 rate_limited` と `Retry-After`（秒）を返します。`Retry-After` の秒数だけ待ってから、要求をやり直してください。
- 書き出し（`/transcript`）は文字おこしくんの API 全体で同時に 2 本までです。混んでいると、文字おこしくんは `429 rate_limited`（`Retry-After: 5`）を返します。
- ほかの上限は[レート制限と上限](limits.md)にあります。

## Idempotency-Key（やり直しの安全）

`POST /v1/sessions`・`POST /v1/sessions/{sessionId}/tokens`・`POST /v1/sessions/{sessionId}/downloads` は `Idempotency-Key` ヘッダーを受け付けます。ネットワークの失敗でやり直すときに**同じキー**を使うと、文字おこしくんはセッションやトークンを二重に作りません。

| 項目 | 内容 |
|---|---|
| 値 | 1〜255 文字の印字可能な ASCII（UUID を勧める）。この形に合わない値には、文字おこしくんが `400 invalid_request` を返す |
| 同じ要求とみなす範囲 | API キー × メソッドとパス × キーの値 |
| 覚える期間 | 24 時間 |
| 覚える応答 | 成功（2xx）した応答だけ。失敗した要求は同じキーでやり直せる |
| 同じキー・同じ本文 | 文字おこしくんは、最初の応答をそのまま返す（`Idempotent-Replayed: true`） |
| 同じキー・違う本文 | 文字おこしくんは `422 idempotency_mismatch` を返す |
| 同じキーの要求が処理中 | 文字おこしくんは `409 idempotency_in_progress`（`Retry-After: 1`）を返す |

## ページ送り（cursor）

`GET /v1/sessions` は新しい順に `limit` 件（既定 20、最大 100）を返し、続きがあれば `nextCursor` を付けます。次のページは `?cursor=<nextCursor>` で取ります。カーソルは不透明な文字列なので、中身に依存しないでください。ページの間にセッションが増えても、重複も抜けも出ません。`nextCursor` が無ければ最後のページです。

## ヘルスチェック（Health） {#tag-health}

クライアントは、この操作で文字おこしくんが動いているかを確かめる。

### 文字おこしくんが動いているかを確かめる（GET `/healthz`） {#getHealth}

この操作は文字おこしくんが動いているかを返す。この操作には認証もレート制限も無い。

- 認証: なし
- operationId: `getHealth`

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが動いている。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `ok` | true | 必須 |  |

```json
{
  "ok": true
}
```

```sh
curl -sS "$BASE/healthz"
```

## セッションとトークン（Sessions） {#tag-sessions}

クライアントは、API キーで、セッションの一覧・状態・作成・終了と、セッショントークンの発行を扱う。

### セッションを一覧する（GET `/v1/sessions`） {#listSessions}

文字おこしくんは、セッションを新しい順（`createdAt` の降順、同時刻は `sessionId` の降順）に返す。続きがあれば応答に `nextCursor` が付くので、次のページを取るときは、その値を `cursor` に渡す（キーセットのカーソルなので、ページの間にセッションが増えても、重複も抜けも出ない）。文字おこしくんは、API キーのテナント（Discord サーバー）のセッションだけを返す。

- 認証: API キー（`transcripts:read`）
- operationId: `listSessions`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `status` | query | "active" / "ended" | 任意 | `active`（終了していない）か `ended`（終了した）を指定する。省くと、文字おこしくんは両方を返す。 |
| `limit` | query | integer（1〜100、既定 20） | 任意 | 1 ページの件数を指定する（既定 20）。 |
| `cursor` | query | string（2048 文字まで） | 任意 | 前のページの `nextCursor` を渡す。不正な値には、文字おこしくんが 400 `invalid_request` を返す。 |
| `endedSince` | query | integer（0 以上） | 任意 | 指定すると、文字おこしくんは、終了していないセッションと、この時刻（エポックミリ秒）以降に終了したセッションだけを返す。 |

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが、セッションの一覧を返した。 |
| 400 | `status`・`limit`・`cursor`・`endedSince` の値が不正である（`invalid_request`） |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに `transcripts:read` のスコープが無い。または、テナントで API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 429 | レート制限を超えた。`Retry-After` の秒数の後にやり直す（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `sessions` | object[] | 必須 | 文字おこしくんは、セッションを、`createdAt` の降順（同時刻は `sessionId` の降順）に並べる。 |
| `sessions[].sessionId` | string | 必須 | セッションの ID を表す。 |
| `sessions[].label` | string（1〜100 文字） | 必須 | 表示名を表す。 |
| `sessions[].externalRef` | string（1〜256 文字） | 任意 | 入力元での識別子を表す。公式の Discord ボットは、`discord:<guildId>:<channelId>` を使う。 |
| `sessions[].tenantId` | string | 任意 | テナント（Discord ではギルド ID）を表す。 |
| `sessions[].createdAt` | number（エポックミリ秒） | 必須 |  |
| `sessions[].endedAt` | number（エポックミリ秒） | 任意 | 終了した時刻を表す。セッションが終了していなければ、このフィールドは無い。 |
| `sessions[].live` | boolean | 必須 | 文字おこしくんのサーバーのプロセスで、セッションが稼働中かどうかを表す（再起動の直後は、音声の送り手（Ingest）か受け手（Subscribe）がつなぎ直すまで false）。 |
| `sessions[].audioSeconds` | number（0 以上） | 必須 | このセッションの利用量を表す。利用量は音声認識にかけた音声の秒数で、小数第 3 位までの値である。 |
| `sessions[].retainUntil` | number（エポックミリ秒） | 任意 | 終了したセッションが、保持期間を過ぎて消える時刻を表す。文字おこしくんがセッションを消さないなら、このフィールドは無い。テナント（Discord サーバー）の保持期間が延びると、文字おこしくんは、この時刻を、セッションの終了から延びた保持期間の後まで遅らせる。文字おこしくんは、この時刻を早めない。 |
| `nextCursor` | string（2048 文字まで） | 任意 | 続きがあるときだけ付く。次のページを取るときは、この値を `cursor` に渡す（不透明な文字列なので、中身に依存しない）。 |

```json
{
  "sessions": [
    {
      "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
      "label": "#雑談 VC",
      "externalRef": "discord:111111111111111111:222222222222222222",
      "tenantId": "111111111111111111",
      "createdAt": 1790350826814,
      "live": true,
      "audioSeconds": 312.5
    },
    {
      "sessionId": "7d1e2f3a-8b9c-4d0e-a1b2-c3d4e5f60718",
      "label": "#雑談 VC",
      "externalRef": "discord:111111111111111111:222222222222222222",
      "tenantId": "111111111111111111",
      "createdAt": 1790264426814,
      "endedAt": 1790268026814,
      "live": false,
      "audioSeconds": 1234.567,
      "retainUntil": 1792860026814
    }
  ],
  "nextCursor": "eyJjIjoxNzkwMzUwODI2ODE0LCJpIjoiMGYwYzNjMWUtNWI3YS00ZDJlLTljNjEtM2YyZDhhNGI3ZTEwIn0"
}
```

```sh
curl -sS "$BASE/v1/sessions?status=active&limit=5" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY"
```

### セッションを作る（POST `/v1/sessions`） {#createSession}

クライアントは、この操作で、自分で音声を送る（[Ingest](https://moji.kaitoranma.com/docs/ingest.html)）ためのセッションを作る。文字おこしくんは、作ったセッションをすぐ稼働させ、Ingest の接続を待つ（30 分つながらなければ、セッションは自動で終わる）。クライアントは、応答の `ingest.token` を音声の送り手に、`subscribe.token` / `viewerUrl` / `overlayUrl` を見る側に渡す。`reuseActive: true` なら、文字おこしくんは同じ `externalRef` の稼働中のセッションを使い回す（200）。テナントの今日の利用量が上限に達していれば、文字おこしくんは 429 `quota_exceeded` を返す（公式の文字おこしくんには、1 日の上限は無い）。文字おこしくんは、API キーで作ったセッションで開いているトラックを、全部の API キー（ほかの利用者のキーを含む）で合わせて数え、同時に 20 本までにする。API キーで作ったセッションのトラックの合計が 20 本に達しているときは、文字おこしくんは、新しい `track_open` を、Ingest の `track_limit_exceeded` で断る。**公式の Discord ボットが使っているセッションには、音声を送らないこと**（1 セッションの Ingest は 1 本だけ）。

- 認証: API キー（`ingest:write`）
- operationId: `createSession`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | 任意 | 同じ操作を安全にやり直すためのキーを表す（UUID を勧める）。文字おこしくんは、成功した応答を 24 時間覚え、同じキー・同じ本文の要求には最初の応答をそのまま返す（`Idempotent-Replayed: true`）。同じキーで本文が違えば、文字おこしくんは 422 `idempotency_mismatch` を返す。同じキーの要求が処理中なら、文字おこしくんは 409 `idempotency_in_progress` を返す。文字おこしくんは失敗した要求を覚えないので、同じキーでやり直せる。文字おこしくんは、認証に使った資格情報とメソッド・パスごとにキーを区別する。 |

要求の本文（JSON）:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `label` | string（1〜100 文字） | 必須 | 表示名を表す（前後の空白を除いて 1〜100 文字）。 |
| `externalRef` | string \| null（1〜256 文字） | 任意 | 入力元での識別子を表す。`reuseActive` で使い回すセッションを探す鍵になる。API キーで `discord:` で始まる値を使うなら、`discord:<自分の tenantId>:` で始まる値だけを使える（違えば、文字おこしくんは 403 `forbidden` を返す）。 |
| `reuseActive` | boolean \| null | 任意 | true のとき、同じ `externalRef`・同じテナントの終了していないセッションがあれば、文字おこしくんはそのセッションを使い回す（200、`reused: true`）。 |
| `tenantId` | string \| null | 任意 | テナントを表す。クライアントは、テナントを省くか、API キーのテナントと同じ値にする（違えば、文字おこしくんは 403 `forbidden` を返す）。 |

```json
{
  "label": "会議室マイク",
  "externalRef": "my-app:room-42",
  "reuseActive": true
}
```

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが、稼働中のセッションを使い回した（`reused: true`）。ラベルは既存のままで、トークンは新しく発行する。 |
| 201 | 文字おこしくんが、セッションを新しく作った。 |
| 400 | 本文か `Idempotency-Key` が不正である（`invalid_json` / `invalid_request`） |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | `tenantId` か `externalRef` が API キーのテナントと違う。または、スコープが足りないか、API キーの利用が無効になっている（`forbidden` / `insufficient_scope` / `api_access_disabled`） |
| 409 | 文字おこしくんが、同じ `Idempotency-Key` の前の要求をまだ処理している（`idempotency_in_progress`） |
| 422 | クライアントが、同じ `Idempotency-Key` で、本文の違う要求を送った（`idempotency_mismatch`） |
| 429 | 今日（UTC）の利用上限に達した。または、レート制限を超えた（`quota_exceeded` / `rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `sessionId` | string | 必須 | セッションの ID を表す。 |
| `label` | string（1〜100 文字） | 必須 | 表示名を表す。 |
| `externalRef` | string（1〜256 文字） | 任意 | 入力元での識別子を表す。公式の Discord ボットは、`discord:<guildId>:<channelId>` を使う。 |
| `tenantId` | string | 任意 | テナント（Discord ではギルド ID）を表す。 |
| `createdAt` | number（エポックミリ秒） | 必須 |  |
| `reused` | boolean | 必須 | 既存のセッションを使い回したかどうかを表す（使い回しなら 200、新規なら 201）。 |
| `ingest` | object | 必須 |  |
| `ingest.url` | string | 必須 | WebSocket の URL（`wss://<host>/v1/ingest`）を表す。 |
| `ingest.token` | string（セッショントークン） | 必須 | `ingest:write` のセッショントークンを表す。クライアントは、このトークンを hello で送る。 |
| `subscribe` | object | 必須 |  |
| `subscribe.url` | string | 必須 | WebSocket の URL（`wss://<host>/v1/subscribe`）を表す。 |
| `subscribe.token` | string（セッショントークン） | 必須 | `transcript:read` のセッショントークンを表す。クライアントは、このトークンを hello で送る。 |
| `viewerUrl` | string | 必須 | 表示ページの URL を表す（トークンはフラグメント `#token=` に入る）。 |
| `overlayUrl` | string | 必須 | OBS のブラウザソース用の URL を表す（`viewerUrl` + `&overlay=1`）。 |
| `expiresAt` | number（エポックミリ秒） | 必須 | 2 つのトークンの有効期限を表す。 |

```json
{
  "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
  "label": "会議室マイク",
  "externalRef": "my-app:room-42",
  "tenantId": "111111111111111111",
  "createdAt": 1790350826814,
  "reused": true,
  "ingest": {
    "url": "wss://moji.kaitoranma.com/v1/ingest",
    "token": "eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJpbmdlc3Q6d3JpdGUifQ.Zr8Tq1wE5yU3iO7pA9sD2fG4hJ6kL0zX1cV3bN5mQ7w"
  },
  "subscribe": {
    "url": "wss://moji.kaitoranma.com/v1/subscribe",
    "token": "eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OnJlYWQifQ.Hq3Jx9mK2vLw8ZtN4pR7sYc1dF5gH6jK0lM2nB3vC4x"
  },
  "viewerUrl": "https://moji.kaitoranma.com/#token=eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OnJlYWQifQ.Hq3Jx9mK2vLw8ZtN4pR7sYc1dF5gH6jK0lM2nB3vC4x",
  "overlayUrl": "https://moji.kaitoranma.com/#token=eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OnJlYWQifQ.Hq3Jx9mK2vLw8ZtN4pR7sYc1dF5gH6jK0lM2nB3vC4x&overlay=1",
  "expiresAt": 1790354426000
}
```

```sh
curl -sS -X POST "$BASE/v1/sessions" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"label":"会議室マイク","externalRef":"my-app:room-42","reuseActive":true}'
```

### セッションの状態を見る（GET `/v1/sessions/{sessionId}`） {#getSession}

文字おこしくんは、セッションの状態と利用量を返す。API キーで別のテナントのセッションを指定すると、文字おこしくんは 404 を返す（存在を漏らさない）。

- 認証: API キー（`transcripts:read`）
- operationId: `getSession`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが、セッションの状態を返した。 |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに `transcripts:read` のスコープが無い。または、テナントで API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 404 | セッションが無い。保持期間を過ぎて消えたセッションと、別のテナントのセッションにも、文字おこしくんはこの応答を返す（`session_not_found`） |
| 429 | レート制限を超えた。`Retry-After` の秒数の後にやり直す（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `sessionId` | string | 必須 | セッションの ID を表す。 |
| `label` | string（1〜100 文字） | 必須 | 表示名を表す。 |
| `externalRef` | string（1〜256 文字） | 任意 | 入力元での識別子を表す。公式の Discord ボットは、`discord:<guildId>:<channelId>` を使う。 |
| `tenantId` | string | 任意 | テナント（Discord ではギルド ID）を表す。 |
| `createdAt` | number（エポックミリ秒） | 必須 |  |
| `endedAt` | number（エポックミリ秒） | 任意 | 終了した時刻を表す。セッションが終了していなければ、このフィールドは無い。 |
| `live` | boolean | 必須 | 文字おこしくんのサーバーのプロセスで、セッションが稼働中かどうかを表す。 |
| `ingestConnected` | boolean | 必須 | Ingest（音声の送り手）がつながっているかどうかを表す。 |
| `viewers` | integer（0 以上） | 必須 | 購読中の Subscribe の接続数を表す。 |
| `audioSeconds` | number（0 以上） | 必須 | このセッションの利用量を表す。利用量は音声認識にかけた音声の秒数で、小数第 3 位までの値である。文字おこしくんは、利用量をトラックごとに数えて足す。 |
| `retainUntil` | number（エポックミリ秒） | 任意 | 終了したセッションが、保持期間を過ぎて消える時刻を表す。文字おこしくんがセッションを消さないなら、このフィールドは無い。テナント（Discord サーバー）の保持期間が延びると、文字おこしくんは、この時刻を、セッションの終了から延びた保持期間の後まで遅らせる。文字おこしくんは、この時刻を早めない。 |

```json
{
  "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
  "label": "#雑談 VC",
  "externalRef": "discord:111111111111111111:222222222222222222",
  "tenantId": "111111111111111111",
  "createdAt": 1790350826814,
  "endedAt": 1790354426814,
  "live": false,
  "ingestConnected": false,
  "viewers": 0,
  "audioSeconds": 1234.567,
  "retainUntil": 1792946426814
}
```

```sh
curl -sS "$BASE/v1/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY"
```

### セッションを削除する（DELETE `/v1/sessions/{sessionId}`） {#deleteSession}

文字おこしくんは、終わったセッションの記録（文字起こし・話者・セッションの利用量の内訳）を消す。消した記録は戻らない。文字おこしくんが応答を返した時点で、セッションは一覧・状態・書き出しから消える。文字おこしくんは、記録の本文もすぐに裏で消し切る（ファイルにも残さない）。文字おこしくんは、テナントの利用量の累計を減らさない。そのセッションの Webhook の出来事（`session.ended` など）をまだ送り終えていなければ、文字おこしくんはその出来事を送らない。稼働中か終了の途中のセッションには、文字おこしくんは 409 `session_active` を返す（先に `POST /v1/sessions/{sessionId}/end` でセッションを終える）。

- 認証: API キー（`transcripts:delete`）
- operationId: `deleteSession`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |

| ステータス | 意味 |
|---|---|
| 204 | 文字おこしくんが記録を消した（本文なし）。 |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに `transcripts:delete` のスコープが無い。または、テナントで API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 404 | セッションが無い。消えたセッションと、別のテナントのセッションにも、文字おこしくんはこの応答を返す（`session_not_found`） |
| 409 | セッションが稼働中か、終了の途中である（`session_active`） |
| 429 | レート制限を超えた。`Retry-After` の秒数の後にやり直す（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

```sh
curl -sS -X DELETE "$BASE/v1/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY"
```

### セッションを終える（POST `/v1/sessions/{sessionId}/end`） {#endSession}

文字おこしくんは、音声認識に送った音声の結果を最後まで受け取ってから（最大約 5 秒）、Subscribe でつないでいる全員に `session_ended` を送り、Ingest の接続も切る。文字おこしくんは、Ingest の接続を切った後に応答を返す。この操作は冪等で、文字おこしくんは、終了済みのセッションには最初の `endedAt` を返す。要求に本文は要らない。

- 認証: API キー（`ingest:write`）
- operationId: `endSession`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんがセッションを終えた。 |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに `ingest:write` のスコープが無い。または、テナントで API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 404 | セッションが無い（`session_not_found`） |
| 429 | レート制限を超えた。`Retry-After` の秒数の後にやり直す（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `sessionId` | string | 必須 |  |
| `endedAt` | number（エポックミリ秒） | 必須 | 終了した時刻を表す（終了済みなら最初の時刻）。 |

```json
{
  "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
  "endedAt": 1790354426814
}
```

```sh
curl -sS -X POST "$BASE/v1/sessions/$SESSION_ID/end" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY"
```

### セッショントークンを発行する（POST `/v1/sessions/{sessionId}/tokens`） {#createSessionToken}

文字おこしくんは、ブラウザ・OBS・ボットに渡す短期のセッショントークンを発行する。`transcript:read` のトークンは Subscribe（`/v1/subscribe` の `hello`）と書き出しに、`ingest:write` のトークンは Ingest に使う。API キーで発行するには、`transcript:read` には `transcripts:read` のスコープが、`ingest:write` には `ingest:write` のスコープが要る。有効期間を指定しなければ 3600 秒になる。最大は 86400 秒である。文字おこしくんには、トークンを 1 つずつ失効させる仕組みが無い。クライアントは、有効期間を短くし、期限が近づいたら新しいトークンを取り直す。終わったセッションにも、クライアントは `transcript:read` のトークンを発行できる（書き出しと過去のログの表示に使う）。終わったセッションの `ingest:write` のトークンを要求すると、文字おこしくんは 409 `session_ended` を返す。

- 認証: API キー（`transcripts:read`） / API キー（`ingest:write`）
- operationId: `createSessionToken`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |
| `Idempotency-Key` | header | string | 任意 | 同じ操作を安全にやり直すためのキーを表す（UUID を勧める）。文字おこしくんは、成功した応答を 24 時間覚え、同じキー・同じ本文の要求には最初の応答をそのまま返す（`Idempotent-Replayed: true`）。同じキーで本文が違えば、文字おこしくんは 422 `idempotency_mismatch` を返す。同じキーの要求が処理中なら、文字おこしくんは 409 `idempotency_in_progress` を返す。文字おこしくんは失敗した要求を覚えないので、同じキーでやり直せる。文字おこしくんは、認証に使った資格情報とメソッド・パスごとにキーを区別する。 |

要求の本文（JSON）:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `scope` | "transcript:read" / "ingest:write" | 必須 | `transcript:read`（Subscribe と書き出し。API キーでは `transcripts:read` が要る）か `ingest:write`（Ingest。API キーでは `ingest:write` が要る）を指定する。 |
| `ttlSeconds` | integer \| null（1〜86400） | 任意 | 有効期間を秒で表す（既定 3600）。 |

```json
{
  "scope": "transcript:read",
  "ttlSeconds": 3600
}
```

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんがトークンを発行した。 |
| 400 | `scope`・`ttlSeconds`・`Idempotency-Key` が不正である（`invalid_json` / `invalid_request`） |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに、そのトークンを発行するスコープが無い。または、API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 404 | セッションが無い（`session_not_found`） |
| 409 | 終わったセッションの `ingest:write` のトークンを要求した。または、文字おこしくんが同じ `Idempotency-Key` の前の要求をまだ処理している（`session_ended` / `idempotency_in_progress`） |
| 422 | クライアントが、同じ `Idempotency-Key` で、本文の違う要求を送った（`idempotency_mismatch`） |
| 429 | レート制限を超えた（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `token` | string（セッショントークン） | 必須 | セッショントークンを表す。ブラウザ・OBS に渡してよい（使えるセッションも権限も 1 つだけで、期限がある）。 |
| `expiresAt` | number（エポックミリ秒） | 必須 | 有効期限を表す。文字おこしくんは、接続するときにだけ有効期限を確かめる（つながっている WebSocket は切れない）。 |

```json
{
  "token": "eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OnJlYWQifQ.Hq3Jx9mK2vLw8ZtN4pR7sYc1dF5gH6jK0lM2nB3vC4x",
  "expiresAt": 1790354426000
}
```

```sh
curl -sS -X POST "$BASE/v1/sessions/$SESSION_ID/tokens" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"scope":"transcript:read","ttlSeconds":3600}'
```

## 書き出し（Transcripts） {#tag-transcripts}

クライアントは、API キーか `transcript:read` のセッショントークンで、文字起こしを書き出す。クライアントは、API キーで書き出しのリンクと記録を ChatGPT に渡すリンクを作り、資格情報を持たない人にリンクを渡すこともできる。

### 文字起こしを書き出す（GET `/v1/sessions/{sessionId}/transcript`） {#exportTranscript}

文字おこしくんは、発話ごとの最新の改訂（`final` / `refined`。認識途中は含まない）を `startedAt` の順に返す。稼働中のセッションでは、その時点までの発話を返す。文字おこしくんは応答を少しずつ流す（chunked）。文字おこしくんの API 全体で同時に流せる書き出しは 2 本までで、超えると、文字おこしくんは 429 `rate_limited`（`Retry-After: 5`）を返す。`HEAD` では、文字おこしくんはヘッダーだけを返す。文字おこしくんは、セッションの終了から保持期間が過ぎた記録を消し、消した記録の要求には 404 を返す。書き出しのリンク（`POST /v1/sessions/{sessionId}/downloads` が作る）は `transcript:download` のトークンをクエリの `token` に載せて、この操作を呼ぶ。リンクで要求すると、文字おこしくんは、`Content-Disposition: attachment`（ファイル名つき）・`Referrer-Policy: no-referrer`・`X-Robots-Tag: noindex` を付ける。リンクに `disposition=inline` を付けると、文字おこしくんは、`Content-Disposition: inline` にし、`X-Content-Type-Options: nosniff` と `Content-Security-Policy: default-src 'none'; sandbox` も付ける（ブラウザは、応答をページとして表示する）。クエリの `token` には `transcript:download` のトークンだけを使える（`transcript:read` のトークンは使えない）。クライアントは、クエリの `token` と `Authorization` ヘッダーを同時に付けない（同時に付けると、文字おこしくんは 400 を返す）。

- 認証: API キー（`transcripts:read`） / セッショントークン（`transcript:read`） / クエリの `token`（`transcript:download`）
- operationId: `exportTranscript`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |
| `format` | query | "json" / "text" / "csv"（既定 "json"） | 任意 | `json`（既定）・`text`（1 発話 1 行）・`csv`（見出し `日時,話者,発言` と 1 発話 1 行。Excel で開ける）のどれかを指定する。 |
| `tz` | query | string（1 文字以上、既定 "Asia/Tokyo"） | 任意 | `text` の時刻のタイムゾーンを IANA 名で表す（既定は `Asia/Tokyo`）。`csv` の日時と、書き出しのリンクで保存するファイルの名前の日時にも、文字おこしくんはこのタイムゾーンを使う。 |
| `disposition` | query | "attachment" / "inline"（既定 "attachment"） | 任意 | 書き出しのリンク（クエリの `token`）で、ブラウザに応答をどう扱わせるかを指定する。`attachment`（既定）ならファイルとして保存させ、`inline` ならページとして表示させる。ほかの資格情報では、文字おこしくんは値を確かめるだけで、`Content-Disposition` を付けない。 |

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが、文字起こしを書き出した。文字おこしくんは、`format=json` なら JSON を、`format=text` なら `[HH:mm:ss] 名前: 本文` の行を、`format=csv` なら CSV（RFC 4180。先頭に UTF-8 の BOM、改行は CRLF、1 行目は見出し `日時,話者,発言`、日時は `YYYY-MM-DD HH:mm:ss`）を返す。CSV では、文字おこしくんは、`=`・`+`・`-`・`@`・タブ・CR で始まるセルの先頭に `'` を付ける（表計算ソフトに数式として読ませない）。 |
| 400 | `format`・`tz`・`disposition` のどれかが不正である。または、クエリの `token` と `Authorization` ヘッダーを同時に付けた（`invalid_request`） |
| 401 | 資格情報が、無いか、無効か、期限切れである（`unauthorized`） |
| 403 | トークンが別のセッションのものである。または、API キーのスコープが足りないか、API キーの利用が無効になっている（`forbidden` / `insufficient_scope` / `api_access_disabled`） |
| 404 | セッションが無い。保持期間を過ぎて消えたセッションにも、文字おこしくんはこの応答を返す（`session_not_found`） |
| 429 | レート制限を超えた。または、書き出しが混み合っている（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `sessionId` | string | 必須 |  |
| `label` | string | 必須 |  |
| `speakers` | object[] | 必須 | そのセッションに一度でも現れた話者（途中で抜けた人も含む）の最後の情報を表す。 |
| `speakers[].speakerId` | string | 必須 |  |
| `speakers[].displayName` | string（100 文字まで） | 必須 |  |
| `speakers[].avatarUrl` | string（http(s) の URL） | 任意 |  |
| `utterances` | object[] | 必須 | 文字おこしくんは、発話ごとの最新の改訂を、`startedAt` の昇順に並べる。 |
| `utterances[].utteranceId` | string | 必須 | WebSocket の utterance と同じ ID を表す（WebSocket の発話と対応づけるのに使える）。 |
| `utterances[].trackId` | string | 必須 |  |
| `utterances[].speakerId` | string | 任意 | 話者を表す。話者の分からない発話には、このフィールドは無い。 |
| `utterances[].speakerName` | string | 必須 | 表示名を表す（空・不明なら代替名）。 |
| `utterances[].stage` | "final" / "refined" | 必須 | `final`（音声認識の確定）か `refined`（整形後）を表す。認識途中（`partial`）は含まない。 |
| `utterances[].text` | string | 必須 | 本文を表す（整形しなかったら `rawText` と同じ）。 |
| `utterances[].rawText` | string | 必須 | 整形前の音声認識の結果を表す。 |
| `utterances[].startedAt` | number（エポックミリ秒） | 必須 | 発話の最初の音声を文字おこしくんが受け取った時刻を表す。 |

```json
{
  "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
  "label": "#雑談 VC",
  "speakers": [
    {
      "speakerId": "discord:333333333333333333",
      "displayName": "たろう",
      "avatarUrl": "https://cdn.discordapp.com/avatars/333333333333333333/a.png"
    },
    {
      "speakerId": "discord:444444444444444444",
      "displayName": "はなこ"
    }
  ],
  "utterances": [
    {
      "utteranceId": "5e0b8a54-3f21-4c7a-9d0e-7b6a5c4d3e21",
      "trackId": "discord:333333333333333333",
      "speakerId": "discord:333333333333333333",
      "speakerName": "たろう",
      "stage": "refined",
      "text": "こんにちは、今日はよろしくお願いします。",
      "rawText": "こんにちは今日はよろしくお願いします",
      "startedAt": 1790350831814
    },
    {
      "utteranceId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "trackId": "discord:444444444444444444",
      "speakerId": "discord:444444444444444444",
      "speakerName": "はなこ",
      "stage": "final",
      "text": "よろしくお願いします",
      "rawText": "よろしくお願いします",
      "startedAt": 1790350835814
    }
  ]
}
```

`format=text` の応答:

```text
[21:03:05] たろう: こんにちは、今日はよろしくお願いします。
[21:03:09] はなこ: よろしくお願いします
```

`format=csv` の応答（先頭の BOM は省いて示す）:

```csv
日時,話者,発言
2026-09-26 21:03:05,たろう,こんにちは、今日はよろしくお願いします。
2026-09-26 21:03:09,はなこ,よろしくお願いします
```

```sh
curl -sS "$BASE/v1/sessions/$SESSION_ID/transcript?format=text&tz=Asia/Tokyo" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY"
```

### 書き出しのリンクを作る（POST `/v1/sessions/{sessionId}/downloads`） {#createTranscriptDownloads}

文字おこしくんは、クリックするだけで文字起こしをファイルとして保存できるリンク（CSV・JSON・テキスト）と、記録を ChatGPT に渡すリンク（`chatgptUrl`）を作る。書き出しのリンクは、`transcript:download` のトークンをクエリに載せた `GET /v1/sessions/{sessionId}/transcript` の URL である。`chatgptUrl` は、同じトークンを載せた `GET /v1/sessions/{sessionId}/chatgpt` の URL である。`transcript:download` のトークンでは、書き出しと ChatGPT への転送だけができる（Subscribe には使えない）。クライアントは、リンクを、チャットなどに貼って、資格情報を持たない人に渡してよい。文字おこしくんには、リンクを 1 つずつ失効させる仕組みが無い。有効期間を指定しなければ 3600 秒になる。最大は 86400 秒である。セッションに保持期限（`retainUntil`）があれば、文字おこしくんは、リンクの有効期限を保持期限より後にしない。保持期限を過ぎたセッションには、文字おこしくんは 404 を返す。クライアントは、稼働中のセッションにもリンクを作れる。リンクを開いた人は、開いた時点までの記録を受け取る。`hasUtterances` が false なら、書き出しに載る発話がまだ無い。クライアントは、要求の本文を省ける。

- 認証: API キー（`transcripts:read`）
- operationId: `createTranscriptDownloads`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |
| `Idempotency-Key` | header | string | 任意 | 同じ操作を安全にやり直すためのキーを表す（UUID を勧める）。文字おこしくんは、成功した応答を 24 時間覚え、同じキー・同じ本文の要求には最初の応答をそのまま返す（`Idempotent-Replayed: true`）。同じキーで本文が違えば、文字おこしくんは 422 `idempotency_mismatch` を返す。同じキーの要求が処理中なら、文字おこしくんは 409 `idempotency_in_progress` を返す。文字おこしくんは失敗した要求を覚えないので、同じキーでやり直せる。文字おこしくんは、認証に使った資格情報とメソッド・パスごとにキーを区別する。 |

要求の本文（JSON）:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `ttlSeconds` | integer \| null（1〜86400） | 任意 | リンクの有効期間を秒で表す（既定 3600）。セッションに保持期限（`retainUntil`）があれば、文字おこしくんは、有効期限を保持期限より後にしない。 |

```json
{
  "ttlSeconds": 3600
}
```

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが、書き出しのリンクを作った。 |
| 400 | `ttlSeconds` か `Idempotency-Key` が不正である（`invalid_json` / `invalid_request`） |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに `transcripts:read` のスコープが無い。または、テナントで API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 404 | セッションが無い。保持期間を過ぎたセッションと、別のテナントのセッションにも、文字おこしくんはこの応答を返す（`session_not_found`） |
| 409 | 文字おこしくんが、同じ `Idempotency-Key` の前の要求をまだ処理している（`idempotency_in_progress`） |
| 422 | クライアントが、同じ `Idempotency-Key` で、本文の違う要求を送った（`idempotency_mismatch`） |
| 429 | レート制限を超えた。`Retry-After` の秒数の後にやり直す（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `expiresAt` | number（エポックミリ秒） | 必須 | リンクの有効期限を表す。どのリンクも同じトークンを使うので、有効期限も同じである。 |
| `hasUtterances` | boolean | 必須 | リンクを作った時点で、書き出しに載る発話（本文のある発話）が 1 件でもあったかを表す。false なら、クライアントはリンクを人に見せなくてよい。 |
| `urls` | object | 必須 | 形式ごとの書き出しのリンクを表す。リンクは `GET /v1/sessions/{sessionId}/transcript?format=<形式>&token=<transcript:download のトークン>` の形である。リンクを開いたブラウザは、応答をファイルとして保存する（リンクに `&disposition=inline` を足すと、ブラウザは応答をページとして表示する）。文字おこしくんは、リンクを開いた時点までの記録を書き出す。 |
| `urls.json` | string | 必須 | `format=json` の書き出しのリンクを表す。 |
| `urls.text` | string | 必須 | `format=text` の書き出しのリンクを表す。 |
| `urls.csv` | string | 必須 | `format=csv` の書き出しのリンクを表す。 |
| `chatgptUrl` | string | 必須 | 記録を ChatGPT に渡すリンク（`GET /v1/sessions/{sessionId}/chatgpt?token=<同じトークン>`）を表す。リンクを開いたブラウザは、定型文と記録の URL を入れた ChatGPT の新しい会話を開く。公式のサーバーでは、リンクの長さは Discord のリンクボタンの上限（512 文字）に収まる。 |

```json
{
  "expiresAt": 1790354426000,
  "hasUtterances": true,
  "urls": {
    "json": "https://moji.kaitoranma.com/v1/sessions/0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10/transcript?format=json&token=eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OmRvd25sb2FkIn0.Wm3Kp8Rt2Yv6Nq1Lx4Hc9Jd0Fs5Gb7Tz3Ea8Uw2Qo6r",
    "text": "https://moji.kaitoranma.com/v1/sessions/0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10/transcript?format=text&token=eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OmRvd25sb2FkIn0.Wm3Kp8Rt2Yv6Nq1Lx4Hc9Jd0Fs5Gb7Tz3Ea8Uw2Qo6r",
    "csv": "https://moji.kaitoranma.com/v1/sessions/0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10/transcript?format=csv&token=eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OmRvd25sb2FkIn0.Wm3Kp8Rt2Yv6Nq1Lx4Hc9Jd0Fs5Gb7Tz3Ea8Uw2Qo6r"
  },
  "chatgptUrl": "https://moji.kaitoranma.com/v1/sessions/0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10/chatgpt?token=eyJ2IjoxLCJzaWQiOiIwZjBjM2MxZS01YjdhIiwic2NvcGUiOiJ0cmFuc2NyaXB0OmRvd25sb2FkIn0.Wm3Kp8Rt2Yv6Nq1Lx4Hc9Jd0Fs5Gb7Tz3Ea8Uw2Qo6r"
}
```

```sh
curl -sS -X POST "$BASE/v1/sessions/$SESSION_ID/downloads" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"ttlSeconds":3600}'
```

### 記録を ChatGPT に渡す（GET `/v1/sessions/{sessionId}/chatgpt`） {#openTranscriptInChatGpt}

文字おこしくんは、クエリの `token`（書き出しのリンクと同じ `transcript:download` のトークン）を確かめてから、ブラウザを ChatGPT の新しい会話（`https://chatgpt.com/?q=…`）へ 302 で転送する。`q` には定型文「次の URL は、Discord のボイスチャンネルの会話の文字おこしです。内容を読んで、議事録として要約してください。」と、記録をページとして表示する URL（`GET /v1/sessions/{sessionId}/transcript?format=text&disposition=inline&token=<同じトークン>`）が入る。`POST /v1/sessions/{sessionId}/downloads` の応答の `chatgptUrl` が、この操作の URL である。ChatGPT の URL は日本語の定型文を含んで長いので、文字おこしくんは、Discord のリンクボタン（URL は 512 文字まで）に載せられる短い URL を、この操作で ChatGPT の URL に転送する。保持期間を過ぎたセッションには、文字おこしくんは 404 を返す。クライアントは、クエリの `token` と `Authorization` ヘッダーを同時に付けない（同時に付けると、文字おこしくんは 400 を返す）。`HEAD` では、文字おこしくんは同じ応答を本文なしで返す。

- 認証: クエリの `token`（`transcript:download`）
- operationId: `openTranscriptInChatGpt`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `sessionId` | path | string | 必須 | セッションの ID を指定する。 |

| ステータス | 意味 |
|---|---|
| 302 | 文字おこしくんが、ブラウザを ChatGPT の新しい会話へ転送した（転送先は `Location`）。 |
| 400 | クエリの `token` と `Authorization` ヘッダーを同時に付けた（`invalid_request`） |
| 401 | クエリの `token` が、無いか、無効か、期限切れである（`unauthorized`） |
| 403 | トークンが別のセッションのものである（`forbidden`） |
| 404 | セッションが無い。保持期間を過ぎたセッションにも、文字おこしくんはこの応答を返す（`session_not_found`） |
| 429 | レート制限（送信元 IP ごと）を超えた（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

```sh
curl -sS -o /dev/null -w "%{redirect_url}\n" "$BASE/v1/sessions/$SESSION_ID/chatgpt?token=$DOWNLOAD_TOKEN"
```

## 利用量（Usage） {#tag-usage}

クライアントは、`usage:read` の API キーで、利用量を見る。

### 利用量を見る（GET `/v1/usage`） {#getUsage}

文字おこしくんは、API キーのテナントの日ごとの利用量と、今日（UTC）の上限・残りを返す。利用量は音声認識にかけた音声の秒数で、文字おこしくんは利用量をトラックごとに数えて足す。`from`〜`to` には両端を含めて最大 366 日を指定できる。

- 認証: API キー（`usage:read`）
- operationId: `getUsage`

| パラメーター | 場所 | 型 | | 説明 |
|---|---|---|---|---|
| `from` | query | string | 任意 | 最初の日（UTC、YYYY-MM-DD）を指定する。既定は今日。 |
| `to` | query | string | 任意 | 最後の日（UTC、YYYY-MM-DD）を指定する。既定は今日。`from` 以後の日にする。 |

| ステータス | 意味 |
|---|---|
| 200 | 文字おこしくんが、利用量を返した。 |
| 400 | `from` / `to` が、不正か、逆順か、長すぎる（`invalid_request`） |
| 401 | `Authorization: Bearer` が無いか、不正である。または、API キーが失効している（`unauthorized`） |
| 403 | API キーに `usage:read` のスコープが無い。または、テナントで API キーの利用が無効になっている（`insufficient_scope` / `api_access_disabled`） |
| 429 | レート制限を超えた。`Retry-After` の秒数の後にやり直す（`rate_limited`） |
| default | ほかのステータスのエラー（problem details）。たとえば `413 payload_too_large`・`405 method_not_allowed`・`500 internal`・`503 shutting_down` がある。`code` で分岐し、知らないコードはステータスで扱う（5xx と 429 は待ってやり直す）。 |

応答 200:

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `tenantId` | string | 必須 | テナント（Discord ではギルド ID）を表す。 |
| `plan` | string | 必須 | プラン名を表す。 |
| `from` | string | 必須 | UTC の日付（YYYY-MM-DD）を表す。 |
| `to` | string | 必須 | UTC の日付（YYYY-MM-DD）を表す。 |
| `days` | object[] | 必須 | 文字おこしくんは、`from`〜`to` のすべての日（使っていない日は 0）を、日付の昇順に並べる。 |
| `days[].date` | string | 必須 | UTC の日付（YYYY-MM-DD）を表す。 |
| `days[].audioSeconds` | number（0 以上） | 必須 | その日の利用量を秒で表す。 |
| `totalAudioSeconds` | number（0 以上） | 必須 | 期間の合計を秒で表す。 |
| `today` | object | 必須 | 文字おこしくんは、範囲によらず、常に今日（UTC）の値と上限を返す。 |
| `today.date` | string | 必須 | UTC の日付（YYYY-MM-DD）を表す。 |
| `today.audioSeconds` | number（0 以上） | 必須 | 今日の利用量を秒で表す（文字おこしくんがまだ保存していない直近の分も含む）。 |
| `today.limitAudioSeconds` | integer \| null（0 以上） | 必須 | 1 日の上限を秒で表す。null なら、日ごとの上限は無い。 |
| `today.remainingAudioSeconds` | number \| null（0 以上） | 必須 | 残りを秒で表す（0 未満にならない）。上限が無いなら null になる。 |
| `today.exceeded` | boolean | 必須 | `audioSeconds >= limitAudioSeconds` かどうかを表す（上限が無いなら false）。 |
| `today.resetsAt` | number（エポックミリ秒） | 必須 | 次の UTC 0 時（日本時間 9:00）を表す。 |

```json
{
  "tenantId": "111111111111111111",
  "plan": "level-0",
  "from": "2026-09-24",
  "to": "2026-09-26",
  "days": [
    {
      "date": "2026-09-24",
      "audioSeconds": 0
    },
    {
      "date": "2026-09-25",
      "audioSeconds": 5400.25
    },
    {
      "date": "2026-09-26",
      "audioSeconds": 1234.567
    }
  ],
  "totalAudioSeconds": 6634.817,
  "today": {
    "date": "2026-09-26",
    "audioSeconds": 1234.567,
    "limitAudioSeconds": null,
    "remainingAudioSeconds": null,
    "exceeded": false,
    "resetsAt": 1790467200000
  }
}
```

```sh
curl -sS "$BASE/v1/usage?from=2026-09-24&to=2026-09-26" \
  -H "Authorization: Bearer $MOJIOKOSHI_API_KEY"
```

## WebSocket のエンドポイント（WebSocket） {#tag-websocket}

WebSocket のエンドポイントには Ingest と Subscribe がある。メッセージの一覧は asyncapi.yaml にある。

### WebSocket の Ingest（音声を送る）に接続する（GET `/v1/ingest`） {#ingestWebSocket}

クライアントは、この操作で、WebSocket の Ingest（音声を送る）に接続する。メッセージの一覧は [asyncapi.yaml](https://moji.kaitoranma.com/docs/asyncapi.yaml) と [WebSocket API リファレンス](https://moji.kaitoranma.com/docs/reference.html) にある。クライアントは、URL やヘッダーではなく、接続した後の最初のメッセージ `hello` で `ingest:write` のセッショントークンを送る。普通の HTTP で要求すると、文字おこしくんは 426 を返す。同時接続数は、文字おこしくんの API 全体で 4,096、同じ送信元から 1,024 までである。超えると、文字おこしくんは upgrade に 503 / 429 の `too_many_connections` を返す。

- 認証: 接続後の `hello` でセッショントークン
- operationId: `ingestWebSocket`

| ステータス | 意味 |
|---|---|
| 101 | 接続が WebSocket に切り替わった。この後のメッセージは asyncapi.yaml にある。 |
| 426 | クライアントが、WebSocket ではない普通の HTTP で要求した（`upgrade_required`） |
| 429 | 同じ送信元からの同時接続が多すぎるので、文字おこしくんが upgrade を断った。または、普通の HTTP での要求がレート制限を超えた（`too_many_connections` / `rate_limited`） |
| 503 | 文字おこしくんの API 全体の同時接続が多すぎる。または、文字おこしくんが停止の途中である（`too_many_connections` / `shutting_down`） |

```js
const ws = new WebSocket("wss://moji.kaitoranma.com/v1/ingest");
ws.onopen = () => ws.send(JSON.stringify({ type: "hello", protocolVersion: 1, token: INGEST_TOKEN }));
ws.onmessage = (event) => console.log(event.data); // 詳しくは asyncapi.yaml
```

### WebSocket の Subscribe（結果を受け取る）に接続する（GET `/v1/subscribe`） {#subscribeWebSocket}

クライアントは、この操作で、WebSocket の Subscribe（結果を受け取る）に接続する。メッセージの一覧は [asyncapi.yaml](https://moji.kaitoranma.com/docs/asyncapi.yaml) と [WebSocket API リファレンス](https://moji.kaitoranma.com/docs/reference.html) にある。クライアントは、URL やヘッダーではなく、接続した後の最初のメッセージ `hello` で `transcript:read` のセッショントークンを送る。普通の HTTP で要求すると、文字おこしくんは 426 を返す。同時接続数は、文字おこしくんの API 全体で 4,096、同じ送信元から 1,024 までである。超えると、文字おこしくんは upgrade に 503 / 429 の `too_many_connections` を返す。

- 認証: 接続後の `hello` でセッショントークン
- operationId: `subscribeWebSocket`

| ステータス | 意味 |
|---|---|
| 101 | 接続が WebSocket に切り替わった。この後のメッセージは asyncapi.yaml にある。 |
| 426 | クライアントが、WebSocket ではない普通の HTTP で要求した（`upgrade_required`） |
| 429 | 同じ送信元からの同時接続が多すぎるので、文字おこしくんが upgrade を断った。または、普通の HTTP での要求がレート制限を超えた（`too_many_connections` / `rate_limited`） |
| 503 | 文字おこしくんの API 全体の同時接続が多すぎる。または、文字おこしくんが停止の途中である（`too_many_connections` / `shutting_down`） |

```js
const ws = new WebSocket("wss://moji.kaitoranma.com/v1/subscribe");
ws.onopen = () => ws.send(JSON.stringify({ type: "hello", protocolVersion: 1, token: READ_TOKEN }));
ws.onmessage = (event) => console.log(event.data); // 詳しくは asyncapi.yaml
```

## TypeScript・Python の例 {#examples}

依存なしのクライアント [examples/mojiokoshi-http.ts](examples/mojiokoshi-http.ts)（Node 22.18 以降 / Deno / Bun / tsx）か [examples/mojiokoshi_http.py](examples/mojiokoshi_http.py)（Python 3.10 以降、標準ライブラリだけ）と、ライブの購読の参照実装 [examples/mojiokoshi-subscribe.js](examples/mojiokoshi-subscribe.js) か [examples/mojiokoshi_subscribe.py](examples/mojiokoshi_subscribe.py)（`websockets`）を同じフォルダに置いて、例を動かします。例のコードは、API キーを環境変数 `MOJIOKOSHI_API_KEY` から読みます。

### TypeScript

```ts
// recipes.mts を node recipes.mts で実行する（Node 22.18 以降。または npx tsx recipes.mts。examples/ の 2 つのファイルを同じフォルダに置く）
import { createMojiokoshiHttp } from "./mojiokoshi-http.ts";
import { subscribe, speakerName } from "./mojiokoshi-subscribe.js";

// MOJIOKOSHI_BASE_URL を省くと、公式の文字おこしくん（https://moji.kaitoranma.com）につなぐ
const api = createMojiokoshiHttp({ apiKey: process.env.MOJIOKOSHI_API_KEY!, baseUrl: process.env.MOJIOKOSHI_BASE_URL });

// 1. 自分のセッションを新しい順に一覧する（続きは nextCursor で取る。全部を取るなら allSessions() を使う）
const { sessions } = await api.listSessions({ limit: 5 });
for (const s of sessions) console.log(s.sessionId, s.live ? "live" : "ended", s.label);

// 2. 文字起こしを書き出す（JSON と、1 発話 1 行のテキストで）
const latest = sessions[0];
if (latest !== undefined) {
  const transcript = await api.exportTranscript(latest.sessionId);
  console.log(transcript.utterances.length, "発話");
  console.log(await api.exportTranscriptText(latest.sessionId, "Asia/Tokyo"));
}

// 3. transcript:read のトークンを発行して、ライブで購読する
const [live] = (await api.listSessions({ status: "active", limit: 1 })).sessions;
if (live !== undefined) {
  const { token } = await api.mintReadToken(live.sessionId, 3600); // ブラウザ・OBS には、このトークンだけを渡す
  subscribe({
    url: api.subscribeUrl(),
    token,
    onChange(state, message) {
      if (message.type === "utterance" && message.utterance.stage !== "partial") {
        console.log(`${speakerName(state, message.utterance)}: ${message.utterance.text}`);
      }
    },
    onStatus(status, detail) {
      // 期限（1 時間）の後に再接続すると、文字おこしくんが unauthorized を返し、購読が止まる。長く動かすなら、mintReadToken でトークンを取り直して、subscribe し直す。
      if (status === "failed" || status === "ended") console.error(status, detail?.code ?? "");
    },
  });
}

// 4. 今日（UTC）の利用量を見る
const usage = await api.getUsage();
console.log(`今日 ${(usage.today.audioSeconds / 60).toFixed(1)} 分`);
```

### Python

```python
# recipes.py を python recipes.py で実行する（examples/ の mojiokoshi_http.py と mojiokoshi_subscribe.py を同じフォルダに置く）
import asyncio
import os

from mojiokoshi_http import DEFAULT_BASE_URL, MojiokoshiHttp
from mojiokoshi_subscribe import run  # ライブの購読（pip install websockets）

api = MojiokoshiHttp(os.environ["MOJIOKOSHI_API_KEY"], os.environ.get("MOJIOKOSHI_BASE_URL", DEFAULT_BASE_URL))

# 1. 自分のセッションを新しい順に一覧する（続きは nextCursor を cursor に渡して取る）
page = api.list_sessions(limit=5)
for s in page["sessions"]:
    print(s["sessionId"], "live" if s["live"] else "ended", s["label"])

# 2. 文字起こしを書き出す（JSON と、1 発話 1 行のテキストで）
if page["sessions"]:
    session_id = page["sessions"][0]["sessionId"]
    print(len(api.export_transcript(session_id)["utterances"]), "発話")
    print(api.export_transcript_text(session_id, tz="Asia/Tokyo"))

# 3. 今日（UTC）の利用量を見る
today = api.get_usage()["today"]
print(f"今日 {today['audioSeconds'] / 60:.1f} 分")

# 4. transcript:read のトークンを発行して、ライブで購読する（Ctrl+C で止める）
live = api.list_sessions(status="active", limit=1)["sessions"]
if live:
    token = api.mint_read_token(live[0]["sessionId"], ttl_seconds=3600)["token"]
    asyncio.run(run(api.subscribe_url(), token))
```
