Cloudflare Stream は、localhost やローカル IP アドレスへ Webhook 通知 を送れません。ローカル開発中に Webhook をテストするには、ローカルマシンへリクエストを転送する公開 URL が必要です。
この例では、次の手順を行います。
- Cloudflare Tunnel を起動し、ローカル環境向けの公開 URL を取得します。
- その URL を Webhook エンドポイントとして登録し、署名用シークレットを受け取ります。
- Stream の Webhook イベントを受け取り、署名を検証する Cloudflare Worker を作成します。
- Stream が有効な Cloudflare アカウント ↗
- Node.js ↗(v18 以降)
- インストール済みの Wrangler CLI(
npm install -g wrangler)
Webhook リクエストを受け取る新しい Worker プロジェクトを作成します。
npm create cloudflare@latest stream-webhook-handlerWebhook URL を登録する前に、ローカルマシンを指す公開 URL が必要です。ターミナルで、Wrangler 開発サーバーのデフォルトポート(8787)へ転送する クイックトンネル を起動します。
npx cloudflared tunnel --url http://localhost:8787cloudflared は、次のような公開 URL を出力します。
https://example-words-here.trycloudflare.comこの URL をコピーします。トンネルを再起動するたびに変わります。
Stream API で、トンネル URL を Webhook 通知 URL に設定します。API レスポンスの secret フィールドは、Webhook 署名の検証に使います。
Required API token permissions
At least one of the following token permissions is required:Stream Write
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/stream/webhook" \
--request PUT \
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
--json '{
"notificationUrl": "https://example-words-here.trycloudflare.com"
}'レスポンスには secret フィールドが含まれます。
{
"result": {
"notificationUrl": "https://example-words-here.trycloudflare.com",
"modified": "2024-01-01T00:00:00.000000Z",
"secret": "85011ed3a913c6ad5f9cf6c5573cc0a7"
},
"success": true,
"errors": [],
"messages": []
}secret の値を保存します。次の手順で使います。
Worker プロジェクトのルートに .dev.vars ファイルを作成し、API レスポンスの Webhook シークレットを追加します。
WEBHOOK_SECRET=85011ed3a913c6ad5f9cf6c5573cc0a7値は手順 3 の実際のシークレットに置き換えます。wrangler dev 実行時、Wrangler は .dev.vars を自動で読み込みます。
Worker プロジェクトの src/index.ts を次のコードで置き換えます。この Worker は Webhook の POST リクエストを受け取り、署名を検証 し、ペイロードをログに出します。
export interface Env {
WEBHOOK_SECRET: string;
}
async function verifyWebhookSignature(
request: Request,
secret: string,
): Promise<{ valid: boolean; body: string }> {
const signatureHeader = request.headers.get("Webhook-Signature");
if (!signatureHeader) {
return { valid: false, body: "" };
}
const body = await request.text();
// Parse "time=<unix_ts>,sig1=<hex_signature>"
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => {
const [key, value] = part.split("=");
return [key, value];
}),
);
const time = parts["time"];
const receivedSig = parts["sig1"];
if (!time || !receivedSig) {
return { valid: false, body };
}
// Build the source string: "<time>.<body>"
const sourceString = `${time}.${body}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(sourceString),
);
const expectedSig = [...new Uint8Array(signature)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Use a timing-safe comparison.
// Do not return early when lengths differ — that leaks the expected
// signature's length through timing. Compare against self and negate instead.
const expectedBytes = encoder.encode(expectedSig);
const receivedBytes = encoder.encode(receivedSig);
const lengthsMatch = expectedBytes.byteLength === receivedBytes.byteLength;
const signaturesMatch = lengthsMatch
? crypto.subtle.timingSafeEqual(expectedBytes, receivedBytes)
: !crypto.subtle.timingSafeEqual(expectedBytes, expectedBytes);
return { valid: signaturesMatch, body };
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
if (!env.WEBHOOK_SECRET) {
console.error("WEBHOOK_SECRET is not set");
return new Response("Server misconfigured", { status: 500 });
}
const { valid, body } = await verifyWebhookSignature(
request,
env.WEBHOOK_SECRET,
);
if (!valid) {
console.error("Invalid webhook signature");
return new Response("Invalid signature", { status: 403 });
}
console.log("Webhook signature verified successfully");
const payload = JSON.parse(body);
console.log("Stream webhook received:", JSON.stringify(payload, null, 2));
console.log("Video UID:", payload.uid);
console.log("Status:", payload.status?.state);
console.log("Ready to stream:", payload.readyToStream);
// Add your own processing logic here — for example, update a database
// or notify a downstream service.
return new Response("OK", { status: 200 });
},
} satisfies ExportedHandler<Env>;別のターミナルで(トンネルは起動したまま)、Wrangler で Worker をローカル起動します。
npx wrangler devWrangler は .dev.vars から WEBHOOK_SECRET を自動で読み込みます。
Stream に動画をアップロードして、Webhook イベントを発生させます。動画の処理が終わると、wrangler dev を実行しているターミナルに Webhook ペイロードと、署名が検証されたことの確認が表示されます。
ローカルテストが終わったら、Worker をデプロイし、Webhook URL を本番エンドポイントに更新します。
npx wrangler deploy次に、Webhook の購読先をデプロイ済み Worker の URL に更新します。
Required API token permissions
At least one of the following token permissions is required:Stream Write
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/stream/webhook" \
--request PUT \
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
--json '{
"notificationUrl": "https://your-worker.your-subdomain.workers.dev"
}'