Billing
Stripe‑backed subscription management for the authenticated account. Every
endpoint is owner‑scoped through the Clerk actor — the
Stripe customer is resolved from the account, never from request input. Plans
are server‑driven (no fixed enum): the live, checkout‑eligible set is returned
on each status response as availablePlanIds.
All routes live under https://app.swayzio.com/api/swayzio/v1/billing. Request
bodies are validated with .strict() schemas — unknown fields are rejected.
returnPath must be an app‑relative path (starts with /, not //).
Endpoints
| Method | Path | Purpose | Auth | Request | Response |
|---|---|---|---|---|---|
POST | /v1/billing/checkout | Start a Stripe Checkout session for a plan | Clerk actor | createBillingCheckoutRequestSchema | { sessionId, url } 201 |
POST | /v1/billing/portal | Open the Stripe customer portal | Clerk actor | createBillingPortalRequestSchema | { sessionId, url } |
POST | /v1/billing/change-plan | Move to another plan (checkout or in‑place switch) | Clerk actor | createBillingChangePlanRequestSchema | billingChangePlanResponseSchema |
POST | /v1/billing/cancel | Cancel at period end | Clerk actor | (none) | billingCancelResponseSchema |
POST | /v1/billing/resume | Undo a pending cancellation | Clerk actor | (none) | billingCancelResponseSchema |
GET | /v1/billing/status | Current subscription + entitlement status | Clerk actor | (query: none) | billingStatusResponseSchema |
The Stripe webhook (POST /v1/webhooks/stripe, signature‑verified) is
documented on the Webhooks page — it is the source of truth for
subscription state and is not a caller‑facing endpoint.
Plan ids are not a fixed enum. availablePlanIds on the status response lists
the ids the server currently has Stripe prices configured for — render the full
pricing ladder from your catalog but only enable checkout for ids in this set.
Plan ids are never Stripe price ids.
Errors
Billing errors are normalized to a small set of stable codes:
| Code | Status | When |
|---|---|---|
validation_error | 400 | Request body failed schema validation |
<billing_request_error> | 4xx | Bad input the service rejects (e.g. unknown plan) |
<billing_configuration_error> | 5xx | Stripe prices/plans not configured on the server |
stripe_unavailable | 502 / 503 | Stripe client could not be reached |
billing_database_unavailable | 503 | DATABASE_URL / billing migrations not present |
See Conventions for the shared error envelope and response headers, and Data Contracts for full field detail.
POST /v1/billing/checkout
Creates a Stripe Checkout session for planId and returns the hosted URL to
redirect the user to. Returns 201.
curl -s -X POST https://app.swayzio.com/api/swayzio/v1/billing/checkout \
-H "Authorization: Bearer $SWAYZIO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "planId": "pro", "returnPath": "/settings/billing" }'{
"sessionId": "cs_live_…",
"url": "https://checkout.stripe.com/c/pay/cs_live_…"
}POST /v1/billing/portal
Opens the Stripe customer portal (update payment method, view invoices, manage the subscription). Same response shape as checkout.
curl -s -X POST https://app.swayzio.com/api/swayzio/v1/billing/portal \
-H "Authorization: Bearer $SWAYZIO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "returnPath": "/settings/billing" }'{ "sessionId": "bps_…", "url": "https://billing.stripe.com/p/session/…" }POST /v1/billing/change-plan
Moves the account to planId. The response mode tells you what happened:
- New subscriber →
mode: "checkout"with aurl(andsessionId) to redirect to. No subscription exists yet, so checkout creates one. - Existing subscriber →
mode: "switched", the subscription is updated in place (no second subscription is created) and the newplanIdis echoed back.
curl -s -X POST https://app.swayzio.com/api/swayzio/v1/billing/change-plan \
-H "Authorization: Bearer $SWAYZIO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "planId": "studio", "returnPath": "/settings/billing" }'// existing subscriber — switched in place
{ "mode": "switched", "planId": "studio" }// new subscriber — redirect to checkout
{
"mode": "checkout",
"sessionId": "cs_live_…",
"url": "https://checkout.stripe.com/c/pay/cs_live_…"
}POST /v1/billing/cancel · POST /v1/billing/resume
cancel marks the subscription to cancel at period end (the account keeps
access until currentPeriodEnd). resume clears a pending cancellation. Both
take no body and return billingCancelResponseSchema.
curl -s -X POST https://app.swayzio.com/api/swayzio/v1/billing/cancel \
-H "Authorization: Bearer $SWAYZIO_TOKEN"{
"status": "active",
"cancelAtPeriodEnd": true,
"currentPeriodEnd": "2026-07-10T00:00:00.000Z"
}GET /v1/billing/status
Returns the account’s billing and entitlement status, plus the configured plan
ids. status is one of active, canceled, free, incomplete,
past_due, trialing, unpaid.
curl -s https://app.swayzio.com/api/swayzio/v1/billing/status \
-H "Authorization: Bearer $SWAYZIO_TOKEN"{
"customerConfigured": true,
"planId": "pro",
"status": "active",
"stripeCustomerId": "cus_…",
"stripeSubscriptionId": "sub_…",
"availablePlanIds": ["free", "pro", "studio"],
"cancelAtPeriodEnd": false,
"currentPeriodEnd": "2026-07-10T00:00:00.000Z"
}When no Stripe customer is attached yet, customerConfigured is false,
status is typically free, and the Stripe id fields are omitted.