A bulk import was rejected
Every validation message verbatim — and the four ways a bulk import reports success while silently doing nothing useful. Use invitations instead, and here is why.
The bulk-import endpoints take a list of people and create accounts for them. This article gives every validation message they can return, verbatim, so you can match what you saw — and then a longer section on the failures that produce no message at all, which are the ones that actually cost people time.
The rest of this article is still worth having: the endpoints exist, some people are using them, and knowing what the messages mean is better than guessing. But if you are choosing an approach today rather than debugging one, stop here and use invitations.
The two endpoints and what they expect
POST /api/v1/organizations/{orgID}/users/bulk-import JSON body
POST /api/v1/organizations/{orgID}/users/bulk-import/csv the CSV IS the bodyThe CSV endpoint takes the file as the raw request body, not as a multipart form upload. Posting a multipart form to it fails at the header read. The JSON body is {"users": [ {"email": "...", "password": "...", "first_name": "...", "last_name": "..."} ], "invited": false}. Setting invited to true routes each row through the invitation path instead, so no password is needed.
For CSV: the first row must be a header row. Columns are matched by name and case-sensitively — email and password are required, first_name and last_name are optional, and the order does not matter. Email with a capital E is not email, and a spreadsheet that helpfully title-cases your headers on export will produce a file that does not work.
The loud failures: every validation message
| Message | Endpoint | Cause |
|---|---|---|
invalid body | JSON | The body did not parse. A trailing comma, a smart quote pasted from a document, or a file sent where JSON was expected. |
users array is required | JSON | The users array is absent or empty. Note that an empty array is an error here, while an empty CSV is a success — see the silent failures below. |
maximum 1000 users per batch | JSON | More than 1,000 entries. Split the file. This is the only place the thousand-row limit is enforced honestly. |
failed to read CSV header | CSV | The first row could not be read at all — an empty body, a multipart form, a binary spreadsheet file rather than a CSV, or a file whose first line is malformed. |
CSV must contain email and password columns | CSV | The header row has neither an email nor a password column. Read the next section carefully: this check only fires if the file has at least one data row. |
insufficient permissions (403) | both | Your role lacks the users write permission. See Insufficient permissions. |
| A raw internal error message (500) | both | A database or driver message passed straight through. Ours. Send it to us — it will often name a table or a constraint, which is more than you should be seeing. |
Rows that fail individually do not stop the batch. They are collected into an errors array in the response, and there are exactly three forms:
missing email or password for entry— one of the two required fields was blank. It names neither the address nor the row number, so with twenty of these you know that twenty rows are bad and nothing about which. Check your file for blank cells before importing rather than after.hash failed for [email protected]: <reason>— the password could not be hashed. Rare.[email protected]: user email [email protected]: already exists— a duplicate address. Note the doubled address: that is genuinely what the string looks like, because the row prefix and the underlying error both name it. If you are searching your logs for this, search foralready existsrather than trying to reproduce the whole shape.
The response is HTTP 200 in all of these cases, with a body of {"created": N, "failed": N, "errors": [...], "user_ids": [...]}. A partial success is the normal outcome, so always read the body; the status code alone tells you nothing.
The silent failures, which matter more
Four things go wrong without producing any message at all. These are the ones that lose an afternoon, because the response says 200 and the numbers look plausible.
| What happens | What you see | What to do |
|---|---|---|
| The file is truncated at 1,000 rows. The CSV reader stops at a thousand and discards the rest of the body. | {"created": 1000, "failed": 0} and no mention of the remainder. A 2,500-row file quietly becomes a 1,000-row import. | Split files into batches of under a thousand yourself, and reconcile the count afterwards. Do not rely on the endpoint to tell you it stopped early — the JSON endpoint refuses a batch over a thousand with a clear error, and the CSV endpoint silently trims one. Same limit, opposite behaviour. |
| Malformed rows are dropped from both counts. A row with the wrong number of fields, or broken quoting, is skipped. | It is not created, not counted as failed, and produces no entry in errors. created plus failed will be less than the number of rows in your file. | Compare the row count of your file against created + failed. Any difference is malformed rows. The usual causes are an unescaped comma inside a name and a stray quotation mark. |
| A header-only file with the wrong columns returns success. The column check sits inside the row loop, so a file with no data rows never reaches it. | {"created": 0, "failed": 0} and HTTP 200. The same file with one data row added returns 400 CSV must contain email and password columns. | Always confirm created is the number you expected. A zero-created success is the shape of this failure. |
| Imported users get no organisation membership. | Accounts exist, created is correct, and none of the people appear anywhere in your console or can sign in. | There is no fix from your side. Use invitations. If you have already imported, the accounts exist and can be given memberships individually, but at that point invitations would have been less work. |
One more thing to weigh before using the password path at all: it requires you to choose everybody's initial password and send it to us in the request body. That means the passwords exist in cleartext in your CSV file, in your shell history if you used a command line, and in whatever proxy or log sat between you and us. Invitations avoid this entirely, because each person sets their own password on a link that only they receive.
If you have to use it anyway
Check your file before you send it
Confirm the header row is exactly
email,password,first_name,last_namein lower case, that no cell containing a comma is left unquoted, and that no required cell is blank. Count the data rows and write the number down.Keep each batch under a thousand rows
Not at a thousand — under it. That way a truncation cannot happen silently.
Reconcile three numbers afterwards
Rows in your file,
createdin the response, andfailedin the response. If created plus failed is less than the row count, the difference is malformed rows you were not told about.Then verify the people actually exist
GET /api/v1/organizations/{orgID}/usersshould list them. If it does not, you have hit the membership defect and the import achieved nothing usable. Do not try again with different options; there are none that help.
- Inviting your team — the route that works.
- Bulk importing users — the reference for the endpoints themselves.
- SCIM inbound provisioning — if your people already exist in a directory, provisioning them from it is better than importing a file at all.