Skip to content
API documentation
View as MarkdownOpen in Claude

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

LanguagePackageInstallNeedsStatus
Node.js / TypeScript@main-team/api-clientnpm install @main-team/api-clientNode.js 20 or neweravailable
PHPmain-team/api-clientcomposer require main-team/api-clientPHP 8.1 or newer, ext-curl, ext-jsonavailable
  • 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

ConcernWithout a clientWith a client
AuthenticationYou 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 URLYou join https://api.main-team.org/v1 and the path.Fixed. The client can't be pointed anywhere else (see below).
OrganizationsYou 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 envelopeYou 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.
ErrorsYou parse { error: { code, message, documentation_url, request_id } }.You catch typed exceptions that carry the same code and HTTP status.
Impossible callsThe server answers 400 bad_request.The client throws the same bad_request / 400 before sending, so one handler covers both.
PaginationYou loop page until totalPages.A helper walks every page for you.
DownloadsYou 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 whenCall 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 Deprecation and Sunset headers. 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.

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.

Search the API documentation

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