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, event  and schema_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

  1. In Bread & Butter, open Integrations and choose the Webhook tile.
  2. Integration name: a name your team will recognize, like "Webhook - Engaged Leads".
  3. Webhook Endpoint: paste the URL from step 1.
  4. Trigger: choose which leads are sent, either a lead stage (Engaged Lead is the default) or a funnel you've saved.
  5. 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.
  6. Click Test Connection. A green check means we reached your URL. A red X usually means the endpoint was pasted incorrectly.
  7. 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:

  1. Read t  and v1  from the header.
  2. Reject the request if t  is more than five minutes from your current time. This stops someone replaying a delivery they captured earlier.
  3. Compute HMAC-SHA256(secret, "{t}." + rawBody)  and compare it with v1 .

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.

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.