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

# Importing contacts

> Bring your existing users over from a CSV — and what an import will not pretend it knows.

You already have users. Moving them here is one upload, and this page is mostly
about the two things the import refuses to do on your behalf, because both of
them are things you cannot undo afterwards.

## The shape of it

```bash theme={null}
curl -X POST "$API/v1/organization/contact-imports?environment=live&filename=incumbent.csv" \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: $ORG" \
  -H "Content-Type: text/csv" \
  --data-binary @incumbent.csv
```

```json theme={null}
{
  "import": {
    "id": "8c1f…",
    "filename": "incumbent.csv",
    "columns": ["email", "name", "external_id", "customer"],
    "status": "pending",
    "total": 4312,
    "pending": 4312,
    "created": 0,
    "matched": 0,
    "conflicts": 0,
    "failed": 0
  }
}
```

The answer is **202**, not 201: the file is accepted and durable, and none of it
has been applied yet. A worker applies the lines in batches; poll
`GET /v1/organization/contact-imports/{id}` and the tallies fill in.

`environment` is **required** here. Every other read on this surface defaults to
`live`, and this one refuses to — reading the wrong environment is a wasted
click, and ten thousand contacts written into the wrong one cannot be
un-written.

The panel does the same thing from **Contacts → Import**, and it names the
environment on the confirmation for the same reason.

## Columns

`email` and `external_id` are the identifiers. Your file needs at least one of
the two columns; without either, every line would fail for the same reason, so
the file is refused rather than turned into a report of identical failures.

| Column                                                                                            | Read as                              |
| ------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `email`, `email_address`, `e-mail`                                                                | The address, as an identity          |
| `external_id`, `id`, `user_id`                                                                    | Your own primary key for this person |
| `name`, `full_name`, `nome`                                                                       | Their name                           |
| `customer`, `company`, `team`, `account`                                                          | The team they belong to              |
| `customer_role`                                                                                   | `owner` or `member`                  |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `referrer`, `landing_page` | First-touch attribution              |

Matching ignores case and reads spaces, dashes and dots as underscores, so
`First Name` and `first_name` are one column. Anything the table does not name is
ignored — a real export carries a dozen columns this has nowhere to put.

`id` maps to `external_id` on purpose: it is what most providers call their own
primary key, and from here that is exactly what it is.

## What a line does

**Nobody by that identity yet** — the contact is created, with everything the
line carried, including first-touch attribution. That last part is worth
noticing: this is the only moment where a contact is born long after the visit,
so the file gets to say where the person came from instead of having "csv"
invented for them.

**Somebody already here** — the line **matches** them and leaves their profile
alone. Your previous provider's export 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. If the line brings
an identifier that is free — an `external_id` for somebody we only knew by email
— that edge attaches.

**A line naming two different people** — reported as a `conflict`, and nothing
is written for it.

That last one is the decision the whole feature is built around, so it is worth
a heading.

## A collision is reported, never resolved

Identifying values never re-point. An `email` or an `external_id` that already
belongs to another contact is a **duplicate to merge by hand**, and there is no
un-merge — the merge record exists precisely so a wrong one can be repaired by
hand rather than by restore.

An import of ten thousand lines is the fastest way ever invented to create wrong
merges in bulk. So it creates none. When a line's `email` belongs to one contact
and its `external_id` belongs to another, the line lands in the report as a
`conflict` naming **both** ids, and nothing is written:

```bash theme={null}
curl -s "$API/v1/organization/contact-imports/$ID?environment=live&outcome=conflict" \
  -H "Authorization: Bearer $UK_SESSION" -H "X-Organization-Id: $ORG"
```

```json theme={null}
{
  "rows": [
    {
      "line": 812,
      "email": "grace@example.com",
      "external_id": "user_8421",
      "status": "conflict",
      "error_code": "identity_conflict",
      "error": "the email and the external_id on this line belong to two different contacts — merge them by hand and import the line again",
      "contact_id": "3f9a…",
      "conflicting_contact_id": "b711…"
    }
  ]
}
```

Both ids are there because the repair takes two: pass them to
`POST /v1/organization/contacts/{id}/merge` — `{id}` is the row that disappears,
`target_id` in the body is the survivor — and then import that line again. A
report naming one of the pair would be a report nobody could act on.

## An import cannot prove an address

`email_verified` on a contact means somebody proved they read mail at that
address: we sent something there and they came back. A file cannot produce that.

So **every imported contact arrives with `email_verified: false`**, and there is
no parameter, header or column that changes it. A CSV column called
`email_verified` is refused by name, with a `422` explaining why — refused rather
than ignored, because you put it there for a reason and silence would let you
believe you got it.

That matters more than it looks, because `email_verified` is what a
[session JWT's claim](/en/customer-auth/session-tokens) carries and what the
SDK's `<Verified>` guard reads. The moment one import could set it, both of those
would be statements about a spreadsheet.

Which leaves the honest question: **what happens to the people you just
imported?**

* They are **identified**. `boot` finds them, sign-in finds them, `<SignedIn>`
  sees them, and they appear in your contact list. Being unverified is not being
  unknown.
* The first [magic link or email code](/en/customer-auth/hosted) to that address
  **proves** it and flips the flag — permanently, on first use. Those two flows
  exist for exactly this, which is also why they work in both hosted and
  federated mode. A single "sign in with a link" is your whole verification
  backfill, one person at a time, paid for by the people who actually come back.
* In [federated mode](/en/customer-auth/federated), `email_verified` is not on
  the path at all: the HMAC proves the `external_id`, and the address is an
  attribute. If your product keeps its own login, importing changes nothing about
  who can get in.
* If `<Verified>` currently gates something your imported users need on day one,
  gate it on `<SignedIn>` instead and put the address proof where it belongs — in
  front of the screens where another person's data could leak through. That is
  the boundary `<Verified>` was drawn for.

Passwords are the same posture: `password` and `password_hash` are refused by
name. Credential migration is its own project, and quietly dropping the column is
how a migration discovers on launch day that nobody can sign in.

## Nothing is mailed

Not one message, of any kind, to anybody in the file. An import is the fastest
way to send ten thousand emails to addresses that have never heard from you, and
one of those ends a sending domain's reputation — yours and everybody else's on
the platform. The report is where the outcome lives instead.

If you want the people you imported to hear from you, that is a deliberate act
you perform afterwards, at whatever rate your sending domain can carry.

## Bad lines are reported, not fatal

A line that cannot be read does not stop the import. Stopping at line 4,312 is
useless to somebody migrating; ignoring it silently is worse, because you would
finish believing everybody arrived.

```bash theme={null}
curl -s "$API/v1/organization/contact-imports/$ID?environment=live&outcome=failed" \
  -H "Authorization: Bearer $UK_SESSION" -H "X-Organization-Id: $ORG"
```

| `error_code`                     | The line                                     |
| -------------------------------- | -------------------------------------------- |
| `no_identifier`                  | Carries neither an email nor an external\_id |
| `invalid_email`                  | Has an address that is not one               |
| `invalid_customer_role`          | Names a role that is not `owner` or `member` |
| `customer_role_without_customer` | Names a role on no team                      |
| `malformed_row`                  | Does not line up with its own header         |
| `identity_conflict`              | Names two different existing contacts        |
| `apply_failed`                   | Could not be applied after three attempts    |

Everything except `identity_conflict` and `apply_failed` is decided while the
file is being read, which is why the failures are already in the report when the
upload answers.

## Teams

A `customer` column puts people on teams. The name is matched within the
environment — the only handle a spreadsheet has on a team — and a team that does
not exist is created. The oldest match wins when two teams share a name, so
importing the same file twice converges on one team instead of forking a new one.

The first person into a **newly created** team becomes its `owner` unless the
file says otherwise: a team must have one, and the file offers exactly one
candidate. Somebody already on a team keeps the role they already hold — a file
may put a person on a team, it may not re-role them.

## Running it twice is safe

Nothing dedupes on the file's bytes, so uploading the same CSV twice produces two
imports. It also produces no second audience: every line matches the contact the
first run created, so the second report reads `matched` from top to bottom.

The same property is what makes an interrupted import resumable rather than
restarted. Progress lives on the lines, not in a message — a worker that dies
mid-file leaves the applied lines terminal and the rest pending, and the next
pass continues from there.

## Size

One upload carries up to **50,000 lines** or **8 MiB**, whichever comes first.
Past either the file is refused whole with a `413`, which is the one place
refusing the file rather than the line is right: there is nothing to report about
a line that was never read. Split it and import the parts.

## Events

Every contact the import creates publishes `contact.identified`, the same fact
every other door publishes — so your automations do not need to know an import
happened.

When the last line goes terminal, the import publishes
`contact_import.completed` once, with the tallies. If you have a
[webhook endpoint](/en/guides/webhooks) and you want the starting gun without ten
thousand individual facts on migration day, subscribe it to that type and not to
`contact.identified`.
