Skip to content

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.sent
  • invoice.viewed
  • invoice.partiallyPaid
  • invoice.paid
  • invoice.overdue
  • invoice.voided
  • estimate.sent
  • estimate.accepted
  • estimate.declined
  • payment.succeeded
  • payment.declined
  • payment.refunded
  • payment.returned
  • client.created
  • agentAction.proposed
  • agentAction.approved
  • agentAction.rejected
  • agentAction.executed
  • agentAction.failed
  • agentAction.expired
  • agentAction.withdrawn
  • workConfig.changed
  • job.created
  • job.moved
  • job.updated
  • job.stageChanged
  • job.adjustmentIssued
  • job.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.

Node.jsSample
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.

Next guide

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.

Continue to Tax categories →

Try it in your sandbox

A bmk_test_ key reaches only your sandbox workspace.

We use Google Analytics on our website by default to understand visits. Choose Decline to turn it off; you can change this at any time on the Cookies page. We never send Google your name, email or account. Cookie policy