Send Webhook
Send Bread & Butter lead data to any service that accepts webhooks, such as n8n, Make, or your own server.
In short: a webhook integration sends a JSON payload to a URL you choose whenever a lead reaches the trigger you set: a lead stage, or a funnel you've saved. You choose what to send: the List (name, email and profile link, sent as soon as we capture the email address), the Intelligence Brief (sent once the visit ends, so the research and User Journey Summary are complete), or the Intelligence Stream (the Brief plus the full history behind it). Every delivery starts with two fields,
eventandschema_version, that tell your receiver which payload it has and which format version. Every delivery is also signed with a secret only you and we hold, so you can confirm it came from us and wasn't altered.
Step 1: Get a webhook URL from your destination
In the service you want to send data to, create a new trigger or receiver of the webhook type (often called Catch Hook or Custom Webhook) and copy the URL it gives you.
Step 2: Create the integration in Bread & Butter
- In Bread & Butter, open Integrations and choose the Webhook tile.
- Integration name: a name your team will recognize, like "Webhook - Engaged Leads".
- Webhook Endpoint: paste the URL from step 1.
- Trigger: choose which leads are sent, either a lead stage (Engaged Lead is the default) or a funnel you've saved.
- What to send: List, Intelligence Brief or Intelligence Stream. Which of these you can choose depends on your plan; Integrations and Automation has the full matrix.
- Click Test Connection. A green check means we reached your URL. A red X usually means the endpoint was pasted incorrectly.
- Click Save & close, then check your destination received the test request.
Leads are only sent once we've captured their email address. The List goes out immediately. The Intelligence Brief and Intelligence Stream go out once the visit ends, which can take up to an hour after the lead's last activity, because that's when the User Journey Summary and research are complete. When the lead comes back, the Brief or Stream is sent again after that visit ends.
If your account runs out of credits, an integration set to the Intelligence Brief or Intelligence Stream keeps sending it in the same format, so nothing breaks in your receiver. The lead's name and email are filled in, and every researched field arrives as null . The payload also carries out_of_credits: true and a notice field that reads "Your Bread & Butter account is out of credits. Add Bread & Butter credits to resume the full Intelligence Brief." Nothing is held back to send later. See How Credits work.
What we send
Every delivery is a JSON body. Its first two fields tell you what you're receiving:
| Field | Type | Description |
|---|---|---|
event |
string | Which payload this is: basic_list , intelligence_brief or intelligence_stream |
schema_version |
number | The payload format version. Currently 1 . |
Check event before processing anything else. One endpoint can receive all three payloads, and their shapes differ. The Stream contains everything in the Brief plus the full session history; the List is a flat structure with its own field names.
Branch on schema_version . If we ever make a change that could break an existing receiver, this number goes up, so you can handle the new format deliberately rather than discover it in production.
Every field in the Brief and Stream is listed in Field Mapping Definitions.
Verifying that a delivery came from us
Anyone who learns your webhook URL can send requests to it. To confirm a delivery really came from Bread & Butter, and wasn't changed on the way, each request carries a signature made with a secret that only you and we hold.
Signing applies to Webhook integrations only. Zapier Webhook deliveries include event and schema_version , but they aren't signed.
Your signing secret
Each webhook integration has its own signing secret, created automatically with the integration. Administrators can find it at the bottom of the webhook integration's Edit dialog. It's hidden until you choose to reveal it, and you can copy it from there. Other roles don't see it.
Use the secret exactly as shown, a 43-character string. It's the HMAC key as it is, so don't base64-decode it.
Rotate Signing Secret, in the same place, replaces the secret immediately, and the old one stops working. Any receiver still using it will reject deliveries, so update your receiver as soon as you rotate.
Webhook integrations created before signing was introduced don't have a secret yet, so their deliveries arrive without a signature header. If you don't see the header, that's why. An administrator can generate one from the integration's Edit dialog. From then on deliveries carry a signature, so make sure your receiver is ready to check it.
The signature header
X-BreadButter-Signature: t=1790196585,v1=1db61503701efa04182ca7efa34f64658f7ccb03a52ad6d49b5f5c9b6f348003
t is the Unix timestamp, in seconds, of when we signed the request. v1 is an HMAC-SHA256, in lowercase hex, of the string {t}.{body} , using your signing secret as the key.
To verify a delivery:
- Read
tandv1from the header. - Reject the request if
tis more than five minutes from your current time. This stops someone replaying a delivery they captured earlier. - Compute
HMAC-SHA256(secret, "{t}." + rawBody)and compare it withv1.
Use the raw request body. This is where most integrations go wrong. The signature covers the exact bytes we sent. If you parse the JSON and re-serialize it before hashing, whitespace and key order change and the signature will never match, even though nothing is actually wrong.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
// express.raw gives you the unparsed body. Don't use express.json() on this route.
app.post('/breadbutter-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-BreadButter-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map(p => { const i = p.indexOf('='); return [p.slice(0, i), p.slice(i + 1)]; })
);
const { t: timestamp, v1: signature } = parts;
if (!timestamp || !signature) return res.status(400).send('Missing signature');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('Signature timestamp out of range');
}
const expected = crypto
.createHmac('sha256', process.env.BREADBUTTER_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
console.log('Received', payload.event, 'schema', payload.schema_version);
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(name)
SECRET = os.environ["BREADBUTTER_WEBHOOK_SECRET"]
@app.post("/breadbutter-webhook")
def breadbutter_webhook():
header = request.headers.get("X-BreadButter-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
abort(400, "Missing signature")
if abs(time.time() - int(timestamp)) > 300:
abort(400, "Signature timestamp out of range")
raw = request.get_data() # bytes, before any JSON parsing
expected = hmac.new(
SECRET.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401, "Invalid signature")
payload = request.get_json()
print("Received", payload["event"], "schema", payload["schema_version"])
return "", 200
Compare in constant time. Both examples use a timing-safe comparison (crypto.timingSafeEqual , hmac.compare_digest ) rather than == . A plain comparison returns faster on an early mismatch, which can leak information about the expected value over many attempts.
Read v1 by name, not by position. We send a single v1 signature today, but looking it up by its key rather than by its position keeps your receiver working if we add others later.