Skip to main content
Goal: every night, push the day’s billing activity to the accounting system (Xero, QuickBooks, Sage — the shape is the same): new and changed invoices with their lines, payments received, how payments were allocated to invoices, and anything voided. Re-runnable, gap-free, and never double-posting. Key: read-only — this integration writes nothing to PracticeHub. Resources used: invoices, line_items, payments, payment_allocations, payment_methods, /me (plus payments.deleted / payment_allocations.deleted webhooks if you want same-day reversals).

The design

  • Incremental by updated. Every record carries created and updated (UTC). Each run asks for everything with updated >= last watermark, sorted by updated, walked by cursor. The watermark is the newest updated you processed, minus a small overlap.
  • Voided invoices are updates; voided payments and released allocations disappear. Voiding an invoice sets state: "void" (and bumps updated, so it re-enters your window). Voiding a payment, or the allocations an invoice void releases, soft-deletes those rows: they stop being returned by the API (a GET by id is 404) and fire payments.deleted / payment_allocations.deleted webhooks. Reconcile those either from the webhooks or from the invoice void itself (below).
  • Post the money, not the arithmetic. Amounts are fixed 2-dp strings ("45.00"); keep them as decimals in your language, never floats. Currency comes from /me.
  • Idempotent posting on your side keyed by PracticeHub id (invoice-4821, payment-9002), so a re-run of a window updates rather than duplicates.

Client (PHP 8.2, Guzzle)

1. Once: account context and reference data

invoice_date and payment_date are clinic-local calendar dates (YYYY-MM-DD) — that is what the practice sees on the invoice, so post those. created / updated are UTC timestamps and are only for windowing.

2. Each night: the window

Overlap by a few minutes so a row that committed just after your previous read isn’t missed; the idempotent posting below makes reprocessing harmless. Set the new watermark to $runStartedAt at the end (not “now” — anything updated during the run is picked up next time).

3. Invoices with their lines

An invoice’s lines can be edited after it was raised (the practice corrects a description, adds a product) — that bumps the invoice’s updated, so it re-enters your window and you upsert lines by line-{id}. Lines removed in PracticeHub simply don’t come back; if your ledger needs an explicit delete, diff against what you last posted.

4. Payments and allocations

A payment is money received; an allocation is that money being applied to an invoice. One payment can be split across invoices; an unallocated payment is credit on account. Post payments as receipts and allocations as the invoice ↔ receipt link.
Voided payments are soft-deleted: they no longer appear in payments, so a nightly window cannot see them. Two ways to catch them — pick one:
  • Webhooks (once available): subscribe to payments.deleted and payment_allocations.deleted; the handler reverses payment-{id} / allocation-{id} in the ledger. See the webhook receiver.
  • Nightly re-check: for receipts you posted in the last N days, GET /payments/{id} — a 404 means it was voided; reverse it. Cheap for a small clinic, wasteful for a large one.
Allocations released by an invoice void are handled in the invoice loop above (unallocateAllFor), because the void is what you see in the window.

5. Customers

Post each patient once as a customer, keyed patient-{id}, on first sight. Fetch the name lazily:
Whether to send patient names/emails to the accounting system at all is a decision for the practice (it is personal data). Many exports use "Patient {id}" as the customer name and keep the mapping in PracticeHub — the API supports either.

6. Finish

Gotchas

Going further

  • Trigger the run from invoices.updated / payments.created webhooks instead of nightly, using the same idempotent handlers.
  • Tag posted invoices from your side with metadata.ledger_ref (needs a write key) so staff can see the accounting reference on the PracticeHub record — the loop above already reads it back.