Cloudflare Stream 无法向 localhost 或本地 IP 地址发送 webhook 通知。要在本地开发期间测试 webhook,您需要一个可公开访问的 URL,将请求转发到本地机器。
本示例展示如何:
- 启动 Cloudflare Tunnel 以获取本地环境的公开 URL。
- 将该 URL 注册为 webhook 端点,并返回签名密钥。
- 创建 Cloudflare Worker 接收 Stream webhook 事件并验证其签名。
- 已启用 Stream 的 Cloudflare 账户 ↗
- Node.js ↗(v18 或更高版本)
- 已安装 Wrangler CLI(
npm install -g wrangler)
创建一个接收 webhook 请求的新 Worker 项目:
npm create cloudflare@latest stream-webhook-handler在注册 webhook URL 之前,您需要一个指向本地机器的公开 URL。在终端中,启动快速隧道,转发到默认 Wrangler 开发服务器端口(8787):
npx cloudflared tunnel --url http://localhost:8787cloudflared 将输出类似以下的公开 URL:
https://example-words-here.trycloudflare.com复制此 URL。每次重启隧道时 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 请求,验证签名,并记录 payload。
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 payload 日志,以及签名已验证的确认信息。
本地测试完成后,部署 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"
}'