# Register many students at once

- Endpoint: `POST /v1/student/import`
- Production: `https://api.main-team.org/v1/student/import`
- Sandbox: `https://apisnd.main-team.org/v1/student/import`
- Operation: `createStudentImport` (Students)
- Authentication: `Authorization: Bearer <token>`, a short-lived token you sign with your API key and secret
- Permission: `student/create:$org:$ID`

## Description

Registers 30 to 1000 students in one request. Every row follows `registerStudent`’s rules exactly, so read that operation first: this one is the same registration, a class at a time.

**It answers before it registers anybody.** The whole batch is checked while you wait — every field, every `country`, `grade`, `city` and `school`, and every email address — and then `202` with an import you read to follow it. The `Location` header is where to read it. Nothing exists yet when you get that answer: poll `getStudentImport` every few seconds until `status` is no longer `queued` or `running`.

**All of them or none of them.** The students are written in one transaction, so a batch either registers every row or registers nothing. There is no partial import to reconcile and nothing to undo. A batch that fails says why, and you fix the rows and send it again.

**No passwords.** A row takes everything `registerStudent` takes except `password`: hashing is deliberately slow, and a thousand of them would keep the batch waiting and leave the plaintext queued meanwhile. Sign the students in with `createSigninLink`, or set a password per student afterwards with `setStudentPassword`.

**The welcome email is the one `registerStudent` sends**, one per student, with the same text.

**Limits.** One unfinished import per account: send the next batch when this one has finished. 10 requests an hour. Bodies up to 1.5 MB here, where every other operation takes 100 kB.

**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, so a request whose answer you never saw can simply be sent again.

**If anything is wrong with the rows** the answer is `422`, nothing is queued, and `error.details.rows` lists every row at fault with its position, property and a code to branch on. Fix them and send the batch again.

## Request body

Required fields: `students`.

```json
{
  "students": [
    {
      "firstName": "Jane",
      "lastName": "Doe",
      "birth": "14/05/2008",
      "sex": "f",
      "email": "jane.doe@example.com",
      "email2": "<email2>",
      "phone": "+1 555 0100",
      "country": "6650a1b2c3d4e5f6a7b8c9d1",
      "grade": "10",
      "school": "Springfield High School",
      "city": "Springfield",
      "activatedPlatformsThisSeason": [
        "common"
      ],
      "externalRef": "roster-2026-114"
    }
  ],
  "clientReference": "year-10-autumn-2026"
}
```

## Response

`202` Accepted: `message` is "Import accepted. The students are registered in the background; read the import to follow it.". Or `message` is "You already sent this batch. Its import is unchanged.": You sent these exact rows inside the last 24 hours. The import in `data` is the one you already have; no second one was made.

```json
{
  "data": {
    "_id": "65f0c2a1d3e4f5a6b7c8d901",
    "clientReference": "year-10-autumn-2026",
    "createdAt": "2026-10-06T08:15:00.000Z",
    "expiresAt": "2026-11-05T08:15:09.000Z",
    "failure": {
      "code": "rows_rejected",
      "message": "Two rows could no longer be registered. Nothing was registered."
    },
    "finishedAt": "2026-10-06T08:15:09.000Z",
    "registered": 250,
    "startedAt": "2026-10-06T08:15:04.000Z",
    "status": "queued",
    "students": [
      {
        "email": "jane.doe@example.com",
        "error": {
          "code": "email_taken_by_your_student",
          "duplicateOf": 12,
          "message": "One of your students already has this email address.",
          "studentId": "6650a1b2c3d4e5f6a7b8c9d0"
        },
        "externalRef": "roster-2026-114",
        "row": 0,
        "status": "registered",
        "studentId": "6650a1b2c3d4e5f6a7b8c9d0"
      }
    ],
    "total": 250
  },
  "message": "Import accepted. The students are registered in the background; read the import to follow it.",
  "success": true
}
```

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | The body is not valid JSON, breaks a field’s rules, or has a field this operation does not accept ("property <name> should not exist"). |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | A row breaks one of `registerStudent`’s rules. The message names the row and the property, counting rows from 0. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `students` has fewer than 30 rows or more than 1000. Below 30, call `registerStudent` per student. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | A row carries `password`, which this operation does not take, or any other property it does not accept. |
| 401 | [`unauthorized`](https://hub.main-team.org/api/errors#unauthorized) | The token is missing or malformed, is not signed with your account’s `apiSecret`, breaks the `iat` and `exp` rules, has expired or been revoked, or its account is not active. All of these answer the same. |
| 403 | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | The token is valid, but no role on your account allows `student/create` on `mto`, the organization every operation without `:organizationId` acts on, or a role denies it. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | Bulk registration is not switched on for the environment you are calling. Nothing was queued. Ask support before you build against it. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | You already have an import that has not finished. Read it, and send the next one when it has. |
| 413 | [`payload_too_large`](https://hub.main-team.org/api/errors#payload_too_large) | The body is larger than 1.5 MB. |
| 415 | [`unsupported_media_type`](https://hub.main-team.org/api/errors#unsupported_media_type) | The body declares a charset that is not a UTF one (send UTF-8), or a `Content-Encoding` other than gzip, deflate or br. |
| 422 | [`unprocessable_entity`](https://hub.main-team.org/api/errors#unprocessable_entity) | One or more rows cannot be registered. Nothing was queued. `error.details.rows` lists every row at fault with a `code` to branch on: `invalid_field`, `unexpected_field`, `unknown_reference`, `duplicate_in_request`, `email_taken_by_your_student` (with that student’s `_id`) and `email_unavailable`, which never says who holds the address. The list is left out, and only counted, when the only problem is unavailable addresses and your account has already been shown those rows several times today. |
| 429 | [`too_many_requests`](https://hub.main-team.org/api/errors#too_many_requests) | Your account has made more than 10 requests to this operation in the current hour. Wait the seconds in `Retry-After` before sending again. This operation has a budget of its own, lower than the 100 per 60 seconds every other operation gets. |
| 500 | [`internal_error`](https://hub.main-team.org/api/errors#internal_error) | Something failed on our side. Retry later, and quote `request_id` if it goes on. |
| 503 | [`service_unavailable`](https://hub.main-team.org/api/errors#service_unavailable) | This server is already checking another batch. Nothing was queued; wait the seconds in `Retry-After` and send it again. |
| 503 | [`service_unavailable`](https://hub.main-team.org/api/errors#service_unavailable) | Bulk registration is paused for a scheduled window, such as an exam morning. Nothing was queued; `Retry-After` is how long the window lasts. An import already queued is not lost — it waits and then runs. |
| 503 | [`service_unavailable`](https://hub.main-team.org/api/errors#service_unavailable) | Checking the batch took too long. Nothing was queued; send it again, or in smaller batches. |

## Code samples

### curl

```bash
# $TOKEN: a short-lived token you minted with your API key and secret
curl -sS -X POST 'https://api.main-team.org/v1/student/import' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary @- <<'JSON'
{
  "students": [
    {
      "firstName": "Jane",
      "lastName": "Doe",
      "birth": "14/05/2008",
      "sex": "f",
      "email": "jane.doe@example.com",
      "email2": "<email2>",
      "phone": "+1 555 0100",
      "country": "6650a1b2c3d4e5f6a7b8c9d1",
      "grade": "10",
      "school": "Springfield High School",
      "city": "Springfield",
      "activatedPlatformsThisSeason": [
        "common"
      ],
      "externalRef": "roster-2026-114"
    }
  ],
  "clientReference": "year-10-autumn-2026"
}
JSON
```

### Node.js

```js
const token = process.env.TOKEN; // a short-lived token you minted with your API key and secret

const res = await fetch('https://api.main-team.org/v1/student/import', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "students": [
      {
        "firstName": "Jane",
        "lastName": "Doe",
        "birth": "14/05/2008",
        "sex": "f",
        "email": "jane.doe@example.com",
        "email2": "<email2>",
        "phone": "+1 555 0100",
        "country": "6650a1b2c3d4e5f6a7b8c9d1",
        "grade": "10",
        "school": "Springfield High School",
        "city": "Springfield",
        "activatedPlatformsThisSeason": [
          "common"
        ],
        "externalRef": "roster-2026-114"
      }
    ],
    "clientReference": "year-10-autumn-2026"
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
console.log(body.data);
```

### PHP

```php
<?php
$token = getenv('TOKEN'); // a short-lived token you minted with your API key and secret

$ch = curl_init('https://api.main-team.org/v1/student/import');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $token,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'students' => [
            [
                'firstName' => 'Jane',
                'lastName' => 'Doe',
                'birth' => '14/05/2008',
                'sex' => 'f',
                'email' => 'jane.doe@example.com',
                'email2' => '<email2>',
                'phone' => '+1 555 0100',
                'country' => '6650a1b2c3d4e5f6a7b8c9d1',
                'grade' => '10',
                'school' => 'Springfield High School',
                'city' => 'Springfield',
                'activatedPlatformsThisSeason' => [
                    'common',
                ],
                'externalRef' => 'roster-2026-114',
            ],
        ],
        'clientReference' => 'year-10-autumn-2026',
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
if ($response === false) {
    throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($response, true);
if ($status >= 400) {
    $error = $body['error'];
    throw new RuntimeException("$status {$error['code']}: {$error['message']}");
}
print_r($body['data']);
```
