> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Change the plan

> Requires `billing:write`. Moves a subscription onto a different price — now, or when the current period ends.

## The two timings are different acts

`at_period_end: false` changes it today. `at_period_end: true` leaves the current period exactly as it was and starts the new price when it ends, which is what somebody who has paid for a month expects. A gateway that cannot schedule answers **501** rather than doing it now: an immediate charge is not a degraded version of a scheduled one.

## prorate decides whether money moves today

`false` — the default — means the new price applies from the next invoice and nothing is charged or credited now. `true` credits or charges the difference for the remainder of the period.

It defaults to `false` on purpose: of the two possible mistakes, not collecting a difference is recoverable and charging a card nobody agreed to is not. A gateway that cannot prorate answers 501 when asked for it.

## Nothing is written here optimistically

The gateway decides. After it accepts, the subscription is re-read and this answers with what it said — so a scheduled change comes back looking unchanged, which is correct: nothing about the bill differs until the period ends. Check `mirror_refreshed` before treating the body as the gateway's word.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/organization/subscriptions/{id}/change-plan
openapi: 3.1.0
info:
  title: UserKit API
  version: 1.0.0
  description: >-
    The HTTP surface of UserKit.


    Two planes share one API. The **staff plane** is what a human uses in the
    panel: users, organizations, roles, members, keys. The **customer plane** is
    what a developer's own product uses: contacts, identities, hosted or
    federated sign-in.


    Every error answers the same envelope — `{"error": {"code", "message"}}`.
    The `code` is a stable contract to branch on; the `message` is for a person
    and may change.
servers:
  - url: '{baseUrl}'
    description: The API host.
    variables:
      baseUrl:
        default: https://api.userkit.dev
        description: Base URL of the API, no trailing slash.
security:
  - sessionToken: []
tags:
  - name: Authentication
    description: >-
      Public sign-up, sign-in, two-factor and password recovery for staff
      accounts.
  - name: Session
    description: >-
      The active session: who the caller is, which organization they are in, and
      signing out.
  - name: Account
    description: The caller's own account — profile, password, sessions, avatar.
  - name: Two-factor
    description: >-
      TOTP setup, activation and recovery codes. Returns 501 when two-factor is
      unavailable on the server.
  - name: Organizations
    description: The organizations a user belongs to, and the active one.
  - name: Environments
    description: >-
      The live and test environments seeded with every organization, and their
      identity settings.
  - name: Members
    description: Memberships and invitations.
  - name: Roles
    description: Roles and the permission catalogue they draw from.
  - name: Audit log
    description: >-
      What staff did inside an organization. Append-only, and read behind its
      own permission.
  - name: API keys
    description: Secret keys (`uk_sk_…`) and publishable keys (`uk_pk_…`).
  - name: Contacts (staff session)
    description: >-
      The staff view of the customer plane, opened by a staff session. Reads
      take `?environment=` as an explicit view parameter.
  - name: Contacts (API key)
    description: >-
      The machine surface, authenticated by an API key. The environment is the
      key's environment and cannot be named by the caller.
  - name: Customer plane
    description: >-
      Called from the developer's own pages with a publishable key: boot, magic
      link, and the contact's own session.
  - name: Hosted auth
    description: >-
      Sign-up, sign-in, verification and recovery for contacts, when UserKit
      owns the account.
  - name: Customer teams
    description: >-
      A customer is a team. Its roster, its invitations and its two roles —
      owner and member — administered by the contact's own session. The active
      customer travels in `X-Customer-Id`.
  - name: Webhooks
    description: >-
      Outbound webhooks: endpoints, the published event catalogue, the delivery
      log, replay and test sends. Never gated by a plan — webhooks are a
      developer primitive.
  - name: Catalogue
    description: >-
      The plans, prices and features **you** sell to your own customers. Per
      environment, a price per currency, and `recurring` or `one_time`. Distinct
      from the plan you are on with UserKit, which is `GET
      /v1/organization/entitlements`.
  - name: Subscriptions
    description: >-
      What one of **your** customers is paying you, mirrored from the gateway
      that charges them. The gateway is the truth about money — it holds the
      schedule, runs the retries and decides what a proration is worth — so
      these routes ask it to change something and answer with what it then said.
      `provider_synced_at` is how stale the copy admits to being.
  - name: Provider webhooks
    description: >-
      Where a payment provider delivers to. Not a surface you call — it is a URL
      you paste into the provider's dashboard, which is why it sits outside
      `/v1`: a version bump must never mean editing a setting in somebody else's
      product. Signed with the secret of the connection named in the path, and
      safe to retry.
  - name: Entitlements
    description: >-
      What one of **your** customers may do, resolved: the plan their
      subscription carries, plus the overrides you promised them on top. This is
      the read your own gate calls, so it is cached and answers in one round
      trip. It is a different question from `GET /v1/organization/entitlements`,
      which is the plan **you** are on with UserKit — two catalogues, same word,
      different money.
paths:
  /v1/organization/subscriptions/{id}/change-plan:
    post:
      tags:
        - Subscriptions
      summary: Change the plan
      description: >-
        Requires `billing:write`. Moves a subscription onto a different price —
        now, or when the current period ends.


        ## The two timings are different acts


        `at_period_end: false` changes it today. `at_period_end: true` leaves
        the current period exactly as it was and starts the new price when it
        ends, which is what somebody who has paid for a month expects. A gateway
        that cannot schedule answers **501** rather than doing it now: an
        immediate charge is not a degraded version of a scheduled one.


        ## prorate decides whether money moves today


        `false` — the default — means the new price applies from the next
        invoice and nothing is charged or credited now. `true` credits or
        charges the difference for the remainder of the period.


        It defaults to `false` on purpose: of the two possible mistakes, not
        collecting a difference is recoverable and charging a card nobody agreed
        to is not. A gateway that cannot prorate answers 501 when asked for it.


        ## Nothing is written here optimistically


        The gateway decides. After it accepts, the subscription is re-read and
        this answers with what it said — so a scheduled change comes back
        looking unchanged, which is correct: nothing about the bill differs
        until the period ends. Check `mirror_refreshed` before treating the body
        as the gateway's word.
      operationId: changeSubscriptionPlan
      parameters:
        - $ref: '#/components/parameters/OrganizationHeader'
        - $ref: '#/components/parameters/EnvironmentQuery'
        - $ref: '#/components/parameters/SubscriptionId'
        - $ref: '#/components/parameters/GatewayIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - price_id
              properties:
                price_id:
                  type: string
                  format: uuid
                  description: >-
                    The price to move onto. Recurring, not archived, and mapped
                    onto the same merchant account this subscription is charged
                    on.
                quantity:
                  type: integer
                  format: int64
                  default: 1
                  minimum: 1
                at_period_end:
                  type: boolean
                  default: false
                  description: >-
                    Wait for the current period to end. Needs the gateway's
                    `scheduled_plan_change`.
                prorate:
                  type: boolean
                  default: false
                  description: >-
                    Credit or charge the difference for the remainder of the
                    period. Needs the gateway's `proration`.
            example:
              price_id: 9a2e…
              quantity: 5
              at_period_end: true
              prorate: false
      responses:
        '200':
          description: >-
            The subscription as the gateway now holds it, re-read after the
            change. `mirror_refreshed` says whether that re-read got through.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            `subscription_not_at_provider` — the checkout was never completed,
            so there is nothing at the gateway to change.
            `subscription_canceled` — it has ended; coming back is a new
            checkout at a price the customer agrees to.
            `provider_account_changed` — the subscription was created on a
            different merchant account than the connection now opens. Plus the
            price and connection refusals listed on `/checkout`, and
            `provider_refused` carrying the gateway's own message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '501':
          description: >-
            `provider_capability_unsupported` — this gateway cannot schedule a
            plan change, or cannot prorate, and was asked for one. The refusal
            is the point: the alternative would be an act nobody asked for. Also
            `provider_unavailable` when this server has no adapter for the
            gateway.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            `provider_unreachable` — the gateway could not be reached and
            nothing was changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  parameters:
    OrganizationHeader:
      name: X-Organization-Id
      in: header
      required: false
      schema:
        type: string
      description: >-
        The organization the caller is acting on — the `org_…` code that appears
        in the panel URL. It *identifies*; the membership JOIN is what
        *authorizes*, so a forged code reads nothing. Absent, the session's
        default organization answers.
    EnvironmentQuery:
      name: environment
      in: query
      required: false
      schema:
        type: string
        enum:
          - live
          - test
        default: live
      description: >-
        Which environment to act in. A view parameter, valid only on the staff
        surface — a machine credential never chooses its environment, it is
        resolved from the key.
    SubscriptionId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: The subscription's id here, not the gateway's id for it.
    GatewayIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: >-
        Forwarded to the payment gateway as **its** idempotency key, so a retry
        of this call does not charge twice or move a subscription twice.


        This is not the guarantee `Idempotency-Key` carries on the API-key
        surface: nothing is stored here and a replay is not answered with the
        original response. It is passed through, and what it buys is whatever
        the gateway does with it — for Stripe, the original result is replayed
        for 24 hours.


        Send a fresh key per act you mean to perform, and the same key with the
        same body when you are retrying one. The gateway compares the
        parameters, so a key reused for a different act is refused there rather
        than replayed.
  schemas:
    Subscription:
      type: object
      description: >-
        What one of your customers is paying, **mirrored** from the gateway.


        That word decides how to read every field below. The gateway is the
        source of truth about money; this is a local copy kept so an entitlement
        can be answered without a network call, joined to the rest of your data,
        and read while the gateway is unreachable. `provider_synced_at` is when
        the copy was last confirmed — `null` means never, which is what a
        checkout that was started and not completed looks like.
      properties:
        id:
          type: string
          format: uuid
        customer_id:
          type: string
          format: uuid
          description: >-
            Who is billed. A subscription always hangs off a customer, including
            when that customer is a team of one.
        plan_id:
          type: string
          format: uuid
          description: >-
            What they are subscribed to, as opposed to how it is billed — which
            is `items`. Entitlements are computed from the plan's features, so
            this always has exactly one answer.
        status:
          type: string
          enum:
            - incomplete
            - trialing
            - active
            - past_due
            - paused
            - canceled
            - unknown
          description: >-
            In **our** vocabulary, never a gateway's spelling, so this does not
            change when you change gateway.


            `incomplete` is created and not yet paid — no access. `past_due` is
            paid before and a payment has failed; whether that keeps access is a
            grace-period decision and not this field's. `unknown` means the
            adapter did not recognise what the gateway said: it is a reason to
            look, never a reason to charge anybody or to cut them off, and it
            deliberately does not read as `active` or as `canceled`.
        current_period_start:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            The period paid for. Null together with the end, never one alone: an
            `incomplete` subscription has no period, and a made-up one is a date
            an entitlement expires on that nothing happens at.
        current_period_end:
          type:
            - string
            - 'null'
          format: date-time
        cancel_at_period_end:
          type: boolean
          description: >-
            A cancellation that has been **scheduled** and has not happened. The
            subscription is still active until `current_period_end`, which is
            why this is its own field rather than a status.
        canceled_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When it actually stopped. Null while it has not.
        trial_ends_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            Null when there is no trial — which is a different fact from a trial
            that ended, and only one of them should make a screen say a trial
            expired.
        provider:
          type: string
          enum:
            - stripe
          description: >-
            Which gateway holds this subscription. Exactly one: that is who is
            charging the card.
        provider_account_id:
          type: string
          description: >-
            The merchant account, as the gateway names it. Empty until a health
            check has asked who the keys belong to.
        provider_subscription_id:
          type: string
          description: >-
            The gateway's own id. **Empty** for a checkout that was started and
            never completed — a row that exists here and nowhere else, and the
            one state in which no act on this subscription is possible.
        provider_synced_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When this copy was last re-read from the gateway, as opposed to when
            the row was last written. Null means never confirmed. A mirror that
            cannot say how stale it is would be claiming to be the source.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        items:
          type: array
          items:
            $ref: '#/components/schemas/SubscriptionItem'
          description: The lines. Present on a single subscription, absent from the list.
        capabilities:
          $ref: '#/components/schemas/BillingProviderCapabilities'
        mirror_refreshed:
          type: boolean
          description: >-
            Present only on the answer to an **act**. `true` means the gateway
            was re-read afterwards and everything above is what it said. `false`
            means the act happened and the copy above is not (or not entirely)
            the gateway's answer — either the re-read did not get through, or it
            did and part of it could not be written down. `mirror_stale_reason`
            says which. Treat it as a reason to read again, never as a sign the
            act failed.
        mirror_stale_reason:
          type:
            - string
            - 'null'
          enum:
            - provider_reread_failed
            - price_not_mapped_to_provider
            - subscription_has_extra_lines
            - null
          description: >-
            Present whenever `mirror_refreshed` is, and **null** when it is
            `true` — an absent field would leave you inferring that nothing is
            wrong.


            `provider_reread_failed` — the gateway was not reachable after it
            accepted the act. Nothing above was updated; read again.


            `price_not_mapped_to_provider` — the gateway named a price that is
            not mapped onto any price in this catalogue for this environment, so
            `plan_id` was left as it was while everything else was applied. This
            one matters: entitlements are computed from the plan, so until you
            map the price (see `unmapped_provider_price_id`) the customer keeps
            the plan they were on. Prices created in the gateway's own dashboard
            are the usual cause.


            `subscription_has_extra_lines` — the gateway holds more priced lines
            than one, so which of them names the plan is not decidable and both
            `plan_id` and `items` were left as they were.
        unmapped_provider_price_id:
          type:
            - string
            - 'null'
          description: >-
            The gateway's own price id to map, when `mirror_stale_reason` is
            `price_not_mapped_to_provider`. Null otherwise.
    Error:
      type: object
      description: Every error in this API answers this envelope.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Stable. Branch on this.
            message:
              type: string
              description: For a person. May change.
          required:
            - code
            - message
      required:
        - error
      example:
        error:
          code: forbidden
          message: your role does not allow this action
    SubscriptionItem:
      type: object
      description: >-
        One line of a subscription: a price and a quantity. There is exactly one
        today — a subscription is billed on one price, and a second line for the
        same price is a quantity rather than a row.
      properties:
        id:
          type: string
          format: uuid
        quantity:
          type: integer
          format: int64
          description: >-
            Seats, units, licences. Never 0: a line for none of something is not
            a line.
        price:
          type: object
          description: >-
            The price this line points at, as it was agreed. A price is
            immutable, so this still says exactly what the customer consented to
            pay — including when the offer has since been withdrawn.
          properties:
            id:
              type: string
              format: uuid
            plan_id:
              type: string
              format: uuid
            kind:
              type: string
              enum:
                - recurring
                - one_time
            currency:
              type: string
              description: >-
                ISO 4217, always beside the amount: an amount with no currency
                is not a price.
            amount_minor:
              type: integer
              format: int64
              description: >-
                An integer in the currency's minor unit. `1000` is R$10.00 in
                BRL and ¥1000 in JPY.
            tax_behavior:
              type: string
              enum:
                - inclusive
                - exclusive
            archived:
              type: boolean
              description: >-
                Whether the offer has been withdrawn. `true` is normal on a
                subscription sold before a reprice, and it changes nothing about
                what is charged.
    BillingProviderCapabilities:
      type: object
      description: >-
        What a gateway can do **beyond the minimum every gateway can do**. Every
        flag defaults to false, so a provider that declares nothing is treated
        as the weakest one — silence is never a claim. Read it to stop offering
        an act that would be refused.


        **Every flag below is always present.** The object is emitted whole or
        not at all: absent means this server has no adapter loaded for the
        gateway, and it never means "some flags were left out". A reader must
        therefore distinguish `false` from missing rather than testing for
        falsiness — a key that is not here is not a gateway that cannot do
        something, it is a question nobody asked.
      required:
        - scheduled_plan_change
        - proration
        - cancel_at_period_end
        - hosted_portal
        - tax_calculation
        - resume_subscription
        - list_invoices
        - list_catalogue
      properties:
        scheduled_plan_change:
          type: boolean
          description: >-
            A plan change that takes effect at the end of the current period
            rather than now. Without it, asking for one is refused rather than
            applied immediately — which would be a charge the customer did not
            agree to.
        proration:
          type: boolean
          description: >-
            Money already paid can be credited or charged pro rata on a
            mid-period change. Without it, the new price applies from the next
            period.
        cancel_at_period_end:
          type: boolean
          description: >-
            A cancellation that is a schedule rather than an act. Without it,
            cancelling is immediate.
        hosted_portal:
          type: boolean
          description: >-
            The gateway hosts a page where the customer manages their own
            subscription.
        tax_calculation:
          type: boolean
          description: >-
            The gateway computes tax. UserKit never calculates it — the
            catalogue stores whether a price includes tax and nothing more.
        resume_subscription:
          type: boolean
          description: A scheduled cancellation can be undone.
        list_invoices:
          type: boolean
          description: The gateway has an invoice history to read.
        list_catalogue:
          type: boolean
          description: >-
            The gateway's own catalogue can be read, so an existing one can be
            imported instead of retyped. False where the gateway has a price and
            no catalogue above it.
  responses:
    BadRequest:
      description: '`invalid_request` — the body or a parameter is malformed.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: >-
        `unauthorized` — missing, malformed or expired credential. A well-formed
        credential from the wrong family says so: "this endpoint expects a staff
        session token, not an organization API key".
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: '`forbidden` — your role does not allow this action.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: >-
        `not_found`. Also the answer for a resource that exists in another
        organization or environment — the 404 never reveals which.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    sessionToken:
      type: http
      scheme: bearer
      description: >-
        A staff session token, `uk_st_…`. Minted by sign-up, sign-in or the
        two-factor exchange.

````