Skip to content

Code hosting

How Bitbucket Cloud signs webhooks

Bitbucket Cloud signs webhooks that have a secret with an HMAC-SHA256 of the raw body, sent as X-Hub-Signature: sha256=….

Checked against Bitbucket Cloud's documentation on October 31, 2026. Bitbucket Cloud webhook docs

How the signature works

HeaderX-Hub-Signature: sha256={signature}
Signed content{raw body}
AlgorithmHMAC-SHA256
Encodinghex
KeyAny text; can't be viewed again after saving
Where to find itThe secret set when the webhook was created (Repository settings → Webhooks). It can't be viewed again; set a new one if unsure.
Replay protectionNo signed timestamp.
  • Bitbucket says the method in method=signature may change: read it from the header rather than assuming sha256.

verified Source: support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/

Verification code

Generated from the scheme above and tested against a signed example request, a changed body and a wrong secret. Pass the raw request body, before any JSON or form parsing.

const crypto = require('node:crypto');

/**
 * Verify a Bitbucket Cloud webhook. rawBody: the request body as a Buffer, exactly as received
 * (before any JSON parsing); headers: lower-case names, as in req.headers.
 */
function verifyWebhook(rawBody, headers, secret, url = '', method = 'POST') {
  rawBody = Buffer.from(rawBody);
  const header = headers['x-hub-signature'] || '';
  if (!header.startsWith('sha256=')) return false;
  const signatures = [header.slice(7)];
  const signed = rawBody;
  const key = secret;
  const expected = crypto.createHmac('sha256', key).update(signed).digest('hex');
  const valid = signatures.some((sig) => {
    const a = Buffer.from(sig.toLowerCase());
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return false;
  return true;
}

// Express: keep the raw body for this route
// app.post('/webhooks/bitbucket', express.raw({ type: '*/*' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.headers, process.env.BITBUCKET_WEBHOOK_SECRET)) return res.sendStatus(401);
//   res.sendStatus(200); // then process the event asynchronously
// });
import base64
import hashlib
import hmac


def verify_webhook(raw_body: bytes, headers, secret: str, url: str = '', method: str = 'POST') -> bool:
    """Verify a Bitbucket Cloud webhook. raw_body: the request body exactly as received (bytes);
    headers: a mapping with lower-case names (Flask and Django headers are case-insensitive)."""
    header = headers.get('x-hub-signature', '')
    if not header.startswith('sha256='):
        return False
    signatures = [header[7:]]
    signed = raw_body
    key = secret.encode()
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = digest.hex()
    if not any(hmac.compare_digest(sig.lower(), expected) for sig in signatures):
        return False
    return True


# Flask: verify_webhook(request.get_data(), request.headers, SECRET)
<?php

/**
 * Verify a Bitbucket Cloud webhook. $rawBody: file_get_contents('php://input'), before any decoding;
 * $headers: lower-case names, e.g. array_change_key_case(getallheaders()).
 */
function verify_webhook(string $rawBody, array $headers, string $secret, string $url = '', string $method = 'POST'): bool
{
    $header = (string) ($headers['x-hub-signature'] ?? '');
    if (! str_starts_with($header, 'sha256=')) {
        return false;
    }
    $signatures = [substr($header, 7)];
    $signed = $rawBody;
    $key = $secret;
    $expected = hash_hmac('sha256', $signed, $key);
    $valid = false;
    foreach ($signatures as $sig) {
        $valid = $valid || hash_equals($expected, strtolower($sig));
    }

    return $valid;
}
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
	"strings"
)

// VerifyWebhook checks a Bitbucket Cloud webhook. rawBody is the request body exactly as received
// (io.ReadAll(r.Body) before any decoding).
func VerifyWebhook(rawBody []byte, h http.Header, secret, url, method string) bool {
	header := h.Get("X-Hub-Signature")
	if !strings.HasPrefix(header, "sha256=") {
		return false
	}
	signatures := []string{strings.TrimPrefix(header, "sha256=")}
	signed := []byte{}
	signed = append(signed, rawBody...)
	key := []byte(secret)
	mac := hmac.New(sha256.New, key)
	mac.Write(signed)
	expected := hex.EncodeToString(mac.Sum(nil))
	valid := false
	for _, sig := range signatures {
		if hmac.Equal([]byte(strings.ToLower(sig)), []byte(expected)) {
			valid = true
		}
	}
	return valid
}

Headers Bitbucket Cloud sends

HeaderMeaning
X-Event-KeyEvent, e.g. repo:push or pullrequest:created
X-Hook-UUIDThe webhook's id
X-Request-UUIDId of this delivery
X-Attempt-NumberDelivery attempt
X-Hub-Signaturesha256= HMAC of the body (only with a secret)

Timeouts and retries

Response timeoutNot published.
Counts as success2xx (not stated explicitly).
RetriesNot described in the current documentation.
OrderingNot documented.
DuplicatesX-Request-UUID identifies a delivery.
DisablingNot documented.

partly unverified Source: support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/

Event types

The event is in X-Event-Key header. Examples:

repo:pushpullrequest:createdpullrequest:fulfilledrepo:commit_status_updatedissue:created

verified Source: support.atlassian.com/bitbucket-cloud/docs/event-payloads/

Source IP addresses

Bitbucket Cloud sends webhooks from these addresses (11 ranges, as of 2026-10-31):

104.192.136.0/21
185.166.140.0/22
13.52.5.96/28
13.236.8.224/28
18.136.214.96/28
18.184.99.224/28
18.234.32.224/28
18.246.31.224/28
52.215.192.224/28
13.200.41.128/25
2401:1d80:3000::/36

Atlassian's outgoing (egress) ranges for Bitbucket; they cover all of Bitbucket's outbound traffic, not only webhooks.

Machine-readable list: https://ip-ranges.atlassian.com/ (we refresh our copy weekly)

An allow-list only filters traffic: proxies, CDNs and shared cloud addresses mean the signature is still the real proof of origin.

verified Source: support.atlassian.com/organization-administration/docs/ip-addresses-and-domains-for-atlassian-cloud-products/

Common mistakes

  • Expecting a signature when no secret is set: Bitbucket only signs webhooks that have one.
  • Hashing parsed JSON instead of the raw body.
  • Assuming sha256 forever: read the method before the = sign.

Stuck? Point the webhook at a free webhook inspector bin: it detects Bitbucket, checks the signature with your secret in the browser and shows the expected value next to the received one.

Not confirmed in Bitbucket Cloud's documentation

  • Timeout, retries and the X-Attempt-Number / X-Request-UUID headers are not in the current Bitbucket Cloud documentation; they come from observed deliveries.
  • The User-Agent shown is an example.

FAQ

How do I verify a Bitbucket Cloud webhook signature?

Compute HMAC-SHA256 over {raw body} using webhook secret, encode it as hex and compare it in constant time with the value in X-Hub-Signature (format: sha256={signature}).

How long does Bitbucket Cloud wait for a webhook response?

Not published. Success means: 2xx (not stated explicitly).

Does Bitbucket Cloud retry failed webhooks?

Not described in the current documentation.

What IP addresses does Bitbucket Cloud send webhooks from?

Bitbucket Cloud publishes its webhook source addresses: https://ip-ranges.atlassian.com/. Signatures are still the real check, because proxies and CDNs can change the address you see.

Why does my Bitbucket Cloud signature check fail?

Expecting a signature when no secret is set: Bitbucket only signs webhooks that have one. Hashing parsed JSON instead of the raw body.

Other webhook providers

Stripe GitHub GitLab Shopify Slack Twilio Paddle Billing Zoom Linear Square PayPal SendGrid Mailgun Discord Svix Standard Webhooks Twitch EventSub Intercom HubSpot Jira Cloud Calendly Typeform