Guides
Registering many students at once
createStudentImport registers 30 to 1000 students in one request. Every row follows
registerStudent's rules exactly, so read
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
- You send the rows. The whole batch is checked while you wait: every field, every
country,grade,cityandschool, and every email address. - If anything is wrong, you get
422and nothing is queued.error.details.rowslists every row at fault, so one round trip tells you everything to fix. - If everything is right, you get
202with an import, and theLocationheader is where to read it. - You poll
getStudentImportevery few seconds untilstatusis no longerqueuedorrunning. - On
succeeded, every row carries its student's_id. Keep them.
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:
{
"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:
{
"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:
createSigninLinksends one of them straight into a panel with a single-use link. See Sign-in links.setStudentPasswordsets a password per student afterwards, if you really want one. See Student 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 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:
{
"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.
{
"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.
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.
"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
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.