Skip to content

Developers

Webhooks

Get a signed notification on your server every time something changes in your account, without polling the API.

What they are

A webhook is an address on your server to which BMC sends a POST request when an event happens: an invoice issued, a payment recorded, a signature envelope completed. Each notification is thin: it carries the event type, the resource identifier and a few non-personal fields. For the detail, request the resource from the API with that identifier.

With webhooks you do not need to poll the API every few minutes to learn what changed, and you spend less of your quota (Rate limits). There are 44 events across 9 areas.

How to create a webhook

There are two ways:

  • In the client area, under Developers › Webhooks: enter a name, the destination URL and the events.
  • With the API, using POST https://app.bm.consulting/api/v1/webhooks/subscriptions.
Create a subscription
curl -X POST "https://app.bm.consulting/api/v1/webhooks/subscriptions" \
  -H "Authorization: Bearer $BMC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Issued invoices to my ERP",
    "url": "https://erp.example.com/webhooks/bmc",
    "events": ["invoice.issue", "invoice.pay"]
  }'
Response (201)
{
  "id": "…",
  "name": "Issued invoices to my ERP",
  "url": "https://erp.example.com/webhooks/bmc",
  "events": ["invoice.issue", "invoice.pay"],
  "secret": "whsec_…",
  "message": "Save the secret now: it will not be shown again. …"
}

Save the secret now. The secret field (whsec_…) is shown only when the subscription is created and when it is rotated. BMC keeps it encrypted and cannot show it to you again.

Requirements

  • The URL must be https and resolve to a public address. Credentials in the URL, internal names and private IP addresses are not accepted.
  • The token needs the developers:webhooks.create permission and, in addition, the read permission of each event it subscribes to (the «Required permission» column of the catalogue). If any is missing, the response is 403 and lists the missing permissions.
  • A token bound to an account only receives events of that account.

Webhook API routes

RouteWhat it doesPermission
GET /webhooks/eventsCatalogue of events your token can subscribe to (?lang=en).developers:webhooks.read
GET /webhooks/subscriptionsList your subscriptions.developers:webhooks.read
POST /webhooks/subscriptionsCreate a subscription and return the secret.developers:webhooks.create
GET /webhooks/subscriptions/{id}Get a subscription.developers:webhooks.read
PATCH /webhooks/subscriptions/{id}Change the name, URL, events or active state.developers:webhooks.update
DELETE /webhooks/subscriptions/{id}Delete the subscription.developers:webhooks.delete
POST /webhooks/subscriptions/{id}/testSend a webhook.test event with a single attempt.developers:webhooks.test
POST /webhooks/subscriptions/{id}/rotate-secretGenerate a new secret; the previous one stops working.developers:webhooks.update
GET /webhooks/subscriptions/{id}/deliveriesDelivery log with status.developers:webhooks.read
POST /webhooks/deliveries/{id}/redeliverResend a specific delivery.developers:webhooks.update

What your server receives

Body

JSON with Content-Type: application/json. Keys always arrive in the same order.

{
  "id": "evt_…",
  "type": "invoice.issue",
  "occurred_at": "2026-10-02T09:15:00.000Z",
  "account_id": "…",
  "data": {
    "object": "invoice",
    "id": "…"
  }
}
FieldContents
idEvent identifier. It is the same across retries and resends.
typeEvent type, for example invoice.issue.
occurred_atTime of the event, in ISO 8601.
account_idAccount the event belongs to, or null.
data.objectResource type (invoice, contact…), the one to request from the API.
data.idResource identifier. Some events add non-personal fields, such as merged_id on merges.

Headers

POST /webhooks/bmc HTTP/1.1
Content-Type: application/json
User-Agent: BMC-Webhooks/1.0 (+https://bm.consulting)
X-BMC-Webhook-Event: invoice.issue
X-BMC-Webhook-Id: evt_…
X-BMC-Webhook-Delivery: …
X-BMC-Webhook-Date: 2026-10-02T09:15:01.204Z
X-BMC-Webhook-Account-Id: …
X-BMC-Webhook-Version: v1
X-BMC-Webhook-Signature: t=1791191701,sha256=9f2c…
HeaderContents
X-BMC-Webhook-EventEvent type.
X-BMC-Webhook-IdEvent identifier. Use it to discard duplicates.
X-BMC-Webhook-DeliveryIdentifier of this particular delivery.
X-BMC-Webhook-DateDate of this attempt, in ISO 8601.
X-BMC-Webhook-Account-IdAccount the event belongs to; empty if none.
X-BMC-Webhook-VersionContract version: v1.
X-BMC-Webhook-SignatureSignature: t=<unix>,sha256=<hex>.

Verify the signature

Anyone who knows your URL can send requests to it. Always check the signature before trusting the content:

  1. Read the X-BMC-Webhook-Signature header and split out t (Unix seconds) and sha256.
  2. Reject the delivery if t is more than 300 seconds away from your clock. This way a captured delivery cannot be replayed later.
  3. Compute an HMAC-SHA256 with your full secret (whsec_…) as the key over the text {t}.{body}, where the body is the exact bytes received, without re-serialising the JSON.
  4. Compare with sha256 in constant time.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.BMC_WEBHOOK_SECRET; // whsec_…
const TOLERANCE = 300; // seconds

app.post("/webhooks/bmc", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-BMC-Webhook-Signature") ?? "";
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE) {
    return res.sendStatus(400);
  }

  // req.body is a Buffer with the exact bytes received
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(req.body).digest("hex");
  const received = parts.sha256 ?? "";
  const valid =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
  if (!valid) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  const eventId = req.get("X-BMC-Webhook-Id");
  // 1. If you already processed eventId, answer 200 and do nothing.
  // 2. Otherwise store eventId and queue the work.
  res.sendStatus(200);
});
Python (Flask)
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["BMC_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE = 300  # seconds


def verify(payload: bytes, header: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t, received = int(parts["t"]), parts["sha256"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > TOLERANCE:
        return False
    expected = hmac.new(SECRET.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


@app.post("/webhooks/bmc")
def bmc_webhook():
    payload = request.get_data()  # exact bytes, before parsing
    if not verify(payload, request.headers.get("X-BMC-Webhook-Signature", "")):
        return "", 400
    event_id = request.headers["X-BMC-Webhook-Id"]
    # 1. If you already processed event_id, answer 200 and do nothing.
    # 2. Otherwise store event_id and queue the work.
    return "", 200

Expected response and retries

  • Answer with any 2xx status to confirm receipt. Be quick: BMC waits 10 seconds at most. If the work is long, store the event, answer and process it afterwards.
  • Any other response counts as a failure, including redirects: BMC does not follow them. Not answering in time or failing to connect is also a failure.
  • After a failure BMC retries with increasing waits: 1 min, 5 min, 30 min, 2 h, 12 h. That is 6 attempts in total, counting the first. After all of them the delivery is marked as failed.
  • Deliveries and their status are shown under Developers › Webhooks and in GET /webhooks/subscriptions/{id}/deliveries. A failed delivery can be resent by hand.

Idempotency

Because of retries, your server may receive the same event more than once. X-BMC-Webhook-Id (and the id field of the body) is the same on every attempt and resend of the event: store it when you process the event and, if it arrives again, answer 200 without repeating the work. X-BMC-Webhook-Delivery changes on each delivery and is useful to trace it in the log.

Arrival order is not guaranteed. If order matters, compare occurred_at or request the current state of the resource from the API.

Testing and secret rotation

  • Send a test. From the client area or with POST /webhooks/subscriptions/{id}/test a webhook.test event is sent with a single attempt and the result is shown. It is not a subscribable event.
  • Rotate the secret. It generates a new one; the previous one stops working. Update your server with the new value, and do it whenever you suspect the secret has been exposed.
  • Pause. A deactivated subscription receives no events.

Event catalogue

Each event requires the read permission of the resource it refers to, the same one the API route that returns it asks for. Permission names are in the permissions catalogue. A subscription can only include the events whose permission the token holds.

Contacts

EventWhen it is sentResourceRequired permission
contact.createContact createdcontactcontacts:contacts.read
contact.updateContact updatedcontactcontacts:contacts.read
contact.deleteContact deletedcontactcontacts:contacts.read
contact.mergeContacts mergedcontactcontacts:contacts.read
account.createAccount createdaccountcontacts:accounts.read
account.updateAccount updatedaccountcontacts:accounts.read
account.deleteAccount deactivatedaccountcontacts:accounts.read
account.mergeAccounts mergedaccountcontacts:accounts.read

CRM and matters

EventWhen it is sentResourceRequired permission
case.createMatter openedcasecrm:cases.read
case.updateMatter updatedcasecrm:cases.read

Sales

EventWhen it is sentResourceRequired permission
invoice.createSales document createdinvoicesales:invoices.read
invoice.updateSales document updatedinvoicesales:invoices.read
invoice.deleteSales draft deletedinvoicesales:invoices.read
invoice.issueInvoice issuedinvoicesales:invoices.read
invoice.sendInvoice emailedinvoicesales:invoices.read
invoice.payPayment recordedinvoicesales:invoices.read
invoice.voidInvoice voidedinvoicesales:invoices.read

Purchases

EventWhen it is sentResourceRequired permission
supplier_document.createExpense receivedsupplier_documentpurchases:documents.read
supplier_document.updateExpense updatedsupplier_documentpurchases:documents.read
supplier.createSupplier createdsupplierpurchases:suppliers.read

Accounting

EventWhen it is sentResourceRequired permission
journal_entry.createJournal entry createdjournal_entryaccounting:journal.read
journal_entry.updateJournal entry updatedjournal_entryaccounting:journal.read
journal_entry.postJournal entry postedjournal_entryaccounting:journal.read
journal_entry.deleteJournal entry deletedjournal_entryaccounting:journal.read
fixed_asset.createFixed asset createdfixed_assetaccounting:fixed_assets.read
fixed_asset.updateFixed asset updatedfixed_assetaccounting:fixed_assets.read

Treasury

EventWhen it is sentResourceRequired permission
bank_account.createBank account createdbank_accounttreasury:bank_accounts.read
bank_account.updateBank account updatedbank_accounttreasury:bank_accounts.read
bank_movement.createBank movements importedbank_movement_importtreasury:bank_movements.read
reconciliation.confirmMovement reconciledbank_movementtreasury:reconciliation.read

Tax

EventWhen it is sentResourceRequired permission
tax_return.updateTax return preparedtax_returntax:returns.read
tax_return.submitTax return submittedtax_returntax:returns.read
tax_profile.updateTax profile updatedtax_profiletax:profile.read
notice.createGovernment notice receivednoticetax:notices.read

Documents

EventWhen it is sentResourceRequired permission
file.createFile uploadedfiledocuments:files.read
file.updateFile updatedfiledocuments:files.read
file.deleteFile deletedfiledocuments:files.read
data_room_file.createFile added to the data roomdata_room_filedocuments:data_room.read

E-signature

EventWhen it is sentResourceRequired permission
envelope.sentEnvelope sent for signatureenvelopesign:envelopes.read
envelope.completedEnvelope completedenvelopesign:envelopes.read
envelope.declinedSignature declinedenvelopesign:envelopes.read
envelope.voidedEnvelope voidedenvelopesign:envelopes.read
envelope.reissuedEnvelope reissuedenvelopesign:envelopes.read
envelope.reassignedSigner reassignedenvelopesign:envelopes.read
Email
Contact