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

# Import the catalogue from a payment provider

> Requires `billing:write`. Reads what this connection already sells at the gateway and turns it into plans, prices and their mappings here. Answers **202**: the listing is accepted and durable, and none of it has been applied yet — a worker applies the lines in batches and the report fills in as it goes.

It is one-directional. Nothing here creates, renames or archives anything at the gateway: your catalogue is the source of truth for what you sell, the gateway is how it gets charged, and pushing rows outward would give one object two owners.

## The connection has to be usable

A connection that is not `connected` refuses the import with **409** and the code `provider_connection_not_ready` — and the message names which repair that status calls for, because "not connected" is not something you can act on: `invalid_credentials` means paste a new key, `mode_mismatch` means paste the *other* key, and `account_not_ready` means the fix is in the gateway's own dashboard and the key is fine. Run `POST .../health` first.

## What a line does

A gateway **product** becomes a plan, named after the product and addressed by a handle derived from that name — `Pro Anual` becomes `pro-anual`. A plan's key cannot be renamed afterwards, so it is derived rather than invented; a name that yields no usable handle falls back to the product's own id, which is ugly and visibly the gateway's own name for the thing.

A **price** becomes a catalogue price and is born mapped: it carries the gateway's price id, so a checkout can charge it immediately. Outcomes per line are `created` (the price did not exist here), `mapped` (it did, said the same thing, and now knows how to be charged), `matched` (it was already imported — nothing was written), `conflict` and `failed`.

**Running it twice is free.** A gateway price that is already mapped is `matched` and nothing is written.

## What it refuses to decide

A catalogue price cannot be edited — the money is what somebody agreed to pay — so an import never becomes an update. Where a line and this catalogue disagree, the line is **reported** and the repair is yours:

- `offer_taken` — this plan already sells a live price for that currency and recurrence, at a different amount or a different tax behaviour. Choosing between two prices is a repricing, which is a commercial decision; use `POST /v1/organization/catalogue/prices/{id}/reprice` if that is what you meant.
- `plan_conflict` — the product's prices are already mapped onto one plan while the handle its name derives to belongs to another. Both plan ids are reported, because deciding which plan the product is takes a person.
- `already_mapped` — the local price for that offer is already mapped to a different id at this gateway. Re-pointing it would silently move what a checkout charges.
- `product_spans_plans`, `plan_archived`, `mapped_price_disagrees`, `mapped_in_another_environment`.

And a line this catalogue cannot hold is `failed` with the reason, never a guess: `tax_behavior_unstated` (whether an amount includes tax is an input only the person who typed it knows), `no_unit_amount` (a tiered or usage table is not one number), `metered_price`, `usage_unstated`, `kind_unstated`, `unsupported_currency`, `product_unnamed`, `one_time_with_interval`.

## Nothing is charged and nothing is mailed

An import is a read followed by local writes.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/organization/environments/{id}/billing-providers/{provider}/catalogue-import
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}/catalogue-import:
    post:
      tags:
        - Catalogue
      summary: Import the catalogue from a payment provider
      description: >-
        Requires `billing:write`. Reads what this connection already sells at
        the gateway and turns it into plans, prices and their mappings here.
        Answers **202**: the listing is accepted and durable, and none of it has
        been applied yet — a worker applies the lines in batches and the report
        fills in as it goes.


        It is one-directional. Nothing here creates, renames or archives
        anything at the gateway: your catalogue is the source of truth for what
        you sell, the gateway is how it gets charged, and pushing rows outward
        would give one object two owners.


        ## The connection has to be usable


        A connection that is not `connected` refuses the import with **409** and
        the code `provider_connection_not_ready` — and the message names which
        repair that status calls for, because "not connected" is not something
        you can act on: `invalid_credentials` means paste a new key,
        `mode_mismatch` means paste the *other* key, and `account_not_ready`
        means the fix is in the gateway's own dashboard and the key is fine. Run
        `POST .../health` first.


        ## What a line does


        A gateway **product** becomes a plan, named after the product and
        addressed by a handle derived from that name — `Pro Anual` becomes
        `pro-anual`. A plan's key cannot be renamed afterwards, so it is derived
        rather than invented; a name that yields no usable handle falls back to
        the product's own id, which is ugly and visibly the gateway's own name
        for the thing.


        A **price** becomes a catalogue price and is born mapped: it carries the
        gateway's price id, so a checkout can charge it immediately. Outcomes
        per line are `created` (the price did not exist here), `mapped` (it did,
        said the same thing, and now knows how to be charged), `matched` (it was
        already imported — nothing was written), `conflict` and `failed`.


        **Running it twice is free.** A gateway price that is already mapped is
        `matched` and nothing is written.


        ## What it refuses to decide


        A catalogue price cannot be edited — the money is what somebody agreed
        to pay — so an import never becomes an update. Where a line and this
        catalogue disagree, the line is **reported** and the repair is yours:


        - `offer_taken` — this plan already sells a live price for that currency
        and recurrence, at a different amount or a different tax behaviour.
        Choosing between two prices is a repricing, which is a commercial
        decision; use `POST /v1/organization/catalogue/prices/{id}/reprice` if
        that is what you meant.

        - `plan_conflict` — the product's prices are already mapped onto one
        plan while the handle its name derives to belongs to another. Both plan
        ids are reported, because deciding which plan the product is takes a
        person.

        - `already_mapped` — the local price for that offer is already mapped to
        a different id at this gateway. Re-pointing it would silently move what
        a checkout charges.

        - `product_spans_plans`, `plan_archived`, `mapped_price_disagrees`,
        `mapped_in_another_environment`.


        And a line this catalogue cannot hold is `failed` with the reason, never
        a guess: `tax_behavior_unstated` (whether an amount includes tax is an
        input only the person who typed it knows), `no_unit_amount` (a tiered or
        usage table is not one number), `metered_price`, `usage_unstated`,
        `kind_unstated`, `unsupported_currency`, `product_unnamed`,
        `one_time_with_interval`.


        ## Nothing is charged and nothing is mailed


        An import is a read followed by local writes.
      operationId: importProviderCatalogue
      parameters:
        - $ref: '#/components/parameters/OrganizationHeader'
        - $ref: '#/components/parameters/EnvironmentId'
        - $ref: '#/components/parameters/BillingProvider'
      responses:
        '202':
          description: >-
            Accepted. Nothing has been applied yet — poll the import to watch
            it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  import:
                    $ref: '#/components/schemas/CatalogueImport'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >-
            The environment does not exist, `unknown_provider`, or `not_found` —
            nothing is connected.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >-
            `provider_connection_not_ready` — the connection cannot be used, and
            the message names the repair. `provider_account_unknown` — the
            connection has never reported which merchant account it opens, and a
            gateway price id is only unique inside one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: >-
            `too_many_prices` — the account lists more active prices than one
            import carries; archive what is no longer sold, or create the plans
            by hand. `listing_did_not_end` — the gateway kept reporting more
            prices than it delivered, so the listing was stopped rather than
            looped.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '501':
          description: >-
            `billing_provider_unavailable` — reading a stored credential is
            unavailable on the server. `provider_unavailable` — the server has
            no adapter for that gateway. `catalogue_listing_unsupported` — this
            gateway's catalogue cannot be enumerated, which is not a broken
            gateway: the simplest ones have a price and no catalogue object
            above it. Create the plans here and map their prices instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            `provider_unreachable` — the catalogue could not be read. Nothing
            was imported and nothing about the connection changed; 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.
    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:
    CatalogueImport:
      type: object
      description: >-
        One import of a gateway's catalogue. Progress is derived from its lines
        on every read rather than accumulated as the worker goes — the tallies
        are a query, never a counter.
      properties:
        id:
          type: string
          format: uuid
        provider:
          type: string
          example: stripe
        provider_account_id:
          type: string
          description: >-
            The merchant account the listing was read from, fixed when the
            import was created. A gateway price id is only unique inside one
            account, so it is recorded here rather than re-read later: a key
            rotated onto a different account mid-run must not finish an import
            with ids that were never unique inside it.
        status:
          type: string
          enum:
            - pending
            - processing
            - completed
        total:
          type: integer
          description: Prices the listing carried, fixed when the import was created.
        pending:
          type: integer
          description: Lines not applied yet.
        created:
          type: integer
          description: Prices that did not exist here and now do.
        mapped:
          type: integer
          description: >-
            Prices you had already typed in here that now carry the gateway's
            id, so a checkout can charge them. Kept apart from `created` because
            for anybody who built their catalogue before connecting, this number
            is the whole value of importing.
        matched:
          type: integer
          description: >-
            Prices already imported. Nothing was written for them, which is what
            makes running an import twice free.
        conflicts:
          type: integer
          description: >-
            Lines this catalogue could hold but not without deciding something.
            Nothing was written for them; the report names both sides.
        failed:
          type: integer
          description: >-
            Lines the catalogue cannot represent — an unstated tax behaviour, no
            single amount, a usage rate.
        created_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true
    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
  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.

````