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
Route
What it does
Permission
GET /webhooks/events
Catalogue of events your token can subscribe to (?lang=en).
developers:webhooks.read
GET /webhooks/subscriptions
List your subscriptions.
developers:webhooks.read
POST /webhooks/subscriptions
Create 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}/test
Send a webhook.test event with a single attempt.
developers:webhooks.test
POST /webhooks/subscriptions/{id}/rotate-secret
Generate a new secret; the previous one stops working.
developers:webhooks.update
GET /webhooks/subscriptions/{id}/deliveries
Delivery log with status.
developers:webhooks.read
POST /webhooks/deliveries/{id}/redeliver
Resend a specific delivery.
developers:webhooks.update
What your server receives
Body
JSON with Content-Type: application/json. Keys always arrive in the same order.
Anyone who knows your URL can send requests to it. Always check the signature before trusting the content:
Read the X-BMC-Webhook-Signature header and split out t (Unix seconds) and sha256.
Reject the delivery if t is more than 300 seconds away from your clock. This way a captured delivery cannot be replayed later.
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.
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.