Skip to content
API documentation
View as MarkdownOpen in Claude

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

  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.
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:

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:

codeWhat to do
invalid_fieldThe row breaks one of registerStudent's rules. field says which property.
unexpected_fieldThe row carries a property this operation does not accept, password among them.
unknown_referenceThe country, grade, city or school matches nothing. Nothing is ever created for you; fix the name or send an id.
duplicate_in_requestTwo rows in your own body have the same address. duplicateOf is the earlier one.
email_taken_by_your_studentOne of your students already has the address. studentId is theirs — update them with updateStudent instead of registering a second record.
email_unavailableThe 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:

statusMeaning
queuedAccepted, not started.
runningBeing registered now.
succeededEvery row registered. Each carries its studentId.
failedNo row registered. failure says why, and the rows that explain it carry an error.
cancelledStopped 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

LimitValue
Rows per request30 to 1000
Body size1.5 MB on this operation; 100 kB on every other
Requests10 per hour per account
Imports runningone 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.

Search the API documentation

Guides, endpoints by name, path or permission, and error codes such as not_found.