Concepts
Versioning and deprecation
You build against the API once and expect it to keep working. This page explains the promises that make that possible: which changes can happen without notice, which ones can't, how you'll hear about them, and what your client has to do on its side.
Two version numbers
| Where you see it | What it tells you | |
|---|---|---|
| Major version | The URL: https://api.main-team.org/v1/... | The contract you call. Every operation is under /v1. A breaking change needs a new major version, a new URL prefix such as /v2. |
| Documented version | The version badge on these pages, and the changelog | The exact behavior these pages describe, as semantic versioning: MAJOR.MINOR.PATCH. |
The API reference shows the documented version these pages describe. The changelog lists every version and what changed in it; the first, 1.0.0, also lists the behaviors the API started with.
The documented version moves like this:
- Patch (
1.0.0→1.0.1): a fix or clarification that changes nothing your client can rely on. - Minor (
1.0.x→1.1.0): something new that is backward compatible, such as a new operation or a new response field. - Major (
1.x→2.0.0): a breaking change. It comes with a new URL prefix, and/v1keeps working alongside it (see Deprecation and removal).
Put the base URL, /v1 included, in one place in your configuration. The API sends no version header, and it doesn't need one: the path says which contract you are calling.
What we will change without notice
These changes are backward compatible. They can arrive in any minor or patch version, and your client must not break when they do:
- New operations, on new paths.
- New fields in responses, at any level of any object.
- New optional fields in requests. Until you send them, nothing changes for you.
- New values in a response field that already has a fixed set of values.
- New error codes, for situations that didn't have their own code before.
- New response headers.
- Different wording in
messagefields, on successes and on errors. - A different order of fields in an object, or of items in a list you didn't ask to have sorted.
- More organizations in
GET /v1/organization, and new countries, grades, exam categories and exams in the reference data. - Relaxed validation, meaning a request that used to be refused is now accepted.
What counts as breaking
These changes need a new major version:
- Removing or renaming an operation, a path, a request field or a response field.
- Changing a field's type or format, for example turning a string into an object, or changing the
DD/MM/YYYYbirth-date format. - Making an optional request field required, or adding a new required field.
- Changing the status code or the
error.codethe API gives for an existing situation. - Requiring a different permission for an existing operation.
- Changing the success or error envelope.
- Changing what an operation does in a way that makes a correct client wrong, for example making
POST /v1/{organizationId}/applicationstop being idempotent.
Build a tolerant client
The API is strict about what it accepts: it refuses any request field it doesn't know with 400 bad_request. Your client should be the opposite about what it receives. That asymmetry is what lets the API add things without breaking you.
Ignore what you don't recognize. Read the fields you need and ignore the rest. Don't validate responses against a closed schema that fails on an unknown field. That goes for errors too: error may carry an optional details object, and only where the operation says so. Today one does — createStudentImport puts the rows it cannot register there. Treat a details you do not recognize as absent.
Handle unknown values. When a field holds a value you've never seen, fall back to a safe default. Don't throw.
Branch on status and error.code, never on message. Messages are for people and may be reworded at any time. The code values are stable identifiers. If you meet a code your client doesn't know, handle it by its status class:
function classify(status, error) {
switch (error?.code) {
case 'unauthorized':
return 'refresh-token';
case 'too_many_requests':
return 'wait';
case 'conflict':
return 'check-state';
// ...the codes you handle specially
default:
if (status >= 500) return 'retry-later';
if (status >= 400) return 'fix-request';
return 'ok';
}
}
<?php
function classify(int $status, ?array $error): string
{
return match ($error['code'] ?? null) {
'unauthorized' => 'refresh-token',
'too_many_requests' => 'wait',
'conflict' => 'check-state',
default => $status >= 500 ? 'retry-later' : ($status >= 400 ? 'fix-request' : 'ok'),
};
}
Send only documented fields. Because unknown request fields are refused, don't send whole records copied from your own system "just in case". Build each request body from the documented fields.
Treat ids as opaque. Record ids (_id) are 24-character hexadecimal strings. Store them as strings, compare them as strings, and don't read meaning into them. The same goes for the other identifiers you receive, such as a student's username or a document's shortId.
Treat links as opaque. Follow documentation_url in an error, and the url of a sign-in link, as they are. Don't build them yourself or parse them.
Treat a missing field as missing. An exam nobody priced has no price field at all, which is not the same as a price of 0.
Deprecation and removal
When something is going to be removed or changed in a breaking way:
- We announce it in the changelog at least six months before it changes, with what is affected and what to use instead.
- Responses from the affected operations carry
DeprecationandSunsetheaders for that whole period.Deprecationmarks the operation as deprecated, andSunsetgives the date after which it may stop working. - The removal itself only happens in a new major version. Nothing is removed from
/v1. When/v2exists,/v1keeps working. Retiring/v1altogether is also a removal, so it follows the same rule: at least six months' notice in the changelog.
No operation is deprecated at the moment. A deprecation is always announced in the changelog first.
Have your client watch for the headers, so a deprecation reaches someone who can act on it even if nobody reads the changelog that month:
// Wrap your HTTP call once; log every deprecated operation you still use.
function warnIfDeprecated(method, path, res) {
const deprecation = res.headers.get('deprecation');
if (!deprecation) return;
const sunset = res.headers.get('sunset') ?? 'no date given';
console.warn(`[main-team-api] ${method} ${path} is deprecated (sunset: ${sunset}). See https://hub.main-team.org/api/changelog`);
}
<?php
// $headers: response headers with lower-cased names, as collected by the retry helper.
function warnIfDeprecated(string $method, string $path, array $headers): void
{
if (!isset($headers['deprecation'])) {
return;
}
$sunset = $headers['sunset'] ?? 'no date given';
error_log("[main-team-api] {$method} {$path} is deprecated (sunset: {$sunset}). See https://hub.main-team.org/api/changelog");
}
Staying informed
- The changelog lists every version, with breaking changes marked. Each version has its own page, such as 1.0.0.
- The
DeprecationandSunsetheaders reach your logs even when nobody reads the changelog. - The version badge on these pages tells you which version you are reading about.
- Questions go to info@main-team.org. See Support.