Kaiten

Monetization

Without a Payment Provider

Billing with no payment provider — Kaiten composes each invoice, records it as ready to bill, and hands it to your own accounting system through a pull queue — claim, book, acknowledge, then mark paid. Works on every installation, air-gapped ones included.

Without a payment provider, Kaiten composes every invoice and stops there. Taxing it, numbering it, sending it, and collecting it are your own systems' work — an ERP, an accounting tool, a bank transfer — and each invoice waits for them in a handoff queue until they take it. Kaiten remains the reference for usage and for what each invoice says; everything before the handoff — prices, add-ons, vouchers, trials, the usage journal, the composition of each invoice — is the same as with Stripe.

It is the default. A subscription that names no provider — providerKind left out, or NOOP — runs this way, and Settings → Billing lists it as Manual hand-off, with nothing to connect. It needs no Vault, no connection from Kaiten to the outside, and no webhook: your system pulls the queue over the API, so it works on every installation, self-hosted and air-gapped ones included.

Kaiten Sushi Shop bills Kappa Kyoto this way — an example to build: the demo seed writes Premium v1's prices, not the subscription. kappa-kyoto-prod subscribes monthly to Premium v1 at 240.00 EUR, and its invoices go to the shop's accounting system. An earlier invoice of 244.65 EUR — the base, plus 31 orders above the allowance at 0.15 — is overdue, so the subscription is past due; the latest one waits in the queue.

Invoices, Handoff view: Kappa Kyoto Production's latest invoice, Ready to bill, Waiting for your ERPInvoices, Handoff view: Kappa Kyoto Production's latest invoice, Ready to bill, Waiting for your ERP

The invoice that is handed off

At each boundary, the subscription's invoice is composed as any other, then issued at once:

  • its status is MANUAL — Ready to bill in the console: issued, for you to collect;
  • it is sent for payment (SEND_INVOICE), due after the subscription's payment terms, or the organization's default — 30 days unless you change it on Settings → Billing;
  • its handoffStatus is PENDING — Waiting for your ERP — until your system acknowledges it;
  • INSTANCE_INVOICE_ISSUED is emitted.

Two invoices never reach the queue. One of zero is paid at issue — Ninja Osaka's free Starter subscription produces only such invoices — and announced by INSTANCE_INVOICE_PAID with source: ZERO_TOTAL. One held for a check of its usage journal waits as a draft until someone releases or recomposes it, then enters the queue.

The invoice is data, not a document. Its amounts are integers in minor units, before tax (money); its id never changes and is the key to book it under; and the reads your system makes — the claim, GET /invoices/{invoiceId}, the export — carry the customer's billingEmail, which no event does. It has no invoice number, no tax, and no postal address: your system adds them, and sends its own number back as externalReference.

Stripe invoices too, when you want them. Hand off Stripe invoices on Settings → Billing (handoffStripeInvoices in PUT /billing/settings) also puts the invoices Stripe issues in the queue once they are pushed, for an accounting system that wants every invoice.

Drain the queue

A job of yours runs every few minutes with a token that holds write:billing. It claims the oldest invoices waiting, under one lease:

curl -X POST "$KAITEN_API/billing/handoff/claim" \
  -H "Authorization: Bearer $KAITEN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "limit": 25, "leaseSeconds": 900 }'
{
  "leaseId": "6b0d3c1e-5a7f-4c2e-9e1d-2f8a40c1b7aa",
  "leasedUntil": "2026-10-27T09:15:00Z",
  "invoices": [
    {
      "id": "0b9c6f1e-3d2a-4f8e-a5c4-7e61d09b2f13",
      "kind": "RENEWAL",
      "status": "MANUAL",
      "providerKind": "NOOP",
      "customerSlug": "kappa-kyoto",
      "instanceSlug": "kappa-kyoto-prod",
      "currency": "EUR",
      "total": 24000,
      "dueAt": "2026-11-26T09:00:00Z",
      "billingEmail": "…",
      "lines": [ "…" ],
      "handoff": { "status": "PENDING", "leaseId": "6b0d3c1e-5a7f-4c2e-9e1d-2f8a40c1b7aa", "claimCount": 1, "…": "…" },
      "…": "…"
    }
  ]
}

limit is 1 to 100 (25 when omitted), leaseSeconds 60 to 3600 (900). The job books each invoice in your system, idempotently on its id, then acknowledges it with the number your system gave it:

curl -X POST "$KAITEN_API/billing/handoff/0b9c6f1e-3d2a-4f8e-a5c4-7e61d09b2f13/ack" \
  -H "Authorization: Bearer $KAITEN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "leaseId": "6b0d3c1e-5a7f-4c2e-9e1d-2f8a40c1b7aa", "externalReference": "ACC-2026-0412" }'

200 with the invoice, its handoff now ACKNOWLEDGED, and INSTANCE_INVOICE_HANDOFF_ACKNOWLEDGED is emitted. The queue's rules:

RuleWhat it means for your job
At least onceAn invoice not acknowledged before its lease ends can be claimed again, under a new lease. Book on the invoice id so a second delivery changes nothing
One consumer per invoiceTwo jobs claiming at once get different invoices: several can drain one queue
A claim is not idempotentWhen a claim's response is lost, its invoices stay leased until leasedUntil, then come back. Nothing is lost; the handoff is only delayed
An acknowledgement is safe to repeatThe same acknowledgement again answers 200. Another reference than the one an invoice was acknowledged under answers 409 AckHandoff.ReferenceMismatch: take it as already handed off. With leaseId, an invoice another claim took since answers 409 AckHandoff.LeaseMismatch; an invoice that is not in the queue, 409 AckHandoff.NotRequired
Reading never leasesGET /billing/handoff?status=PENDING — or ACKNOWLEDGED — lists the queue, oldest issue first, without taking anything; it needs read:billing

@kaitencloud/server wraps the claim and the acknowledgement as KaitenBilling's handoff.claim() and handoff.ack() — see the SDKs. The health of billing counts the invoices waiting, the oldest one's issue, and the invoices claimed again in the last 24 hours — a sign of a job that claims without acknowledging (billing health).

An event can start the job sooner: INSTANCE_INVOICE_ISSUED reaches a webhook endpoint on Kaiten Cloud, or your consumer of the event stream on a self-hosted stack, and can trigger a claim at once. The queue remains the source of truth: a missed event delays nothing that the next claim does not pick up.

Follow what changes after the handoff

  • Voided or written off while still pending, an invoice stays in the queue: your job claims it with its new status, VOID or UNCOLLECTIBLE, and acknowledges it, so your system learns of it.
  • Once acknowledged, an invoice never returns to the queue. Read what changed afterwards — a payment, a void, a write-off — with GET /invoices?updatedSince=…, which lists the invoices changed since an instant, oldest change first: keep the updatedAt of the last one you read, and pass it next time.
  • A voided invoice composed again arrives as a new invoice, with a new id and replacesInvoiceId naming the one it replaces.

Record the payment

When the money arrives, POST /invoices/{invoiceId}/mark-paid records it:

curl -X POST "$KAITEN_API/invoices/0b9c6f1e-3d2a-4f8e-a5c4-7e61d09b2f13/mark-paid" \
  -H "Authorization: Bearer $KAITEN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "externalReference": "ACC-2026-0412", "paidAt": "2026-10-20T00:00:00Z", "note": "Transfer 4471-KK" }'
  • paidAt is now or earlier — now when omitted — and may precede the invoice's issue, for a payment received in advance.
  • externalReference, 1 to 255 characters, is the invoice's number in your system; an invoice still pending is acknowledged by the same write. Another reference than the one it was handed off under answers 409 MarkInvoicePaid.ReferenceMismatch.
  • note, up to 1,000 characters — a transfer's reference — is kept only in the INSTANCE_INVOICE_PAID event, whose source is MARK_PAID.

Marking it paid again with the same reference answers it unchanged. Only a MANUAL invoice can be marked paid: one a payment provider collects is settled there (409 MarkInvoicePaid.ProviderManaged). The console's invoice page does the same, from Mark as paid.

When you give up collecting an invoice, POST /invoices/{invoiceId}/write-off with a reason of 1 to 500 characters makes it UNCOLLECTIBLE, which is final (INSTANCE_INVOICE_MARKED_UNCOLLECTIBLE).

Overdue. A MANUAL invoice still unpaid after its due date is overdue — GET /invoices?overdue=true, the Overdue view of Invoices — and its subscription becomes PAST_DUE. That changes nothing to what the Instance may use. Once nothing of the subscription is overdue, marked paid or written off, it leaves PAST_DUE.

Invoices, Overdue view: Kappa Kyoto Production's earlier invoice of 244.65 EUR, Ready to bill and overdueInvoices, Overdue view: Kappa Kyoto Production's earlier invoice of 244.65 EUR, Ready to bill and overdue

Export

GET /invoices/export streams the invoices the list's filters select, as CSV — one row per line or per invoice (granularity) — or as NDJSON, one invoice with its lines per line (format=json). Amounts are integers in minor units, with the currency's exponent beside them, so a spreadsheet never reads a float. The export carries billing e-mails: it is an authenticated read for your accounting, under read:billing.

Moving to a payment provider

A subscription can move to Stripe at any time: PATCH /instances/{instanceSlug}/billing with "providerKind": "STRIPE" changes who issues and collects its invoices from the next one on, and emits INSTANCE_BILLING_PROVIDER_CHANGED. Invoices already composed stay with the queue. See Stripe.

Reference

OperationRouteScope
Read the queueGET /billing/handoffread:billing
Claim invoices under a leasePOST /billing/handoff/claimwrite:billing
Acknowledge an invoicePOST /billing/handoff/{invoiceId}/ackwrite:billing
List, read invoicesGET /invoices, GET /invoices/{invoiceId}read:billing
Mark paid, write off, voidPOST /invoices/{invoiceId}/mark-paid, /write-off, /voidwrite:billing
Export invoicesGET /invoices/exportread:billing
Hand off Stripe invoices tooPUT /billing/settingswrite:billing

Events: INSTANCE_INVOICE_ISSUED, INSTANCE_INVOICE_HANDOFF_ACKNOWLEDGED, INSTANCE_INVOICE_PAID, INSTANCE_INVOICE_MARKED_UNCOLLECTIBLE, INSTANCE_INVOICE_VOIDED, and INSTANCE_BILLING_STATUS_CHANGED when a subscription becomes or stops being past due — in the event catalog. Shapes: the API reference.

Where to go next

How each invoice is composed, held, voided and recomposed: Invoices. The subscription it belongs to: Subscriptions. Collecting through Stripe instead: Stripe.

On this page