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.
Authorizations
A staff session token, uk_st_…. Minted by sign-up, sign-in or the two-factor exchange.
Headers
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.
Query Parameters
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.
live, test What to call the file in the report. Only read with a text/csv body; the JSON body carries its own filename.
Body
The body is of type string.
"email,name,external_id,customer\nana@example.com,Ana,usr_1,Acme Inc\nbia@example.com,Bia,usr_2,Acme Inc\n"
Response
Accepted. Nothing has been applied yet — poll the import to watch it.
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.