Skip to main content
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

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. 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:
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 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 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, 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.
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 and you want the starting gun without ten thousand individual facts on migration day, subscribe it to that type and not to contact.identified.