Webhook リファレンス

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

送信先を登録する

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

出来事

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

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

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(エポックミリ秒) 必須 セッションを作った時刻を表す。
{
  "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

文字おこしくんはセッションが終わると、音声認識の結果を最後まで受け取ってから、この出来事を送る。発言を保存する 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(エポックミリ秒) 任意 セッションの記録が保持期間を過ぎて削除される時刻を表す。文字おこしくんが記録を削除しないなら、このフィールドは無い。
{
  "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

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 サーバー)を表す。
{
  "id": "evt_7Bv3Mh6Ks1Dx9Tq4Wj2Ne8Uc",
  "type": "webhook.test",
  "createdAt": 1790350226814,
  "data": {
    "webhookId": "wh_4fT9kQ2mX7pL1vR8cZ3nB6yD",
    "tenantId": "111111111111111111"
  }
}

ヘッダー

文字おこしくんは次のヘッダーを付けて、本文を 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 つが一致すれば要求を受け入れる。

署名を確かめる

文字おこしくんは Standard Webhooks の形で本文に署名します。署名を確かめると、要求が文字おこしくんから届いたことと、本文が途中で書き換えられていないことが分かります。文字おこしくんは 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(Node 22.18 以降 / Deno / Bun / Cloudflare Workers)と Python の examples/mojiokoshi_webhook.py(Python 3.10 以降、標準ライブラリだけ)にあります。

TypeScript

// 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

# 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 で受け取り口につながるようにしてください。

例の値で試す

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

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

本文(1 行の 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}}

応答のしかた

送り直し

文字おこしくんは失敗した出来事を、次の間隔で送り直します。送り直しても 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 サーバーの管理者が再開するまで、文字おこしくんはその送信先に出来事を送らず、その間に起きた出来事を後から送ることもありません(送らないとき)。

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

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

届け方

送らないとき

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

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

「保存しない」設定で記録を残す

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

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

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

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