Verify webhooks
Every delivery is signed with your webhook's secret and carries an event id, so your server can check it came from BrytIO, turn away a replayed copy and handle each event once. Setting up a webhook and the REST API are in the Docs.
What BrytIO sends
One POST to each webhook subscribed to the event, with a JSON body like this one.
{
"event": "deal.won",
"organizationId": "cm2k8x0ab0001qz7h3c9d4e5f",
"sentAt": "2026-10-22T14:03:11.482Z",
"data": {
"deal": {
"id": "cm2k9a1cd0004qz7hb2e6f7g8",
"title": "Annual support renewal",
"value": 18400,
"stage": "WON"
}
},
"id": "evt_5f2b8c1e9a7d4f3b8e6c2a1d0f9e8b7c",
"created": "2026-10-22T14:03:11.120Z"
}| Field | What it holds |
|---|---|
event | The event's name, such as deal.won. |
organizationId | Your workspace's id. |
sentAt | When this delivery was sent, in ISO 8601 (UTC). |
data | What the event is about, usually the record under its kind: deal, contact, company, quote or invoice. A deletion carries only the record's id there, such as data.deal.id, and a deal.deleted from the BrytIO app also carries it as data.dealId, as it always has. invoice.overdue carries the invoice's invoiceId and number. |
id | The event's id. Every webhook that receives the event gets the same one, and it stays the same if the event is ever sent again. |
created | When the event happened, in ISO 8601 (UTC). |
New fields can appear, so ignore the ones you don't use. id and created were added after the first four, which kept their names and their order.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | BrytIO-Webhooks/1.0 (+https://brytio.com) |
BrytIO-Event-Id | The event's id, the same as id in the body. The signature doesn't cover headers, so skip repeats by the body's id once the signature checks out, not by this header. |
BrytIO-Event-Type | The event's name, the same as event in the body. Not covered by the signature either, so route on the body's event. |
BrytIO-Signature | t=<Unix time in seconds>,v1=<signature>. How to check it is below. |
X-Synaptiq-Signature | Legacy: sha256=<hex HMAC SHA-256 of the raw body>, keyed with the same secret. It has no timestamp, so it can't stop a replayed delivery. |
X-Synaptiq-Event | Legacy: the event's name. |
The two legacy headers carry the product's old name. They are still sent, unchanged, so receivers built on them keep working. Use BrytIO-Signature in anything new. Header names aren't case sensitive, and many servers show them in lowercase.
Check the signature
- Read the body exactly as it arrived, before any JSON parsing. Parsing and writing it out again changes the bytes, and the signature no longer matches.
- Split
BrytIO-Signatureat its commas.tis when BrytIO signed the delivery, in Unix seconds. Eachv1is a signature. - Compute the HMAC SHA-256 of
<t>.<raw body>(the value oft, a period, then the body), keyed with your webhook's signing secret, the whole of it withwhsec_included, as lowercase hex. - Compare it with each
v1in constant time, withcrypto.timingSafeEqualin Node orhmac.compare_digestin Python. Accept the delivery when any of them matches: more than one can appear. - Turn the delivery away when
tis more than 5 minutes from your server's clock, either way. A copy someone captured can't be replayed later.
Node
const crypto = require("node:crypto");
// rawBody: the request body exactly as it arrived (a Buffer), before any JSON parsing.
// header: the BrytIO-Signature header. secret: your webhook's signing secret, whsec_ included.
function verifyBrytioWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const pairs = String(header || "").split(",").map((part) => {
const [key, ...value] = part.trim().split("=");
return [key, value.join("=")];
});
const timestamp = pairs.find(([key]) => key === "t")?.[1] || "";
const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);
if (!/^\d+$/.test(timestamp) || signatures.length === 0) {
throw new Error("Missing or malformed BrytIO-Signature header");
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
throw new Error("Timestamp is too old or too far ahead");
}
// Compared as the hex text itself, so only the exact 64 lowercase characters match.
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex"));
const valid = signatures.some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!valid) throw new Error("Signature does not match");
return JSON.parse(rawBody.toString("utf8"));
}// Express: express.raw() keeps the body as it arrived, for this route only.
app.post("/webhooks/crm", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyBrytioWebhook(req.body, req.get("BrytIO-Signature"), process.env.WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(err.message);
}
res.sendStatus(200);
// Then do the work, once: skip event.id if you have already handled it.
});Python
import hashlib
import hmac
import json
import time
# raw_body: the request body exactly as it arrived (bytes), before any JSON parsing.
# header: the BrytIO-Signature header. secret: your webhook's signing secret, whsec_ included.
def verify_brytio_webhook(raw_body, header, secret, tolerance_seconds=300):
pairs = [part.strip().split("=", 1) for part in (header or "").split(",")]
pairs = [pair for pair in pairs if len(pair) == 2]
timestamp = next((value for key, value in pairs if key == "t"), "")
signatures = [value for key, value in pairs if key == "v1"]
if not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
raise ValueError("Missing or malformed BrytIO-Signature header")
# Whole seconds as integers: a t too long for a float is turned away like any stale one.
if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
raise ValueError("Timestamp is too old or too far ahead")
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
if not any(hmac.compare_digest(expected, signature.encode()) for signature in signatures):
raise ValueError("Signature does not match")
return json.loads(raw_body)# Flask: request.get_data() is the body as it arrived. app is your Flask app.
import os
from flask import request
@app.post("/webhooks/crm")
def crm_webhook():
try:
event = verify_brytio_webhook(
request.get_data(), request.headers.get("BrytIO-Signature"), os.environ["WEBHOOK_SECRET"]
)
except ValueError:
return "", 400
# Skip event["id"] if you have already handled it, and answer quickly.
return "", 200Keep the signing secret on your server, never in a browser or an app. It is shown once, when the webhook is added in Settings > Developers. For a new secret, add the webhook again and then delete the old one: until you do, both get every event with the same event id, so a receiver that skips ids it has seen still handles each event once.
Answer fast and handle each event once
Answer with any 2xx status as soon as the signature checks out, then do the work. BrytIO waits 8 seconds at most for the answer.
Keep the ids you've handled and skip one you've seen, using
idfrom the body you checked. The signature covers the body but not the headers, so a copy with a changedBrytIO-Event-Idstill checks out. Every webhook that receives an event gets the same id, so two webhooks pointed at one receiver don't do the work twice.Events can arrive in any order. Compare
created, or read the record through the API, when the order matters.
How delivery works today
Each webhook gets one attempt per event. Nothing is retried, so a delivery that fails is not sent again.
A delivery fails when your server doesn't answer within 8 seconds, answers with a status outside 2xx, or can't be reached.
Redirects aren't followed, except a 307 or 308 to the same host, such as http to https or an added trailing slash, at most twice.
Deliveries go only to public internet addresses, never to private or internal ones.
Settings > Developers shows each webhook's latest delivery: the event, the answer's status and when it was sent.
Webhooks deliver while your workspace is on a plan that includes the API and webhooks: Team and Enterprise.
Events
A webhook subscribes to any of these in Settings > Developers.
deal.createddeal.wondeal.lostdeal.deletedcontact.createdcompany.createdquote.createdquote.acceptedinvoice.createdinvoice.paidinvoice.overdue
These are sent only for changes made through the API, never for edits or deletions made in the BrytIO app:
deal.updatedcontact.updatedcontact.deletedcompany.updatedcompany.deleted
Which actions send which events, and from where, is in the table in the Docs.
Call webhook steps in automations
A Call webhook step in an automation sends
BrytIO-Event-Id, a new id for each call, andBrytIO-Event-Type, the event that started the automation. It is not signed, because a step has no signing secret, and its body is the one the step sets.