# Guide to Braze Webhooks: Features and Best Practices

Braze webhooks are a messaging channel. Where an email campaign sends an email to each user it targets, a webhook campaign sends an HTTP request: to a CRM, a messaging provider, an internal service, or any other system that should act when a user reaches a step in a customer journey.

This guide covers what a Braze webhook sends, how to authenticate it with a saved Basic credential, how Braze retries, and the practices that keep a receiver for Braze webhooks reliable in production. It does not cover Braze Currents, the separate data export product.

## What are Braze webhooks?

A Braze webhook is an outbound request you build in the composer of a webhook campaign, a Canvas webhook step or a webhook template. You choose the URL, the HTTP method, the headers and the body, and Braze sends the request once for each user who reaches the message. Liquid personalisation works in the URL path and query and in the body, so each request can carry that user's attributes.

That means there is no list of Braze webhook events and no event field. The request is whatever the marketer wrote. If one endpoint receives several campaigns, put a key in each body that says which campaign or step sent it.

Braze does not sign webhook requests. To authenticate them, attach a saved credential: with **Basic authentication**, Braze sends an `Authorization: Basic` header built from the username and password you stored. Braze also publishes outbound IP addresses for each of its clusters, which you can allowlist as an extra layer.

## Braze webhook features

| Feature | Details |
| --- | --- |
| Where they come from | Webhook campaigns, Canvas webhook steps and webhook templates |
| What is sent | One request per targeted user, built from the composer's URL, method, headers and body |
| HTTP methods | `POST` (the most common), `PUT`, `GET` (no body) and `DELETE` |
| Body | JSON key-value pairs, or raw text in any format (XML, form data, plain text), with Liquid |
| Ports | 80 and 443 only |
| Authentication | A saved Basic or token credential, or manual request headers. No signature |
| Timeout | 120 seconds per request |
| Retries | `408`, `429` and `5XX`, up to five attempts in total with increasing delay, for up to 24 hours if the endpoint is unreachable |
| Not retried | `30X` and other `4XX`, including `401` |
| Failures | Logged in the Message Activity Log |
| Source IPs | Published per Braze cluster |

## What a request looks like

With JSON key-value pairs, the composer builds a JSON object from the keys and Liquid-rendered values you enter. A key of `message_body` with the value `Your order just arrived!` produces:

```json
{ "message_body": "Your order just arrived!" }

```

Add `user_id`, attributes and your own discriminator the same way. Raw text lets you write any format instead, and with `Content-Type: application/x-www-form-urlencoded` the body has to be a URL-encoded string. A `GET` request has no body at all.

Give every Liquid value a default. A missing attribute with no default can render a malformed body, and Braze's translation tags can break JSON if they wrap anything other than human-readable text.

> **Inspect Braze webhooks as they arrive.** Point a test webhook at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the exact headers and body Braze sends, with no account or setup required.

## Setting up Braze webhooks

Pick a username and generate a long random password, for example with `openssl rand -hex 32`, and store both on your receiver.

1. In Braze, go to **Settings > Credential Management** and add a credential with **Basic authentication**. Give it a name, enter the username and password, and set the **allowed domain** to your webhook's hostname. The allowed domain includes every subdomain, so use the most specific hostname you control. Creating credentials needs the "Manage App Group" permission.
2. Create a webhook campaign, add a **Webhook** step to a Canvas, or open a webhook template.
3. In the **Compose** tab, enter an HTTPS webhook URL with a fixed hostname. Liquid is fine in the path and query, but a hostname that comes from Liquid or a Content Block can't use a saved credential.
4. Choose the HTTP method, then select your credential under **Credential**. The list only shows credentials whose allowed domain matches the URL.
5. Write the request body, and add a key that identifies the campaign or step.
6. Open the **Test** tab and send a test webhook. The test uses the saved credential.

Credentials belong to one workspace, so create one in each workspace that needs it, including development and staging. The webhook preview leaves out the credential's `Authorization` header, so don't expect to see it there.

To put Hookdeck in front of your service, create a Hookdeck source with the **Braze** type and use its URL as the webhook URL, with the Hookdeck hostname as the credential's allowed domain. Enter the same **Username** and **Password** on the source. The source pre-selects `POST`. If a campaign uses `PUT`, `GET` or `DELETE`, allow that method on the source as well, or Hookdeck will not accept those requests.

If you prefer to type the header by hand under **Request headers**, `Authorization: Basic <base64 of username:password>` works with the same source. If you also select a saved credential, its header wins over the manual one.

## Securing Braze webhooks

Every request with a Basic credential carries `Authorization: Basic base64(username:password)`. Decode it, split on the first colon, and compare the username and password with your stored values in constant time, evaluating both before you decide.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyBasicAuth(header, username, password) {
  if (!username || !password) return false; // never accept when unconfigured
  const match = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(header || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64');
  const colon = decoded.indexOf(0x3a); // split on the first colon: passwords may contain ':'
  if (colon === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, colon), Buffer.from(username, 'utf8'));
  const passOk = safeEqual(decoded.subarray(colon + 1), Buffer.from(password, 'utf8'));
  return userOk && passOk; // evaluate both before deciding
}

const app = express();

// The composer chooses the method, so accept POST, PUT, GET and DELETE.
app.all('/webhooks/braze', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyBasicAuth(
    req.get('authorization'),
    process.env.BRAZE_WEBHOOK_USERNAME,
    process.env.BRAZE_WEBHOOK_PASSWORD
  );
  if (!ok) return res.sendStatus(401);

  const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
  // The body may be JSON, XML, form data or empty, depending on the campaign.
  if (req.is('application/json') && raw) {
    let payload;
    try {
      payload = JSON.parse(raw);
    } catch {
      return res.sendStatus(400);
    }
    // Route on the discriminator you put in the body, e.g. payload.campaign.
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac
import os
import re

BASIC_PATTERN = re.compile(r"^basic\s+([A-Za-z0-9+/]+={0,2})\s*$", re.IGNORECASE)

def verify_basic_auth(header, username, password) -> bool:
    if not username or not password:
        return False  # never accept when unconfigured
    match = BASIC_PATTERN.match(header or "")
    if not match:
        return False
    try:
        decoded = base64.b64decode(match.group(1), validate=True)
    except (binascii.Error, ValueError):
        return False
    received_user, sep, received_password = decoded.partition(b":")  # first colon only
    if not sep:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    user_ok = hmac.compare_digest(received_user, username.encode("utf-8"))
    password_ok = hmac.compare_digest(received_password, password.encode("utf-8"))
    return user_ok and password_ok

# verify_basic_auth(request.headers.get("authorization"),
#                   os.environ.get("BRAZE_WEBHOOK_USERNAME"),
#                   os.environ.get("BRAZE_WEBHOOK_PASSWORD"))

```

A Basic credential proves the sender knows the password. It does not cover the body and carries no timestamp, so it cannot detect a modified payload or a replayed request. Use a long random password over HTTPS only, rotate it if it might have leaked, and treat Braze's IP allowlist as a firewall aid on top of the credential, not instead of it.

> **Make Braze webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Basic credential, absorbs the burst a large campaign produces, and delivers each request to your service at a rate you choose.

## Braze webhook limitations and pain points

### A static credential instead of a signature

**The Problem:** Nothing in a Braze request proves the body is unchanged or that the request is new. Anyone who has the credential can send requests your endpoint will accept.

**Why It Happens:** Braze's webhook security options are saved credentials, manual headers and IP allowlisting. None of them is computed over the body.

**Workarounds:**

* Use a long random password, and a specific allowed domain so the credential can't be attached to a webhook pointing somewhere else.
* Make handlers idempotent so a replayed request does no harm.
* Allowlist the outbound IPs for your Braze cluster at your firewall as a second check.

**How Hookdeck Can Help:** Hookdeck's Braze source checks the Basic username and password on every request. Hookdeck still answers with a `200`, but a request with missing or wrong credentials is recorded as failed verification and never reaches your destination.

### A large campaign is a traffic spike

**The Problem:** A campaign sent to a big segment produces one request per user in a short time, which can overwhelm a service sized for steady traffic.

**Why It Happens:** Webhooks are a channel like email, so Braze sends them at campaign scale. A `429` is retried, but only up to five attempts in total.

**Workarounds:**

* Use Braze's rate limiting to spread a large send out.
* Keep the handler fast: authenticate, enqueue, return `200`.

**How Hookdeck Can Help:** Hookdeck accepts every request as it arrives and delivers to your service at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you set, so the campaign finishes at Braze's pace and your service processes it at its own.

### Credential errors are never retried

**The Problem:** After a password rotation or a typo, every request fails with `401` and Braze gives up on each one immediately.

**Why It Happens:** Braze retries only `408`, `429` and `5XX`. A `401`, like any other `4XX` or a `30X`, marks the send as failed with no retry, and the only record is the Message Activity Log.

**Workarounds:**

* Rotate the credential in Braze and on your receiver together, and accept both passwords for a short overlap if you can.
* Check the Message Activity Log after any change, and send a test webhook from the **Test** tab.
* Don't return a `4XX` for a transient problem in your own code.

**How Hookdeck Can Help:** Hookdeck keeps every request, including those that fail verification, with full headers, so you can see what Braze sent. Because a valid request is stored before your service sees it, an error in your service becomes a Hookdeck [retry](/docs/retries) you control rather than a failed send in Braze.

### No event type, any format

**The Problem:** One endpoint receiving several campaigns can't tell them apart, and a campaign that switches to raw text or a different method breaks a JSON-only, `POST`-only handler.

**Why It Happens:** The marketer composes every part of the request, and Braze adds nothing that names the campaign.

**Workarounds:**

* Agree a body convention with whoever builds campaigns, such as a `campaign` key and a `user_id` in every JSON body.
* Accept every method you use, and check `Content-Type` before parsing.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route requests to different destinations by a body field, a header or the path, so each campaign can reach the service that handles it.

## Best practices

### Use saved credentials, not typed headers

A saved credential keeps the password out of the composer, is limited to its allowed domain, and can be reused across campaigns. Typed headers are visible to anyone who can edit the campaign.

### Put an identifier in every body

Include a campaign or step name and a user identifier in every JSON body. That gives you something to route on, and a key to deduplicate retries with. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Default every Liquid value

A missing attribute can produce invalid JSON, and your endpoint rejecting it as a `400` means no retry. Defaults keep every request parseable.

### Acknowledge fast

Braze waits 120 seconds, but a handler that takes long at campaign scale will back up. Return `200` once the request is authenticated and process it from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Braze webhooks are requests your marketers compose, sent once per targeted user, with no event list and no signature. Attach a saved Basic credential with a specific allowed domain, compare the username and password in constant time over HTTPS, put an identifier in every body, and remember that a `401` is never retried.

[Hookdeck Event Gateway](https://hookdeck.com) checks the credential, absorbs campaign-sized bursts, and holds each request until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Braze webhooks reliably in minutes.