Skip to content
API documentation
View as MarkdownOpen in Claude

Concepts

Rate limits

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

Note

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 apply the numbers on this page.

One operation has a lower limit of its own

Note

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

What "per operation" means

Each operation, such as listStudents or createApplication, 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, 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 GET /v1/student and listOrgStudents GET /v1/<organizationId>/student are two operations with two counters. So are the two single student reads, getStudent and getOrgStudent, and the two student updates, updateStudent and updateOrgStudent.

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.

CountedNot counted
Every successful request, file downloads included401 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 conflict403 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).

The headers

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

HeaderMeaning
X-RateLimit-LimitThe limit for this operation: 100
X-RateLimit-RemainingHow many requests you have left in the current window, never below 0
X-RateLimit-ResetSeconds until the current window ends, rounded up
curl -sS -D - -o /dev/null "https://api.main-team.org/v1/student?limit=100" \
  -H "Authorization: Bearer $TOKEN"
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.

TimeRequestX-RateLimit-RemainingX-RateLimit-Reset
0:001st request to listStudents9960
0:3060th4030
0:40100th020
1:00The window ends
1:05Next request9960

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/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 60
X-Request-Id: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
{
  "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:

StrategyWhat happensTime lost
Stop at X-RateLimit-Remaining: 0 (0:40) and wait X-RateLimit-Reset secondsThe window ends at 1:00, and the next request at 1:00 succeeds20 seconds
Send the 101st request at 0:45Refused. Pause until 1:45; everything sent before then is refused60 seconds

Warning

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.
  3. Use limit=100 on lists. It is five times fewer requests than the default limit=20. See Pagination.
  4. Cache what rarely changes. Organizations, countries and grades are reference data; load them once and keep them (see 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:

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

TaskOperationMost per minute
Register studentsregisterStudent100 registrations
Register a class at a timecreateStudentImport10,000 registrations an hour, in batches of up to 1000, one batch at a time
Send students into the panelcreateSigninLink100 links, across all organizations together
Enter students for examscreateApplication100 applications, across all organizations together
Read any list, at limit=100Any list operation10,000 records
Download certificatesdownloadCertificate100 files

Note

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.

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

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.

Search the API documentation

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