AUTH4D-40 billing checkout
apps/api/src/modules/billing (wired by apps/api/src/runtime/features/billing.ts) serves:
POST /management/tenants/{tenantId}/billing/checkout
The route is tenant-scoped like the other management routes. The ticket’s
/management/billing/checkout shape has no tenant, and a console user can belong to several
projects.
Request
Authorization: Bearer <management token>from AUTH4D-17, with thetenant:writescope.Idempotency-Key: required, 16–128 characters from[A-Za-z0-9_.:-]. A UUID works.- Body:
CreateCheckoutRequest,{ "planId": "indie" | "studio" }. OnlyplanIdis read. Anyamount,priceorcurrencyfield is ignored. The price always comes from the@auth4/billingcatalog.
Checks, in order
| Step | Failure |
|---|---|
| Management token, session and live user (AUTH4D-17) | 401 unauthorized |
| Path tenant id | 404 not_found |
| Per-actor management budget | 429 rate_limited |
| Live membership in an active customer project | 403 forbidden |
Project role is owner or admin |
403 insufficient_role, audited as denied |
BILLING_ENABLED=true and complete Stitch config |
503 billing_unavailable, with no configuration details |
Idempotency-Key and paid planId |
400 invalid_request |
| Key already used for a different plan | 422 idempotency_key_reused |
| Another request holds the key | 409 checkout_in_progress, Retry-After: 5 |
| Project already has an entitled paid subscription | 409 subscription_exists |
| A recorded payment is no longer pending | 409 checkout_closed |
| Stitch failed or returned an untrusted URL | 502 payment_provider_unavailable; retry with the same key |
Response
201 with CheckoutSessionResponse:
{
"paymentId": "pay_…",
"redirectUrl": "https://express.stitch.money/…?redirect_url=…",
"expiresAt": "…"
}
A repeated key returns the stored body with 201 and Idempotent-Replayed: true. Checkouts
expire 30 minutes after creation. Stitch receives that time as expiresAt.
Behaviour
- The key is stored only as a SHA-256 hash, scoped to the tenant, in
billing_checkout_requests(migration0006). A request claims the key with a 60-second lease. A failed attempt or an expired lease can be taken over. createCheckoutwrites the pending subscription, the open invoice and the pending payment row before Stitch is called, so the ledger can always tie a Stitch link to a payment.- A Stitch Express payment link is created (
POST /api/v1/payment-links) with the stored catalog amount in cents and theclient_paymentrequestscope. It sends the payment id asmerchantReference(pay-…, with a hyphen) and the payer nameauth4.dev customer. - The return URL is built only from the configured
CONSOLE_ORIGIN:{CONSOLE_ORIGIN}/billing/return?tenant_id=…&payment_id=…. It must be HTTPS, or loopback HTTP. The hosted URL Stitch returns is accepted only if it is HTTPS onstitch.moneyor a subdomain (or a loopback mock whenRUNTIME_ENV=local) and its singleredirect_urlequals that return URL. Stitch only returns payers to a registered redirect URL, compared without the query string, so{CONSOLE_ORIGIN}/billing/returnmust be registered once per environment (infra/stitch/setup.mjs). On return Stitch addsreferenceand its ownpayment_id; the console keeps ourpay_id. - The provider payment id is stored in the ledger and then attached to the payment. A retry with the same key after a provider failure reuses the same payment row.
- A successful checkout writes the tenant audit event
billing.checkout.created. Its target is the payment, and its metadata holds the plan, amount, currency, actor role, whether the checkout was resumed, and the number of ignored client fields. Provider failures write the same event withoutcome: failure. Structuredbilling.checkoutlog lines carry only the request id, tenant id, payment id, plan id, outcome, status, duration and Stitch error kind.
Source: docs/api-spec/auth4d-40.md