# Webhookで完了を通知する

> 文字起こしの完了・失敗を署名付きリクエストで受け取り、署名を検証します。

Canonical: https://transcript1.com/ja/docs/webhooks
Language: ja
Last updated: 2026-10-14

文字起こしの完了・失敗はWebhookで受け取れます。作成時に`webhook_url`を指定すると、そのURLへ署名付き`POST`リクエストを送ります。

Webhookを設定できるのはREST APIだけです。[MCPサーバー](https://transcript1.com/ja/docs/mcp)にWebhookのパラメーターはありません。

## 通知先URLを指定する

動画リンクを送るときや、分割アップロードを作成するときは、`POST /v1/transcriptions`のJSONボディに`webhook_url`を指定します。1リクエストでファイルを送る場合は、フォームフィールドに指定してください。

```json
{
  "platform": "youtube",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "webhook_url": "https://example.com/hooks/transcript1"
}
```

通知先URLには次の条件があります。

- HTTPSを使います。
- ユーザー名やパスワードを含めません。
- 名前解決先が公開アドレスである必要があります。

プライベート、ループバック、リンクローカルなど、非公開アドレスを指すURLには`422 invalid_request`を返します。アドレスは作成時だけでなく、通知を送るたびに検証します。

`reuse_recent=true`との併用はできません。`webhook_url`を使うリクエストでは指定しないでください。

## 通知するイベント

| `type` | 通知の条件 |
|---|---|
| `transcription.completed` | ステータスが`completed`になると送ります。 |
| `transcription.failed` | ステータスが`failed`になると送ります。`data.error`に`code`と`message`が入ります。 |

文字起こしをキャンセルした場合は、Webhookを送りません。

## 通知リクエストの内容

次のJSONをリクエストボディとして送ります。

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

通知には文字起こしテキストを含めません。`data`は`transcript`を除いた文字起こしオブジェクトで、上の例では一部のフィールドを省略しています。テキストが必要な場合は、`data`の`id`で`GET /v1/transcriptions/:id`を呼び出してください。`created_at`はイベントを作成した日時です。

ヘッダーには`Content-Type: application/json`と、`transcript1-webhooks/1.0`を値に持つ`User-Agent`を付けます。署名用ヘッダーも送ります。

## 受信時に署名を検証する

通知を受け取ったら、必ず付いている`X-Transcript1-Signature`ヘッダーで署名を検証してください。

```text
X-Transcript1-Signature: t=<unix seconds>,v1=<hex digest>
```

- `t`：リクエストに署名した時刻をUnix時間の秒で表します。
- `v1`：`"<t>.<raw body>"`のHMAC-SHA256を16進数で表します。署名対象は、`t`の値、ドット、受信したままのボディを連結した文字列です。HMACのキーには、`whsec_`を含む署名シークレット全体を使います。

検証には、JSONとしてパースする前のボディのバイト列を使ってください。パース後にJSONへ戻すと、空白やキーの順序が変わり、署名が一致しなくなることがあります。

署名の比較には定数時間で動く関数を使います。さらに`t`と現在時刻の差を確認し、大きくずれていれば受け付けません。以下のコードでは許容幅を5分にしています。

### Node.js

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

Expressでは、先にJSONをパースしないよう、ボディをそのままテキストとして読み取ります。

```javascript
import process from 'node:process';
import express from 'express';
import { verifySignature } from './verify-signature.js';

const app = express();

app.post('/hooks/transcript1', express.text({ type: 'application/json' }), (request, response) => {
  const header = request.get('X-Transcript1-Signature') ?? '';

  if (!verifySignature(process.env.TRANSCRIPT1_WEBHOOK_SECRET, header, request.body)) {
    response.sendStatus(400);
    return;
  }

  const { type, data } = JSON.parse(request.body);
  console.log(type, data.id, data.status);
  response.sendStatus(200);
});
```

### Python

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

Flaskを使う場合は、`raw_body`に`request.get_data(as_text=True)`を渡してください。

## 署名シークレットの確認と更新

`whsec_`で始まる署名シークレットはチーム共通です。そのチームが作成するすべての文字起こしのWebhookに、同じシークレットで署名します。

確認・ローテーションは、ダッシュボードの「[Webhooks](https://transcript1.com/dashboard/webhooks)」（`/dashboard/webhooks`）で行います。ローテーションするとすぐに新しい値へ切り替わり、以降のWebhookには新しいシークレットで署名します。

## 応答・再送・重複への対応

- `2xx`を返せば配信成功です。
- それ以外のステータスコード、接続エラー、10秒以内に応答がない場合は配信失敗です。
- 配信に失敗すると、指数バックオフで間隔を延ばしながら再送します。初回を含めて最大6回です。再送までの待ち時間は順に2分、4分、8分、16分、32分で、最後の送信は初回から約1時間後になります。
- transcript1はリダイレクト先を追いません。`3xx`も配信失敗になるので、通知先には最終的なURLを指定してください。
- サーバーがタイムアウト後に応答した場合など、同じ通知が複数回届くことがあります。`data.id`と`type`を組み合わせ、処理済みの通知は無視してください。
