Skip to content

Start a Stripe Checkout session for a paid plan

POST
/api/billing/checkout/
curl --request POST \
--url http://localhost:8000/api/billing/checkout/ \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "price_id": "price_1Q8XYZabc123def456ghi789", "return_url": "https://ai.aikynetix.app/settings?section=subscription" }'

Creates a hosted Stripe Checkout session for the given plan price and returns its public url. The FE redirects the signed-in coach to that URL; on success Stripe sends customer.subscription.created + invoice.payment_succeeded back to our webhook, which (a) flips the team off the Free Subscription via the promotion gate, (b) creates the new paid Subscription row, and (c) grants the plan’s session_quota to the ledger.

When to call this

  • User picks Starter / Pro / Org on the pricing page → POST here with the corresponding stripe_price_id.
  • User switches plans mid-period → do NOT call this; use GET /api/billing/portal/ so Stripe handles the proration consistently with the buyer’s existing customer record.
  • User is on Free or Strategic → calling this with a Free / Strategic price_id is a 400 (those tiers have no Stripe SKU). The pricing-page CTAs for Free + Strategic route elsewhere.

Dev mode

When STRIPE_SECRET_KEY is unset (local dev without the sandbox wired up), the response sets dev_only=true and url=null so the FE can render a stub state instead of redirecting. Real Stripe Checkout requires the key — configure it via the runbook (ops/runbooks/billing-stripe-setup.md).

object
price_id
required

The Stripe price_… id of the tier the user picked. Resolve via GET /api/plans/ (the FE rendering passes the price id from the catalog through). 400s if the id isn’t seeded in our local plans table — re-run manage.py sync_plans on the BE side, then retry.

string
>= 1 characters <= 128 characters
return_url
Any of:
string format: uri
lang

FE language code (en / es / ru — mirrors apps/web SUPPORTED_LANGS). Mapped to Stripe locale so Checkout renders in the language the buyer picked in Settings → Language, instead of Stripe’s browser-sniff. Unknown / unset → auto (Stripe’s documented fallback). Append here when the SPA picker grows.

string
<= 8 characters
promo

Optional promotion code from a deep-link (e.g. ?promo=NSCA2026 at a conference booth). Resolved server-side to a Stripe Promotion Code and applied as a discount at Checkout. Invalid/expired/unknown codes are ignored gracefully — Checkout still opens with the typed promo box. No-op unless BILLING_CONFERENCE_PROMO_ENABLED.

string
<= 64 characters
Examples

Upgrade to Professional (monthly)

{
"price_id": "price_1Q8XYZabc123def456ghi789",
"return_url": "https://ai.aikynetix.app/settings?section=subscription"
}
Media typeapplication/json
object
url
required

Hosted Stripe Checkout URL. Redirect the buyer here. Null in dev-mode (no Stripe key) so the FE can render a stub state.

string
nullable
session_id
required

Stripe Checkout Session id (cs_…). Mostly for log correlation — the FE doesn’t act on it directly.

string
nullable
dev_only
required

True when STRIPE_SECRET_KEY is unset (dev / test envs). The FE renders a ‘billing not configured’ stub instead of redirecting.

boolean
promo_applied
required

True when a promo code on the request resolved to a live Stripe Promotion Code and was attached as a discount (discounts=[…]). False when no promo was sent, the feature flag is off, or the code was invalid / expired / unresolvable (the request still succeeds — Checkout opens with the typed promo box). The FE uses this to show an honest ‘discount applied’ vs. ‘couldn’t apply that code’ toast. Issue #470.

boolean
Examples

Checkout created — FE redirects to `url`

{
"url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3d4e5f6g7h8",
"session_id": "cs_test_a1b2c3d4e5f6g7h8",
"dev_only": false,
"promo_applied": false
}

price_id is unknown or inactive. Run manage.py sync_plans on the BE and retry.

Media typeapplication/json
object
detail
required

Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).

string
Examplegenerated
{
"detail": "example"
}

Caller is a partner aik_… API key. Billing is a read-only surface for keys — opening Checkout requires the Firebase scheme.

Media typeapplication/json
object
detail
required

Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).

string
Examplegenerated
{
"detail": "example"
}

The team already has a live Stripe-managed paid subscription. Two shapes:

  • {detail: "already_on_plan", subscription_id, plan_role} — buyer clicked the CTA for the plan they’re already on. FE shows a ‘You’re already on this plan’ toast; no Stripe call was made.
  • {detail: "use_portal_update_plan", subscription_id, current_plan_role, hint} — buyer wants a different paid plan. FE routes to GET /api/billing/portal/update-plan/?price=<id> and redirects the browser to the returned URL (or toasts the hint). CheckoutSessionView only creates NEW Stripe subs; plan switches go through the Stripe Billing Portal deep-link (ADR-0019 §8 Update 2026-05-20).