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 carriescreatedandupdated(UTC). Each run asks for everything withupdated >= last watermark, sorted byupdated, walked by cursor. The watermark is the newestupdatedyou processed, minus a small overlap. - Voided invoices are updates; voided payments and released allocations disappear. Voiding an invoice sets
state: "void"(and bumpsupdated, 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 (aGETby id is404) and firepayments.deleted/payment_allocations.deletedwebhooks. 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
$runStartedAt at the end (not “now” — anything updated during the run is picked up next time).
3. Invoices with their lines
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.payments, so a nightly window cannot see them. Two ways to catch them — pick one:
- Webhooks (once available): subscribe to
payments.deletedandpayment_allocations.deleted; the handler reversespayment-{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}— a404means it was voided; reverse it. Cheap for a small clinic, wasteful for a large one.
unallocateAllFor), because the void is what you see in the window.
5. Customers
Post each patient once as a customer, keyedpatient-{id}, on first sight. Fetch the name lazily:
"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.createdwebhooks 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.