Skip to content

Documentation · API

Billing API

12 min read · REST reference · Updated 2026-05-21

REST12 minMoney-handlingShare
Documentation sections

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.

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.

issue_invoice.curl
bash
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"
  }'
invoice_object.json
json
{
  "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": [{ /* … */ }]
}

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:

mark_paid.curl
bash
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

  1. Success-page hook. Fires when Stripe redirects the user back. The server action looks up the cs_* session id and runs verify.
  2. Platform webhook. STRIPE_WEBHOOK_SECRET. Fires on platform-account events (e.g. checkout.session.completed) within ~2–5s of the charge.
  3. Connect webhook. STRIPE_WEBHOOK_SECRET_CONNECT. Fires on connected-account events (charge.succeededon the host's Stripe account). Same endpoint, dual-secret verification.
  4. 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

state diagram

All four layers converge on verify_payment; idempotent on payment_intent_id.

Booking row inserted
status=pending_payment
Stripe Checkout
user redirected
Success page
Layer 1 · success_page
Platform webhook
Layer 2 · platform_webhook
Connect webhook
Layer 3 · connect_webhook
Scheduler cron
Layer 4 · scheduler_self_heal
verify_payment
booking-manager edge fn
status=confirmed
stripe_payment_intent_id set
verify_payment.example
bash
# 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_heal

Section 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

state diagram

Terminal states (paid, void, cancelled) skip the cron — inline call required.

draft
local only
open
synced to Xero
authorised
Xero confirmed
paid
TERMINAL · cron skips
void
TERMINAL · inline voidInvoice required
cancelled
TERMINAL · cron skips
Reconciliation done
Xero AmountPaid = total
Audit trail preserved
settings_audit_log

Important Xero invariants

  • No standalone “GST (15%)” line item. Per-line TaxType on 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). The backfill_reference edge action repairs legacy LiteHQ #N rows 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-sync filters with .not('status', 'in', '("paid","void","cancelled")') — a row that flips to void after sync is invisible to the cron. Code paths that void invoices (today: checkout.session.expiredvia KARO-221's notify_booking_expired) call XeroClient.voidInvoice directly.

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 to refunded (full).
  • Void. Cancel an unpaid invoice. No money movement. Used when a booking is cancelled before payment lands, or when reissuing a corrected invoice.
refund_payment.curl
bash
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
  }'
void_invoice.curl
bash
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."
  }'

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:

  1. 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.
  2. 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.
  3. LiteHQ invoices vs Xero invoices. The Xero Reference field (KARO-440 shape) is your join key. Anything in Xero without LiteHQ.com # prefix is external; anything in LiteHQ without a xero_idhasn't synced yet.
  4. Voided/cancelled invoices.Confirm Xero status matches LiteHQ status. Mismatches here are the KARO-221 class — flag and call backfill_reference or the void endpoint to resync.
  5. Audit log review. settings_audit_log records 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.