Webhooks
HTTP notifications for platform events.
How it works
You register a URL and the event types you care about. When an event occurs, Brivio sends a POST with a JSON body and a signature header computed with the subscription secret.
Delivery and retries
Delivery is at-least-once: a non-2xx response is retried with increasing backoff. Make your handler idempotent on the event id — an event can arrive twice, and that is not a defect.
Verifying the signature
Every delivery follows Standard Webhooks and carries three headers:
webhook-id— the event id (evt_…), the same value asidin the body. Dedupe on it.webhook-timestamp— Unix seconds when the delivery was signed.webhook-signature— a space-separated list ofv1,<signature>entries, where<signature>is the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{raw body}. During a secret rotation the list holds one entry per active secret; accept the delivery if any of them matches.
The subscription secret looks like whsec_<base64>; the HMAC key is the base64-decoded part after whsec_. Reject deliveries whose timestamp is more than 5 minutes away from your clock, and compute the HMAC over the raw bytes of the body, before any JSON parsing. X-Brivio-Event and X-Brivio-Delivery are informational and not signed.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyBrivioWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
return String(headers["webhook-signature"]).split(" ").some((entry) => {
const [version, value] = entry.split(",");
const got = Buffer.from(value ?? "", "base64");
return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
});
}Payment events
invoice.paid fires when an issued invoice is settled in full; invoice.partially_paid when a payment lands and a balance remains. Both fire from every way money gets recorded — a payment typed in by hand, a bank line matched in reconciliation, a card payment through the pay page, a honoured bill of exchange, a collective receipt — so a subscriber can mirror the open balance without polling.
Both events are idempotent per payment: the delivery id is evt_pay_<paymentId>, so a processor replaying its notification, or Brivio redelivering after your endpoint was down, carries the same id. Dedupe on it.
{
"id": "evt_pay_3f2a1b4c000040008000000000000001",
"type": "invoice.partially_paid",
"created": 1790000000,
"environment": "live",
"data": {
"invoice_id": "…",
"number": "IMS42",
"currency": "RON",
"total": "1000.00",
"paid_amount": "400.00",
"remaining_amount": "600.00",
"status": "PARTIALLY_PAID",
"payment": { "id": "…", "amount": "400.00", "payment_date": "2026-09-23", "method": "BANK_TRANSFER", "reference": "BANK:…", "source": "bank_match" },
"paid_at": null,
"contact_id": "…"
}
}payment.source tells you where the money came from: manual, mark_paid, bank_match, provider (card/processor), instrument, receipt, telegram, api (recorded through POST /v1/invoices/{id}/payments; a bank line matched through the API reports bank_match). Refunds do not emit these events. When a recorded payment is reversed, invoice.payment_reversed fires once per reversal (id evt_rev_<reversalId>) with the re-opened balance and reversal.reverses_payment_id naming the payment to undo; payload schema InvoicePaymentReversedWebhookData. The full list of subscribable types is GET /v1/webhooks/events; the payload schema is InvoicePaymentWebhookData in the OpenAPI document.
Every balance payload also carries credited_amount: the sum of the issued credit notes reversing the invoice. remaining_amount is always total − credited_amount − paid_amount, never negative.
invoice.credited
A credit note (storno) cancels debt — it is not a payment, so it never fires invoice.paid. invoice.credited fires when a credit note reversing an issued invoice is issued or cancelled and the source invoice's credited amount changes. data.invoice_id is the source invoice; credit_note_id is the credit note that triggered the event. status is the source's new status — CREDITED once it is fully reversed (fully_credited: true). When the customer had already paid more than the net total, refund_due is what you owe them back.
The id is evt_cred_<sourceId>_<creditedAmountCents>: a re-sync that lands on the same credited amount carries the same id (dedupe on it), while issuing and then cancelling a credit note produces two events. Payload schema InvoiceCreditedWebhookData.
{
"id": "evt_cred_3f2a1b4c000040008000000000000001_40000",
"type": "invoice.credited",
"created": 1790000000,
"environment": "live",
"data": {
"invoice_id": "…",
"number": "IMS42",
"currency": "RON",
"total": "1000.00",
"credited_amount": "400.00",
"paid_amount": "0.00",
"remaining_amount": "600.00",
"refund_due": "0.00",
"status": "SENT",
"fully_credited": false,
"credit_note_id": "…",
"contact_id": "…"
}
}contact.fiscal_status_changed fires when a customer that still has open issued invoices changes fiscal status at ANAF or the trade registry. changes[].kind is one of became_inactive, reactivated, struck_off (struck off or dissolved), insolvency, vat_registered, vat_deregistered, cash_vat_on, cash_vat_off (TVA la încasare), with effective_date when ANAF publishes one. open_invoices gives the count and the open balance in RON. Status is checked daily; the first check of a contact records a baseline and sends nothing. The id is evt_fsc_<contactId>_<kind>_<date> for the first entry in changes; payload schema ContactFiscalStatusWebhookData.
Routes, fleet and court cases
These fire the same way whether the change was made in the app or through the API, after the database write has completed. They carry identifiers only, no personal data.
delivery_route.dispatched— a delivery route was sent to its driver:id,number,driver_id,vehicle_id,stop_count,dispatched_at. A re-dispatch is a new event. Schema:DeliveryRouteDispatchedWebhookData.fleet_vehicle.created,fleet_vehicle.updated,fleet_vehicle.deleted—idandplate; onupdated,changed_fieldslists the names of the fields that changed, never their values. An edit that changes nothing emits nothing. Schema:FleetVehicleWebhookData.court_case.hearing_added— a new hearing on a court case:id(the hearing),case_id,hearing_date,court. No parties, panel, solution or notes. Idevt_court_hearing_<hearingId>. Schema:CourtHearingAddedWebhookData.
Email received
email.received fires once for each inbound message that lands in a Brivio mailbox owned by your organisation, in the inbox or in a custom folder. It does not fire for spam, trash, drafts or messages your own mailbox sent, nor for the history imported when a mailbox is first synced — only for messages received in the last 24 hours. Personal mailboxes that do not belong to an organisation never emit it.
The event carries metadata only, so you can route or count mail without Brivio forwarding its content:
id— the stored message in Brivio;message_id— the RFC 5322Message-ID, ornull.mailbox— the Brivio mailbox it landed in;recipient— the mailbox or alias address it was delivered to.from,subject,received_at.size_bytes(ornullwhen the provider reports none) andhas_attachments.
It never includes the body, a snippet, cc/bcc recipients or attachment names. Fetch those from the app if you need them.
The delivery id is stable per message: evt_<32 hex> is derived from the stored message id, so a mailbox re-sync never announces the same message twice, and a redelivery after your endpoint was down carries the same id. Schema: EmailReceivedWebhookData.
{
"id": "evt_3f2a1b4c…",
"type": "email.received",
"created": 1791200000,
"environment": "live",
"data": {
"object": "email_message",
"id": "…",
"message_id": "<CA+abc@mail.example.com>",
"mailbox": "contact@dragoscatalin.ro",
"recipient": "hello@dragoscatalin.ro",
"from": "visitor@example.com",
"subject": "Hello",
"received_at": "2026-10-05T10:00:00.000Z",
"size_bytes": 18234,
"has_attachments": false,
"organizationId": "…"
}
}organizationId is added by the delivery layer to every domain event.