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

EventWhendata carries
invoice.createdAn invoice is createdinvoice_id, totals, status, and an invoice snapshot
invoice.paidA payment settles an invoiceinvoice_id, payment_id, amount, status, and an invoice snapshot
bill.paidA bill is paidbill_id, payment_id, amount, status, and a bill snapshot
invoice.payment_reversedA payment recorded on an invoice is reversedinvoice_id, payment_id, amount, reversal_transaction_id, released_bank_transaction_ids, reason, and the invoice snapshot after the reversal
bill.payment_reversedA payment recorded on a bill is reversedbill_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.createdA journal entry is postedtransaction_id, date, debit/credit totals, line count
payout.matchedA payment-processor payout is matched to the bank deposit it becamethe 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_retries times (default 5, so 6 attempts in all), waiting 1, 5, 15, 30 and 60 minutes (a higher max_retries goes 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}/deliveries shows every attempt and what your server answered. POST /api/v1/webhooks/endpoints/{id}/deliveries/{deliveryId}/redeliver sends a failed one again (same event id, so de-duplication still holds).