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-curlandext-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:
| Value | What it is | Where it goes |
|---|---|---|
apiKey | Your public account identifier, key_ followed by 24 characters. | Anywhere. It's not a secret. |
apiSecret | Your 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.
| Answer | Meaning |
|---|---|
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 conflict | The 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_found | The 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 code | Retry? | Typical fix |
|---|---|---|
400 bad_request | No | Fix the request. The message names the field. |
401 unauthorized | No | Check the key, the secret and your clock. |
403 forbidden | No | Ask your operator for the permission. See Permissions. |
404 not_found | No | Check the id, and that the record is yours. |
409 conflict | No | Read the message: it names the rule. |
429 too_many_requests | Yes, after Retry-After | Slow down. See below. |
500 internal_error | Yes, with backoff | Retry 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,supervisorandpartneras objects when they're set. An application read carries itsexam, itspaymentand a shortuser(_id,mainId,firstName,lastName). The exception is the application listing by exam, whereexamstays an id. Registration and updates answer with the reference fields as ids. - The student on an application carries
_idandmainId. Theuseron an application is the organization's copy of the student, so match on itsmainId, 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
- Register and apply: the full flow, end to end.
- Collect results: a nightly job for certificates and reports.
- Node.js client and Build your own.