Skip to content
API documentation
View as MarkdownOpen in Claude

Concepts

Retries and idempotency

A request can fail in two different ways. Either the API answers with an error, and you know exactly what happened, or the connection breaks and you don't know whether the API did the work. This page covers every write operation: what happens when you send the same request again, and how to recover from both kinds of failure. The goal is to avoid duplicate students or applications and to avoid burning through your rate limit.

The short version

What came backDid this request change anything?What to do
2xxYes, if it was a writeNothing more. Store the result.
400, 413, 415NoFix the request. Sending it again unchanged gets the same answer.
401NoSign a fresh token and send the request once more. If that also fails, stop and check your token.
403NoDon't retry. A permission or an access rule refused it.
404NoDon't retry. After a delete that timed out, see Deleting an application.
409NoDon't retry unchanged. The message names the state that blocks the request; the group challenge submits also say it in error.details.reason.
429NoWait the number of seconds in Retry-After, then send it again.
500MaybeRetry with backoff if the operation is safe to repeat (next section). Otherwise check first.
502, 503, 504, a timeout, or a dropped connectionMaybeSame as 500.

Note

Every 4xx is a refusal. The API checks a request completely before it writes anything, so a 4xx never leaves a half-done change behind. You only need a "did it happen?" check after a 5xx or when no response arrived.

A 429, a 401, and a 403 for a missing permission are all refused before the operation starts. That makes them safe to resend for any operation, including ones that are not idempotent, once you have fixed the cause or waited.

Which operations are safe to repeat

Every GET has no side effects, so you can repeat it freely. This includes the lists, the single reads, the exam picker and the two PDF downloads. The table covers the writes.

OperationWhat a repeat doesSafe to retry after a timeout?
Register a student POST /v1/studentNot idempotent. If the first attempt worked, the repeat answers 409 conflict with the new student's _id in the message.Yes, if you handle that 409. See Registering a student.
Register many students at once POST /v1/student/importIdempotent for 24 hours. The same rows from your account inside a day answer 202 with the import you already have, not a second one, and the message says so.Yes. See Registering many students at once.
Update a student PUT /v1/student/{studentId}Same body, same result.Yes
Update an organization student PUT /v1/{organizationId}/student/{studentId}Same body, same result. The organization is added to the student's access list only once.Yes
Set a password PUT /v1/student/{studentId}/passwordSets the same password again. Answers 409 if the student confirmed their email address in between.Yes
Link a supervisor PUT /v1/{organizationId}/student/{studentId}/supervisorLinks the same supervisor again and answers 200 each time.Yes
Create a sign-in link POST /v1/{organizationId}/auth/signinMints a new, separate link. The earlier one expires unused.Yes, retry the request. Never reopen or resend the link itself.
Create an application POST /v1/{organizationId}/applicationIdempotent for the same student and exam: 201 the first time, then 200 "Application already exists." with the same application.Yes, one attempt at a time
Move an application PUT /v1/{organizationId}/application/{applicationId}Idempotent. Moving to the exam the application already has answers 200 "Application already uses that exam." and writes nothing.Yes
Delete an application DELETE /v1/{organizationId}/application/{applicationId}The first call deletes. A repeat answers 404 "Application not found!".Yes. Treat that 404 as "already deleted" if you know the application existed.
Submit a group challenge step POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submitIdempotent. A step already submitted, by anyone, answers 200 with changed: false and writes nothing.Yes. See Group challenge submits.
Send a group's work POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/final-submitIdempotent. Work already sent answers 200 with changed: false; nothing is written and no second e-mail goes out.Yes. See Group challenge submits.
Revoke a token POST /v1/api-account/revoke-tokenThe first call revokes the token. A repeat with the same token answers 401, because that token is now revoked.Yes. A 401 on the repeat means the first call worked.

Registering many students at once

POST /v1/student/import is the one write on this API that is safe to repeat exactly as it is. Send the same rows again inside 24 hours and you get 202 with the import you already have — data._id is the id you already had, and the message says the batch was already sent.

So a request whose answer you never saw needs no special handling at all:

const send = () =>
  fetch('https://api.main-team.org/v1/student/import', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ students: rows }),
  });

let answer = await send();
if (answer.status >= 500 || answer.status === 408) answer = await send();

"The same rows" means the rows themselves: addresses are compared without regard to case or surrounding spaces, and clientReference is ignored. Change one row and it is a new batch, which is refused with 409 while the first one is still running.

The batch itself is all-or-nothing — every student is registered or none is — so there is never a half-finished import to reconcile after a retry. See Bulk registration.

Creating an application

POST /v1/{organizationId}/application is idempotent. Applying the same student to the same exam twice is one request sent twice, so the API answers the repeat with the application that already exists instead of creating a second one.

curl -X POST "https://api.main-team.org/v1/$ORGANIZATION_ID/application" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: 1f0c2d9e-5b7a-4c3e-8f21-6a9d0b4e7c35.1" \
  -d '{"studentId":"652f1c9b8e4b2a0012a3c4d5","examId":"64a1f0b2c9d8e7f600112233"}'

The first time, the application is created and you get 201 Created:

{
  "success": true,
  "message": "Application created successfully.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:30:02.511Z",
    "__v": 0
  }
}

Send the same body again and you get 200 OK. data is the same application, with the same _id:

{
  "success": true,
  "message": "Application already exists.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:30:02.511Z",
    "__v": 0
  }
}

Branch on the status: 201 means this request created the application, 200 means it already existed. Both give you the application to store. (user here is the organization's own id for the student. See Identifiers.)

The API runs its checks in this order. The first one that fails decides the answer:

  1. The student belongs to your account (otherwise 404 not_found).
  2. The student has signed in to this organization at least once (otherwise 409 conflict).
  3. The exam exists in this organization (otherwise 404 not_found).
  4. The exam is one the picker would offer this student: open, their grade, their country, a language set (otherwise 409 conflict, or 400 bad_request when the student has no grade).
  5. The application already exists: 200 OK.
  6. The student holds no other exam in the same category on the same sitting (otherwise 409 conflict).
  7. The application is created: 201 Created.

Warning

The repeat is recognized at step 5, after the eligibility checks. If the exam has closed since your first attempt, or the student's grade or country has changed, a repeat answers 409 conflict even though the application exists. When a retry answers 409, list the student's applications with GET /v1/{organizationId}/application/student-applications/{studentId} before you conclude that the first attempt failed.

Warning

Send at most one create for a given student and exam at a time. The API recognizes a repeat only once the earlier request has finished. Two identical requests in flight at the same moment, from two workers say, are not guaranteed to be treated as one. Retry after the previous attempt has completed or timed out, never in parallel with it.

// Apply once, surviving timeouts. callApi is the helper at the end of this page.
export async function applyOnce(organizationId, studentId, examId) {
  const res = await callApi('POST', `/${organizationId}/application`, {
    body: { studentId, examId },
    repeatable: true,
  });
  const body = await res.json();

  if (res.status === 201) return { created: true, application: body.data };
  if (res.status === 200) return { created: false, application: body.data };

  if (res.status === 409) {
    // The exam may have closed after an earlier attempt succeeded. Look before giving up.
    const existing = await findApplication(organizationId, studentId, examId);
    if (existing) return { created: false, application: existing };
  }
  throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
}

Registering a student

POST /v1/student is not idempotent, on purpose. Registering an email address that is already registered is not a repeat, it is two students colliding on one address. So the API refuses it with 409 conflict rather than returning the existing record as though it had just been created.

That refusal is what you use to recover from a timeout. Send the registration again, and read the answer:

Answer to the second attemptWhat it meansWhat to do
201 CreatedThe first attempt never landed. This one created the student.Store data._id.
409 with A student with that email is already registered to this account (652f1c9b8e4b2a0012a3c4d5).The first attempt landed, or you had already registered this address. The student is yours either way.Use the id in the message. Fetch the record with GET /v1/student/{studentId} if you need it.
409 with That email address is already registered.Another account registered this address. Email addresses are unique across the whole platform.You cannot use this address. Ask the student for a different one.

The existing student's _id appears only in the message, so this is the one place you read it from there. Take the 24-character hex id from between the parentheses:

// Register, or recover the id of a registration that already happened.
export async function registerOrRecover(student) {
  let res;
  try {
    res = await callApi('POST', '/student', { body: student, repeatable: false });
  } catch {
    // No answer: the student may exist now. Asking again is safe because of the 409.
    res = await callApi('POST', '/student', { body: student, repeatable: false });
  }
  const body = await res.json();

  if (res.status === 201) return body.data._id;
  if (res.status === 409) {
    const yours = /\(([0-9a-f]{24})\)\.?$/.exec(body.error.message);
    if (yours) return yours[1];
    throw new Error('This email address belongs to another account.');
  }
  throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
}
<?php
// Register, or recover the id of a registration that already happened.
function registerOrRecover(array $student): string
{
    try {
        $res = callApi('POST', '/student', $student, false);
    } catch (RuntimeException $noAnswer) {
        $res = callApi('POST', '/student', $student, false);
    }

    if ($res['status'] === 201) {
        return $res['body']['data']['_id'];
    }
    if ($res['status'] === 409) {
        if (preg_match('/\(([0-9a-f]{24})\)\.?$/', $res['body']['error']['message'], $m)) {
            return $m[1];
        }
        throw new RuntimeException('This email address belongs to another account.');
    }
    throw new RuntimeException($res['status'] . ' ' . $res['body']['error']['code']);
}

A few more things matter for registration:

  • Keep your own mapping from email address to student _id. No operation looks a student up by email, so the id you store at registration is how you find them again.
  • Register one address from one worker at a time. If two of your workers register the same new address at the same moment, one of them gets the 409 without an id, as though another account held it.
  • Allow time for a password. When the body carries a password, the API hashes it with a deliberately slow algorithm, so the request takes noticeably longer (often more than a second). A 30-second read timeout is comfortable.

Updating a student

Both student updates are partial: send only the fields you are changing, and fields you leave out keep their value. The same body sent twice gives the same result, so both are safe to retry.

  • Changing the email address withdraws the student's email confirmation, because nobody has proved the new address yet. A repeat carrying the same new address is not a change, so it doesn't withdraw anything a second time. Re-sending the address a student already has, in any letter case, never withdraws it.
  • PUT /v1/{organizationId}/student/{studentId} also adds that organization to the student's access list. A repeat doesn't add it twice.
  • PUT /v1/student/{studentId}/password sets the same password again on a repeat. If the student confirms their email address between your attempts, the repeat answers 409 conflict. Stop there: the account is the student's now. Like registration, this request takes longer than others because of the password hashing.
  • PUT /v1/{organizationId}/student/{studentId}/supervisor links the same supervisor again and answers 200 with the same result.

Moving an application

PUT /v1/{organizationId}/application/{applicationId} moves an application to another exam. If the application already points at the exam you send, the API answers without writing anything:

{
  "success": true,
  "message": "Application already uses that exam.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600445566",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:41:17.020Z",
    "__v": 0
  }
}

So after a move that timed out, send it again. 200 "Application updated successfully." means the first attempt didn't land and this one did. 200 "Application already uses that exam." means the first one did. Either way the application now has the exam you asked for.

This check comes before the eligibility rules, so a repeat still answers 200 if the exam has closed since your first attempt. Only one earlier check can change the answer: once the student has started or handed in the exam, every move of that application answers 409 conflict with This exam has already been started and can no longer be changed., even a move to the exam it already has. The rules a move has to pass are in Applications.

Deleting an application

The first DELETE removes the application and answers 200 with the deleted record:

{
  "success": true,
  "message": "Application deleted successfully.",
  "data": {
    "_id": "66c3e5f7a9b1c2d3e4f50617",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5a6b7c8d9e0",
    "payment": "66c3e5f7a9b1c2d3e4f50618",
    "ExamBucksRedeemed": false,
    "partners": [],
    "definedQuota": false,
    "participated": false,
    "reversable": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "simulationV2Enabled": false,
    "isSimulationV2": false,
    "carriedPoints": 0,
    "uuid": "3fa-9c1-e07",
    "cameraRecord": false,
    "createdAt": "2026-09-15T09:30:02.511Z",
    "updatedAt": "2026-09-15T09:30:02.511Z",
    "__v": 0
  }
}

A repeat answers 404 not_found with Application not found!. If you got the id from the API and the delete timed out, a 404 on the retry means the application is gone, most likely because your first attempt worked.

A 409 conflict on a delete means the application has been paid for. The API does not delete paid applications, because that would need a refund it cannot make. Retrying won't change that.

Group challenge submits

submitGroupChallengeStep and submitGroupChallengeWork are safe to send again. A submit that already happened — your earlier attempt, a member in the panel, another request — answers 200 with changed: false, so after a timeout, send it again and read changed:

Answer to the second attemptWhat it means
200, changed: trueThe first attempt never landed; this one did the submit.
200, changed: falseIt was already done, by your first attempt or by someone else. Nothing changed.
503, details.reason: busySomeone was changing the group at that moment; nothing was written. Wait the second in Retry-After and send it again.
409The group's state refuses it; details.reason says why. Don't retry unchanged. See conflict.

POST /v1/{organizationId}/auth/signin mints a new link every time you call it. Retrying the request is harmless: any link you didn't use expires on its own after 120 seconds.

The link itself is different. It works once, for 120 seconds. Once a browser has opened it, opening it again fails. The student sees an "Invalid or expired access token" message instead of their panel. So:

  • Never retry a redirect with the same URL. Mint a new link.
  • Mint the link when the student clicks, and redirect at once. Don't generate links ahead of time.
  • Keep links away from anything that fetches URLs on its own, such as chat previews, email scanners and browser prefetch. A fetch like that uses the link up.

If minting fails with 500 internal_error and the message Could not issue a sign-in token, please retry., retry straight away. Nothing was issued. See Sign-in links for the whole flow.

Downloads

GET .../certificate/download/{certificateId} and GET .../report/download/{reportId} stream a PDF.

  • An error arrives before any bytes, as the usual JSON error envelope with its status. Handle it like any other error.
  • A failure after the file has started can't be reported with a status any more, so the API closes the connection early. Your client sees a transfer that ends before Content-Length bytes (when the header is present) or with a network error. Throw the partial file away and download it again.
  • Every download starts from the first byte. The API always sends the whole file and doesn't serve byte ranges, so you can't resume a partial download.

Write to a temporary file and rename it only once the transfer has completed, so a truncated PDF never looks like a finished one.

Timeouts

SettingSuggested valueWhy
Connect timeout5 sFail fast when the network is the problem.
Read timeout, JSON operations30 sCovers the slowest ordinary request, a registration or password change with hashing.
Read timeout, downloads120 s or more, depending on file sizePDFs are streamed.

The API expects to receive your whole request, headers and body, within 30 seconds of you starting to send it. Don't stream request bodies slowly. Reuse connections (HTTP keep-alive) rather than opening a new one for each request.

Note

A timeout is not a failure. It only means you didn't see the answer. Treat it like a 500: retry if the operation is safe to repeat, and check first if it isn't.

Backing off

Wait longer after each failed attempt, and add randomness so that many clients (or many workers of yours) don't retry in lockstep:

delay = random(0.5, 1.0) × min(30 s, 1 s × 2^attempt)
Attempt that failedWait before the next one
1st0.5–1 s
2nd1–2 s
3rd2–4 s
4th4–8 s
5thgive up and alert

Three rules sit on top of that:

  • Retry-After wins. On a 429, wait at least the number of seconds the header gives. A request sent sooner is refused too.
  • Cap the attempts. Five in total is plenty. After that, stop and alert a person. Retrying forever turns an outage into a flood.
  • Pause a whole batch when many requests fail. If several requests in a row come back 5xx, stop the batch. Then check the API in two steps before you resume.

First, GET /v1/health tells you whether the API is reachable at all. It needs no token and doesn't count against your rate limit:

curl https://api.main-team.org/v1/health
{
  "success": true,
  "message": "Request completed successfully.",
  "data": { "status": "ok" }
}

A 200 here only means the API is up and answering. It doesn't check the systems your operations need behind it, so it can answer 200 while your operations are still failing. Second, once it answers, send one cheap read that your account is allowed, such as GET /v1/grade?limit=1 (needs grade/read on mto). Resume the batch when that read succeeds.

Retries and your rate limit

Each API account may make 100 requests per 60 seconds to each operation. One operation's budget is shared by all its calls, whatever ids are in the path and whichever organization they name. Two operations never share a budget.

A retry is a request like any other, so every attempt that reaches an operation counts. A few things work in your favor:

  • A request refused for authentication (401), for a missing permission (403) or for an unknown organization (404 Organization not found!) is not counted.
  • After a 429, every request to that operation is refused until the Retry-After time has passed. The first 429 says Retry-After: 60, because the block lasts a full 60 seconds from the request that went over the limit. Refusals during the block are not counted and don't extend it, but they don't achieve anything either.
  • The 429 response carries Retry-After only. The X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers come on the responses that were counted, so read them there to slow down before you reach the limit.

The network in front of the API also limits how fast each client address may send requests, whether or not they carry a token. A request refused there gets a 429 without the API's JSON error envelope. Wait about 10 seconds before you send again, and spread steady high volume over time instead of sending it in bursts.

See Rate limits for client-side throttling patterns.

Request ids

Every response carries an X-Request-Id header, and every error body repeats it as error.request_id. Quote it when you ask for help: it is how a request is found.

You can send your own. The API reuses it if it is 1 to 256 characters of letters, digits and . _ : ; = + / @ -. Otherwise the API assigns an id of its own. Don't depend on the format of an id you didn't send. A useful pattern for retries is one id per logical operation plus the attempt number, such as 1f0c2d9e-5b7a-4c3e-8f21-6a9d0b4e7c35.1, .2, .3. Each attempt stays unique, and all attempts of one operation share a prefix you can search for.

Warning

The request id is not an idempotency key. The API does not deduplicate requests by it. Two POSTs with the same X-Request-Id are two requests. Idempotency comes from the operation itself, as described above.

Never put personal data such as an email address or a name into a request id. It is repeated in error bodies and written to logs.

A complete retry helper

This helper implements everything on this page: backoff with jitter, Retry-After, a fresh token after a 401, per-attempt request ids, and no blind retries of operations that aren't safe to repeat.

// api.mjs: Node.js 18+ (global fetch and AbortSignal.timeout)
import { randomUUID } from 'node:crypto';
import { getToken } from './token.mjs'; // getToken({ forceNew }) returns a signed JWT

const BASE_URL = 'https://api.main-team.org/v1';
const RETRY_ON = new Set([500, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function delayMs(attempt, retryAfter) {
  const seconds = Number(retryAfter);
  if (retryAfter != null && Number.isFinite(seconds)) {
    return seconds * 1000 + Math.random() * 1000; // never sooner than asked
  }
  const ceiling = Math.min(30_000, 1_000 * 2 ** attempt);
  return ceiling / 2 + (Math.random() * ceiling) / 2;
}

/**
 * repeatable: true when sending the request twice is safe (see the table above).
 * Returns the Response for 2xx and 4xx; throws when attempts run out.
 */
export async function callApi(method, path, { body, repeatable, maxAttempts = 5, timeoutMs = 30_000 } = {}) {
  const operation = randomUUID();
  let forceNewToken = false;
  let lastProblem;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    let res;
    try {
      res = await fetch(BASE_URL + path, {
        method,
        headers: {
          Authorization: `Bearer ${getToken({ forceNew: forceNewToken })}`,
          'Content-Type': 'application/json',
          'X-Request-Id': `${operation}.${attempt + 1}`,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (error) {
      if (!repeatable) throw error; // outcome unknown: the caller must check
      lastProblem = error;
      await sleep(delayMs(attempt));
      continue;
    }

    if (res.status === 401 && !forceNewToken) {
      forceNewToken = true; // one more try with a freshly signed token
      continue;
    }
    if (res.status === 429 || (repeatable && RETRY_ON.has(res.status))) {
      lastProblem = new Error(`HTTP ${res.status} (request ${res.headers.get('x-request-id')})`);
      await sleep(delayMs(attempt, res.headers.get('retry-after')));
      continue;
    }
    return res;
  }
  throw lastProblem;
}
<?php
// api.php: PHP 8.1+ with ext-curl and ext-json. apiToken(bool $forceNew) returns a signed JWT.

function backoffMicroseconds(int $attempt, ?string $retryAfter): int
{
    $jitter = mt_rand() / mt_getrandmax();
    if ($retryAfter !== null && is_numeric($retryAfter)) {
        return (int) (((float) $retryAfter + $jitter) * 1_000_000); // never sooner than asked
    }
    $ceilingMs = min(30_000, 1_000 * (2 ** $attempt));
    return (int) (($ceilingMs / 2 + $jitter * $ceilingMs / 2) * 1_000);
}

/** @return array{status:int, headers:array<string,string>, body:mixed} */
function callApi(string $method, string $path, ?array $body, bool $repeatable, int $maxAttempts = 5): array
{
    $operation = bin2hex(random_bytes(16));
    $forceNewToken = false;
    $lastProblem = 'no attempt made';

    for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
        $headers = [];
        $ch = curl_init('https://api.main-team.org/v1' . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . apiToken($forceNewToken),
                'Content-Type: application/json',
                'X-Request-Id: ' . $operation . '.' . ($attempt + 1),
            ],
            CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
        }
        $raw = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($raw === false) {
            if (!$repeatable) {
                throw new RuntimeException('No answer (' . $error . '); check before retrying.');
            }
            $lastProblem = $error;
            usleep(backoffMicroseconds($attempt, null));
            continue;
        }
        if ($status === 401 && !$forceNewToken) {
            $forceNewToken = true;
            continue;
        }
        if ($status === 429 || ($repeatable && in_array($status, [500, 502, 503, 504], true))) {
            $lastProblem = 'HTTP ' . $status . ' (request ' . ($headers['x-request-id'] ?? '-') . ')';
            usleep(backoffMicroseconds($attempt, $headers['retry-after'] ?? null));
            continue;
        }
        return ['status' => $status, 'headers' => $headers, 'body' => json_decode($raw, true)];
    }
    throw new RuntimeException('Gave up: ' . $lastProblem);
}

Checklist

  • Every 4xx except 401 (once) and 429 is handled without a retry.
  • 429 waits at least Retry-After seconds.
  • 5xx and timeouts are retried with jittered backoff, at most five attempts in total, and only for operations that are safe to repeat.
  • A registration that timed out is re-sent, and its 409 is read for the student's id.
  • An application create is never sent twice in parallel, and a 409 on a retry is checked against the student's applications.
  • Sign-in links are minted on demand and never reopened.
  • Downloads are written to a temporary file and discarded if truncated.
  • Each attempt carries its own X-Request-Id, with no personal data in it.

Endpoints covered

Search the API documentation

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