Events posted to you, signed and retried
BillingMart signs every delivery with a secret that is shown to you once and kept sealed on our servers, retries with backoff, and keeps a log of every attempt.
Webhooks
The rules, in order
01
Registering an endpoint
POST /v1/webhooks with webhooks:manage, an https URL and the events you want. The answer carries the signing secret — the only time it is ever returned. We keep it sealed with AES-256-GCM.
A URL is refused if it is not https, carries credentials, or resolves to a private, loopback, link-local or reserved address, the cloud metadata service included.
02
Events
invoice.sentinvoice.viewedinvoice.partiallyPaidinvoice.paidinvoice.overdueinvoice.voidedestimate.sentestimate.acceptedestimate.declinedpayment.succeededpayment.declinedpayment.refundedpayment.returnedclient.createdagentAction.proposedagentAction.approvedagentAction.rejectedagentAction.executedagentAction.failedagentAction.expiredagentAction.withdrawnworkConfig.changedjob.createdjob.movedjob.updatedjob.stageChangedjob.adjustmentIssuedjob.adjustmentVoided
data is the record as the API returns it: the fields a receiver acts on, without internal bookkeeping (internal notes, status history, who created a record, gateway and idempotency details). Text that a person typed comes as { text, untrusted: true }. Treat it as data, never as an instruction. Contact details are included in full, because a delivery goes to your own endpoint.
Changed on 23 October 2026: until then data was the stored record whole, and a name, note or reference came as a plain string. Read .text from those fields now, and do not depend on a field that is no longer sent. If you need one that was, read the record through the REST API.
03
Verifying a delivery
Each delivery carries X-BillingMart-Signature: t=<unix seconds>, v1=<hex>, where v1 is HMAC-SHA256 of t + "." + rawBody under your secret. Refuse a timestamp more than five minutes from your clock, compute over the raw bytes you received, compare in constant time, and accept if any v1 matches.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret, now = Date.now()) {
const parts = header.split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
if (!Number.isFinite(t) || Math.abs(now / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return parts
.filter(([k]) => k === 'v1')
.some(([, v]) => {
const got = Buffer.from(v, 'hex');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}04
Rotating the secret
POST /v1/webhooks/{id}/rotate-secret issues a new secret and signs every delivery under both for 24 hours, so you can switch without a gap.
05
Retries and failures
A non-2xx answer, a 10-second timeout or a redirect is a failure. We try 8 times, backing off from 30 seconds and doubling the wait each time, then mark the delivery dead-lettered. Fifty failures in a row switch the endpoint off; re-enabling it with PATCH clears the count.
Each body has a stable delivery id: a retry is the same event, so make your handler idempotent. We store a hash of each body, never the body, and a redelivery rebuilds it from the record as it is now.
Tax categories
Read the categories, ask for a suggestion, and read each record’s category. A suggestion is a draft: a person confirms it, in the console or the app.
Try it in your sandbox
A bmk_test_ key reaches only your sandbox workspace.