# Rate limits

> How many requests your account may make, how they are counted, the X-RateLimit and Retry-After headers, what a 429 costs you, and how to stay under the limit.

The API limits how fast each account can call each operation. The limit is generous for normal integrations, but bulk jobs such as a nightly sync or a registration import will reach it. This page explains exactly how requests are counted, what the headers tell you, what happens when you go over, and how to write a client that never does.

## The limit

**100 requests per 60 seconds, per API account, per operation.**

The limit is the same for every account, and the same in both environments: production and the [sandbox](https://hub.main-team.org/api/environments#sandbox) apply the numbers on this page.

### One operation has a lower limit of its own

**[createStudentImport](https://hub.main-team.org/api/reference/create-student-import): 10 requests per hour, per API account.**

One request to it registers up to 1000 students, so the budget is counted in batches rather than in
requests. Everything else on this page — how the counter works, the headers, what a `429` means —
works the same way for it; only the numbers differ. `X-RateLimit-Limit` on its answers reads `10`.

Two further limits bound it, and neither is a rate limit:

- **One unfinished import per account.** A second one while the first is running is `409`, not
  `429`. Sending the *same* rows again is neither: it answers `202` with the import you already
  have.
- **One check per server at a time.** Checking a batch is the expensive part, so a server already
  checking one answers `503` with `Retry-After`. Nothing was queued.

See [Bulk registration](https://hub.main-team.org/api/guides/bulk-registration#limits).

## What "per operation" means

Each operation, such as [listStudents](https://hub.main-team.org/api/reference/list-students) or [createApplication](https://hub.main-team.org/api/reference/create-application), has its own counter for your account. Calling one operation does not use up another's budget, so different operations can run side by side at full speed.

The counter belongs to the operation, not to the URL:

- **Different ids share one counter.** `GET /v1/<organizationId>/application/<A>` and `GET /v1/<organizationId>/application/<B>` are the same operation.
- **Different organizations share one counter.** `GET /v1/<stemId>/exam` and `GET /v1/<neoId>/exam` are the same operation, [listExams](https://hub.main-team.org/api/reference/list-exams), so together they get 100 requests per 60 seconds, not 100 each.
- **Two operations never share a counter**, even when they do similar work. [listStudents](https://hub.main-team.org/api/reference/list-students) `GET /v1/student` and [listOrgStudents](https://hub.main-team.org/api/reference/list-org-students) `GET /v1/<organizationId>/student` are two operations with two counters. So are the two single student reads, [getStudent](https://hub.main-team.org/api/reference/get-student) and [getOrgStudent](https://hub.main-team.org/api/reference/get-org-student), and the two student updates, [updateStudent](https://hub.main-team.org/api/reference/update-student) and [updateOrgStudent](https://hub.main-team.org/api/reference/update-org-student).

## Counted per account, not per server

The count belongs to your API account, wherever the requests come from. Two servers, ten containers or a fleet of workers using the same account all draw on the same budget, so spreading calls over more machines does not raise the limit. Two different API accounts each have their own budget, even when they call from the same address.

## What counts

A request is counted once the API knows it is yours and allowed. That means: your token has been accepted, the organization in the path exists, and your account holds the permission.

| Counted | Not counted |
|---|---|
| Every successful request, file downloads included | `401 unauthorized`: the token was refused |
| Requests the operation itself refuses: `400` for a body that breaks a rule, `404` for a record it cannot find, `409` for a conflict | `403 forbidden` for a missing permission |
| | `404 not_found`, `Organization not found!` |
| | `413`, `415` and malformed JSON: the body was refused before anything else happened |
| | A path that matches no operation |
| | `GET /v1/health`, which no account's budget covers |

The reason for the split: the counter needs to know which account a request belongs to, and that is only settled once the token, the organization and the permission have all been checked. Separately, the network in front of the API limits how fast each client address may send requests, whether or not they carry a token. That limit is not part of your account's budget, and a request it refuses gets a `429` without the JSON error envelope (see [too_many_requests](https://hub.main-team.org/api/errors#too_many_requests)).

## The headers

Every counted response carries three headers, including counted error responses such as a `404` or a `409`:

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | The limit for this operation: `100` |
| `X-RateLimit-Remaining` | How many requests you have left in the current window, never below `0` |
| `X-RateLimit-Reset` | Seconds until the current window ends, rounded up |

```bash
curl -sS -D - -o /dev/null "https://api.main-team.org/v1/student?limit=100" \
  -H "Authorization: Bearer $TOKEN"
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: 4e3d2c1b-0a9f-4e8d-b7c6-a5b4c3d2e1f0
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 17
```

This response means: 42 more requests to this operation are allowed in the next 17 seconds. After that the counter starts again at 100.

## How the window works

The window is fixed, not rolling. Your first counted request to an operation starts a 60-second window for that operation. Each counted request uses one of the 100. When the 60 seconds are up, the window ends, and your next request starts a fresh one.

| Time | Request | `X-RateLimit-Remaining` | `X-RateLimit-Reset` |
|---|---|---|---|
| 0:00 | 1st request to `listStudents` | `99` | `60` |
| 0:30 | 60th | `40` | `30` |
| 0:40 | 100th | `0` | `20` |
| 1:00 | The window ends | | |
| 1:05 | Next request | `99` | `60` |

## Going over: a 429 and a 60-second pause

If you send another request to an operation after `X-RateLimit-Remaining` has reached `0`, within the same window, it is refused:

- Status `429`, code `too_many_requests`.
- A `Retry-After` header: the number of seconds to wait. The first refusal says `60`.
- No `X-RateLimit-*` headers.

```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 60
X-Request-Id: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
```

```json
{
  "error": {
    "code": "too_many_requests",
    "message": "Too many requests to this operation. Wait the number of seconds in Retry-After, then try again.",
    "documentation_url": "https://hub.main-team.org/api/errors#too_many_requests",
    "request_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
  }
}
```

**Going over costs you a full 60 seconds.** The first refused request starts a pause of 60 seconds for that operation on your account, even if the window would otherwise have ended sooner. During the pause:

- Every request to that operation is refused with `429`, and its `Retry-After` counts down the seconds left.
- Those refused requests are not counted and do not make the pause longer. They are wasted calls.
- Every other operation keeps working normally.

When the pause ends, a fresh window of 100 begins.

Compare the two timelines:

| Strategy | What happens | Time lost |
|---|---|---|
| Stop at `X-RateLimit-Remaining: 0` (0:40) and wait `X-RateLimit-Reset` seconds | The window ends at 1:00, and the next request at 1:00 succeeds | 20 seconds |
| Send the 101st request at 0:45 | Refused. Pause until 1:45; everything sent before then is refused | 60 seconds |

Do not retry a `429` in a tight loop. Every retry during the pause is refused, and none of them brings the end of the pause closer. Wait the `Retry-After` seconds, then send the request once.

## Staying under the limit

1. **Watch `X-RateLimit-Remaining`.** When it reaches `0`, wait `X-RateLimit-Reset` seconds before your next call to that operation. You never pay the 60-second pause.
2. **On `429`, wait `Retry-After` seconds**, plus a second or two of random jitter, then retry the same request. `429` is the only 4xx status worth retrying automatically; see [Retries and idempotency](https://hub.main-team.org/api/retries-and-idempotency).
3. **Use `limit=100` on lists.** It is five times fewer requests than the default `limit=20`. See [Pagination](https://hub.main-team.org/api/pagination).
4. **Cache what rarely changes.** Organizations, countries and grades are reference data; load them once and keep them (see [Reference data](https://hub.main-team.org/api/guides/reference-data)).
5. **Schedule bulk work.** Run imports and syncs as queued jobs that go at a steady pace, rather than firing everything at once.
6. **Key your own budget by operation, not by URL.** One operation's counter covers every id and every organization in its path.

Node.js (18 or later). A small per-operation gate: pass the operation name (for example `'listStudents'`), and it waits when the budget for that operation is spent and retries after a `429`:

```js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const budgets = new Map(); // operation -> { remaining, resetAt }

// Use one name per operation, such as its operationId: every operation has its
// own counter, whichever ids and organization are in the path.
export async function withinLimit(operation, send, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const budget = budgets.get(operation);
    if (budget && budget.remaining <= 0 && Date.now() < budget.resetAt) {
      await sleep(budget.resetAt - Date.now());
    }

    const res = await send(); // must return a fetch Response

    if (res.status === 429) {
      const wait = Number(res.headers.get('retry-after') ?? 60);
      budgets.set(operation, { remaining: 0, resetAt: Date.now() + wait * 1000 });
      await sleep(wait * 1000 + Math.random() * 2000);
      continue;
    }

    const remaining = res.headers.get('x-ratelimit-remaining');
    const reset = res.headers.get('x-ratelimit-reset');
    if (remaining !== null && reset !== null) {
      budgets.set(operation, {
        remaining: Number(remaining),
        resetAt: Date.now() + Number(reset) * 1000,
      });
    }
    return res;
  }
  throw new Error(`Still rate-limited after ${maxAttempts} attempts: ${operation}`);
}

// Usage
const res = await withinLimit('application.create', () =>
  fetch(`https://api.main-team.org/v1/${organizationId}/application`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ studentId, examId }),
  }),
);
```

PHP (8.1 or later). The same idea for a single long-running worker:

```php
<?php
final class RateGate
{
    /** @var array<string, array{remaining:int, resetAt:float}> */
    private array $budgets = [];

    /**
     * $send performs the request and returns [int $status, array $headers, string $body],
     * with header names in lower case.
     */
    public function call(string $operation, callable $send, int $maxAttempts = 5): array
    {
        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $b = $this->budgets[$operation] ?? null;
            if ($b !== null && $b['remaining'] <= 0 && microtime(true) < $b['resetAt']) {
                usleep((int) (($b['resetAt'] - microtime(true)) * 1_000_000));
            }

            [$status, $headers, $body] = $send();

            if ($status === 429) {
                $wait = (int) ($headers['retry-after'] ?? 60);
                $this->budgets[$operation] = ['remaining' => 0, 'resetAt' => microtime(true) + $wait];
                usleep(($wait * 1_000_000) + random_int(0, 2_000_000));
                continue;
            }
            if (isset($headers['x-ratelimit-remaining'], $headers['x-ratelimit-reset'])) {
                $this->budgets[$operation] = [
                    'remaining' => (int) $headers['x-ratelimit-remaining'],
                    'resetAt' => microtime(true) + (int) $headers['x-ratelimit-reset'],
                ];
            }
            return [$status, $headers, $body];
        }
        throw new RuntimeException("Still rate-limited after {$maxAttempts} attempts: {$operation}");
    }
}
```

The gate lives in one process. If several processes share one account, give each a share of the budget, or send all calls to one operation through a single queue.

## Capacity planning

At 100 requests per 60 seconds per operation:

| Task | Operation | Most per minute |
|---|---|---|
| Register students | [registerStudent](https://hub.main-team.org/api/reference/register-student) | 100 registrations |
| Register a class at a time | [createStudentImport](https://hub.main-team.org/api/reference/create-student-import) | 10,000 registrations an hour, in batches of up to 1000, one batch at a time |
| Send students into the panel | [createSigninLink](https://hub.main-team.org/api/reference/create-signin-link) | 100 links, across all organizations together |
| Enter students for exams | [createApplication](https://hub.main-team.org/api/reference/create-application) | 100 applications, across all organizations together |
| Read any list, at `limit=100` | Any list operation | 10,000 records |
| Download certificates | [downloadCertificate](https://hub.main-team.org/api/reference/download-certificate) | 100 files |

**Sign-in links are created on demand, one per click, and each is capped like any other operation.** If a large group of students clicks "Go to my panel" in the same minute, your account can create at most 100 links in that minute. Links are single-use and valid for 120 seconds, so you cannot create them ahead of time. If your portal expects bursts, queue the clicks and show the student a short "preparing your link" state while you wait. See [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links).

A worked example: a nightly job for 3,000 students on two organizations lists each student's certificates (6,000 calls to one operation, since the two organizations share its counter), then downloads the new files (a different operation, with its own counter). The listing alone needs at least 60 minutes. The downloads can run alongside it without slowing it down. [Collect results](https://hub.main-team.org/api/tutorials/collect-results) builds this job step by step.

## Common questions

**Can my account get a higher limit?**
The limit is the same for every account. If your volumes do not fit, write to info@main-team.org with your numbers, and plan your jobs around the current limit in the meantime.

**Do failed requests count?**
Only if they got past the token, organization and permission checks. A `400`, `404` or `409` from the operation itself counts; a `401`, a `403` or `Organization not found!` does not. See [What counts](#what-counts).

**Is the limit per IP address?**
No. Your budget is per API account. The network in front of the API also caps how fast any one client address may send, with or without a token, but a steadily paced integration does not reach it.

**Does the health check count?**
No. No account's budget covers `GET /v1/health`, so it never uses yours. The per-address limit in front of the API still applies, so poll it at a steady interval. See [Environments](https://hub.main-team.org/api/environments).
