Test Bitbucket Cloud webhooks locally
Receive Bitbucket Cloud webhook events on localhost with a Horizon tunnel, and verify the X-Hub-Signature header.
Receive Bitbucket Cloud events on your laptop while you build, with a URL Bitbucket can reach.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Bitbucket Cloud repository where you are an administrator
- A Next.js app that uses the App Router and runs on port 3000
Start your app
When you set a secret on a webhook, Bitbucket signs each payload. It computes an HMAC (a keyed hash) of the body with your secret and sends it in the X-Hub-Signature header as sha256=<hex digest>. Bitbucket says to process the payload verbatim, so read the raw body with request.text() and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.BITBUCKET_WEBHOOK_SECRET;
if (!secret) {
return new Response("Missing BITBUCKET_WEBHOOK_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-hub-signature") ?? "";
const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const event = request.headers.get("x-event-key");
console.log(`Received Bitbucket event: ${event}`);
return new Response("ok", { status: 200 });
}Pick a secret and store it in an environment variable. Use a random, high-entropy string.
BITBUCKET_WEBHOOK_SECRET=replace-with-a-long-random-stringStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your Bitbucket webhook would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-bitbucket-appHORIZON: Tunnel connected
URL https://my-bitbucket-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-bitbucket-appYour public URL is https://my-bitbucket-app.hrzn.run. Keep this terminal open.
Add the webhook in Bitbucket
- Open your repository in Bitbucket.
- In the left sidebar, select More actions (…) next to the repository name, then select Settings.
- Under Workflow, select Webhooks.
- Select Add webhook.
- Enter a Title, for example
Horizon local. - Set URL to
https://my-bitbucket-app.hrzn.run/api/webhooks/bitbucket. - Set Secret to the same value as
BITBUCKET_WEBHOOK_SECRET. - Leave Active checked. Leave Skip certificate verification unchecked, because Horizon tunnels use HTTPS.
- Under Triggers, the default is Repository push. Select Choose from a full list of triggers to pick more events.
- Select Save.
The Secret field is optional in Bitbucket. Without it, Bitbucket sends no X-Hub-Signature header and the handler above rejects every request.
Trigger an event
Bitbucket documents no test button, so trigger a real event. The default trigger is Repository push, so push a commit to the repository.
git commit --allow-empty -m "Test Bitbucket webhook"
git pushTo see what Bitbucket sent and how your app answered, open Settings, Webhooks, then View requests on your webhook. If the log is empty, select Enable history first. Select View details on a row to see the headers, the payload and your response.
Check it works
After the push, your Horizon terminal prints one line:
POST 200 /api/webhooks/bitbucketYour app terminal prints:
Received Bitbucket event: repo:pushIn the Bitbucket request log, the Status column shows 200. If the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
The Horizon line shows [401]. Check these in order:
BITBUCKET_WEBHOOK_SECRETis identical to the Secret in Bitbucket, with no extra spaces or newline.- The handler computes the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - You restarted
npm run devafter you edited.env.local. - The webhook has a Secret. Without one, the header is missing.
The request log is empty
Bitbucket records requests only after you enable history. Open View requests and select Enable history. Then trigger the event again.
Bitbucket reports a timeout
Bitbucket waits 10 seconds for a response, then logs TIMEOUT. For a 5xx response, Bitbucket sends the request up to two more times. Reply fast and do slow work after.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-bitbucket-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the URL in Bitbucket ends with
/api/webhooks/bitbucket. - Check that Active is checked and that the event you trigger is selected under Triggers.
Next steps
- Read Bitbucket's Manage webhooks guide.
- See the event payloads for every event key.
Test Shopify webhooks locally
Receive Shopify webhook events on localhost with a Horizon tunnel, and verify the X-Shopify-Hmac-Sha256 signature in Next.js.
Test Buildkite webhooks locally
Receive Buildkite pipeline webhook events on localhost with a Horizon tunnel, and verify the X-Buildkite-Signature header.