# Start a plan subscription (Stripe Checkout, subscription mode)

`POST /v1/console/subscription/checkout` (operation id `createSubscriptionCheckout`)

Creates a Stripe Checkout Session in subscription mode for the plan's monthly price (excluding VAT; Stripe
Tax adds VAT or applies the reverse charge). The subscription starts when Stripe reports the first payment;
the weekly allowance then runs in 7-day weeks from that moment. An account with a live subscription gets 409
`subscription_exists`: change plans with `updateSubscription` instead.

Founding round: every plan has a limited number of seats (`seats_left` in `listPlans`). When none is left the
answer is 409 `plan_sold_out` with `hints`: `join_waiting_list` (joinWaitingList), `choose_plan` (a plan with
seats left, when there is one) and `pay_as_you_go`. An open checkout holds its seat until it expires (an hour);
an account invited from the waiting list has a seat held for it for 72 hours.

Authentication: Console session cookie (`__Host-dex_session`), or an app session token (`Authorization: Bearer dex_ses_...`). With the cookie, state-changing routes also need the `x-dex-csrf` header.

## Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `x-dex-csrf` | header | string | no | The `csrf_token` returned by `createSession` (or handed over in the `completeGoogleSignIn` redirect). Required with the session cookie on every state-changing console route; not used with a bearer app session.  (32 to 64 characters) |
| `idempotency-key` | header | string | no | Makes retries safe. Within 24 hours, a repeat with the same key and the same body is never charged again. It answers with `idempotent-replayed: true` and the original response: the same `id`, `created`, `model`, answers and `usage`, including the original charge fields. The same key with a different body answers 409.  (1 to 255 characters; pattern ^[\x21-\x7E]+$) |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan` | PlanId | yes | A subscription plan (docs/pricing-v2.md). New plans may appear within v1. (one of "base", "hot", "fierce") |
| `return_path` | string | no | Console path to return to after checkout. (pattern ^/[A-Za-z0-9/_-]{0,200}$) |

## Responses

### 201

Checkout session created; send the browser to `url`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `object` | string | yes | (always "checkout_session") |
| `kind` | string | yes | A prepaid top-up (payment mode), a plan subscription (subscription mode) or a weekly allowance reset (payment mode). (one of "top_up", "subscription", "reset") |
| `plan` | string \| null | yes | The plan a subscription or reset session is for; null for a top-up. (one of "base", "hot", "fierce", null) |
| `url` | string | yes | (format uri) |
| `amount_cents` | integer | yes | The EUR value excluding VAT: a top-up's credit, a subscription's monthly price or a reset's price in euro cents. What the buyer pays is the regional price (`Plan.prices`, `PlanList.top_up_packs`) in the currency Stripe presents; the balance and the plan are the same in every currency.  |
| `currency` | string | yes | The currency of `amount_cents` (always EUR); see `charge_currency` for the payment. (always "EUR") |
| `charge_currency` | string \| null | yes | The currency Stripe will charge when it is fixed in advance: the account's `billing_currency`, or EUR for a top-up of a free amount. null when Stripe picks it from the buyer's location at checkout. New currencies may appear within v1.  (one of "EUR", "USD", "GBP", "INR", null) |
| `expires_at` | string | yes | (format date-time) |

### 400

The request could not be read. Codes: `invalid_json` (not JSON, not UTF-8, or a `\u` escape that is half of a
surrogate pair, such as `"\ud800"` without its low half), `duplicate_key` (a JSON object
repeats a key; `param` is the JSON pointer of the repeated member, such as `/questions/q1/options/a`),
`unsupported_media_type` (not `application/json`) and `invalid_header` (a malformed `idempotency-key` or
`x-client-request-id`; `param` names the header).


### 401

Missing, unknown, revoked or expired credentials.

### 403

The credentials are valid but not allowed to do this (missing scope, suspended account or failed CSRF check).

### 409

The idempotency key was used with a different body, or the first request with this key is still running.
Console billing routes also answer 409 for a state conflict: `subscription_exists` (createSubscriptionCheckout
while a subscription is live, joinWaitingList for a plan the account holds), `no_subscription`
(updateSubscription, cancelSubscription, createResetCheckout or createBillingPortalSession without one),
`plan_sold_out` (createSubscriptionCheckout or updateSubscription to a plan with no founding-round seat left;
with `hints`), `key_revoked` (updateKey on a revoked key).


### 422

The request is well-formed JSON but breaks a validation rule. `param` names the field as a dotted path.
Codes: `unknown_field`, `missing_field`, `invalid_type` (wrong JSON type), `invalid_value` (right type, value
out of range: an empty or over-long string, a state, `instructions`, `criteria` string or option description
of only whitespace, an empty `questions` or `options` object, an empty state object or array, a pattern or
allowed-value mismatch such as a label or level with outer whitespace, a state nested deeper than 32 levels, a
bad date or date range), `field_not_allowed` (`options` or `levels` on the wrong question type),
`invalid_question_id`, `too_many_questions`, `too_many_options`, `invalid_levels` (including two levels equal
after Unicode NFC normalisation and lower-casing), `invalid_min_confidence`, `duplicate_label` (two option
labels equal after Unicode NFC normalisation and lower-casing, such as `Billing` and `billing`),
`state_path_not_found`, `state_not_json` and, on the console, `top_up_limit_exceeded`.


### 429

A rate limit or quota is exhausted. Retry after the given number of seconds. For `test_daily_quota` that is
the time until 00:00 UTC; clients should not retry it automatically.


### 500

An unexpected error. Any charge was refunded. Retry with the same idempotency key.

### 503

`maintenance`: the console's database, sign-in or payments are unavailable for a moment (for
`createGoogleSession` also: Google sign-in is not configured, or Google's signing keys cannot be fetched).
Retry after `retry-after`. Nothing was changed.


## Examples

Illustrative values. Examples show the shape of requests and responses; the numbers in them are not measured results.

Request: base

```json
{
  "plan": "base",
  "return_path": "/console/billing"
}
```

Response: session

```json
{
  "id": "cs_live_b2C3d4E5f6G7h8I9",
  "object": "checkout_session",
  "kind": "subscription",
  "plan": "base",
  "url": "https://checkout.stripe.com/c/pay/cs_live_b2C3d4E5f6G7h8I9",
  "amount_cents": 999,
  "currency": "EUR",
  "charge_currency": null,
  "expires_at": "2026-10-20T12:30:00Z"
}
```
