The Billing API is where invoices, payments, Stripe Connect, and Xero sync converge. It's the most carefully-tested surface we ship — money flows through here, so we've hardened the invariants and surfaced them as both DB constraints and documentation.
This page covers the API and the state machines underneath. If you only read one section, read Stripe Connect flow — the four-layer confirmation pipeline is the single most-misunderstood part of the platform.
cs_*-in-pi_* regression, KARO-440 Reference shape) before they reach the data layer, but a defensive client still beats a noisy 422.Section 1
Invoices vs payments
Two tables · two life-cycles · one source of truth
Our domain model separates invoices (a promise of money) from payments(the actual receipt of money). They're joined by a foreign key, but they have independent state machines and you'll mutate them through different endpoints.
/v1/invoices— line items, amounts, statuses, Xero ids./v1/payments— Stripe payment intents, refunds, settlement timestamps.
A single invoice can have multiple payment rows (e.g. an initial charge, a partial refund, a top-up). A payment row always points to exactly one invoice.
Section 2
Issuing an invoice
POST /v1/invoices
Invoices can be issued ad hoc(one-off charges, custom amounts) or are auto-generated by the booking + subscription engines. The API endpoint creates ad-hoc invoices — subscription invoices come from the daily cron and aren't client-mutable.
curl -X POST https://api.litehq.com/v1/invoices \
-H 'Authorization: Bearer sk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"company_id": "co_tide",
"currency": "GBP",
"due_at": "2026-06-21T00:00:00Z",
"lines": [
{
"description": "Meeting room hire — Studio · 22 May",
"quantity": 1.5,
"unit_amount_minor": 5600,
"tax_code": "OUTPUT2"
}
],
"reference": "Tide Labs Q2 — ad hoc"
}'{
"id": "inv_R3v...",
"status": "open",
"currency": "GBP",
"total_minor": 8400,
"due_at": "2026-06-21T00:00:00Z",
"xero_id": null,
"xero_reference": "LiteHQ.com #2143 — Tide Labs Q2 — ad hoc",
"lines": [{ /* … */ }]
}Reference shape is LiteHQ.com #N [— <user-ref>] (KARO-440). We construct it server-side; sending a custom reference appends to it after the em-dash. The LiteHQ.com #Nprefix is the workspace-stable id and must never be overwritten — if you do, Xero dedup logic in XeroClient.updateInvoiceReference back-fills it next sync.Section 3
Marking paid
POST /v1/invoices/{id}/mark-paid
99% of invoices flip to paid automatically when a Stripe webhook confirms payment. For invoices paid outside Stripe (bank transfer, cheque, cash) you'll mark them paid manually:
curl -X POST https://api.litehq.com/v1/invoices/inv_R3v.../mark-paid \
-H 'Authorization: Bearer sk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"amount_minor": 8400,
"paid_at": "2026-05-22T11:00:00Z",
"method": "bank_transfer",
"reference": "BT-2026-05-22-001"
}'The amount_minormust equal the invoice total — partial manual payments create separate payment rows but don't flip the invoice to paid until the running total matches. The endpoint also pushes the payment to Xero if Xero sync is enabled (single-payment apply, full invoice amount).
Section 4
Stripe Connect flow
4-layer confirmation pipeline · idempotent on payment_intent_id
Every paid booking lands through Stripe Connect — you own the Connect account, funds settle directly to your bank, we never touch the money. Confirmation is guaranteed by four independent layers, each idempotent on the same payment_intent_id. They converge on the verify_payment action in the booking-manager edge function.
The four layers
- Success-page hook. Fires when Stripe redirects the user back. The server action looks up the
cs_*session id and runs verify. - Platform webhook.
STRIPE_WEBHOOK_SECRET. Fires on platform-account events (e.g.checkout.session.completed) within ~2–5s of the charge. - Connect webhook.
STRIPE_WEBHOOK_SECRET_CONNECT. Fires on connected-account events (charge.succeededon the host's Stripe account). Same endpoint, dual-secret verification. - Scheduler self-heal. Hourly Supabase cron sweeps any
pending_paymentrows older than 30 min and runs verify against Stripe's API. Catches the rare case where all 3 above layers missed.
4-layer confirmation pipeline
All four layers converge on verify_payment; idempotent on payment_intent_id.
stripe_payment_intent_id column must never hold a cs_*value — that's a Stripe Checkout Session id, semantically distinct from a PaymentIntent id. They go in stripe_checkout_session_id instead. DB-level bookings_pi_not_checkout_session + invoices_pi_not_checkout_session CHECK constraints reject regressions (KARO-387, migration 20260514083734). If you see a 422 on payment verify, this is the first suspect.# All 4 layers call this same edge function
POST https://lkncumfmtfziiahsrtai.supabase.co/functions/v1/booking-manager
Authorization: Bearer <service-role>
body: { action: 'verify_payment', payment_intent_id: 'pi_3OqWxz...' }
# Returns idempotent result; safe to re-run any number of times.
# Sentry tag: source = success_page | platform_webhook | connect_webhook | scheduler_self_healSection 5
Xero sync state machine
cron-xero-sync · in-flight invoices only
Invoice + payment sync to Xero is a one-way hourly cron from us to Xero. Once an invoice flips to a terminal state (paid, void, cancelled) the cron stops touching it — sync direction is Xero→local for terminal states, never local→Xero. This means any code path that voids a synced invoice must call XeroClient.voidInvoice inline (KARO-221).
Xero sync state machine
Terminal states (paid, void, cancelled) skip the cron — inline call required.
Important Xero invariants
- No standalone “GST (15%)” line item. Per-line
TaxTypeon each LineItem (Booking Fee →OUTPUT2, Stripe fee →NONE) is the canonical shape after KARO-222. A standalone GST line caused Xero to add 15% on top of itself, producing invoices ~15% over-charged. - Reference shape is fixed.
LiteHQ.com #N [— <user-ref>](KARO-440). Thebackfill_referenceedge action repairs legacyLiteHQ #Nrows on demand. - Paid LineItem edits silently no-op.Use the Billing API's adjustment endpoints, not direct Xero POSTs — we implement the delete-payment / edit / re-apply dance for you.
- Voids must be inline.
cron-xero-syncfilters with.not('status', 'in', '("paid","void","cancelled")')— a row that flips tovoidafter sync is invisible to the cron. Code paths that void invoices (today:checkout.session.expiredvia KARO-221'snotify_booking_expired) callXeroClient.voidInvoicedirectly.
Section 6
Refunds + voids
POST /v1/payments/{id}/refund · POST /v1/invoices/{id}/void
Refunds and voids are distinct operations:
- Refund. Money moves back to the payer's card via Stripe. Invoice can stay
paid(partial) or flip torefunded(full). - Void. Cancel an unpaid invoice. No money movement. Used when a booking is cancelled before payment lands, or when reissuing a corrected invoice.
curl -X POST https://api.litehq.com/v1/payments/pay_K8z.../refund \
-H 'Authorization: Bearer sk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"amount_minor": 8400,
"reason": "duplicate",
"notify_payer": true
}'curl -X POST https://api.litehq.com/v1/invoices/inv_R3v.../void \
-H 'Authorization: Bearer sk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"reason": "reissue_with_correction",
"audit_note": "Replaced by inv_R3w — corrected Stripe fee allocation."
}'xero_id IS NOT NULL and status != 'paid'), our endpoint calls XeroClient.voidInvoice(xero_id)inline before returning. The cron won't catch this later — cron-xero-sync only handles in-flight rows (KARO-221).charge.refunded via the same Stripe webhook chain as forward charges. Idempotency: same payment_intent_id + amount_minor tuple is rejected on retry.Section 7
Best practices for reconciliation
Operator playbook · what to check, in what order
End-of-month reconciliation is the single highest-value habit for an operator using the Billing API. Here's the order we recommend:
- Stripe payouts vs Stripe charges.Stripe's dashboard handles this. Confirm net payouts = gross charges − refunds − Stripe fees. If this doesn't reconcile, fix it here first — nothing downstream will work.
- LiteHQ payments vs Stripe charges. GET
/v1/payments?from=...&to=...and diff against Stripe's CSV export. Mismatches usually mean a missed webhook layer — check the scheduler self-heal logs. - LiteHQ invoices vs Xero invoices. The Xero
Referencefield (KARO-440 shape) is your join key. Anything in Xero withoutLiteHQ.com #prefix is external; anything in LiteHQ without axero_idhasn't synced yet. - Voided/cancelled invoices.Confirm Xero status matches LiteHQ status. Mismatches here are the KARO-221 class — flag and call
backfill_referenceor the void endpoint to resync. - Audit log review.
settings_audit_logrecords every high-value config change. End of month is the right time to scan for unexpected tier downgrades, host_id swaps, or stripe_settings edits.
Next steps
Billing is the heaviest API surface. Pick your next deep-dive.
- Webhooks reference
Full event catalogue + signature verification patterns in 4 languages.
- Stripe Connect setup
Onboarding flow, dual-secret verification, payout schedules.
- Calendar API
Where bookings are created — they all flow into invoices + payments.
- Members API
Tier changes drive subscription invoices; understand the joins.