The shape of it
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 — anexternal_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. Anemail 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:
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.
bootfinds 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_verifiedis not on the path at all: the HMAC proves theexternal_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.
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
Acustomer 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 readsmatched 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 a413, 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 publishescontact.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.