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 the tenant:write scope.
  • Idempotency-Key: required, 16–128 characters from [A-Za-z0-9_.:-]. A UUID works.
  • Body: CreateCheckoutRequest, { "planId": "indie" | "studio" }. Only planId is read. Any amount, price or currency field is ignored. The price always comes from the @auth4/billing catalog.

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

  1. The key is stored only as a SHA-256 hash, scoped to the tenant, in billing_checkout_requests (migration 0006). A request claims the key with a 60-second lease. A failed attempt or an expired lease can be taken over.
  2. createCheckout writes 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.
  3. A Stitch Express payment link is created (POST /api/v1/payment-links) with the stored catalog amount in cents and the client_paymentrequest scope. It sends the payment id as merchantReference (pay-…, with a hyphen) and the payer name auth4.dev customer.
  4. 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 on stitch.money or a subdomain (or a loopback mock when RUNTIME_ENV=local) and its single redirect_url equals that return URL. Stitch only returns payers to a registered redirect URL, compared without the query string, so {CONSOLE_ORIGIN}/billing/return must be registered once per environment (infra/stitch/setup.mjs). On return Stitch adds reference and its own payment_id; the console keeps our pay_ id.
  5. 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.
  6. 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 with outcome: failure. Structured billing.checkout log 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