Skip to content
API documentation
View as MarkdownOpen in Claude

Clients

PHP client

main-team/api-client is the official PHP client for the Main Team API. This page takes you from composer require to a complete integration: you register a student, send them into the panel, enter them for an exam, and download their certificates. Each step explains which API rule it relies on, so you know what to expect when a call fails.

The package README is the full method reference. This page covers what you need for a working integration and the API behavior behind each part.

Requirements

  • PHP 8.1 or newer, with ext-curl and ext-json. Both are standard.
  • No other dependencies, so the client doesn't pull an HTTP stack into your project.
  • A server. The client holds your apiSecret, so it runs on your backend only. See Server-side only.

Install

The package is private. Access comes with your API credentials: the access instructions you receive with your apiKey and apiSecret tell you how to configure Composer for it. If you have credentials but no package access, write to info@main-team.org.

composer require main-team/api-client

Set up the client

You need two values, both from your API credentials:

ValueWhat it isWhere it goes
apiKeyYour public account identifier, key_ followed by 24 characters.Anywhere. It's not a secret.
apiSecretYour signing key, secret_ followed by random characters. Shown to you once, and it can't be recovered.An environment variable or a secret store, on the server only.
<?php

use MainTeam\ApiClient\MtoClient;

$client = new MtoClient(
    apiKey: getenv('MTO_API_KEY'),
    apiSecret: getenv('MTO_API_SECRET'),
);

Build the client once per request cycle, or once per worker in a long-running process, and reuse it. It signs short-lived tokens with your secret for you, so there's no token endpoint to call and no token for you to manage.

Security

The secret never leaves your process. Only the signed token travels, and a token expires within an hour at most. Keep the secret out of source control, out of anything served to a browser, and out of logs, including exception traces that dump constructor arguments. If it leaks, ask your operator to deactivate the account. There is no secret rotation; see Security.

There is no base URL to set. The client talks to https://api.main-team.org/v1 only, because any other host would receive a bearer token good for your whole account. See Why the base URL is locked. That includes the sandbox: to call https://apisnd.main-team.org/v1, send plain HTTPS requests, or use the client from Build your own client.

Check that your credentials work

whoami() calls GET /v1/api-account/validate-me and returns the account your token belongs to:

$account = $client->account()->whoami();
echo $account->companyName, PHP_EOL;

On the wire, this is the one JSON route without the response envelope. It returns the account object itself: _id, apiKey, companyName, scopes, roles and isActive, and never apiSecret.

{
  "_id": "66f1a2b3c4d5e6f708192a3b",
  "apiKey": "key_Q2hvb3NlQW5vdGhlcktleUhl",
  "companyName": "Example Learning Ltd",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "*/read", "target": "*" },
    { "effect": "allow", "action": "api/*", "target": "mto" }
  ],
  "isActive": true
}

A 401 unauthorized here means the token was refused. Every token failure gives that same answer, so check the key, the secret and your server clock against Authentication. A 403 forbidden means your roles lack api/* on mto, which this route needs. Other routes may still work.

Choosing an organization

Most operations belong to one organization: mto (the core record), stem, hilingua, neo, gmath or coding. The API's paths take the organization's _id, never its slug. The client resolves slugs for you:

$client->loadOrganizations(); // one request, then cached

$stem = $client->organization('stem');
$exams = $stem->exams()->list();

Or take them all at once, keyed by slug:

$orgs = $client->loadOrganizations();

$orgs->stem->exams()->list();
$orgs['coding']->applications()->create($examId, $studentId);

A slug your account can't reach throws an exception that names the slugs it can. Passing a raw _id always works and skips the lookup.

Note

Operations without an organization in the path, such as students on the core record, countries, grades and organizations, act on mto. Your roles need target mto or * for those. See Organizations and Permissions.

A complete flow

Register a student, send them into an organization once, then enter them for an exam. The Register and apply tutorial walks through the same flow over plain HTTP.

Step 1: register the student

use MainTeam\ApiClient\Exception\MtoConflictError;

// `country` takes an id only. Look it up once and cache it.
$albania = $client->countries()->findByIso2('AL');

$student = $client->students()->register(
    firstName: 'Jane',
    country: $albania->id,
    grade: '8',                               // a grade _id, or its name '1' to '12'
    city: 'Berlin',                           // an id, or a name within the country
    school: 'Berlin International School',    // an id, or a name within country and city
    lastName: 'Doe',
    email: 'jane.doe@example.com',
    birth: '14/05/2010',                      // DD/MM/YYYY
    sex: 'f',                                 // 'm', 'f' or 'n'
);

echo $student->id, ' ', $student->username, PHP_EOL; // keep the id: every route uses it

Required fields are named arguments, so leaving out the country is something your editor catches instead of a 400. Anything the API accepts that the client doesn't model goes through extra:.

grade, city and school take an id or a name. The server looks a name up and refuses it with 400 bad_request if nothing matches. The API never creates a city, a school or a grade. Some countries can't be selected: they're left out of the country list and refused at registration.

Registration is not idempotent. If a retry after a timeout hits a registration that already succeeded, you get 409 conflict:

try {
    $student = $client->students()->register(/* ... */);
} catch (MtoConflictError $e) {
    // "A student with that email is already registered to this account (66f2b7c1e4a9d20012ab34cd)."
    if (!preg_match('/\(([0-9a-f]{24})\)/', $e->getMessage(), $m)) {
        throw $e; // another account holds this address: it can't be used
    }
    $existingId = $m[1]; // fetch or update this student instead
}

Store the id when you register so you rarely need this. See Students.

Step 2: send the student into the organization

An organization can only refer to a student after the student has signed in there once. That first sign-in creates the organization's copy. Mint a link when the student clicks, and redirect:

$stem = $client->organization('stem');
$link = $stem->auth()->signinAsStudent($student->id);

header('Location: ' . $link->url, true, 302);
exit;

The API answers:

{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/…",
    "organization": "stem",
    "studentId": "66f2b7c1e4a9d20012ab34cd",
    "expiresIn": 120
  }
}

Danger

A sign-in link is a credential. It signs whoever opens it in as the student, works once, and expires 120 seconds after it was issued. Redirect straight away; never store, log, email or paste it. Link previews in chat and mail apps use it up. See Sign-in links.

The link is refused with 403 forbidden if the student has no access to that organization, and with 404 not_found if the student isn't one of yours. See Send a student to the panel.

Step 3: choose an exam for the student

Use the exams this student can take. The per-student picker, GET /v1/{organizationId}/exam/available/{studentId}, applies the student's grade and country and leaves out what they already hold. Its tree runs category, then session (date), then language, and every leaf carries a matchedExam. The package README names the method that calls it. The organization's open exams are also available as a plain list:

$openExams = $stem->exams()->list(); // open exams only, for every student

A closed exam isn't in that list, can't be read by id and can't be applied to. See Exams.

Step 4: enter the student

$application = $stem->applications()->create($examId, $student->id);
// $examId is the picker leaf's matchedExam _id; the student id is the core id from registration.
AnswerMeaning
201 "Application created successfully."A new application.
200 "Application already exists."The student already holds this exam. You get the existing application, so a retry is safe.
409 conflictThe picker wouldn't offer this exam (closed, grade, country, no language, or a clash in the same category on the same sitting), or the student has never signed in to this organization. The message says which.
404 not_foundThe exam or the student doesn't exist, or the student isn't yours.

See Applications for moving and deleting applications, and how payments behave.

Exceptions

There is one exception class per code in the error reference, under the MainTeam\ApiClient\Exception namespace, and all of them extend MtoError:

use MainTeam\ApiClient\Exception\MtoConflictError;
use MainTeam\ApiClient\Exception\MtoError;

try {
    $stem->applications()->create($examId, $studentId);
} catch (MtoConflictError $e) {
    error_log($e->getMessage() . ' ' . $e->documentationUrl);
} catch (MtoError $e) {
    error_log("{$e->errorCode} (HTTP {$e->status})");
}

The API's code, such as conflict, is on $e->errorCode, not getCode(). PHP won't let a subclass redeclare the inherited Throwable::$code as a string.

Calls that can't succeed are rejected before the request as MtoValidationError. It extends MtoBadRequestError and carries the same bad_request / 400 the server would have returned, so one catch (MtoBadRequestError $e) covers both.

Branch on the class or on errorCode and status. The message is text for people and can change.

Status and codeRetry?Typical fix
400 bad_requestNoFix the request. The message names the field.
401 unauthorizedNoCheck the key, the secret and your clock.
403 forbiddenNoAsk your operator for the permission. See Permissions.
404 not_foundNoCheck the id, and that the record is yours.
409 conflictNoRead the message: it names the rule.
429 too_many_requestsYes, after Retry-AfterSlow down. See below.
500 internal_errorYes, with backoffRetry later. If it persists, report the request_id.

Pagination

A list call returns a page you can iterate, with its metadata on ->pagination:

$page = $client->students()->list(page: 2, limit: 50);

foreach ($page as $student) {
    echo $student->username, PHP_EOL;
}
echo $page->pagination->total, PHP_EOL;

page starts at 1; limit defaults to 20 and is capped at 100. A larger limit is lowered to 100 rather than refused. Pagination::walk() walks every page:

use MainTeam\ApiClient\Pagination\Pagination;

foreach (Pagination::walk(fn ($page, $limit) => $client->students()->list($page, $limit)) as $student) {
    echo $student->username, PHP_EOL;
}

Each page is a request against that route's rate limit, so at 100 per page, 5,000 students cost 50 requests. See Pagination.

Downloads

Certificates and reports download as PDFs. Only released documents can be listed or downloaded. Pass the document's _id or its shortId:

$file = $stem->certificates()->download($certificateIdOrShortId);

$file->saveTo('/var/exports');   // saves under the server's own file name
$bytes = $file->contents();      // or take the bytes
$stream = $file->stream();       // or pipe it somewhere

The body is held in a php://temp stream, so a large PDF spills to disk instead of sitting in memory. A document that doesn't exist, isn't released, isn't yours or has no file all answer the same 404 not_found. See Certificates and reports.

Rate limits and retries

The limit is 100 requests per 60 seconds, per account, per operation (each operation counted on its own; see Rate limits), counted for your account wherever you call from. Counted responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A 429 too_many_requests carries Retry-After in seconds.

Wait at least that long. Requests sent during a block are refused too, but they don't extend it, and a block never outlasts one 60-second window. If your code doesn't have Retry-After to hand, 60 seconds is always enough:

use MainTeam\ApiClient\Exception\MtoError;

function withRateLimitRetry(callable $call, int $attempts = 3): mixed
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $call();
        } catch (MtoError $e) {
            if ($e->status !== 429 || $attempt === $attempts) {
                throw $e;
            }
            sleep(60); // one full window always clears a block
        }
    }
}

$page = withRateLimitRetry(fn () => $client->students()->list(page: 1, limit: 100));

Only retry what is safe to repeat: reads, and POST /application for the same exam. Don't retry POST /student blindly (see step 1), don't retry other 4xx answers, and never retry a sign-in link. See Retries and idempotency.

Models

Models have typed properties for the documented fields and keep everything else:

echo $student->username;          // a documented field
echo $student['somethingNew'];    // a field added after your package version
var_dump($student->raw);          // the whole decoded object

Only id is guaranteed. Every other property is nullable, and a field that arrives with an unexpected type reads as null instead of throwing. That's what lets an older package keep working when the API adds fields.

Two response details catch people out:

  • Reads resolve their references. A student read carries country, city, school, grade, supervisor and partner as objects when they're set. An application read carries its exam, its payment and a short user (_id, mainId, firstName, lastName). The exception is the application listing by exam, where exam stays an id. Registration and updates answer with the reference fields as ids.
  • The student on an application carries _id and mainId. The user on an application is the organization's copy of the student, so match on its mainId, which is the id you registered them with. See Identifiers.

A different HTTP client

CurlTransport is the default. Implement the Transport interface to run the client through PSR-18, a proxy, or a fixture in your tests. The package README shows the adapter. Whatever you plug in, keep TLS certificate verification on, and keep the Authorization header out of your transport's logs.

Next steps

Search the API documentation

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