Skip to content

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:

Planmax_seatsmax_teamsmonthly_notificationswebhooksagentsAudit retention
Free311,0001130 days
Team25550,000101090 days
Enterpriseunlimitedunlimitedunlimitedunlimitedunlimited365 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 into plans
  • status — one of active, trialing, past_due, canceled, incomplete
  • stripe_customer_id, stripe_subscription_id — populated after first checkout
  • current_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

bash
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

bash
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

bash
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 / deleted
  • invoice.payment_failed → flips status to past_due
  • checkout.session.completed → links stripe_customer_id and stripe_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
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

KeyGated atTrigger
max_teamsPOST /api/admin/teamsCreating a team
max_seatsPOST /auth/invitations/acceptAccepting an invitation
monthly_notificationsklaxon.notify / klaxon.askMCP tool call creating an item
webhooksPOST /api/webhooksCreating a webhook
agentsPOST /api/admin/agentsCreating 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 varRequiredPurpose
STRIPE_SECRET_KEYyes, for live billingsk_live_… or sk_test_…
STRIPE_WEBHOOK_SECRETyeswhsec_… from the Stripe dashboard
STRIPE_PRICE_TEAMyesStripe Price ID for the Team plan
STRIPE_PRICE_ENTERPRISEyesStripe 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:

bash
stripe listen --forward-to http://localhost:3000/webhooks/stripe