Webhooks
A webhook tells your server when something happens to your organization: a quote arrives, an escrow is funded, a shipment reaches port. Owners and admins create webhooks in the panel, or with POST /v1/orgs/{org_id}/webhooks.
What you receive
Each delivery is a POST with a small JSON body:
{
"id": "01a166eb-2f4e-74bd-a53e-6372d70ebead",
"type": "quote.sent",
"created_at": "2026-10-22T09:14:03.120000+00:00",
"data": {"conversation_id": "01a166e9-…", "quote_id": "01a166eb-…"}
}idis the event's id. The same event may arrive more than once; process it once (store the ids you have handled).dataholds ids only, never texts, names, amounts or people. Read the rest with anorg:readkey, e.g.GET /v1/org/quotes/{quote_id}. You always act on the current state, even when events arrive late or out of order.- The event catalog lists every type and the ids it carries. A webhook receives only the types it asked for.
Headers:
| Header | Value |
|---|---|
Birlesa-Signature | t=<unix seconds>,v1=<hex signature> (two v1 values during a secret rotation) |
Birlesa-Event | The event type |
Birlesa-Delivery | This delivery's id: retries of one delivery keep it |
User-Agent | Birlesa-Webhooks/1 |
Verify every delivery
The signature is HMAC-SHA256 over <t>.<raw body>. The key is the whole webhook secret as it was shown to you (brl_whsec_...). Check it on the raw bytes, before parsing JSON. Refuse timestamps older than five minutes, so a captured delivery cannot be replayed later.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(secret: str, body: bytes, header: str) -> bool:
fields = [part.split("=", 1) for part in header.split(",") if "=" in part]
stamps = [value for name, value in fields if name == "t"]
if len(stamps) != 1 or not stamps[0].isdigit():
return False
if abs(time.time() - int(stamps[0])) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{stamps[0]}.".encode() + body, hashlib.sha256)
return any(
hmac.compare_digest(expected.hexdigest(), value) for name, value in fields if name == "v1"
)import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(secret, rawBody, header) {
const fields = header.split(",").map((part) => part.split("=", 2));
const stamps = fields.filter(([name]) => name === "t").map(([, value]) => value);
if (stamps.length !== 1 || !/^\d+$/.test(stamps[0])) return false;
if (Math.abs(Date.now() / 1000 - Number(stamps[0])) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${stamps[0]}.`).update(rawBody).digest();
return fields
.filter(([name]) => name === "v1")
.some(([, value]) => {
const given = Buffer.from(value ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Answer quickly
Answer with any 2xx within 10 seconds, then do the work. We do not read your response body and do not follow redirects: a 3xx counts as a failure.
Retries and switching off
A delivery that fails (no answer, a timeout, a non-2xx status) is tried again after 1, 5 and 15 minutes, 1, 3, 6 and 12 hours, then twice more a day apart: ten attempts over about three days. Then it is marked failed; you can send it again from the delivery history (POST .../deliveries/{id}/retry).
A webhook that has failed for three days in a row, at least ten attempts, is switched off (status: disabled, disabled_reason: failing) and what was waiting is abandoned. Fix the endpoint, switch it back on (PATCH with {"active": true}), then retry the deliveries you need.
Addresses we deliver to
https://on port 443, under a public host name. IP addresses,localhost, internal names (.local,.internal,.svc, ...) and credentials in the URL are refused (webhook_url_refused; the problem'serrors[0].codesays why:not_https,private_address, ...).- Before every delivery the name is resolved again. If any of its addresses is private, loopback, link-local or otherwise not public, nothing is sent (
last_error: private_address). - The certificate must be valid for the host name.
Secrets
- The secret is shown once, when the webhook is created. We keep it sealed and cannot show it again.
POST .../rotategives a new secret. For 24 hours deliveries carry signatures with both the new and the old secret, so you can deploy the new one without missing deliveries. A second rotation within those 24 hours is refused (rotation_pending).
Testing
POST .../test queues a webhook.test delivery (empty data). It is sent like any other, so its outcome shows in the delivery history (GET .../deliveries), with the status your server answered.
Limits
At most 10 webhooks per organization; at most 5 test pings or manual retries a minute. The delivery history is kept for 30 days.