Skip to content
BirlesaBirlesa
Ask or searchSign in
Menu

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-…"}
}
  • id is the event's id. The same event may arrive more than once; process it once (store the ids you have handled).
  • data holds ids only, never texts, names, amounts or people. Read the rest with an org:read key, 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:

HeaderValue
Birlesa-Signaturet=<unix seconds>,v1=<hex signature> (two v1 values during a secret rotation)
Birlesa-EventThe event type
Birlesa-DeliveryThis delivery's id: retries of one delivery keep it
User-AgentBirlesa-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's errors[0].code says 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 .../rotate gives 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.

↵ select↑↓ moveesc close