Start a Stripe Checkout session for a paid plan
const url = 'http://localhost:8000/api/billing/checkout/';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"price_id":"price_1Q8XYZabc123def456ghi789","return_url":"https://ai.aikynetix.app/settings?section=subscription"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}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_idis 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).
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”object
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.
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.
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.
Examples
Upgrade to Professional (monthly)
{ "price_id": "price_1Q8XYZabc123def456ghi789", "return_url": "https://ai.aikynetix.app/settings?section=subscription"}Upgrade to Starter (return to /pricing)
{ "price_id": "price_1Q8XYZstarter_monthly", "return_url": "https://ai.aikynetix.app/pricing"}object
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.
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.
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.
Examplegenerated
price_id=example&return_url=https%3A%2F%2Fexample.com&lang=example&promo=exampleobject
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.
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.
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.
Responses
Section titled “Responses”object
Hosted Stripe Checkout URL. Redirect the buyer here. Null in dev-mode (no Stripe key) so the FE can render a stub state.
Stripe Checkout Session id (cs_…). Mostly for log correlation — the FE doesn’t act on it directly.
True when STRIPE_SECRET_KEY is unset (dev / test envs). The FE renders a ‘billing not configured’ stub instead of redirecting.
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.
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}Checkout created — conference promo deep-link applied
{ "url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3d4e5f6g7h8", "session_id": "cs_test_a1b2c3d4e5f6g7h8", "dev_only": false, "promo_applied": true}Dev-mode stub (no STRIPE_SECRET_KEY)
{ "url": null, "session_id": null, "dev_only": true, "promo_applied": false}price_id is unknown or inactive. Run manage.py sync_plans on the BE and retry.
object
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).
Examplegenerated
{ "detail": "example"}Caller is a partner aik_… API key. Billing is a read-only surface for keys — opening Checkout requires the Firebase scheme.
object
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).
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 toGET /api/billing/portal/update-plan/?price=<id>and redirects the browser to the returned URL (or toasts the hint).CheckoutSessionViewonly creates NEW Stripe subs; plan switches go through the Stripe Billing Portal deep-link (ADR-0019 §8 Update 2026-05-20).