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

# Connect a payment provider

> Requires `billing:connect`, which is owner-only. Deliberately not `billing:write`: editing the catalogue is product operation, while this is what makes an environment able to take money at all.

Pasting the keys again is how they are rotated, and the rotation **resets `status` to `pending`** — a new credential has never been asked about, so inheriting the previous one's health is exactly the state a botched rotation would hide.

Storing and verifying are two acts. This one stores; `POST .../health` asks the provider and is the only thing that writes a status.

`webhook_secret` is optional and omitting it leaves whatever was stored: the provider mints it when the endpoint is registered and rotates it on its own schedule, so folding it into a secret-key rotation would erase a working webhook.

A credential whose mode does not match the environment is refused **before it leaves the server** — most gateways put the mode in the key's own prefix, and live keys on a test environment charge real cards from a staging deploy.

The secret key is encrypted at rest and never returned.



## OpenAPI

````yaml /api-reference/openapi.json put /v1/organization/environments/{id}/billing-providers/{provider}
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/environments/{id}/billing-providers/{provider}:
    put:
      tags:
        - Environments
      summary: Connect a payment provider
      description: >-
        Requires `billing:connect`, which is owner-only. Deliberately not
        `billing:write`: editing the catalogue is product operation, while this
        is what makes an environment able to take money at all.


        Pasting the keys again is how they are rotated, and the rotation
        **resets `status` to `pending`** — a new credential has never been asked
        about, so inheriting the previous one's health is exactly the state a
        botched rotation would hide.


        Storing and verifying are two acts. This one stores; `POST .../health`
        asks the provider and is the only thing that writes a status.


        `webhook_secret` is optional and omitting it leaves whatever was stored:
        the provider mints it when the endpoint is registered and rotates it on
        its own schedule, so folding it into a secret-key rotation would erase a
        working webhook.


        A credential whose mode does not match the environment is refused
        **before it leaves the server** — most gateways put the mode in the
        key's own prefix, and live keys on a test environment charge real cards
        from a staging deploy.


        The secret key is encrypted at rest and never returned.
      operationId: setBillingProvider
      parameters:
        - $ref: '#/components/parameters/OrganizationHeader'
        - $ref: '#/components/parameters/EnvironmentId'
        - $ref: '#/components/parameters/BillingProvider'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - secret_key
              properties:
                secret_key:
                  type: string
                  description: The gateway's server-side key.
                publishable_key:
                  type: string
                  description: The gateway's browser-side key, where it has one.
                webhook_secret:
                  type: string
                  description: >-
                    What an inbound webhook's signature is verified against.
                    Omit to leave the stored one alone.
      responses:
        '200':
          description: Connected. `status` is `pending` until the health check runs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingProviderConnection'
        '400':
          description: >-
            `invalid_request` — `secret_key` is required.
            `environment_mode_mismatch` — these are the other mode's
            credentials. `unrecognized_key` — this does not look like a secret
            key for that provider, which is usually a publishable key pasted
            into the wrong field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The environment does not exist, or `unknown_provider`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '501':
          description: >-
            `billing_provider_unavailable` — storing a gateway credential is
            unavailable on the server.
          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.
    EnvironmentId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: The environment's id.
    BillingProvider:
      name: provider
      in: path
      required: true
      description: >-
        The payment gateway. `stripe` today. A gateway UserKit has no adapter
        for is refused here rather than stored, because a connection nothing can
        charge is discovered at somebody's checkout.
      schema:
        type: string
        enum:
          - stripe
  schemas:
    BillingProviderConnection:
      type: object
      description: >-
        One payment gateway, as this environment holds it. Both secrets are
        absent by design: the secret key is write-only, and `webhook_secret_set`
        is the only thing said about the webhook secret.


        `connected` and `status` answer different questions — a connection that
        exists and is unhealthy is not the same as no connection, and neither is
        a provider this server has no adapter for (`adapter_available`).
      properties:
        provider:
          type: string
          example: stripe
        connected:
          type: boolean
          description: >-
            Whether credentials have been stored for this provider in this
            environment. The fields below it are present only when true.
        adapter_available:
          type: boolean
          description: >-
            Whether this server can talk to the gateway at all. False means the
            health check answers `501` whatever else is reported here.
        capabilities:
          $ref: '#/components/schemas/BillingProviderCapabilities'
        id:
          type: string
          format: uuid
          description: The connection's id. It is what a webhook endpoint is addressed by.
        status:
          type: string
          enum:
            - pending
            - connected
            - invalid_credentials
            - mode_mismatch
            - account_not_ready
          description: >-
            Each value that is not `connected` names a different repair — see
            the endpoint description.
        status_detail:
          type: string
          description: >-
            The gateway's own sentence, for a person to read. The status says
            which repair; this says why. Never parse it.
        provider_account_id:
          type: string
          description: >-
            The merchant account the credentials open, as the gateway names it.
            Empty until a health check has asked, and never erased by a later
            refusal — the catalogue's price mappings join by this value.
        publishable_key:
          type: string
          description: The gateway's browser-side key, where it has one. Public by design.
        webhook_secret_set:
          type: boolean
          description: Whether a webhook endpoint has been registered at the gateway.
        last_verified_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            Null means the gateway has never been asked. It moves even when the
            check could not be completed, which is what separates a fresh
            `pending` from a stale `connected`.
        updated_at:
          type: string
          format: date-time
    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
    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'
  securitySchemes:
    sessionToken:
      type: http
      scheme: bearer
      description: >-
        A staff session token, `uk_st_…`. Minted by sign-up, sign-in or the
        two-factor exchange.

````