> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Billing

> How invoices, payments and allocations fit together, and how balances are derived

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.

<Info>
  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.
</Info>

## The shape of the ledger

```mermaid theme={null}
flowchart LR
    subgraph Invoice
        L1[Line item]
        L2[Line item]
    end
    Invoice -- total --> Bal((Balance))
    P[Payment] -- allocation --> Bal
    Bal -- rolls up into --> PB[Patient balance]

    style Bal fill:#00000000,stroke-width:2px
```

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](/concepts/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:

```bash theme={null}
curl -s "$BASE/invoices?patient_id=eq:340&sort=invoice_date:desc" -H "Authorization: Bearer $KEY"
curl -s "$BASE/payments?patient_id=eq:340&sort=payment_date:desc" -H "Authorization: Bearer $KEY"
curl -s "$BASE/payment_allocations?patient_id=eq:340" -H "Authorization: Bearer $KEY"
```

`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:

```bash theme={null}
curl -X POST $BASE/invoices \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "patient_id": 340,
    "location_id": 1,
    "practitioner_id": 12,
    "invoice_date": "2026-08-19",
    "note": "Initial consultation and exercise plan",
    "line_items": [
      { "description": "Initial consultation", "quantity": 1, "price": 65.00,  "billable_item_id": 17 },
      { "description": "Loyalty discount",      "quantity": 1, "price": -10.00, "billable_item_id": 23 }
    ]
  }'
```

| Field | Notes |
| - | - |
| `patient_id` | Required |
| `location_id` | Optional — the location the invoice is raised at (`GET /locations`). An invoice dated today joins the shift open there, so it appears on that till's shift report; without one it joins no shift. Fixed once raised: `PATCH` refuses it |
| `line_items[]` | Required, at least one. Each needs `description` (≤ 255 chars), `quantity` (whole number ≥ 1), `price` (2 dp, **sign-free** — a negative line is how a discount or credit is written) |
| `line_items[].billable_item_id` | **Required** — every line bills one of the account's services or products (`GET /billable_items` for the catalogue) |
| `line_items[].provider_id` | Optional; defaults to the invoice's `practitioner_id` when omitted, or send `null` for deliberately none |
| `practitioner_id` | Optional |
| `invoice_date` | Optional, `YYYY-MM-DD` — defaults to today in the account's timezone. Backdate freely for migrated history |
| `note` | Optional, ≤ 1000 chars |

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.

```bash theme={null}
curl -X PATCH $BASE/invoices/98 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "line_items": [
      { "id": 501, "price": 55.00 },
      { "description": "Follow-up exercise sheet", "quantity": 1, "price": 15.00, "billable_item_id": 31 }
    ]
  }'
```

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:

```bash theme={null}
curl -X POST $BASE/line_items \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "invoice_id": 98, "lines": [
    { "description": "Consultation", "quantity": 1, "price": 65.00, "billable_item_id": 17 },
    { "description": "Supplement",   "quantity": 2, "price": 12.50, "billable_item_id": 31 }
  ] }'
```

### 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.

```bash theme={null}
curl -X DELETE "$BASE/invoices/98?void_payments=true" -H "Authorization: Bearer $KEY"
```

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.

```bash theme={null}
curl -X POST $BASE/payments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "amount": "45.00", "payment_method_id": 3, "location_id": 1, "payment_date": "2026-08-19" }'
```

`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:

| Method | Recordable via the API? |
| - | - |
| An active, ordinary payment method (Cash, Bank Transfer, a custom method, …) | Yes |
| A method a **payment provider collects** (Stripe cards, GoCardless or Access PaySuite Direct Debit, a custom gateway) | No — `422 card_payment_not_allowed`. Recording one means the provider actually took the money, and only PracticeHub's own collection flow does that |
| An **inactive** method | No — `422 payment_method_inactive` |
| Any special type (negative id) | No — `422 payment_method_not_recordable`. PracticeHub writes these itself as part of its own flow: a refund is `POST /refunds` (see [Refunds](#refunds)); credit on account is spent by allocating existing credit to an invoice; a write-off, a transfer (a linked, zero-sum pair between two ledgers), a rewards credit (which moves points as well as money), a card-on-file or Direct Debit charge (which needs the provider's own reference) are all recorded in PracticeHub. One keyed in on its own would be a row nothing could explain or undo |

`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:

```bash theme={null}
curl -X POST $BASE/refunds \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"payment_id": 6501, "amount": "10.00"}'
```

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.

| Refusal | Status | `code` |
| - | - | - |
| The payment was taken through a provider (Stripe, GoCardless, a card on file). Refunding it moves money at the provider, so it is done in PracticeHub | `422` on `payment_id` | `provider_refund_not_supported` |
| `amount` is more than the payment | `422` | `amount_out_of_range` |
| `amount` is more than what is left after earlier refunds | `409` | `refund_exceeds_net` |
| The payment is a credit-transfer leg or released plan credit | `409` | `billing_rule` / `release_pair` |
| No such payment, or it is voided | `404` | `not_found` |

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:

```bash theme={null}
curl -X POST $BASE/payment_allocations \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "invoice_id": 98, "payment_id": 6497, "amount": 45.00 }'
```

`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:

```bash theme={null}
curl -X DELETE $BASE/payment_allocations/812 -H "Authorization: Bearer $KEY"
```

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

```bash theme={null}
# 1. Raise a £65 invoice
curl -X POST $BASE/invoices -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "location_id": 1, "line_items": [{ "description": "Consultation", "quantity": 1, "price": 65.00, "billable_item_id": 17 }] }'
# → { "id": 98, "balance": 65.00, "state": "unpaid", ... }

# 2. Take a £30 part-payment and allocate it
curl -X POST $BASE/payments -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "amount": "30.00", "payment_method_id": 1, "location_id": 1 }'
# → { "id": 6501, ... }
curl -X POST $BASE/payment_allocations -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "invoice_id": 98, "payment_id": 6501, "amount": 30.00 }'
# → invoice.balance now 35.00, state still "unpaid"

# 3. Take the remaining £35 and allocate it too
curl -X POST $BASE/payments -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "amount": "35.00", "payment_method_id": 1, "location_id": 1 }'
# → { "id": 6502, ... }
curl -X POST $BASE/payment_allocations -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "invoice_id": 98, "payment_id": 6502, "amount": 35.00 }'
# → invoice.balance now 0.00, state flips to "paid"
```

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](/guides/writing#invoices-payments-and-allocations) for the full field-by-field contract and error codes, and [Data migration](/guides/data-migration) for importing billing history in bulk.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.