Simulix

API

Billing

Stripe powers subscription billing for the Studio and Enterprise tiers. The sandbox tier is free and is the default for new organizations. Customers initiate checkout from the pricing page; org and user provisioning happens via the Stripe webhook on payment confirmation.

Creating a checkout session

POST a plan tier and (optionally) a customer email. The response carries checkout_url (Stripe-hosted) and session_id. The frontend redirects the user to checkout_url.

curl -X POST https://api.simulix.com/v1/billing/checkout-session \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "plan_tier": "growth",
    "customer_email": "founder@acme.com"
  }'

The endpoint is anonymous — no Bearer token required. Idempotency-Key is required per the public-POST contract. When STRIPE_SECRET_KEY is unset (dev default) the endpoint returns 503 billing_disabled.

Webhook contract

Stripe POSTs events to /v1/webhooks/stripe signed with HMAC-SHA256 against the request body. The receiver verifies the Stripe-Signature header against the STRIPE_WEBHOOK_SECRET and dispatches by event.type. The endpoint always returns 200 once the signature passes — even on unknown event types — so Stripe stops retrying. Internal failures return 500 so Stripe retries with exponential backoff (up to ~3 days).

Day-11 listens for checkout.session.completed. Day-11b will add customer.subscription.updated and customer.subscription.deleted for plan changes and cancellations.

What happens after payment

On a successful checkout.session.completed event, the receiver:

  1. Verifies the Stripe-Signature header.
  2. Deduplicates by event.id (Redis, 7-day TTL).
  3. Upserts an Organization (keyed on stripe_customer_id) and a User (keyed on email + organization).
  4. Mints a 24-hour magic link for the user.
  5. Sends a welcome email containing only that magic link.

The welcome email contains no raw API key. Plaintext keys persist in mail servers and email logs forever — putting them there is a credential-exposure liability we don't accept. The user clicks the magic link, lands on /dashboard, and creates their first key from the dashboard via the existing reveal-once flow at /dashboard/api-keys.

Customer Portal (self-service)

Authenticated customers can mint a Stripe-hosted Customer Portal URL to manage their payment method, view invoices, change plans, or cancel — all without us writing the UI for each surface.

curl -X POST https://api.simulix.com/v1/billing/portal \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: $(uuidgen)"

Returns { portal_url } — frontend redirects via window.location.href. Either session cookie OR Bearer key auth works (whichever populates request.state.organization_id). Sandbox-only orgs (no Stripe customer record) get 404 portal_unavailable — the frontend uses that to show “Upgrade” instead of “Manage billing”.

Subscription lifecycle

Plan changes and cancellations made through the Customer Portal trigger webhook events the backend reconciles automatically. Two events are handled:

  • customer.subscription.updated — plan tier change (Studio ↔ Enterprise). Org tier updates immediately in the DB; existing user sessions retain stale tier in cached payload until next login (max 7 days). Cosmetic, not a privilege escalation — every privilege check re-reads from the DB.
  • customer.subscription.deleted — cancellation. Org tier downgrades to sandbox; all live keys (sk_live_*) are bulk-revoked. Test keys (sk_test_*) survive — sandbox always allows them, and test integrations should not break on cancellation. The Org and User records persist; re-subscribe with a fresh checkout to restore live access.

Out-of-order webhook delivery (e.g., subscription.updated arriving before checkout.session.completed provisioned the org) returns 500 so Stripe retries — by retry time the org will exist. Replayed events are silent no-ops via Redis dedup on event.id (7-day TTL).

Error responses

  • 422 — body validation (missing or invalid plan_tier).
  • 503 billing_disabled STRIPE_SECRET_KEY (or webhook secret on the receiver) unset. Dev default.
  • 500 billing_misconfigured — env var present but invalid (e.g., STRIPE_PRICE_GROWTH unset while requesting growth). Operator action required.
  • 401 not_authenticated on portal — no valid session or Bearer.
  • 404 portal_unavailable on portal — org has no Stripe customer record (never ran checkout). Frontend should show “Upgrade” instead.
  • 400 on webhook — missing or invalid Stripe-Signature.
  • 500 on webhook — provisioning OR lifecycle failure. Stripe retries.

Every error envelope follows the canonical { data, error, meta } shape with correlation_id in meta for ops trace. See docs/ops/stripe-setup.md for the failure-mode runbook.

Endpoint reference

MethodPathStatusSummary
POST/v1/billing/checkout-session200 / 422 / 503Mint a Stripe-hosted Checkout URL. Public; Idempotency-Key required.
POST/v1/billing/portal200 / 401 / 404 / 503Mint a Stripe Customer Portal URL. Session OR Bearer auth; Idempotency-Key required.
POST/v1/webhooks/stripe200 / 400 / 500 / 503Stripe webhook receiver. Stripe-Signature header (HMAC); skips Bearer auth.