Billing & Plans
Klaxon ships a three-tier plan catalog, Stripe-backed checkout and customer portal, and a plan-limit gate that returns a structured 402 Payment Required when an org tries to exceed its entitlement.
Plans
Seeded by migration 0016_billing.sql and refreshed by 0019_plan_limits.sql:
| Plan | max_seats | max_teams | monthly_notifications | webhooks | agents | Audit retention |
|---|---|---|---|---|---|---|
| Free | 3 | 1 | 1,000 | 1 | 1 | 30 days |
| Team | 25 | 5 | 50,000 | 10 | 10 | 90 days |
| Enterprise | unlimited | unlimited | unlimited | unlimited | unlimited | 365 days |
A null value in the plan's limits JSONB means "unlimited". Personal workspaces (orgs.is_personal = true) are pinned to Free by a database trigger — an UPDATE that tries to change their plan_id raises an exception.
Subscription model
Every org has exactly one org_subscriptions row, created by migration backfill for existing orgs and by the handlers when new orgs are created. Fields:
plan_id— foreign key intoplansstatus— one ofactive,trialing,past_due,canceled,incompletestripe_customer_id,stripe_subscription_id— populated after first checkoutcurrent_period_end,cancel_at_period_end— synced from Stripe webhooks
Endpoints
All billing endpoints require org.billing permission. Owners have it by default.
GET /api/billing/subscription
curl https://api.klaxon.sh/api/billing/subscription \
-H "Authorization: Bearer $TOKEN"Returns the current plan, status, and cancel-at-period-end flag.
POST /api/billing/checkout
curl -X POST https://api.klaxon.sh/api/billing/checkout \
-H "Authorization: Bearer $TOKEN" \
-d '{"plan_id":"team"}'
# → { "url": "https://checkout.stripe.com/…" }Creates a Stripe Checkout Session and returns its hosted URL. Redirect the user there; Stripe redirects back and the webhook updates status + plan_id.
POST /api/billing/portal
curl -X POST https://api.klaxon.sh/api/billing/portal \
-H "Authorization: Bearer $TOKEN"
# → { "url": "https://billing.stripe.com/…" }Returns a Stripe Billing Portal URL so the customer can update payment methods, view invoices, or cancel. Owner-gated.
POST /webhooks/stripe
Unauthenticated — but each request is verified against STRIPE_WEBHOOK_SECRET via HMAC. Handles:
customer.subscription.created/updated/deletedinvoice.payment_failed→ flipsstatustopast_duecheckout.session.completed→ linksstripe_customer_idandstripe_subscription_id
Plan-limit gate
crates/klaxon-auth-core/src/billing_gate.rs is the shared helper that gates resource creation. Both binaries call it.
How it fires
Before inserting a new teams row (or webhook, agent, team-member seat, etc.), the handler calls check_limit(pool, org_id, KEY_*). If the org's plan has a numeric limit for that key and usage is already at or above it, the call returns a GateError::LimitExceeded which serializes to:
HTTP/1.1 402 Payment Required
Content-Type: application/json
{
"error": "plan_limit_exceeded",
"key": "max_teams",
"limit": 1,
"current": 1,
"upgrade_url": "/admin?tab=billing"
}Gated resources
| Key | Gated at | Trigger |
|---|---|---|
max_teams | POST /api/admin/teams | Creating a team |
max_seats | POST /auth/invitations/accept | Accepting an invitation |
monthly_notifications | klaxon.notify / klaxon.ask | MCP tool call creating an item |
webhooks | POST /api/webhooks | Creating a webhook |
agents | POST /api/admin/agents | Creating an API agent |
monthly_notifications is metered via notification_counters (one row per (org_id, period_month)).
Unlimited plans
When plans.limits[key] is null the gate short-circuits to Ok(()) — nothing is counted, nothing is refused.
Configuration
| Env var | Required | Purpose |
|---|---|---|
STRIPE_SECRET_KEY | yes, for live billing | sk_live_… or sk_test_… |
STRIPE_WEBHOOK_SECRET | yes | whsec_… from the Stripe dashboard |
STRIPE_PRICE_TEAM | yes | Stripe Price ID for the Team plan |
STRIPE_PRICE_ENTERPRISE | yes | Stripe Price ID for the Enterprise plan |
Price IDs are patched into plans.stripe_price_id at server startup so a fresh clone can migrate without knowing the tenant's Stripe IDs. Leaving the Stripe env vars unset disables the billing endpoints (they return 503); existing orgs stay on Free.
Testing with Stripe test mode
Use sk_test_… and whsec_… test-mode values from a Stripe sandbox account. The Stripe CLI can forward live webhook events to your local server:
stripe listen --forward-to http://localhost:3000/webhooks/stripeRelated guides
- Admin Panel — the UI surface for billing actions
- Roles & Permissions —
org.billingpermission - Invitations —
max_seatsgating lives here - Teams —
max_teamsgating lives here