# Change the plan, or undo a cancellation

`PATCH /v1/console/subscription` (operation id `updateSubscription`)

`plan` to a dearer plan applies at once: Stripe prorates the price difference on the next invoice and the
weekly allowance switches to the new plan immediately (the current week's used amount is carried over).
`plan` to a cheaper plan is scheduled for the end of the paid month (`scheduled_plan`); until then the current
plan stays. `cancel_at_period_end: false` undoes a cancellation that has not taken effect yet. Without an
active subscription: 409 `no_subscription`. A plan with no founding-round seat left: 409 `plan_sold_out` (a
downgrade too, since it takes a seat on the cheaper plan). When Stripe refuses the change (the restricted API
key lacks the permission, see the billing runbook): 503 `maintenance`; use the Customer Portal instead.

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) |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan` | PlanId | no | A subscription plan (docs/pricing-v2.md). New plans may appear within v1. (one of "base", "hot", "fierce") |
| `cancel_at_period_end` | boolean | no | Only `false` is accepted here, to undo a cancellation; cancel with `cancelSubscription`. |

## Responses

### 200

The subscription after the change.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string | yes | (always "subscription") |
| `plan` | string \| null | yes | The current plan; null when the account has no subscription. (one of "base", "hot", "fierce", null) |
| `status` | string | yes | `none`: never subscribed, or the last subscription ended and the account is back on pay as you go. `incomplete`: Checkout started, the first payment is not settled yet (delayed payment methods). `active`: paid up; the weekly allowance is available. `past_due`: a renewal payment failed, the allowance stays available during the grace period. `frozen`: the grace period passed, requests are paid from the balance until the subscription is paid or ends. `canceled`: ended; `plan` is null.  (one of "none", "incomplete", "active", "past_due", "frozen", "canceled") |
| `cancel_at_period_end` | boolean | yes | The subscription ends at `current_period_end` (cancelSubscription). |
| `scheduled_plan` | string \| null | yes | A cheaper plan that applies at `current_period_end` (updateSubscription to a cheaper plan); null otherwise. (one of "base", "hot", "fierce", null) |
| `current_period_start` | string \| null | yes | (format date-time) |
| `current_period_end` | string \| null | yes | When the paid month ends and Stripe bills the next one; a cancellation or downgrade applies then. (format date-time) |
| `week_started_at` | string \| null | yes | Start of the current 7-day allowance week (weeks run from the subscription start). (format date-time) |
| `week_resets_at` | string \| null | yes | When the allowance refills (week_started_at plus 7 days). (format date-time) |
| `week_allowance_tokens` | integer | yes | (format int64; at least 0) |
| `week_used_tokens` | integer | yes | (format int64; at least 0) |
| `reset_price_cents` | integer \| null | yes | Price excluding VAT of a reset for the current plan; null without a plan. |
| `resets_bought_this_week` | integer | yes | (at least 0) |
| `billing_portal` | boolean | yes | Whether `createBillingPortalSession` works (the Stripe Customer Portal is configured for Dex and the account has a Stripe customer). |
| `currency` | string \| null | yes | The currency the plan is billed in (Stripe's subscription currency, else the account's `billing_currency`); null without a plan or before it is known. `reset_price_cents` stays in euro cents: the reset in this currency is `Plan.prices[currency].reset_price_cents`. New currencies may appear within v1.  (one of "EUR", "USD", "GBP", "INR", null) |

### 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`.


### 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: upgrade

```json
{
  "plan": "hot"
}
```

Request: keep

```json
{
  "cancel_at_period_end": false
}
```
