# Webhook リファレンス

文字おこしくんは文字起こしの開始と終了を、Discord サーバーの管理者が登録した URL に HTTPS の `POST` で知らせます（Webhook）。あなたのサーバーは知らせを受け取ったら、[HTTP API](http.md) で記録を書き出したり、[WebSocket の Subscribe](reference.md) で発言を受け取ったりできます。本文は JSON で、発言の文字は入りません。機械可読な版は [openapi.yaml](openapi.yaml) の `webhooks` にあります。

## 送信先を登録する {#register}

Discord サーバーの管理者は[管理画面](/app/)でその Discord サーバーを選び、「開発者向け設定」の「Webhook」の節の「送信先を追加」で、受け取る URL と送る出来事を登録します。管理画面は署名のシークレット（`whsec_…`）を、送信先を作ったときに 1 回だけ表示します。

- あなたがその Discord サーバーの管理者でなければ、管理者に送信先を登録してもらい、シークレットを受け取ってください。
- シークレットはあなたのサーバー側だけに置いてください（ブラウザ・リポジトリ・ログに置かない）。シークレットをほかの人に知られたときは、Discord サーバーの管理者だけが管理画面の「シークレットを作り直す」で新しいシークレットに替えられます。
- URL は `https://` で始まり、ホストがインターネットに公開しているドメイン名の URL にします（2048 文字まで）。文字おこしくんは IP アドレスを直に書いた URL と、`localhost`・`*.localhost`・`*.local`・`*.internal` の URL を受け付けません。文字おこしくん自身のドメイン（`moji.kaitoranma.com` とその下の名前）の URL と、ユーザー名・パスワード・`#` を含む URL も受け付けません。
- Discord サーバーごとの送信先の数の上限は[レート制限と上限](limits.md)にあります。
- Discord サーバーの管理者が管理画面の「テストを送る」を押すと、文字おこしくんは [`webhook.test`](#webhook-test) を 1 回送ります。受け取る側を作ったら、まず Discord サーバーの管理者に「テストを送る」を押してもらい、あなたのサーバーに `webhook.test` が届くかを確かめてください。

## 出来事 {#events}

| 出来事 | 内容 |
|---|---|
| [`session.started`](#session-started) | 文字起こしが始まったことを知らせる |
| [`session.ended`](#session-ended) | 文字起こしが終わったことを知らせる |
| [`webhook.test`](#webhook-test) | Discord サーバーの管理者が「テストを送る」を押したことを知らせる |

Discord サーバーの管理者は送信先ごとに、管理画面の「送る出来事」で「文字起こしの開始」（`session.started`）と「文字起こしの終了」（`session.ended`）のどちらを送るかを選びます。両方を選ぶこともできます。Discord サーバーの管理者が「テストを送る」を押したときだけ、文字おこしくんは `webhook.test` を送ります。文字おこしくんは出来事の種類と本文のフィールドを足すことがあります。知らない `type` の出来事にも 2xx を返し、知らないフィールドは無視してください。

### `session.started` {#session-started}

文字おこしくんは文字起こしのセッションを新しく作ると、この出来事を送る。公式の Discord ボットが `/start` や自動入室でセッションを作ったときも、あなたが API キーで `POST /v1/sessions` を呼んでセッションを作ったときも、文字おこしくんはこの出来事を送る。文字おこしくんは動いているセッションを使い回したとき（`reuseActive`）と、再起動の後にセッションを動かし直したときには、この出来事を送らない。本文（JSON）には発言が入らない。発言を受け取るときは、あなたのサーバーが `data.sessionId` の `transcript:read` のトークンを発行し、Subscribe で購読する。

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `id` | string | 必須 | 出来事の ID を表す。`webhook-id` ヘッダーと同じ値である。文字おこしくんは送り直しても、同じ出来事を複数の送信先へ送っても、同じ ID を使う。受け取る側はこの ID で重複を捨てる。 |
| `type` | "session.started" | 必須 | 出来事の種類を表す。 |
| `createdAt` | number（エポックミリ秒） | 必須 | 出来事の時刻（セッションを作った時刻。`data.createdAt` と同じ）を表す。 |
| `data` | object | 必須 | 始まったセッションを表す（フィールドの名前と意味は `GET /v1/sessions/{sessionId}` の応答と同じ）。 |
| `data.sessionId` | string | 必須 | セッションの ID を表す。 |
| `data.tenantId` | string | 必須 | セッションのテナント（Discord サーバー）を表す。 |
| `data.label` | string（1〜100 文字） | 必須 | セッションの表示名を表す。 |
| `data.externalRef` | string（1〜256 文字） | 任意 | 入力元での識別子を表す。公式の Discord ボットのセッションでは `discord:<guildId>:<channelId>` になる。入力元での識別子の無いセッションには、このフィールドは無い。 |
| `data.createdAt` | number（エポックミリ秒） | 必須 | セッションを作った時刻を表す。 |

```json
{
  "id": "evt_2Gd7Np4Qx9Ls1Vk6Rc3Wm8Hb",
  "type": "session.started",
  "createdAt": 1790350826814,
  "data": {
    "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
    "tenantId": "111111111111111111",
    "label": "#雑談 VC",
    "externalRef": "discord:111111111111111111:222222222222222222",
    "createdAt": 1790350826814
  }
}
```

### `session.ended` {#session-ended}

文字おこしくんはセッションが終わると、音声認識の結果を最後まで受け取ってから、この出来事を送る。発言を保存する Discord サーバーでは、文字おこしくんは確定した発言を保存し終えてからこの出来事を送るので、あなたがこの出来事を受け取ってから書き出す（`GET /v1/sessions/{sessionId}/transcript`）と、終わりまでの確定した発言を受け取れる。`data.audioSeconds`（利用量）と `data.retainUntil`（削除される時刻）も、この時点で確定している。文字おこしくんの停止と再起動ではセッションは終わらないので、文字おこしくんはこの出来事を送らない。

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `id` | string | 必須 | 出来事の ID を表す。`webhook-id` ヘッダーと同じ値である。文字おこしくんは送り直しても、同じ出来事を複数の送信先へ送っても、同じ ID を使う。受け取る側はこの ID で重複を捨てる。 |
| `type` | "session.ended" | 必須 | 出来事の種類を表す。 |
| `createdAt` | number（エポックミリ秒） | 必須 | 出来事の時刻（セッションが終わった時刻。`data.endedAt` と同じ）を表す。 |
| `data` | object | 必須 | 終わったセッションを表す（フィールドの名前と意味は `GET /v1/sessions/{sessionId}` の応答と同じ）。 |
| `data.sessionId` | string | 必須 | セッションの ID を表す。 |
| `data.tenantId` | string | 必須 | セッションのテナント（Discord サーバー）を表す。 |
| `data.label` | string（1〜100 文字） | 必須 | セッションの表示名を表す。 |
| `data.externalRef` | string（1〜256 文字） | 任意 | 入力元での識別子を表す。公式の Discord ボットのセッションでは `discord:<guildId>:<channelId>` になる。入力元での識別子の無いセッションには、このフィールドは無い。 |
| `data.createdAt` | number（エポックミリ秒） | 必須 | セッションを作った時刻を表す。 |
| `data.endedAt` | number（エポックミリ秒） | 必須 | セッションが終わった時刻を表す。 |
| `data.endReason` | "requested" / "idle" / "quota_exceeded" \| string | 必須 | セッションが終わった理由を表す。今は `requested`（`POST /v1/sessions/{sessionId}/end` で終わった。公式の Discord ボットが Discord の `/stop` で終えたときも含む）・`idle`（Ingest（音声の送り手）がつながらないまま 30 分が過ぎた。または、文字おこしくんの再起動の後に、誰もつなぎ直さなかった）・`quota_exceeded`（テナントの今日（UTC）の利用量が上限に達した。公式の文字おこしくんには 1 日の上限が無い）のどれかである。文字おこしくんは理由を足すことがあるので、受け取る側は知らない値も受け入れる。 |
| `data.audioSeconds` | number（0 以上） | 必須 | このセッションの利用量（音声認識にかけた音声の秒数。小数第 3 位まで）を表す。`GET /v1/sessions/{sessionId}` の `audioSeconds` と同じ値である。 |
| `data.retainUntil` | number（エポックミリ秒） | 任意 | セッションの記録が保持期間を過ぎて削除される時刻を表す。文字おこしくんが記録を削除しないなら、このフィールドは無い。 |

```json
{
  "id": "evt_5Jt1Ry8Fk3Zm6Qa9Lw2Pc4Xn",
  "type": "session.ended",
  "createdAt": 1790354426814,
  "data": {
    "sessionId": "0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10",
    "tenantId": "111111111111111111",
    "label": "#雑談 VC",
    "externalRef": "discord:111111111111111111:222222222222222222",
    "createdAt": 1790350826814,
    "endedAt": 1790354426814,
    "endReason": "requested",
    "audioSeconds": 1234.567,
    "retainUntil": 1792946426814
  }
}
```

### `webhook.test` {#webhook-test}

Discord サーバーの管理者が管理画面の「テストを送る」を押すと、文字おこしくんはこの出来事を 1 回だけ送り、失敗しても自動では送り直さない。管理者が送信の記録の「もう一度送る」を押すと、文字おこしくんは同じ `webhook-id` でもう一度送る（受け取る側はほかの出来事と同じく ID で重複を捨てる）。管理者が送信先を止めているときも、文字おこしくんは `webhook.test` を送る（管理者は再開する前に受け取る側を確かめられる）。受け取る側はほかの出来事と同じく署名を確かめてから 2xx を返す。

| フィールド | 型 | | 説明 |
|---|---|---|---|
| `id` | string | 必須 | 出来事の ID を表す。`webhook-id` ヘッダーと同じ値である。文字おこしくんは送り直しても、同じ出来事を複数の送信先へ送っても、同じ ID を使う。受け取る側はこの ID で重複を捨てる。 |
| `type` | "webhook.test" | 必須 | 出来事の種類を表す。 |
| `createdAt` | number（エポックミリ秒） | 必須 | 出来事の時刻（文字おこしくんがテストを送った時刻）を表す。 |
| `data` | object | 必須 | テストを送った送信先を表す。 |
| `data.webhookId` | string | 必須 | テストを送った送信先の ID を表す。 |
| `data.tenantId` | string | 必須 | 送信先のテナント（Discord サーバー）を表す。 |

```json
{
  "id": "evt_7Bv3Mh6Ks1Dx9Tq4Wj2Ne8Uc",
  "type": "webhook.test",
  "createdAt": 1790350226814,
  "data": {
    "webhookId": "wh_4fT9kQ2mX7pL1vR8cZ3nB6yD",
    "tenantId": "111111111111111111"
  }
}
```

## ヘッダー {#headers}

文字おこしくんは次のヘッダーを付けて、本文を `Content-Type: application/json` で送ります。

| ヘッダー | 内容 |
|---|---|
| `webhook-id` | 出来事の ID を表す（本文の `id` と同じ）。文字おこしくんは送り直しても同じ ID を使う。受け取る側はこの ID で重複を捨てる。 |
| `webhook-timestamp` | この試行の時刻（Unix 秒）を表す。文字おこしくんは送り直すたびに新しい時刻にする。受け取る側は自分の時刻と 5 分より離れた時刻の要求を捨てる。 |
| `webhook-signature` | 署名を表す。署名は `v1,` + base64(HMAC-SHA256(鍵, `<webhook-id>.<webhook-timestamp>.<本文>`)) で、鍵はシークレットの `whsec_` の後ろを base64 で戻した 32 バイトである。シークレットを作り直してから 24 時間、文字おこしくんは前のシークレットの署名と新しいシークレットの署名を空白で区切って並べる。受け取る側はどれか 1 つが一致すれば要求を受け入れる。 |

## 署名を確かめる {#verify}

文字おこしくんは [Standard Webhooks](https://www.standardwebhooks.com/) の形で本文に署名します。署名を確かめると、要求が文字おこしくんから届いたことと、本文が途中で書き換えられていないことが分かります。文字おこしくんは Cloudflare のネットワークから送るので、送信元の IP アドレスではなく署名で確かめてください。

1. JSON として読む前のバイト列のまま、受け取った本文を取っておきます。JSON として読んでから書き直した文字列では、署名が合いません。
2. `webhook-timestamp` があなたのサーバーの今の時刻から 5 分より離れていれば、要求を捨てます。古い要求をもう一度送られても受け付けないためです。
3. シークレットの `whsec_` の後ろを base64 として読み、32 バイトの鍵にします。
4. `webhook-id`・`.`・`webhook-timestamp`・`.`・本文をこの順につないだバイト列の HMAC-SHA256 を、手順 3 の鍵で計算し、base64 にします。
5. `webhook-signature` を空白で区切ります。`v1,` で始まる署名のどれかが `v1,` と手順 4 の値をつないだ文字列と一致すれば、要求は文字おこしくんから届いたものです。署名が一致するかは定数時間で比べる関数（Node の `crypto.timingSafeEqual`、Python の `hmac.compare_digest`）で確かめます。
6. 確かめられなければ、`400` などを返して要求を捨てます。

Discord サーバーの管理者がシークレットを作り直すと、文字おこしくんは 24 時間、前のシークレットの署名と新しいシークレットの署名を `webhook-signature` に並べます。その 24 時間の内に、あなたのサーバーのシークレットを新しいものに替えてください。

Standard Webhooks の各言語のライブラリでも署名を確かめられます。依存の無い例は TypeScript の [examples/mojiokoshi-webhook.ts](examples/mojiokoshi-webhook.ts)（Node 22.18 以降 / Deno / Bun / Cloudflare Workers）と Python の [examples/mojiokoshi_webhook.py](examples/mojiokoshi_webhook.py)（Python 3.10 以降、標準ライブラリだけ）にあります。

### TypeScript {#verify-typescript}

```ts
// webhook.mts を node webhook.mts で実行する（Node 22.18 以降。examples/mojiokoshi-webhook.ts を同じフォルダに置く）
import { createServer } from "node:http";
import { MojiokoshiWebhookError, verifyMojiokoshiWebhook } from "./mojiokoshi-webhook.ts";

const secret = process.env.MOJIOKOSHI_WEBHOOK_SECRET!; // whsec_…（ソースに書かず、環境変数などから読む）
const seen = new Set<string>(); // 受け取った webhook-id（本番では DB に置く）

createServer((request, response) => {
  const chunks: Buffer[] = [];
  request.on("data", (chunk: Buffer) => chunks.push(chunk));
  request.on("end", async () => {
    let event;
    try {
      // JSON として読む前の生の本文で確かめる
      event = await verifyMojiokoshiWebhook(Buffer.concat(chunks), request.headers, secret);
    } catch (error) {
      response.writeHead(error instanceof MojiokoshiWebhookError ? 400 : 500).end();
      return;
    }
    response.writeHead(204).end(); // すぐに 2xx を返し、時間のかかる仕事はこの後に始める
    if (seen.has(event.id)) return; // 同じ出来事が 2 回以上届くことがある
    seen.add(event.id);
    switch (event.type) {
      case "session.started":
        console.log("始まった", event.data.sessionId, event.data.label);
        break;
      case "session.ended":
        console.log("終わった", event.data.sessionId, event.data.endReason);
        break;
      default:
        break; // webhook.test と知らない出来事には何もしない
    }
  });
}).listen(8787);
```

### Python {#verify-python}

```python
# examples/mojiokoshi_webhook.py を同じフォルダに置く（Python 3.10 以降、標準ライブラリだけ）
import os

from mojiokoshi_webhook import HeadersLike, WebhookVerificationError, verify_webhook

seen: set[str] = set()  # 受け取った webhook-id（本番では DB に置く）


def handle(body: bytes, headers: HeadersLike) -> int:
    """生の本文（bytes）とヘッダーから、返すステータスを決める（Flask なら request.get_data() と request.headers を渡す）"""
    try:
        event = verify_webhook(body, headers, os.environ["MOJIOKOSHI_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return 400
    if event["id"] not in seen:  # 同じ出来事が 2 回以上届くことがある
        seen.add(event["id"])
        if event["type"] == "session.ended":
            print("終わった", event["data"]["sessionId"], event["data"]["endReason"])
    return 204  # 時間のかかる仕事は応答を返してから始める
```

`python mojiokoshi_webhook.py 8787` を実行すると、例のファイルが試しの受け取り口になり、届いた出来事を表示します。文字おこしくんから出来事が届くようにするには、外から `https://` の URL で受け取り口につながるようにしてください。

### 例の値で試す {#test-vector}

あなたが書いた署名の確かめ方はこの節の値で試せます。次のシークレット・ヘッダー・本文の組では、署名が一致します。手順 2 の時刻のずれの確かめは外して試してください。シークレットは例のためだけに作った値で、どの送信先のものでもありません。

| 項目 | 値 |
|---|---|
| シークレット | `whsec_RcH9I+W4YiNiF5/mN29sT9OdBZqc5KWM7rKcdRhK84Q=` |
| `webhook-id` | `evt_2Gd7Np4Qx9Ls1Vk6Rc3Wm8Hb` |
| `webhook-timestamp` | `1790350826` |
| `webhook-signature` | `v1,jV/+k3t+ftGc5l+2rz1BaaVl0jG9zw06s4btUWryOEE=` |

本文（1 行の JSON。改行も空白もこの文字列のとおりです）:

```json
{"id":"evt_2Gd7Np4Qx9Ls1Vk6Rc3Wm8Hb","type":"session.started","createdAt":1790350826814,"data":{"sessionId":"0f0c3c1e-5b7a-4d2e-9c61-3f2d8a4b7e10","tenantId":"111111111111111111","label":"#雑談 VC","externalRef":"discord:111111111111111111:222222222222222222","createdAt":1790350826814}}
```

## 応答のしかた {#respond}

- 出来事を受け取ったら、すぐに 2xx を返してください。文字おこしくんは 2xx の応答だけを成功にし、応答の本文を読みません。書き出しのような時間のかかる仕事は 2xx を返してから始めてください。
- 文字おこしくんは 15 秒の内に応答が無い試行を打ち切り、失敗にします。Discord サーバーの管理者が管理画面の「テストを送る」か「もう一度送る」を押したときは、文字おこしくんは 5 秒の内に応答が無い試行を打ち切ります。
- 3xx を含め、2xx のほかのステータスはすべて失敗です。文字おこしくんはリダイレクトをたどりません。
- 受け取る側が `410 Gone` を返すと、文字おこしくんはすぐにその送信先を止めます（管理画面の状態は「送信先が受け取りをやめたため停止」）。Discord サーバーの管理者が管理画面で再開するまで、文字おこしくんはその送信先に出来事を送りません（[送らないとき](#not-sent)）。`410 Gone` はその送信先で出来事を受け取るのをやめるときだけ返してください。Discord サーバーの管理者が「テストを送る」か「もう一度送る」で送った出来事に受け取る側が `410` を返しても、文字おこしくんは送信先を止めません。

## 送り直し {#retries}

文字おこしくんは失敗した出来事を、次の間隔で送り直します。送り直しても `webhook-id` は変わりません。文字おこしくんは送り直すたびに `webhook-timestamp` と署名を作り直します。

| 試行 | 文字おこしくんが送るとき |
|---|---|
| 1 回目 | 出来事が起きたとき |
| 2 回目 | 1 回目が失敗してから 5 秒後 |
| 3 回目 | 2 回目が失敗してから 5 分後 |
| 4 回目 | 3 回目が失敗してから 30 分後 |
| 5 回目 | 4 回目が失敗してから 2 時間後 |
| 6 回目 | 5 回目が失敗してから 5 時間後 |
| 7 回目 | 6 回目が失敗してから 10 時間後 |
| 8 回目 | 7 回目が失敗してから 10 時間後 |

文字おこしくんは最初の試行を含めて 8 回まで送ります。間隔を足すと、最初の試行から最後の試行まで約 27.6 時間です。8 回目も失敗すると、その出来事は失敗になり、文字おこしくんはその送信先を止めます（管理画面の状態は「失敗が続いたため停止」）。止めた送信先のほかの送信待ちの出来事も失敗になります。Discord サーバーの管理者が再開するまで、文字おこしくんはその送信先に出来事を送らず、その間に起きた出来事を後から送ることもありません（[送らないとき](#not-sent)）。

受け取る側へ送れなかった原因が文字おこしくんの側にあるときは、その試行は 8 回までの試行の回数に入りません。文字おこしくんはその出来事を 5 分後に送り直します。原因が文字おこしくんの側にある失敗が続いても、文字おこしくんは送信先を止めません。

Discord サーバーの管理者は管理画面の「送信の記録」の「もう一度送る」で、失敗した出来事を 1 つずつ送り直せます。終わった送信の記録は 7 日で削除されるので、Discord サーバーの管理者は失敗した出来事を 7 日の内に送り直してください。

## 届け方 {#delivery}

- 文字おこしくんは同じ出来事を 2 回以上送ることがあります。たとえば、受け取る側の応答が打ち切りの時間（15 秒）に間に合わなかったときと、受け取る側が 2xx を返した後、文字おこしくんが結果を記録する前に再起動したときに、文字おこしくんは同じ出来事をもう一度送ります。`webhook-id`（本文の `id`）を覚えておき、覚えている `webhook-id` の要求には何もせずに 2xx を返してください。
- 文字おこしくんは送信先ごとに、次の試行の時刻が早い出来事から送るので、出来事を起きた順に届けるとは限りません。たとえば、`session.started` を送り直している間に、同じセッションの `session.ended` が先に届くことがあります。順番が必要なときは、本文の `createdAt` で並べてください。
- 文字おこしくんが止まっている間は、文字おこしくんは出来事を送りません。文字おこしくんは再起動すると、送れていない出来事から送ります。

## 送らないとき {#not-sent}

文字おこしくんは次のときに出来事を送りません。

- 動いているセッションを使い回したとき（`POST /v1/sessions` の `reuseActive`）。公式の Discord ボットも同じ VC で動いているセッションを使い回します。文字おこしくんはセッションを作ったときに 1 回だけ `session.started` を送ります。
- 文字おこしくんの再起動の後に、動いていたセッションを動かし直したとき。文字おこしくんの停止と再起動ではセッションは終わらないので、文字おこしくんは `session.ended` も送りません。
- 文字おこしくんの運営者がその Discord サーバーの API キーの利用を止めているとき（API キーでの要求には、文字おこしくんが [`api_access_disabled`](errors/index.md#api_access_disabled) を返します）。文字おこしくんの運営者が API キーの利用を止める前から送信待ちだった出来事も、文字おこしくんは送らずに失敗にします。
- Discord サーバーの管理者か文字おこしくんが送信先を止めているとき（[応答のしかた](#respond)・[送り直し](#retries)）。止めたときに送信待ちだった出来事は失敗になります。Discord サーバーの管理者が送信先を再開した後も、文字おこしくんは送信先が止まっている間に起きた出来事を送りません。
- Discord サーバーの管理者が送信先の「送る出来事」でその出来事を選んでいないとき。
- 1 つの送信先に送信待ちの出来事が 1000 件あるとき。文字おこしくんはその送信先の送信待ちが 1000 件より少なくなるまで、新しく起きた出来事をその送信先に送らず、送信の記録にも残しません。
- 文字おこしくんが出来事を送り終える前に、その出来事のセッションが削除されたとき（保持期間が過ぎたときと、あなたが [`DELETE /v1/sessions/{sessionId}`](http.md#deleteSession) を呼んだとき）。

Discord サーバーの管理者は管理画面の「送信の記録」の「もう一度送る」で、失敗になった出来事を 7 日の内に送り直せます（[送り直し](#retries)）。送信先が止まっている間に起きた出来事は送信の記録に無いので、Discord サーバーの管理者も送り直せません。あなたは文字おこしくんが出来事を送らなかったセッションを [`GET /v1/sessions`](http.md#listSessions) の一覧から探せます。

## 「保存しない」設定で記録を残す {#store-transcripts-off}

Discord サーバーの管理者が管理画面の「管理者向け設定」の「文字おこしの保存」で発言を保存しないように決めると、文字おこしくんはその Discord サーバーの発言を保存しません。書き出しには何も入りません。あなたが途中から購読すると、文字おこしくんは直近の発言だけを `welcome` で送ります（件数は[レート制限と上限](limits.md#websocket)にあります）。記録をあなたの所に残すときは、`session.started` を受け取ったらすぐに購読を始めます。

Discord サーバーの管理者は参加者に「記録を残さない」と伝えたうえで「保存しない」を選んでいることがあります。あなたの所に記録を残すときは、記録を残すことを参加者に伝え、了承を得てください（[プライバシーポリシー](../legal/privacy.html#who-can-see)）。

1. Discord サーバーの管理者に、管理画面の「送る出来事」で「文字起こしの開始」（`session.started`）と「文字起こしの終了」（`session.ended`）の両方を選んでもらいます。
2. `session.started` を受け取ったら、あなたのサーバーが API キー（`transcripts:read`）で [`POST /v1/sessions/{sessionId}/tokens`](http.md#createSessionToken) を呼び、`data.sessionId` の `transcript:read` のトークンを発行します。API キーの作り方は[クイックスタート](quickstart.md#from-api-key)にあります。
3. 発行したトークンで Subscribe（`/v1/subscribe`）に接続し、確定した発言（`final`・`refined`）を `utteranceId` ごとに置き換えながら保存します（[発話の改訂モデル](revisions.md)。参照実装は [examples/mojiokoshi-subscribe.js](examples/mojiokoshi-subscribe.js)）。トークンの期限の後につなぎ直すときは、トークンを発行し直します（[再接続とエラーの扱い](reconnection.md#token-expiry)）。
4. `session.ended` を受け取ったら、そのセッションの発言の保存を終えます。文字おこしくんは Subscribe の接続にも `session_ended` を送ります。

文字おこしくんが発言を保存する Discord サーバーでは、`session.ended` を受け取ってから [`GET /v1/sessions/{sessionId}/transcript`](http.md#exportTranscript) で書き出すと、終わりまでの記録を受け取れます（[記録と書き出し](guides/archive.md)）。新しく終わったセッションを探して一覧を読み直す必要はありません。
