PATCH behaviour
PATCH does not mean the same thing on every resource. There are two distinct behaviours, and mixing them up will silently wipe or ignore fields. Read this before you write an update.
Invoices: PATCH is a FULL REPLACE
PATCH /api/v1/invoices/{invoice} rewrites the entire invoice from the payload — it is not a partial merge. You must resend every header field and the complete items array:
- The line items are deleted and rewritten from what you send, so any line you omit is gone. To change one line, send them all.
- An omitted
notesbecomesnull. - An omitted
reverse_charge_appliedbecomesfalse. invoice_numbermay be omitted to keep the current number.
Only a draft invoice can be edited — a PATCH on a sent or paid invoice returns 409 Conflict.
curl -X PATCH https://billey.nl/api/v1/invoices/42 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 7,
"currency": "EUR",
"invoice_date": "2026-07-21",
"due_date": "2026-08-04",
"notes": "Keep me — omitting this clears it",
"items": [
{ "description": "Consulting", "quantity": "10", "unit_price": "103.31", "btw_percentage": 21 }
]
}'
The safest pattern: GET the invoice, change the fields you want, and send the whole thing back.
Quotes: PATCH is a FULL REPLACE
PATCH /api/v1/quotes/{quote} is the same full-replace shape as invoices — resend every header field and the complete items array. One difference from invoices: quote_number cannot be changed via PATCH at all. Unlike invoice_number, a quote's number is fixed at creation; any quote_number you send on a PATCH is silently ignored rather than either erroring or renumbering.
Only a draft quote can be edited — a PATCH on a sent, accepted, or declined quote returns 409 Conflict.
curl -X PATCH https://billey.nl/api/v1/quotes/15 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 7,
"currency": "EUR",
"quote_date": "2026-07-10",
"valid_until": "2026-08-10",
"notes": "Keep me — omitting this clears it",
"items": [
{ "description": "Project phase 1", "quantity": "1", "unit_price": "4750.00", "btw_percentage": 21 }
]
}'
Expenses: PATCH is a PARTIAL merge
PATCH /api/v1/expenses/{expense} is a genuine partial update — only the keys you send change, everything else is left alone.
# Only touches the reference; every other field is untouched.
curl -X PATCH https://billey.nl/api/v1/expenses/88 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reference": "INV-2026-991" }'
Expense status transitions ride the status field on this PATCH (Draft → Pending → Paid) — status is not accepted when you create an expense, only here:
curl -X PATCH https://billey.nl/api/v1/expenses/88 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "status": "paid" }'
Touching either of date / due_date re-validates the pair against the resulting record, so due_date must still land on or after date.
Clients and vendors: PATCH is a PARTIAL merge
Client and vendor updates are partial too — send only what changes. One cross-field rule to know on clients: touching any of collection_method, iban, or mandate_date validates the resulting record, so switching collection_method to direct_debit without an IBAN already on file (and without sending one) fails.
Summary
| Resource | PATCH semantics |
|---|---|
| Invoices | Full replace — resend every field and the whole items array; draft only |
| Quotes | Full replace — resend every field and the whole items array; draft only; quote_number can never be changed |
| Expenses | Partial merge — status rides the status field |
| Clients | Partial merge — direct-debit cross-field rule applies |
| Vendors | Partial merge |