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

# Start a checkout

> Requires `billing:write`. Puts one of your prices in front of one of your customers and answers with somewhere to send them.

## What exists after this call

A subscription here, with status `incomplete` and an **empty** `provider_subscription_id`, and a checkout URL at the gateway. Nobody has paid. The subscription becomes real when the payer completes the checkout and the gateway tells us so — until then it is a row that exists here and nowhere else, which is why nothing about it can be changed or cancelled yet.

A checkout the payer abandons leaves that row behind, and that is deliberate: it is never deleted, because a subscription is never deleted. Several pending checkouts in one environment are several real pending checkouts.

## Retrying it

Send an `Idempotency-Key`. A repeat under the same key answers the same subscription and the same checkout URL instead of starting a second one — which is what a lost response needs, since the first attempt's session is already live and payable.

## The price has to be sellable and chargeable

It must be `recurring` — a `one_time` price is a payment and has no subscription behind it — and it must not be archived, because withdrawing an offer is exactly the act of no longer selling it.

It must also be **mapped onto the gateway**: a price in your catalogue is a price here, and the gateway needs its own id for it. The mapping is per merchant account, and the account is only known after the connection's health check has asked who the keys belong to — so an unverified connection is refused with something to go and do rather than a guess about which account collects the money.

## A returning payer is not duplicated

If this customer has paid through this gateway before, the gateway's own id for them is found and reused, so they keep one payment method and one billing history. Finding it takes a read at the gateway, and if that read cannot be made the checkout is **refused** rather than falling through to creating a second payer record — there is no un-merge at a gateway either.

If they have never paid, the gateway creates the payer and collects the address itself.

## trial_days

Passes a free period to the gateway. The trial's end date is not written here until the gateway says what it is: `now + trial_days` would be a date an entitlement expires on that nothing happens at.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/organization/subscriptions/checkout
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/checkout:
    post:
      tags:
        - Subscriptions
      summary: Start a checkout
      description: >-
        Requires `billing:write`. Puts one of your prices in front of one of
        your customers and answers with somewhere to send them.


        ## What exists after this call


        A subscription here, with status `incomplete` and an **empty**
        `provider_subscription_id`, and a checkout URL at the gateway. Nobody
        has paid. The subscription becomes real when the payer completes the
        checkout and the gateway tells us so — until then it is a row that
        exists here and nowhere else, which is why nothing about it can be
        changed or cancelled yet.


        A checkout the payer abandons leaves that row behind, and that is
        deliberate: it is never deleted, because a subscription is never
        deleted. Several pending checkouts in one environment are several real
        pending checkouts.


        ## Retrying it


        Send an `Idempotency-Key`. A repeat under the same key answers the same
        subscription and the same checkout URL instead of starting a second one
        — which is what a lost response needs, since the first attempt's session
        is already live and payable.


        ## The price has to be sellable and chargeable


        It must be `recurring` — a `one_time` price is a payment and has no
        subscription behind it — and it must not be archived, because
        withdrawing an offer is exactly the act of no longer selling it.


        It must also be **mapped onto the gateway**: a price in your catalogue
        is a price here, and the gateway needs its own id for it. The mapping is
        per merchant account, and the account is only known after the
        connection's health check has asked who the keys belong to — so an
        unverified connection is refused with something to go and do rather than
        a guess about which account collects the money.


        ## A returning payer is not duplicated


        If this customer has paid through this gateway before, the gateway's own
        id for them is found and reused, so they keep one payment method and one
        billing history. Finding it takes a read at the gateway, and if that
        read cannot be made the checkout is **refused** rather than falling
        through to creating a second payer record — there is no un-merge at a
        gateway either.


        If they have never paid, the gateway creates the payer and collects the
        address itself.


        ## trial_days


        Passes a free period to the gateway. The trial's end date is not written
        here until the gateway says what it is: `now + trial_days` would be a
        date an entitlement expires on that nothing happens at.
      operationId: startSubscriptionCheckout
      parameters:
        - $ref: '#/components/parameters/OrganizationHeader'
        - $ref: '#/components/parameters/EnvironmentQuery'
        - $ref: '#/components/parameters/CheckoutIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer_id
                - price_id
                - success_url
              properties:
                customer_id:
                  type: string
                  format: uuid
                  description: Who is being billed. Must be a customer of this environment.
                price_id:
                  type: string
                  format: uuid
                  description: >-
                    The catalogue price to sell, in this environment. Recurring,
                    not archived, and mapped onto the gateway.
                quantity:
                  type: integer
                  format: int64
                  default: 1
                  minimum: 1
                  description: >-
                    Seats or units. 1 when omitted; 0 is refused, because a line
                    for none of something is not a line.
                success_url:
                  type: string
                  format: uri
                  description: >-
                    Where the payer lands after paying. Required, and it has to
                    be an `http(s)` URL.
                cancel_url:
                  type: string
                  format: uri
                  description: >-
                    Where they land if they back out. Optional — a gateway with
                    no cancel destination returns them itself.
                trial_days:
                  type: integer
                  minimum: 0
                  description: Free days before the first charge. Omit or 0 for none.
                provider:
                  type: string
                  enum:
                    - stripe
                  description: >-
                    Which gateway to charge through. Only needed when the
                    environment has more than one connected: with one there is
                    nothing to disambiguate, and with two picking for you would
                    be choosing whose merchant account collects.
            example:
              customer_id: 6f1c…
              price_id: 9a2e…
              quantity: 3
              success_url: https://app.example.com/billing/done
              cancel_url: https://app.example.com/billing
      responses:
        '201':
          description: >-
            The subscription row and where to send the payer. `expires_at` is
            null when the gateway does not say — a made-up expiry would be a
            countdown to a moment nothing happens at.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription:
                    $ref: '#/components/schemas/Subscription'
                  checkout:
                    type: object
                    properties:
                      url:
                        type: string
                        format: uri
                        description: Send the payer here.
                      session_ref:
                        type: string
                        description: The gateway's id for the checkout session.
                      expires_at:
                        type:
                          - string
                          - 'null'
                        format: date-time
        '400':
          description: >-
            `invalid_request` — an id that is not a uuid, a `success_url` that
            is not `http(s)`, a quantity below 1, negative `trial_days`; or
            `provider_required` — the environment has more than one gateway
            connected and the body named none.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The act cannot be performed as asked, and each code names a
            different repair.


            `provider_not_connected` — no gateway is connected in this
            environment. `provider_account_unknown` — the connection has never
            been verified, so which merchant account it opens is not known; run
            its health check. `price_not_mapped_to_provider` — the price has no
            gateway price mapped onto that account. `price_archived` — the offer
            has been withdrawn. `price_not_recurring` — a one-time price cannot
            start a subscription. `environment_mode_mismatch` — the stored
            credential belongs to the other half of the gateway, which is
            refused before it leaves this server. `provider_refused` — the
            gateway said no, and its own message is passed through.
            `checkout_already_completed` — the checkout this `Idempotency-Key`
            started has been paid; that subscription is live, and selling again
            needs a new key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: >-
            `idempotency_key_reused` — this `Idempotency-Key` already started a
            checkout for a different customer, price or quantity. Answering it
            with that checkout's session would sell somebody something nobody
            asked for, so it is refused; use a new key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '501':
          description: >-
            `billing_provider_unavailable` or `provider_unavailable` — this
            server cannot hold a gateway credential, or has no adapter for that
            gateway. Nothing about your data is wrong.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            `provider_unreachable` — the gateway could not be reached and
            nothing was charged. Retry.
          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.
    CheckoutIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: >-
        A key you choose that makes starting a checkout safe to retry, end to
        end.


        A repeat under the same key does not start a second checkout: it answers
        the **same** subscription and the same gateway session as the first
        attempt, with `Idempotent-Replay: true`. That is what a lost response
        needs — the first attempt's session is live and payable, and the answer
        that went missing carries the only id tying a payment to a row.


        The key names one act. Sent with a different customer, price or quantity
        it is refused (`422`, `idempotency_key_reused`), and once the checkout
        it started has been paid it is refused (`409`,
        `checkout_already_completed`) rather than putting a second session in
        front of somebody who is already subscribed. Send a fresh key per
        checkout you mean to start: two checkouts for one customer on one price
        are two real checkouts, and the key is the only thing that can say
        otherwise.


        It is forwarded to the gateway as **its** idempotency key too, so the
        retry and the original arrive there as one request. Retry with the body
        unchanged — the gateway compares every parameter, including
        `success_url` and `cancel_url`, and answers a mismatch with a refusal of
        its own.
  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:
    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.

````