Monetization
Self-Serve Checkout
Selling from your own pages — the three public credentials (publishable key, checkout session, customer session), the anonymous checkout end to end from the Turnstile check to the completion, and what you see and receive once a visitor has bought.
Self-serve checkout is the part of billing that runs in browsers you do not control: your pricing page, your checkout page, your customers' billing page. Kaiten serves them under /api/public/, with three credentials of their own — none of them a token of your organization — and keeps every amount on the server: the page shows what Kaiten computed, the payment provider's page takes the card, and a paid License applies once it is paid.
This page covers the credentials, the public catalogue, and the anonymous checkout: a visitor your backend does not know yet buying a License with an e-mail and nothing else. Sell a License online builds it end to end on Kaiten Sushi Shop, and Checkout covers the React components that draw it. Everything here works where billing is enabled for your organization.
Three credentials
| Credential | Held by | Issued by | Reaches | Lives |
|---|---|---|---|---|
Publishable key pk_ | Your web page, in its source | You — Integrations › Publishable keys, or POST /publishable-keys with write:publishable_keys | The public catalogue, from the browser origins it allows; with anonymous checkout on, the start of a checkout | Until revoked |
Checkout session ksc_ | One visitor's browser tab | Kaiten, once, in the answer to the start | That session's read and confirm, and nothing else | Until 24 hours after its session closes |
Customer session kst_ | One signed-in customer's browser | Your backend — POST /customer-sessions with write:customer_sessions | The /public/session/* routes, for that Customer | 5 to 60 minutes, 30 by default |
A pricing page reads the catalogue with the publishable key. A visitor with no account buys through an anonymous checkout, started with the publishable key and carried on with the session's ksc_. A customer who signs in to your product — to see their invoices, change their add-ons, or buy another License — acts through a customer session your backend mints. The three never stand in for one another: each is refused on the routes of the others, and none carries a scope. How each one is sent, and the refusals they share, are on the authentication page.
Publishable keys
A publishable key is not a secret: it ships in your page's source, and what bounds it is the list of browser origins allowed to send it — at most 50, each https://host[:port], or http:// on localhost, with no path. The comparison is exact: https://www.kaiten-sushi.example does not allow https://kaiten-sushi.example. A browser request from another origin answers 403 PublicAuth.OriginNotAllowed, and an empty list allows no browser at all; a request without an Origin header, such as server-side rendering, is accepted. Each key is limited to 20 requests a second, with bursts of 40, counted on each API replica.
The key is shown once, when it is created; the list then shows its last four characters, when it was last used, and its status — Live, or Revoked. Revocation is final: the key stops on the next request, its Turnstile secret is deleted, and the checkout sessions it started stop authenticating. Once Kaiten Sushi Shop sells online, it holds two live keys: Sushi Shop website, allowed on its two production origins and opening the anonymous checkout, and Staging website, which only reads the catalogue.
The anonymous checkout of a key
A key opens the anonymous checkout only when you allow it, and only behind Cloudflare Turnstile: a visitor of the key's origins can create a Customer, so every start carries a Turnstile token that Kaiten verifies itself. The key's form has a section of its own, Anonymous checkout:
- Turnstile site key — the public key of a Turnstile widget registered in Cloudflare for your pages' hostnames. The catalogue hands it to the page, which draws the widget with it.
- Turnstile secret — the secret of the same widget. Kaiten stores it in Vault, beside the payment connector's settings, and never shows it again: a key says only whether one is stored (
turnstileSecretConfigured), and editing it offers Replace and Remove. A deployment with no Vault cannot store a secret (422 …VaultNotConfigured), so none of its keys can open the anonymous checkout. - Allow anonymous checkout — the switch. It stays off until the key holds both values; over the API, a key with the switch on and a value missing is refused with
422 …TurnstileNotConfigured.
At each start, Kaiten sends the token, the secret and the visitor's address to Cloudflare, and checks the answer twice: the token must be valid, and the hostname Cloudflare reports — where the widget was solved — must be the host of one of the key's allowed origins. Either failure answers 422 CreateCheckoutSession.TurnstileFailed; Cloudflare out of reach answers 503 CreateCheckoutSession.TurnstileUnavailable with Retry-After: 30. The verification address is fixed, self-hosted included: the API needs outbound HTTPS to challenges.cloudflare.com — through HTTPS_PROXY where egress goes through a proxy — and nothing skips the check. On the page, the widget loads from the same host: a Content-Security-Policy must allow https://challenges.cloudflare.com in script-src, frame-src and connect-src.
Over the API, the same three members — anonymousCheckout, turnstileSiteKey, turnstileSecret — go in POST /publishable-keys and PATCH /publishable-keys/{keyId}, where an absent member keeps what is stored and an empty Turnstile value clears it.
The public catalogue
GET /public/catalog, read with the key in X-Kaiten-Publishable-Key, is what a pricing page shows: for each License family you made public, its default published version with its active prices and its user-facing entitlements, and the same for public add-on families. Families are private until you make them public — the List … in the public catalog switch on the Licenses list, or isPublic on PATCH /license-families/{familySlug}. include=none leaves the add-ons out, familySlug narrows the read to one family, and the answer may be cached for 60 seconds.
Each License in it says how it is sold:
| Member | Meaning |
|---|---|
pricingType | FREE, PAID, or CUSTOM — priced on request |
selfServe | Whether a visitor can buy it alone: not CUSTOM, with a default flat-fee price, and either FREE or sold through a payment provider that captures payment methods |
selfServeCtaUrl | Where to send a buyer when it is not self-serve — your sales page |
trialPeriodDays, requiresPaymentMethod | Its trial, and whether signing up captures a payment method first |
prices | Its active prices, amounts in minor units (money). A metered price is per unit of usage: never add it into a headline |
capabilities says what the page can offer: checkout (a License can be bought through a customer session), paymentMethods (your payment provider captures payment methods), anonymousCheckout and turnstileSiteKey (this key starts anonymous checkouts, and the site key its widget is drawn with — null unless it does). branding.poweredByRemovable says whether your pages may hide "Powered by Kaiten": on Kaiten Cloud, where your organization's License allows it; on a self-hosted installation, always.
Selling online, Kaiten Sushi Shop lists its three families. Starter is FREE, with one price of 0.00 EUR a month; Standard is PAID — 49.00 EUR a month or 490.00 EUR a year, and 0.50 EUR per order above its allowance; Premium is CUSTOM, its selfServeCtaUrl leading to the sales page. A visitor can buy Starter and Standard from the page; Premium is a conversation.
The anonymous checkout, end to end
A visitor your backend does not know buys a self-serve License from your page. Until they confirm — or, when a payment method is needed, until the payment provider's page is done — the only record is the checkout session: no Customer, no Instance, no customer at the payment provider. The completion creates them, in one transaction.
Start
The page starts the checkout with the publishable key and a fresh token from its Turnstile widget:
| Member | |
|---|---|
turnstileToken | Required. Single-use: every start spends one, a dryRun included |
priceId — or licenseSlug with billingPeriod | The price bought: a flat-fee price of a License in the public catalogue, or that version's default flat fee for the period. One form, never both |
email | The buyer's address, at most 254 characters. It becomes the new Customer's billing e-mail |
returnUrl | Where the payment provider's page sends the visitor back: https, or http on localhost, on an origin the key allows, at most 2,048 characters |
clientReferenceId | Optional. Your own reference, up to 255 characters, echoed on the checkout's events |
addOns, voucherCode, trial | Optional. Public add-ons that fit the License, a voucher code, and false to decline the License's trial |
dryRun | Compute what is due and write nothing. email and returnUrl may then be left out |
The start is refused, before anything is written, when the key does not open the anonymous checkout (403 CreateCheckoutSession.AnonymousCheckoutDisabled), when the Turnstile check fails (422 .TurnstileFailed, 503 .TurnstileUnavailable, 422 .TurnstileNotConfigured), when the price is not a flat fee of a License in the public catalogue (422 .PriceNotPublic, .InvalidPriceSelection, .PriceNotFlatFee), when the License is priced on request (422 .NotSelfServe), when it is PAID and no connected payment provider captures payment methods in its currency (422 .ProviderCannotCapturePayment), and when an amount is due today but the License's family names no fallback (422 .FallbackRequired, below). The other refusals name the member at fault — .InvalidEmail, .InvalidReturnUrl, .VoucherInvalid, .AddonNotPublic, .QuantityExceedsMax, .CurrencyMismatch — and 429 .RateLimited comes with the limits.
A start answers 201 Created with status: "open": the sessionId, its token — the ksc_, returned this once —, expiresAt 24 hours later, dueToday (the currency, the subtotal, the discount, the total and the lines, in minor units, computed by the server, a trial included), trialEndsAt, trialRefusedReason, and requiresPaymentMethod. checkout_session.v1.started is emitted.
With dryRun, the same computation answers 200 OK with status: "preview" and no session: nothing is written and nothing is emitted. A page uses it to show what is due before it asks for an e-mail — it still spends a Turnstile token and counts against the limits. A preview of Standard, monthly:
curl -X POST "$KAITEN_API/public/checkout-sessions" \
-H "X-Kaiten-Publishable-Key: $KAITEN_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"turnstileToken": "XXXX.DUMMY.TOKEN.XXXX",
"licenseSlug": "standard",
"billingPeriod": "MONTHLY",
"dryRun": true
}'{
"status": "preview",
"dueToday": {
"currency": "EUR",
"subtotal": 4900,
"discountTotal": 0,
"total": 4900,
"lines": [
{
"seq": 1,
"type": "BASE",
"label": "Standard, monthly",
"description": "1 × 49.00 EUR",
"quantity": "1",
"unitAmountDecimal": "4900",
"amount": 4900,
"serviceFrom": "2026-10-27T09:00:00Z",
"serviceTo": "2026-11-27T09:00:00Z"
}
]
},
"trialEndsAt": null,
"trialRefusedReason": null,
"requiresPaymentMethod": true
}The token here is the one Cloudflare's always-pass test site key produces, which only the matching test secret accepts — a key set up for tests, as in the guide. A real page sends the token its widget gave it. Responses are illustrative.
Confirm
POST /public/checkout-sessions/{sessionId}/confirm, with Authorization: Bearer ksc_… and no body. Up to 24 hours may have passed since the start, so the confirm checks the order again — the price still public, the License still self-serve, the voucher, the trial — and answers one of three statuses:
status | When | What the page does |
|---|---|---|
completed | Nothing has to be captured: a FREE License, or a trial of a License that does not require a payment method. The Customer, the Instance and the subscription were just created | Shows the outcome: session.customerSlug and session.instanceSlug are set |
requires_payment_method | A payment method is needed: an amount is due today, or the License captures one before its trial | Sends the visitor to setupUrl, the payment provider's hosted page, before setupExpiresAt |
awaiting_payment | Back from that page, the confirm created the Customer, the Instance and the subscription, and the first invoice is being charged | Reads the session until it completes |
The confirm is safe to send again: it answers the session as it stands, and never creates a second Customer, Instance, or charge. When a trial another checkout used up meanwhile is found before any payment method is captured, the session goes on without it: the confirm answers requires_payment_method with the new amount in session.dueToday, for the page to show before it sends the visitor on.
It is refused with 409 ConfirmCheckoutSession.SessionClosed for a session that is no longer open — its status in errors[0].value.status —, 409 .DueTodayChanged when what is due changed after the payment method was saved (nothing is charged: the visitor starts again), 409 .EntitlementLimitReached when your organization has reached its limit of customers or instances, 403 .AnonymousCheckoutDisabled when the key stopped opening the checkout since the start, and the 422 refusals of the start when the order no longer holds.
The payment provider's page
With Stripe, the page is a Stripe Checkout page in setup mode: Stripe creates its own customer with the visitor's e-mail, offers the payment methods turned on in your Stripe account, asks for the cardholder's name and billing address and offers a tax ID field, and charges nothing. It sends the visitor back to returnUrl with kaiten_checkout_session added; the page confirms again, and Kaiten reads the outcome of the setup from Stripe, never from the address.
A visitor who saves a payment method and never comes back loses nothing: Kaiten's sync with the provider sees the completed setup and completes the session itself, and so does the pass that closes sessions at their expiry. The same holds when the browser lost the session's token.
Completion
The completion runs in one transaction, recorded as Kaiten (system) — no one in your organization made it:
- a Customer named after the e-mail, with that e-mail as its billing e-mail and a slug made from it —
orders@izakaya-hanami.examplebecomesorders-izakaya-hanami-example-4f1c2a, the suffix random; - an Instance of that Customer, named after the e-mail as well, with a slug made the same way;
- the subscription: for a paid License, charged automatically through your payment provider — the customer its page created when the visitor saved a payment method is linked to the new Customer; for a
FREELicense, with no payment provider at all.
Kaiten has no field for a company or a person's name: when your page asks for them, your backend renames the Customer after the checkout. Every checkout creates its own Customer — a buyer who checks out twice has two.
A paid License starts on its fallback
Nothing a visitor buys is granted before it is paid. A checkout with an amount due today creates the Instance on the default published version of the family's fallback — the free family its subscriptions fall back to when they end — with its subscription INCOMPLETE. When the first invoice, the activation, is paid, one transaction moves the Instance to the License bought, applies its add-ons and voucher, makes the subscription ACTIVE and the session COMPLETED, and emits checkout_session.v1.completed. Kaiten Sushi Shop's Standard falls back to Starter: a visitor who buys Standard runs on Starter while Stripe charges the card, then on Standard.
While it waits, session.payment.status says how the payment goes: processing, paid, requires_action — the visitor authorizes it, for 3-D Secure, on hostedInvoiceUrl — or failed, where the same page lets them pay with another card. An activation still unpaid after 23 hours ends the checkout: the invoice is voided at the provider, the subscription canceled, and the session EXPIRED, with checkout_session.v1.abandoned. The Customer and the Instance remain, on the fallback License, for you to delete.
A family with an amount due today therefore needs a fallback. Without one — or without a published default version in it — the start and the confirm answer 422 …FallbackRequired, and the Licenses list warns about a public family that has a paid version and no fallback. Set it in the family's License settings, under When a subscription ends, fall back to, or with fallbackLicenseFamilySlug on PATCH /license-families/{familySlug} (family settings): the fallback must be a family whose every version is FREE. Subscriptions covers what the fallback does when a subscription ends.
Trials and free Licenses
Two checkouts complete at the first confirm, with nothing to capture:
- A trial without a card — a License with a trial and Require a payment method at sign-up off. Nothing is due today; the Instance runs on the License bought, its subscription in
TRIAL, and what the trial becomes at its end without a payment method is the family's setting. A trial that does require a payment method goes through the provider's page first, and charges nothing. - A
FREELicense — no payment provider at all. The checkout needs no fallback and no Stripe; the subscription's invoices, of 0.00, are paid at issue.
A trial is given once per e-mail and License family: when a Customer of yours whose billing e-mail is the visitor's has already had a trial of the family, the checkout goes on without one, the first period is due today, and trialRefusedReason says ALREADY_CONSUMED.
Limits and lifetimes
Every start and every confirm counts against limits of its own, on top of the key's 20 requests a second:
| Per visitor address | Per publishable key | |
|---|---|---|
Starts, dryRun included | 10 per 10 minutes and 30 per hour | 1,000 per hour |
| Confirms | 30 per 10 minutes | 1,000 per hour |
The address is the visitor's, as the gateway reports it, and each API replica counts for itself. Past a limit the answer is 429 CreateCheckoutSession.RateLimited or 429 ConfirmCheckoutSession.RateLimited, with Retry-After. A self-hosted operator sets the four numbers with the KAITEN_BILLING_CHECKOUT_* variables.
- A session stays open 24 hours (
expiresAt). One still open after that readsABANDONED, and the next lifecycle pass closes it withcheckout_session.v1.abandoned— or completes it, when the visitor finished the provider's page. - Its
ksc_reads it until 24 hours after it closed, and then answers401. It authenticates only from the starting key's origins, and stops with the key. - A closed session is deleted 90 days after it closed, with no event —
KAITEN_BILLING_CHECKOUT_SESSION_RETENTIONon a self-hosted installation.
What you see and receive
Customers › Checkouts
The Customers section of the console has a Checkouts tab wherever billing is enabled and your session holds read:billing. It lists the anonymous checkouts, newest first: their status — Open, Awaiting payment, Completed, Abandoned, Expired —, the e-mail with your reference under it, the License and its period, what is due today, when each was created and expires, the Customer and the Instance it created, and the publishable key whose page started it. Search and filters cover the e-mail, the reference, the status, and the period. An expired checkout says that its Customer and Instance remain.
A row opens a panel with every member of the checkout: the lines of what was due, the trial or why it was refused, the Customer and the Instance created, and the moments of its life. Neither the list nor the panel ever shows the visitor's token or a voucher code — the API returns neither.
Over the API, GET /checkout-sessions lists them — filtered by status (repeatable), email (in any letter case), and clientReferenceId (exactly), 50 to a page by default and up to 200 — and GET /checkout-sessions/{sessionId} reads one. Both need read:billing. The Customer and the Instance a checkout created say who made them — Created … by Kaiten (system) on their details cards — and the audit trail records the checkout's writes under the same actor.
Linking the buyer
Kaiten sends the buyer nothing — no welcome e-mail, no sign-in link. Your backend learns of the order from the checkout's events and does the rest: a sign-in to your product, your own e-mail, the Customer's name. The three events carry one payload:
| Member | Meaning |
|---|---|
sessionId | The checkout session |
email | The address the visitor gave |
clientReferenceId | Your reference, as the page gave it; null when it gave none |
licenseSlug, billingPeriod | What was bought |
customerSlug, instanceSlug | What the completion created; null before it |
status | OPEN, COMPLETED, ABANDONED, or EXPIRED |
| Event | Emitted when |
|---|---|
checkout_session.v1.started | A checkout starts. A dryRun emits nothing |
checkout_session.v1.completed | It completes: its first invoice is paid, or nothing was due. Provision on this event — never on INSTANCE_CREATED, which comes before the payment |
checkout_session.v1.abandoned | It ends unpaid: ABANDONED, never completed and with no Customer, or EXPIRED, its Customer and Instance left for you to clean up |
On Kaiten Cloud the events arrive as signed webhooks Kaiten Cloud; a self-hosted installation consumes them from its event stream. Link the buyer from the event — or from GET /checkout-sessions when an event was missed — never from what a browser reports: the outcome a page sees, the reference it holds, and the e-mail it typed are the visitor's to change.
Customer sessions
A customer you know — signed in to your product — acts through a customer session. Your backend mints it with a token holding write:customer_sessions, for one Customer and optionally one of its Instances, with a lifetime of 5 to 60 minutes, 30 by default; the token is shown once, and POST /customer-sessions/{sessionId}/revoke ends it sooner — on sign-out, or when the customer's access changes. A browser may use it only from an origin one of your live publishable keys allows. The call itself is on the authentication page.
| Route | What it does |
|---|---|
GET /public/session/portal | The subscription, the effective entitlements with what each layer contributes, the add-ons, the vouchers, the payment method, the trial, and what the customer may do |
GET /public/session/invoices | The customer's invoices, with how to pay each |
POST /public/session/checkout | Subscribe the Instance to a price of the public catalogue, with add-ons and a voucher; dryRun answers what is due today |
POST /public/session/billing/cancel, …/reactivate | Cancel at the end of the period paid for — a trial ends at once — and withdraw that before it ends |
POST /public/session/billing/payment-method-session, …/{sessionId}/complete | Add or replace the payment method on the provider's page |
POST /public/session/billing/portal-session | Open the payment provider's own billing portal |
PUT /public/session/addons/{addonSlug} | Attach an add-on, change its quantity, or remove it: an increase applies at once; on a live subscription, a decrease waits for the next renewal |
POST /public/session/vouchers/validate, …/redeem | Check a voucher code; redeem one on a live subscription |
A session bound to an Instance acts on that Instance's subscription; one bound to the Customer only reads the Customer, its Instances and its payment method, and a route that acts on an Instance answers 422 …InstanceRequired. What a session writes is recorded under the person or service account that minted it, and the events it causes say so in via: customer_session.
The session's checkout keeps the rules of the anonymous one: only a flat-fee price of a License in the public catalogue, every amount from the server, and with an amount due an INCOMPLETE subscription — the License bought applies once the first invoice is paid. An Instance on a free subscription can check out: the free subscription is replaced — once the first invoice is paid, when there is one to pay. Any other live subscription is refused with 409 CreateSessionCheckout.AlreadySubscribed — a change of License is yours to schedule. A trial is given once per Customer and License family.
Reference
| Operation | Route | Credential |
|---|---|---|
| Read the public catalogue | GET /public/catalog | Publishable key |
| Start an anonymous checkout | POST /public/checkout-sessions | Publishable key |
| Read it | GET /public/checkout-sessions/{sessionId} | Its ksc_ |
| Confirm it | POST /public/checkout-sessions/{sessionId}/confirm | Its ksc_ |
| List checkouts, read one | GET /checkout-sessions, GET /checkout-sessions/{sessionId} | read:billing |
| Issue, list, change, revoke keys | POST /publishable-keys, GET, PATCH /publishable-keys/{keyId}, POST …/revoke | write:publishable_keys — read: to list |
| Mint and revoke customer sessions | POST /customer-sessions, POST /customer-sessions/{sessionId}/revoke | write:customer_sessions |
| List a family, name its fallback | PATCH /license-families/{familySlug} | write:licenses |
The events of the checkout and of the keys are in the event catalog.
Where to go next
Sell a License online
The whole path on Kaiten Sushi Shop: Standard priced with Starter as its fallback, Stripe in test mode, the key and its Turnstile check, the React page, the purchase, and the backend that welcomes the buyer.
Checkout components
AnonymousCheckoutFlow for a visitor, CheckoutFlow for a signed-in customer, and the summary, trial and payment-method components around them.
Authentication
The three public credentials beside your organization's tokens: how each is sent, and the refusals they share.
Data model
The publishable keys, customer sessions and checkout sessions as Kaiten stores them.
Vouchers
Codes that act on a price, a grant, or a feature flag — the four kinds, how codes and eligibility work, a voucher's statuses, redemption by your backend or from the portal, revocation, and how a discount reaches Stripe.
Stripe
The Stripe connector — Kaiten composes each invoice, Stripe numbers, sends and collects it. Connecting a restricted key, the options, how an invoice is pushed, reconciled and charged, the sync, currencies, 3-D Secure, the Stripe settings to check, and disconnecting.