Test webhooks locally
Test Cloudflare Stream webhook notifications locally using a Cloudflare Worker and Cloudflare Tunnel.
Cloudflare Stream cannot send webhook notifications to localhost or local IP addresses. To test webhooks during local development, you need a publicly accessible URL that forwards requests to your local machine.
This example shows how to:
- Start a Cloudflare Tunnel to get a public URL for your local environment.
- Register that URL as your webhook endpoint, which returns the signing secret.
- Create a Cloudflare Worker that receives Stream webhook events and verifies their signatures.
- A Cloudflare account ↗ with Stream enabled
- Node.js ↗ (v18 or later)
- The Wrangler CLI installed (
npm install -g wrangler)
Create a new Worker project that will receive webhook requests:
npm create cloudflare@latest stream-webhook-handlerBefore registering a webhook URL, you need a public URL that points to your local machine. In a terminal, start a quick tunnel that forwards to the default Wrangler dev server port (8787):
npx cloudflared tunnel --url http://localhost:8787cloudflared will output a public URL similar to:
https://example-words-here.trycloudflare.comCopy this URL. It changes every time you restart the tunnel.
Use the Stream API to set the tunnel URL as your webhook notification URL. The API response includes a secret field — you will need this to verify webhook signatures.
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" }'The response will include a secret field:
{ "result": { "notificationUrl": "https://example-words-here.trycloudflare.com", "modified": "2024-01-01T00:00:00.000000Z", "secret": "85011ed3a913c6ad5f9cf6c5573cc0a7" }, "success": true, "errors": [], "messages": []}Save the secret value. You will use it in the next step.
Create a .dev.vars file in the root of your Worker project and add the webhook secret from the API response:
WEBHOOK_SECRET=85011ed3a913c6ad5f9cf6c5573cc0a7Replace the value with the actual secret from step 3. Wrangler automatically loads .dev.vars when running wrangler dev.
Replace the contents of src/index.ts in your Worker project with the following code. This Worker receives webhook POST requests, verifies the signature, and logs the 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>;In a separate terminal (keep the tunnel running), start the Worker locally with Wrangler:
npx wrangler devWrangler will load the WEBHOOK_SECRET from your .dev.vars file automatically.
Upload a video to Stream to trigger a webhook event. Once the video finishes processing, you will see the webhook payload logged in the terminal running wrangler dev, along with a confirmation that the signature was verified.
When you are done testing locally, deploy the Worker and update the webhook URL to your production endpoint:
npx wrangler deployThen update the webhook subscription to point to your deployed 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" }'