---
title: "Start a Stripe Checkout session for a self-serve plan"
method: POST
path: "/billing/checkout"
tags: ["Billing"]
---

# Start a Stripe Checkout session for a self-serve plan

`POST /billing/checkout`

## Request

- Base URL: `https://api.customdomain.ai/v1`
- URL: `https://api.customdomain.ai/v1/billing/checkout`
- Auth: HTTP bearer

Starts a subscription Checkout for the caller's workspace. Requires `billing:manage` (owners and admins); a widget token is refused. The session collects the card up front, and carries the customer's `promotion_code` as its discount when one is sent (below). A new workspace's first subscription starts with a 14 day trial; a workspace inside its grace period (the retired Free plan) gets a trial that ends when its grace ends, so subscribing early never costs a day; anyone else is charged today. A workspace that already has a running subscription gets `409` (change the plan with `/billing/change-plan` instead). Nothing is granted until Stripe confirms the checkout.

Supply EITHER `return_url` (embedded Checkout, the response carries `client_secret`) OR both `success_url` and `cancel_url` (hosted Checkout, the response carries `url`).

`promotion_code` is optional: the text a customer typed, at most 64 characters (never a Stripe `promo_` id). It is checked here, before Stripe is asked for a session, and only CustomDomain's own codes are taken (made through the operator console, or limited to this deployment's Stripe products), because a Stripe account can be shared with other products. A code that is not CustomDomain's, does not exist or is no longer active is a `404`, the same for all three, and no session is created; a CustomDomain code that Stripe will not give this order (a first order only code, a minimum amount) is a `400`. The code becomes the session's one discount, and Stripe's own code field on its checkout page is never switched on, so a code of another product cannot be typed there either. A Checkout Session cannot be given a discount after it is created: to add, change or remove a code, start a new checkout (with the new code, or without one) and mount its `client_secret` in place of the old one.

`ui_mode` is optional and picks how the session is shown. Leave it out (a `null` or an empty string counts as leaving it out), or send `embedded`, for the behavior from before it existed: Stripe Embedded Checkout when `return_url` is set, and the hosted redirect when only `success_url` and `cancel_url` are. `elements` is Elements with the Checkout Sessions API: the caller renders the whole page (the plan, the price, the trial, the promotion code box, the totals) and mounts only Stripe's Payment Element with the returned `client_secret`, so the page can wear its own theme. It needs `return_url`, which is where redirect based payment methods and the finished payment land, and the session is created on a Stripe API version of the dahlia release train (the billing guide names the exact version), so mount it with a Stripe.js of that train. The trial, the promotion codes and the card up front are the same in every mode. The response's `ui_mode` says what was really created (`embedded`, `elements` or `hosted`): an `elements` request answers `embedded` when Stripe could not create an Elements session, so read it before mounting anything. `hosted` is only ever an answer, never a request value: a client that keeps the response's `ui_mode` must not send it back (the redirect flow is chosen by sending `success_url` and `cancel_url` without `return_url`). Any other value is a 400.

## Request body

- object
  - `plan` 'starter' | 'startup' | 'growth' | 'premium' | 'enterprise' | 'free', required: `free` is retired: legacy workspaces still carry it until they subscribe, and it cannot be bought or assigned.
  - `success_url` string, uri
  - `cancel_url` string, uri
  - `return_url` string, uri: Selects embedded Checkout, or the elements checkout with ui_mode elements.
  - `ui_mode` 'embedded' | 'elements': How the session is shown. Leave it out (or send null or an empty string) for Stripe Embedded Checkout when `return_url` is set and the hosted redirect when only `success_url` and `cancel_url` are. `elements` requires `return_url`. Never send `hosted`: it is only an answer.
  - `promotion_code` string: A promotion code as a customer types it (never a Stripe `promo_` id). Taken only when it is one of CustomDomain's own, and then the session's one discount. Not found, not CustomDomain's, or no longer active is a `404`.

## Response `200`

Checkout session

- object
  - `id` string
  - `url` string: Hosted Checkout URL (redirect flow).
  - `client_secret` string: Client secret to mount the embedded or elements checkout with.
  - `ui_mode` 'embedded' | 'elements' | 'hosted': How the session was really created. An `elements` request answers `embedded` when Stripe could not create an Elements session.
  - `trial_days` integer: Trial length, when the checkout starts one by days.
  - `trial_end` string, date-time, nullable: When the trial ends, when it ends at a fixed time (the grace end).
  - `first_charge_at` string, date-time: When the card is first charged, when there is a trial.

## Other responses

- `400`: Error envelope: {code, title, details}.
- `401`: Error envelope: {code, title, details}.
- `403`: Error envelope: {code, title, details}.
- `404`: The `promotion_code` does not exist, is no longer active, or is not one of CustomDomain's own.
- `409`: Error envelope: {code, title, details}.
- `503`: Error envelope: {code, title, details}.

---

[API](https://skmtc.dev/customdomain/apis/customdomain-control-plane-api.md) · [All operations](https://skmtc.dev/customdomain/apis/customdomain-control-plane-api/llms.txt) · [OpenAPI document](https://skmtc.dev/customdomain/apis/customdomain-control-plane-api/revisions/34d89cb7a7cd?raw)
