Skip to main content

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.

Written for whoever runs IT9 min readUpdated

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

Bulk import endpoints — both require the users write permission
POST /api/v1/organizations/{orgID}/users/bulk-import        JSON body
POST /api/v1/organizations/{orgID}/users/bulk-import/csv    the CSV IS the body

The 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

Request-level validation. All are HTTP 400 unless stated, and all come back as {"error":"<message>"}.
MessageEndpointCause
invalid bodyJSONThe 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 requiredJSONThe 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 batchJSONMore than 1,000 entries. Split the file. This is the only place the thousand-row limit is enforced honestly.
failed to read CSV headerCSVThe 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 columnsCSVThe 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)bothYour role lacks the users write permission. See Insufficient permissions.
A raw internal error message (500)bothA 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 for already exists rather 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.

Four failure modes that report success. Each is a defect on our side rather than something you configured wrongly.
What happensWhat you seeWhat 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

  1. Check your file before you send it

    Confirm the header row is exactly email,password,first_name,last_name in 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.

  2. Keep each batch under a thousand rows

    Not at a thousand — under it. That way a truncation cannot happen silently.

  3. Reconcile three numbers afterwards

    Rows in your file, created in the response, and failed in the response. If created plus failed is less than the row count, the difference is malformed rows you were not told about.

  4. Then verify the people actually exist

    GET /api/v1/organizations/{orgID}/users should 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.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in troubleshooting