# PHP client

> Install and use main-team/api-client for PHP 8.1+. Covers setup, organizations, a complete flow, exceptions, pagination, downloads, models and transports.

`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](https://hub.main-team.org/api/clients#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](mailto:info@main-team.org).

```bash
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
<?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.

**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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/clients#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](https://hub.main-team.org/api/clients/build-your-own).

## Check that your credentials work

`whoami()` calls [`GET /v1/api-account/validate-me`](https://hub.main-team.org/api/reference/get-current-api-account) and
returns the account your token belongs to:

```php
$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`.

```json
{
  "_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](https://hub.main-team.org/api/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:

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

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

Or take them all at once, keyed by slug:

```php
$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.

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](https://hub.main-team.org/api/organizations) and [Permissions](https://hub.main-team.org/api/permissions).

## A complete flow

Register a student, send them into an organization once, then enter them for an exam. The
[Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply) tutorial walks through the same flow over
plain HTTP.

### Step 1: register the student

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

```php
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](https://hub.main-team.org/api/guides/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:

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

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

The API answers:

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

**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](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/tutorials/send-student-to-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}`](https://hub.main-team.org/api/reference/list-available-exams),
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:

```php
$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](https://hub.main-team.org/api/guides/exams).

### Step 4: enter the student

```php
$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](https://hub.main-team.org/api/guides/applications) for moving and deleting applications, and how
payments behave.

## Exceptions

There is one exception class per code in the [error reference](https://hub.main-team.org/api/errors), under the
`MainTeam\ApiClient\Exception` namespace, and all of them extend `MtoError`:

```php
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](https://hub.main-team.org/api/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`:

```php
$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:

```php
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](https://hub.main-team.org/api/pagination).

## Downloads

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

```php
$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](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/rate-limits#what-per-operation-means)), 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:

```php
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](https://hub.main-team.org/api/retries-and-idempotency).

## Models

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

```php
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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/tutorials/register-and-apply): the full flow, end to end.
- [Collect results](https://hub.main-team.org/api/tutorials/collect-results): a nightly job for certificates and reports.
- [Node.js client](https://hub.main-team.org/api/clients/node) and [Build your own](https://hub.main-team.org/api/clients/build-your-own).
