Webhooks
Instead of polling, subscribe a URL to events in a business. Deliveries are signed, retried, and logged where you can see them.
Subscribe
With a token for the business (owner role, write scope):
POST /api/v1/webhooks/endpoints
{"name": "My app", "url": "https://yourapp.example/hooks/hfb", "event_types": ["invoice.paid", "bill.paid"]}
-> data.endpoint, and data.secret: the signing secret. Shown once; store it per connection.
GET /api/v1/webhooks/event-types lists what can be subscribed to. URLs must be https:// and
resolve to a public address. Manage subscriptions with GET/PUT/DELETE /api/v1/webhooks/endpoints/{id}.
Your app sees and changes only the endpoints it created, never the owner's or another app's. When a customer
disconnects your app, the endpoints you created for that business are deactivated and nothing more is sent to them;
if they connect again, re-activate them (PUT … {"is_active": true}) or subscribe again.
If a secret may have leaked, POST /api/v1/webhooks/endpoints/{id}/rotate-secret returns a new one, once;
every delivery from then on, including queued retries, is signed with it.
Events
| Event | When | data carries |
|---|---|---|
invoice.created | An invoice is created | invoice_id, totals, status, and an invoice snapshot |
invoice.paid | A payment settles an invoice | invoice_id, payment_id, amount, status, and an invoice snapshot |
bill.paid | A bill is paid | bill_id, payment_id, amount, status, and a bill snapshot |
invoice.payment_reversed | A payment recorded on an invoice is reversed | invoice_id, payment_id, amount, reversal_transaction_id, released_bank_transaction_ids, reason, and the invoice snapshot after the reversal |
bill.payment_reversed | A payment recorded on a bill is reversed | bill_id, payment_id, amount, reversal_transaction_id, released_bank_transaction_ids, reason, and the bill snapshot after the reversal, which adds paid_amount and balance_due |
transaction.created | A journal entry is posted | transaction_id, date, debit/credit totals, line count |
payout.matched | A payment-processor payout is matched to the bank deposit it became | the payout and its bank_transaction |
The *.payment_reversed events fire once per reversed payment, part payments included. They are not sent
when Smart Match takes back a payment it had just recorded because the bank line was posted elsewhere at the same
moment; that payment was never announced.
Every delivery is one JSON envelope:
{"id": "evt_invoice_paid_1042_p77", "event": "invoice.paid", "api_version": "2026-10-29",
"entity_id": 7, "resource_id": 1042, "occurred_at": "2026-10-29T15:04:05Z",
"data": {"invoice_id": 1042, "payment_id": 77, "amount": "1250.00", "amount_cents": 125000, "status": "paid",
"invoice": {"invoice_number": "INV-1042", "contact_name": "Acme Landscaping LLC", "total_cents": 125000}}}
id names the occurrence and repeats on a retry. Every amount appears both as a decimal string and as
integer cents (*_cents). A full sample of each event is in GET /api/v1/webhooks/event-types,
and the business's own latest occurrences, in the same envelope, are at
GET /api/v1/webhooks/recent-events?event_type=… — handy for building and testing your handler.
Verify every delivery
Each delivery is a POST of JSON with X-HassleFreBooks-Event, X-HassleFreBooks-Delivery
(stable across retries) and X-HassleFreBooks-Signature: sha256=<hex> — the HMAC-SHA256 of the
raw body, keyed with the endpoint's secret.
// PHP
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_HASSLEFREBOOKS_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
# Python
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-HassleFreBooks-Signature", "")): abort(401)
// Node
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get("X-HassleFreBooks-Signature") || ""))) return res.sendStatus(401);
The SDKs include a verifier.
Delivery
- Answer any 2xx within 30 seconds; do the work after you answer.
- Otherwise we retry up to the endpoint's
max_retriestimes (default 5, so 6 attempts in all), waiting 1, 5, 15, 30 and 60 minutes (a highermax_retriesgoes on to 120, then 240 minutes), then mark it failed. - At-least-once and unordered: de-duplicate on the event
id, and treat the body as a snapshot — fetch the invoice or bill when you need its current state. - Deliveries go out from a job that runs every five minutes, so allow that latency.
GET /api/v1/webhooks/endpoints/{id}/deliveriesshows every attempt and what your server answered.POST /api/v1/webhooks/endpoints/{id}/deliveries/{deliveryId}/redeliversends a failed one again (same eventid, so de-duplication still holds).