Skip to main content
Four resources carry PracticeHub’s money: invoices hold what’s owed, payments hold what’s been received, payment_allocations connect the two, and every balance you read (patients.balance, invoices.balance, …) is computed from those three — never written directly, by you or by the app. Get the first three right and the balances take care of themselves.
Every write below follows the same rules the PracticeHub billing screens enforce. There is no separate, looser API path: a total that doesn’t add up, an invoice with no lines, or a payment allocated twice is refused here exactly as it would be at the front desk.

The shape of the ledger

A payment is not “for” an invoice until it’s allocated to one — an unallocated payment is credit sitting on the patient’s account. An invoice is not “paid” until enough allocations land against it. PracticeHub recomputes invoice.balance, invoice.patient_balance, invoice.third_party_balance, and the matching fields on patients (balance, private_balance, third_party_balance), every time a line item, payment or allocation changes — you read the result, you never set it. See The data model → The ledger is derived, not entered for how that fits with the rest of the resource graph. There is no single GET /ledger endpoint. A patient’s ledger is the three resources filtered by patient_id, ordered by date:
patients.balance is signed: positive is debt, negative is credit, zero is settled. invoices.state is unpaid, paid or void.

Invoices

Create

An invoice never exists without at least one line — there’s no such thing as an empty bill:
An amount carries at most two decimal places and a magnitude up to ±99,999,999.99 — anything else is rejected.

Editing

PATCH /invoices/{id} without line_items changes only the header — note, invoice_date, practitioner_id. Send line_items and the whole set is reconciled by id in one call:
  • A line sent with an id you read back is updated in place.
  • A line sent without an id is added.
  • A line you leave out is removed.
Here line 501 keeps its id and gets a new price; the new line is appended; any third line that existed before this call and isn’t listed is removed. This is the same pattern patients.numbers[] uses, and the same reasoning: read, edit the list, send it back. A few rules apply everywhere a line is touched (PATCH /invoices/{id} with line_items, or POST / PATCH / DELETE on /line_items):
  • Every new line must carry a billable_item_id. On a line sent with id, omitting it leaves the existing catalogue link unchanged; billable_item_id: null is refused — a line can never be stripped of its link.
  • The last line on an invoice cannot be removed — void the invoice instead.
  • A void or care-plan invoice refuses edits entirely (409) — a care-plan invoice is generated and reconciled by the care plan engine, not edited by hand.
  • Every change re-totals the invoice and cascades to its balance and the patient’s.
POST /line_items also accepts several lines at once under lines: [...], added atomically — none land if any one fails:

Void

DELETE /invoices/{id} is the only way an invoice goes away — there is no soft-edit-to-zero, and voiding an already-void invoice is refused (409). Voiding does four things, in one transaction:
  1. Every active allocation against the invoice is released.
  2. If ?void_payments=true is on the request, any payment left with nothing else to pay once those allocations are gone is voided too — including a payment linked to the invoice the older direct way, not just via an allocation row.
  3. The invoice’s care-plan billing links are cleared, and if it was a care-plan invoice the visit’s care-plan usage is reversed.
  4. The appointment the invoice was raised from, if any, is reset to unprocessed — the same state a staff member sees when they un-complete a visit. The change broadcasts to the calendar in real time, exactly as it does in the app.
Without void_payments, a manual payment that was allocated to the voided invoice becomes unallocated credit on the patient’s account rather than being removed — it’s still real money the practice received.

Payments

amount, patient_id and payment_method_id are required; location_id, note and payment_date are optional. location_id is where the money was taken: a payment dated today joins the shift open at that location, so it shows on the till and the shift report — exactly as it does when the front desk records one. Leave it out and the payment is on the ledger but on no shift report. amount is money received: 0.01 or more. payment_date backdates the same way invoice_date does — default today, or any past date for imported history.
payment_method_id is one of the account’s own payment methods (GET /payment_methods, positive ids). PracticeHub’s own special types (negative ids: -1 Refund, -2 Credit, -3/-4 Credit and Debit Transfer, -5 Write-off, -10 Reward Points Credit, -20 Card on File, -21 Direct Debit) appear on payments PracticeHub wrote — they are not in the listing, and none can be recorded through the API. The same policy the billing dialog is held to applies to every write, PATCH included: service and service_id are never accepted — they identify the gateway charge that took the money, and only PracticeHub’s own card-charging flow sets them. Deleting a payment (DELETE /payments/{id}) voids it: it’s soft-deleted, its allocations are released, and the affected balances are recalculated. A payment that is one leg of a credit transfer refuses both edits and voids on its own (409) — reverse the transfer from the ledger UI instead. A refund row (GET /refunds) refuses both too (409 refund_locked): a refund is never edited or voided, only reversed in PracticeHub.

Refunds

A payment’s amount is always money received — 0.01 or more, on create and on PATCH; amount: 0 and negative amounts are refused with a 422 on amount. A refund is not a free-standing negative row: it is tied to the payment it reverses and capped at what that payment has left. So it has its own call — name the payment and how much was handed back:
PracticeHub writes the refund as a row of the payment ledger, releases the matching share of what the payment had settled (those invoices go back to owing) and recalculates the patient’s balance. The response is that ledger row: amount reads back negative ("-10.00"), payment_id points at the refunded payment, and the same id appears in GET /payments with parent_payment_id set. GET /refunds lists refunds only, whether made here or in PracticeHub. A refund is never edited or deleted. To undo a payment recorded by mistake, void it instead with DELETE /payments/{id}.

Allocations

An allocation is what turns a payment into “paid” against a specific invoice. patient_id, invoice_id, payment_id and amount (2 dp, > 0) are all required:
patient_id must be the invoice’s owner — the invoice lookup is scoped to it, and a mismatch is a 404. The payment does not have to belong to the same patient. A payment can settle another patient’s invoice by design — family accounts and group care plans routinely have one person’s payment paying for someone else’s visit — and both patients’ balances are refreshed when it happens: the invoice owner’s balance drops, and the payer’s balance rises by the same amount, because their money is now spent elsewhere. Two guards apply on every allocation, both 409:
  • Over-allocating the invoice — the amount can’t exceed what the invoice still has outstanding.
  • Over-allocating the payment — the amount can’t exceed what the payment still has unspent.
Both figures are recalculated from active allocations at write time under a row lock, so two concurrent allocations can’t both succeed past the limit. Allocations aren’t edited — there’s no PATCH. To change one, delete it and create the one you meant:
Deleting (deallocating) is a soft delete: the invoice’s balance and, when the payment belonged to a different patient, the payer’s balance are both recalculated immediately.

Credit

A payment with nothing allocated to it is credit — money the practice holds against a patient that isn’t attached to any bill yet. It shows up as a negative patients.balance; a payment’s spare amount isn’t a returned field, but it’s one subtraction away — amount minus the sum of its rows in payment_allocations. There’s no separate “credit” resource: it’s simply the gap between what a patient has paid and what’s been allocated to invoices. Import a historical credit balance as an ordinary payment with nothing allocated to it, and it behaves exactly like credit the app itself created.

Worked example: an invoice paid in two parts

Read GET /invoices/98 at any point and balance reflects exactly what’s left — never something you compute client-side from a payment total that might be allocated elsewhere. See Writing for the full field-by-field contract and error codes, and Data migration for importing billing history in bulk.