Clients
Client libraries
There are two official clients for the Main Team API, one for Node.js and one for PHP. You don't need either of them. Every operation is plain HTTPS with a JSON envelope, and the API reference describes all of it. A client handles four things you would otherwise write yourself: signing tokens, unwrapping the response envelope, turning error responses into typed exceptions, and refusing calls that can't succeed before they are sent.
This page tells you what the clients do, how to get them, and when to skip them. The rules at the end apply whether or not you use a client, so read them either way.
The packages
| Language | Package | Install | Needs | Status |
|---|---|---|---|---|
| Node.js / TypeScript | @main-team/api-client | npm install @main-team/api-client | Node.js 20 or newer | available |
| PHP | main-team/api-client | composer require main-team/api-client | PHP 8.1 or newer, ext-curl, ext-json | available |
- Node.js client: ESM, CommonJS and TypeScript types in one package, with no runtime dependencies.
- PHP client: no dependencies beyond two standard extensions, and a pluggable transport if you want PSR-18 or a proxy.
- Build your own: what a client has to do, a minimal client in Node.js and in PHP, and a conformance checklist.
Getting access
Both packages are private. Access is issued with your API credentials: the operator who gives
you your apiKey and apiSecret also gives you access to the packages. If you have credentials
but can't install a package, write to info@main-team.org and say
which package you need.
Note
There is no self-service sign-up for the API or the packages. Accounts are created by an operator by arrangement. The Quickstart explains what you receive and what to do with it.
What a client does for you
| Concern | Without a client | With a client |
|---|---|---|
| Authentication | You sign an HS256 token with your apiSecret, set kid and sub to your apiKey, and replace it before it expires. | You pass apiKey and apiSecret once. The client signs short-lived tokens for you. |
| Base URL | You join https://api.main-team.org/v1 and the path. | Fixed. The client can't be pointed anywhere else (see below). |
| Organizations | You call GET /v1/organization and map slugs to _ids, because paths take the _id only. | You call organization('stem') and the client looks the _id up and caches it. |
| Response envelope | You read data, message and pagination out of { success, message, data, pagination? }, and treat validate-me as the one JSON route without an envelope. | Methods return the data, or { data, pagination } for lists. |
| Errors | You parse { error: { code, message, documentation_url, request_id } }. | You catch typed exceptions that carry the same code and HTTP status. |
| Impossible calls | The server answers 400 bad_request. | The client throws the same bad_request / 400 before sending, so one handler covers both. |
| Pagination | You loop page until totalPages. | A helper walks every page for you. |
| Downloads | You stream the PDF and read the file name from Content-Disposition. | You get a file object with the server's file name and the bytes. |
A client does not change the API's rules. It never invents a restriction the API doesn't have, and it doesn't hide one the API does have. A registration with a duplicate email, an exam the student can't take, or a paid application you try to delete all fail the same way through a client as over HTTP. The difference is that you get a typed exception instead of a JSON body.
Why the base URL is locked
Neither client has a baseUrl option. Every request carries a bearer token that is valid for your
whole account until it expires. If a client could be pointed at another host, by a typo, a
misread environment variable or a malicious configuration, that host would receive a token it
could replay as you. Fixing the host in the client removes that risk.
Note
The sandbox, at https://apisnd.main-team.org/v1, has its own accounts, seeded reference data, no
emails sent and no real payments. The clients accept only the production base URL, so they can't
reach it. For the sandbox, call the API over HTTPS directly, or use a client of your own such as
the one in Build your own client. See
Environments.
Server-side only
Use the clients from your servers only. In production the API sends no CORS headers, so a web
page on another origin can't call it. That is deliberate: a browser page that could call the API
would have to carry your apiSecret, which would hand it to every visitor. The same applies to
mobile and desktop apps: anything you ship to a user can be unpacked.
If your users need to reach the student panel from your site, your server mints a sign-in link and redirects the browser to it. The Send a student to the panel tutorial shows how.
Client or plain HTTP?
| Use a client when | Call the API directly when |
|---|---|
| Your backend is Node.js or PHP. | Your backend is another language: see Build your own. |
| You want typed errors and pre-validated calls. | You already have an HTTP layer with retries, logging and metrics that you want to reuse. |
| You want organization slugs resolved for you. | You need a route before your package version supports it. |
| You'd rather not write token caching. | You're scripting a one-off with curl and openssl. |
Mixing both is fine. The API doesn't know or care which one sent a request.
Keeping a client current
The clients carry the same list of routes as the API. When the API gains a route or a field, the changelog says so, and a client release follows. Two things to know:
- Tolerate what you don't recognize. New fields can appear in responses without notice, because adding a field is not a breaking change. The PHP client's models keep fields they don't model instead of failing on them, and your own code should do the same. See Versioning.
- Watch for deprecations. A route or field that is going away is announced at least 6 months
ahead in the changelog, and responses from it carry
DeprecationandSunsetheaders. Removals happen only in a new major version of the API. The changelog lists every version, and the API reference shows the one these pages describe.
Rules that apply in any language
These are properties of the API, not of the clients. Each one links to the guide that covers it in full.
Every studentId is the core id
Each organization keeps its own copy of a student, under a different _id. Every studentId you
send, on every route, is the id that registration (POST /v1/student) returned. Never use an id
read from an organization's own records. The student on an application is that organization's
copy: its _id is the organization's id for them, and its mainId is the id you registered them
with. Match on mainId. See Identifiers.
A student signs in once before organization actions
Nothing in an organization refers to a student until that organization holds a copy of them, and the copy is created the first time the student follows a sign-in link into that organization. Until then:
- Creating an application, or listing that student's applications, certificates or reports, answers
409 conflict. The message tells you to send a sign-in link first. - Linking a supervisor answers
404 not_found, the one answer that route gives to every refusal. - The organization's application lists leave the student out, without an error.
The organization's student list is different. It shows every student of yours who has access to that organization, whether or not they have signed in there.
Sign-in links are single-use and short-lived
POST /v1/{organizationId}/auth/signin returns a URL that signs the student in once and
expires 120 seconds after it was issued. Redirect the browser to it immediately. Don't store
it, email it, log it or paste it into chat, where a link preview can use it up. Mint a new one
every time. A student whose email address isn't confirmed yet still gets a link. A student with no
access to that organization gets 403 forbidden instead. The optional redirect must be a path on
the organization's site that starts with a single /, such as /dashboard. A full URL is refused
with 400 bad_request.
The exam listing is not a catalog
GET /v1/{organizationId}/exam returns only exams that are open for application. A closed
exam can't be read by id or applied to. To choose an exam for a particular student, use the
per-student picker, GET /v1/{organizationId}/exam/available/{studentId}. It applies that
student's grade and country and leaves out what they already hold. Every leaf of its tree carries a
matchedExam; send matchedExam._id as examId. See Exams.
Applications are limited to what the picker offers
POST /v1/{organizationId}/application runs the same checks as the picker. An exam the picker
wouldn't offer is refused with 409 conflict, and the message says which rule failed. Applying
twice to the same exam is safe: the second call answers 200 with "Application already exists."
and returns the existing application, where the first answered 201. See
Applications.
Registering the same email twice is a conflict
POST /v1/student is not idempotent. If the email is already registered to your account, you
get 409 conflict and the message names the _id of the student who holds it. Fetch or update
that student instead of retrying. If a different account registered the address, you get the same
409 without an id: email addresses are unique across the whole platform. A retry loop won't help
either way. See Students.
Unknown fields are errors
A request body with any property the API doesn't declare is refused with 400 bad_request, and
the message names the property. Send the documented fields, not a whole record copied from your
own system. See Requests and responses.
Passwords are optional, and need an extra permission
Only two routes set a password: registration (optional password) and
PUT /v1/student/{studentId}/password. Setting a password either way needs the auth/signin
permission on mto, the permission a sign-in link needs. A registration that carries a password
without that permission is refused with 403 forbidden. A password must be at least 5 characters and at most 72 bytes,
and must not contain the student's name, username or email address. Once the student has confirmed
their email address, the password route answers 409 conflict, and you send them a sign-in link
instead. See Passwords.
A 403 is a permission, not a token
403 forbidden means the token is fine but your account lacks the permission for that route on
that organization. Re-signing won't help. Ask your operator for the role. See
Permissions.
A 429 means wait
Each account may make 100 requests per 60 seconds to each operation, and each operation is
counted on its own. The count belongs to your
account, not to your IP address, so spreading calls over several servers doesn't raise it. After a
429 too_many_requests, wait the number of seconds in Retry-After. A request sent sooner is
refused too. See Rate limits.
Getting help
When something fails, keep the request_id from the error body (it's also the X-Request-Id
response header) and the time in UTC. Send them to
info@main-team.org with the operation you called. Never send your
apiSecret, a token or a sign-in link. See Support and
Troubleshooting.