> ## 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 contacts from a CSV

> Requires `customers:write`. Answers **202**: the file 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.

Send the file as `text/csv`, or as JSON with the contents in a `csv` field.

## What the header decides

`email` and `external_id` are the identifiers, and the file needs at least one of the two columns. Without either, every line would fail for the same reason, so the file is refused instead of imported into a report of identical failures.

`name`, `customer`, `customer_role` and the first-touch columns — `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `referrer`, `landing_page` — are read when present. Everything else is ignored, because a real export carries a dozen columns this has no place for.

## What a line does

A line naming somebody who does **not** exist here creates them, with everything the line carried.

A line naming somebody who **already** exists matches them and leaves their profile alone. An export from your previous provider is not evidence about a contact this environment already has — their name and address are where their own security notices go, and a stale row is not a reason to redirect them.

A line whose `email` belongs to one contact and whose `external_id` belongs to **another** is reported as a `conflict`, and nothing is written for it. Identifying values never re-point, and there is no un-merge — so an import that resolved collisions on its own would produce wrong merges in bulk, at the exact moment somebody is trusting it with their whole audience. The report names **both** contacts, which is what `POST /v1/organization/contacts/{id}/merge` takes.

A line that cannot be read is reported and the import keeps going. Stopping at line 4,312 is useless to somebody migrating; ignoring it silently is worse, because they finish believing everybody arrived.

## Two columns are refused by name

`email_verified` — and `verified`, `email_verified_at` — because an import cannot prove an address. Nothing was sent and nobody came back, so an imported contact arrives with `email_verified: false`, and the first magic link or email code to that address is what flips it. The column is refused rather than ignored so the position is discoverable: `email_verified` is what a session JWT's claim and the SDK's `<Verified>` guard read, and a spreadsheet may not decide either of those.

`password` and `password_hash`, because importing credentials is not part of a CSV import. Dropping the column silently is how a migration discovers on launch day that nobody can sign in.

## No email is sent

Not one message, of any kind. An import is the fastest way to mail ten thousand addresses that have never heard from you, and one of those ends a sending domain's reputation. The report is where the outcome lives instead.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/organization/contact-imports
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/contact-imports:
    post:
      tags:
        - Contacts (staff session)
      summary: Import contacts from a CSV
      description: >-
        Requires `customers:write`. Answers **202**: the file 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.


        Send the file as `text/csv`, or as JSON with the contents in a `csv`
        field.


        ## What the header decides


        `email` and `external_id` are the identifiers, and the file needs at
        least one of the two columns. Without either, every line would fail for
        the same reason, so the file is refused instead of imported into a
        report of identical failures.


        `name`, `customer`, `customer_role` and the first-touch columns —
        `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`,
        `referrer`, `landing_page` — are read when present. Everything else is
        ignored, because a real export carries a dozen columns this has no place
        for.


        ## What a line does


        A line naming somebody who does **not** exist here creates them, with
        everything the line carried.


        A line naming somebody who **already** exists matches them and leaves
        their profile alone. An export from your previous provider is not
        evidence about a contact this environment already has — their name and
        address are where their own security notices go, and a stale row is not
        a reason to redirect them.


        A line whose `email` belongs to one contact and whose `external_id`
        belongs to **another** is reported as a `conflict`, and nothing is
        written for it. Identifying values never re-point, and there is no
        un-merge — so an import that resolved collisions on its own would
        produce wrong merges in bulk, at the exact moment somebody is trusting
        it with their whole audience. The report names **both** contacts, which
        is what `POST /v1/organization/contacts/{id}/merge` takes.


        A line that cannot be read is reported and the import keeps going.
        Stopping at line 4,312 is useless to somebody migrating; ignoring it
        silently is worse, because they finish believing everybody arrived.


        ## Two columns are refused by name


        `email_verified` — and `verified`, `email_verified_at` — because an
        import cannot prove an address. Nothing was sent and nobody came back,
        so an imported contact arrives with `email_verified: false`, and the
        first magic link or email code to that address is what flips it. The
        column is refused rather than ignored so the position is discoverable:
        `email_verified` is what a session JWT's claim and the SDK's
        `<Verified>` guard read, and a spreadsheet may not decide either of
        those.


        `password` and `password_hash`, because importing credentials is not
        part of a CSV import. Dropping the column silently is how a migration
        discovers on launch day that nobody can sign in.


        ## No email is sent


        Not one message, of any kind. An import is the fastest way to mail ten
        thousand addresses that have never heard from you, and one of those ends
        a sending domain's reputation. The report is where the outcome lives
        instead.
      operationId: createContactImport
      parameters:
        - $ref: '#/components/parameters/OrganizationHeader'
        - name: environment
          in: query
          required: true
          schema:
            type: string
            enum:
              - live
              - test
          description: >-
            Which environment the contacts land in. **Required here**, unlike
            every read on this surface: a read of the wrong environment is a
            wasted click, and ten thousand contacts written into the wrong one
            cannot be un-written.
        - name: filename
          in: query
          required: false
          schema:
            type: string
          description: >-
            What to call the file in the report. Only read with a `text/csv`
            body; the JSON body carries its own `filename`.
      requestBody:
        required: true
        content:
          text/csv:
            schema:
              type: string
              example: |
                email,name,external_id,customer
                ana@example.com,Ana,usr_1,Acme Inc
                bia@example.com,Bia,usr_2,Acme Inc
          application/json:
            schema:
              type: object
              required:
                - csv
              properties:
                csv:
                  type: string
                  description: The file's contents.
                filename:
                  type: string
      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/ContactImport'
        '400':
          description: >-
            `environment_required` — the environment was not named.
            `no_identifier_column` — the header has neither `email` nor
            `external_id`. `empty_file`, `unreadable_file`, `invalid_request`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          description: >-
            `file_too_large` or `too_many_rows` — split the file and import the
            parts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: >-
            `unimportable_column` — the file asks for something an import may
            not do. The message names the column and says why.
          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.
  schemas:
    ContactImport:
      type: object
      description: >-
        One CSV import. Progress is derived from its lines on every read rather
        than accumulated as the worker goes — the tallies are a query, never a
        counter, because a counter maintained by a job the queue may retry
        double-counts.
      properties:
        id:
          type: string
          format: uuid
        filename:
          type: string
        columns:
          type: array
          items:
            type: string
          description: >-
            The header as it arrived, so the report can say what was understood
            without keeping the file.
        status:
          type: string
          enum:
            - pending
            - processing
            - completed
        total:
          type: integer
          description: Lines the parse counted, fixed at upload.
        pending:
          type: integer
          description: Lines not applied yet.
        created:
          type: integer
          description: Contacts that did not exist here and now do.
        matched:
          type: integer
          description: Contacts that already existed. Their profiles were left alone.
        conflicts:
          type: integer
          description: >-
            Lines naming two different existing contacts. Nothing was written
            for them; the repair is a manual merge.
        failed:
          type: integer
          description: Lines that could not be read or could not be applied.
        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.

````