# Registering many students at once

> Send a class of students in one request, follow the import while it runs, and fix the rows it refuses. All of them are registered, or none.

`createStudentImport` registers 30 to 1000 students in one request. Every row follows
[`registerStudent`](https://hub.main-team.org/api/reference/register-student)'s rules exactly, so read
[Students](https://hub.main-team.org/api/guides/students) first: this is the same registration, a class at a time.

Use it when you have a roster. For a handful of students, call `registerStudent` per student —
an import is queued and answered before it runs, and for five students the wait is not worth it.

## How it works

1. You send the rows. The whole batch is checked while you wait: every field, every `country`,
   `grade`, `city` and `school`, and every email address.
2. If anything is wrong, you get `422` and nothing is queued. `error.details.rows` lists every row
   at fault, so one round trip tells you everything to fix.
3. If everything is right, you get `202` with an import, and the `Location` header is where to read
   it.
4. You poll `getStudentImport` every few seconds until `status` is no longer `queued` or `running`.
5. On `succeeded`, every row carries its student's `_id`. Keep them.

```bash
curl -sS -X POST https://api.main-team.org/v1/student/import \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"clientReference":"year-10-autumn","students":[ ... 30 to 1000 rows ... ]}'
```

The answer:

```json
{
  "success": true,
  "message": "Import accepted. The students are registered in the background; read the import to follow it.",
  "data": {
    "_id": "65f0c2a1d3e4f5a6b7c8d901",
    "status": "queued",
    "total": 250,
    "registered": 0,
    "clientReference": "year-10-autumn",
    "students": [],
    "createdAt": "2026-10-06T08:15:00.000Z",
    "expiresAt": "2026-11-05T08:15:00.000Z"
  }
}
```

## All of them, or none of them

The students are written in **one transaction**. A batch registers every row or registers nothing;
there is no partial import to reconcile and nothing to undo.

That is the whole reason this operation exists rather than a loop over `registerStudent`. Half a
class is worse than none: it leaves you reconciling two systems by hand, with welcome mail already
sent to some of the children and not others.

So when `status` is `failed`, no student of that batch was registered, every row reads `skipped`,
and `failure` says why. Fix what it names and send the batch again.

## A row

A row is `registerStudent`'s body without `password`, plus an optional `externalRef` of your own:

```json
{
  "firstName": "Jane",
  "lastName": "Doe",
  "birth": "14/05/2008",
  "sex": "f",
  "email": "jane.doe@example.com",
  "country": "64b7f0c2a1d3e4f5a6b7c8d9",
  "grade": "10",
  "city": "Springfield",
  "school": "Springfield High School",
  "externalRef": "roster-2026-114"
}
```

`externalRef` is yours: it is echoed back on the row when you read the import, so you can line the
answer up with your spreadsheet without matching on the address. It is not stored on the student,
nothing reads it, and two rows may carry the same one.

`activatedPlatformsThisSeason` works exactly as in `registerStudent`, per row, and defaults to
`["common"]`.

## No passwords

A row does not take `password`, and sending one is refused with `400`.

Hashing a password is deliberately slow — that is what makes a stolen database useless — so a
thousand of them would keep your batch waiting, and until they were hashed the passwords you sent
would sit in a queue. Neither is a trade worth making for a convenience.

Sign the students in instead:

- [`createSigninLink`](https://hub.main-team.org/api/reference/create-signin-link) sends one of them straight into a panel
  with a single-use link. See [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links).
- [`setStudentPassword`](https://hub.main-team.org/api/reference/set-student-password) sets a password per student
  afterwards, if you really want one. See [Student passwords](https://hub.main-team.org/api/guides/passwords).

## The welcome email

Each student gets the same welcome email `registerStudent` sends, one per student, with the same
text. Bulk registration changes nothing about it.

That is worth a thought before you send a roster: the addresses in it are the ones you typed, and
every typo is a message to a stranger. Check the batch with
[`checkStudentRegistration`](https://hub.main-team.org/api/reference/check-student-registration) while you are still
building it, row by row, if you are not sure of your data.

## When a batch is refused

`422`, nothing queued, and every row at fault named:

```json
{
  "error": {
    "code": "unprocessable_entity",
    "message": "3 of 250 rows cannot be registered. Nothing was registered and no import was queued; error.details.rows lists every problem.",
    "documentation_url": "https://hub.main-team.org/api/errors#unprocessable_entity",
    "request_id": "0b5c6d0e-8f7a-4b1c-9d2e-3f4a5b6c7d8e",
    "details": {
      "total": 250,
      "rejected": 3,
      "truncated": false,
      "rows": [
        { "row": 4, "field": "birth", "code": "invalid_field", "message": "birth must be a real date in DD/MM/YYYY format" },
        { "row": 17, "field": "email", "code": "email_taken_by_your_student", "message": "One of your students already has this email address.", "studentId": "652f1c9b8e4b2a0012a3c4d5" },
        { "row": 31, "field": "email", "code": "duplicate_in_request", "message": "Row 12 has the same email address.", "duplicateOf": 12 }
      ]
    }
  }
}
```

`row` counts from 0, so it is the index in the `students` array you sent. Branch on `code`, never
on `message`:

| `code` | What to do |
| --- | --- |
| `invalid_field` | The row breaks one of `registerStudent`'s rules. `field` says which property. |
| `unexpected_field` | The row carries a property this operation does not accept, `password` among them. |
| `unknown_reference` | The `country`, `grade`, `city` or `school` matches nothing. Nothing is ever created for you; fix the name or send an id. |
| `duplicate_in_request` | Two rows in your own body have the same address. `duplicateOf` is the earlier one. |
| `email_taken_by_your_student` | One of your students already has the address. `studentId` is theirs — update them with `updateStudent` instead of registering a second record. |
| `email_unavailable` | The address cannot be registered. The answer never says who holds it. |

`truncated` is `true` when `rows` is shorter than `rejected`: fix what is listed and send the batch
again to see the rest. `rows` can also be empty with `rejected` counted, when the only problem is
unavailable addresses and your account has already been shown those rows several times that day.
Sending rosters of addresses you have not collected yourself is not a supported use of this API.

**A field mistake is answered earlier, with `400`.** A row that breaks a property's own rule — a
birth date that is not a date, a missing surname — is refused before the batch is checked at all,
and the message names the row and the property, such as
`students.4.birth must be a real date in DD/MM/YYYY format`. The `422` is for everything that takes
a lookup to see.

## Following an import

`getStudentImport` returns the import and one page of its rows, in the order you sent them. Page it
with `page` and `limit`, as every list operation is paged; see [Pagination](https://hub.main-team.org/api/pagination).

```json
{
  "success": true,
  "message": "Import fetched successfully.",
  "data": {
    "_id": "65f0c2a1d3e4f5a6b7c8d901",
    "status": "succeeded",
    "total": 250,
    "registered": 250,
    "students": [
      { "row": 0, "email": "jane.doe@example.com", "externalRef": "roster-2026-114", "status": "registered", "studentId": "652f1c9b8e4b2a0012a3c4d5" }
    ],
    "createdAt": "2026-10-06T08:15:00.000Z",
    "startedAt": "2026-10-06T08:15:04.000Z",
    "finishedAt": "2026-10-06T08:15:09.000Z",
    "expiresAt": "2026-11-05T08:15:09.000Z"
  },
  "pagination": { "page": 1, "limit": 20, "total": 250, "totalPages": 13 }
}
```

`status` is one of:

| `status` | Meaning |
| --- | --- |
| `queued` | Accepted, not started. |
| `running` | Being registered now. |
| `succeeded` | Every row registered. Each carries its `studentId`. |
| `failed` | No row registered. `failure` says why, and the rows that explain it carry an `error`. |
| `cancelled` | Stopped by an operator. No row registered. |

An import stops being readable 30 days after it finishes, and then answers `404` exactly like one
that never existed. The students stay registered — keep the ids.

## Limits

| Limit | Value |
| --- | --- |
| Rows per request | 30 to 1000 |
| Body size | 1.5 MB on this operation; 100 kB on every other |
| Requests | 10 per hour per account |
| Imports running | one per account at a time |

A second import while one is unfinished is refused with `409`, and the message carries the id of
the one that is running. Read it, and send the next batch when it has finished. See
[Rate limits](https://hub.main-team.org/api/rate-limits).

## Sending the same batch twice is safe

The same rows from your account inside 24 hours answer with the import you already have, not a
second one. The `message` says so, and `data._id` is the id you already had.

So a request whose answer you never saw — a timeout, a dropped connection — can simply be sent
again. See [Retries and idempotency](https://hub.main-team.org/api/retries-and-idempotency).

"The same rows" means exactly that: the rows, with addresses compared without regard to case or
surrounding spaces, and `clientReference` ignored. Change one row and it is a new batch.

## A worked run

```js
const submit = await fetch('https://api.main-team.org/v1/student/import', {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ clientReference: 'year-10-autumn', students: rows }),
});

if (submit.status === 422) {
  const { error } = await submit.json();
  for (const row of error.details.rows) {
    console.log(`row ${row.row}: ${row.code} (${row.field}) — ${row.message}`);
  }
  throw new Error('fix the rows and send it again');
}

const { data: queued } = await submit.json();

let job = queued;
while (job.status === 'queued' || job.status === 'running') {
  await new Promise((wake) => setTimeout(wake, 3000));
  const poll = await fetch(
    `https://api.main-team.org/v1/student/import/${queued._id}?limit=100`,
    { headers: { Authorization: `Bearer ${token}` } },
  );
  ({ data: job } = await poll.json());
}

if (job.status !== 'succeeded') throw new Error(job.failure.message);
```

Page through the rows afterwards for the ids, then send each student a sign-in link or apply them to
an exam. See [Applications](https://hub.main-team.org/api/guides/applications).
