Guides
Certificates and reports
When results come out, an organization publishes certificates and result reports for the students who sat its exams. This API lets you list them for each of your students and download the PDF files. It never creates, changes or withdraws them.
Only released documents
An organization prepares certificates and reports before results are announced. It then releases them. Until then, they are embargoed.
| Document | Visible through the API when |
|---|---|
| Certificate | the organization has released it |
| Report | the organization has released it and has not canceled it |
A document that isn't released is never listed. Asked for by id, it answers exactly like a document that doesn't exist. So an empty list shortly after a sitting usually means "not released yet", not "missing". Check again after the organization announces results.
A report that is withdrawn after release (canceled) disappears from the list and can no longer be downloaded. If you mirror files, remove your copy when a report stops appearing.
Operations
| Operation | Request | Permission | Returns |
|---|---|---|---|
| List a student's certificates | GET /v1/<organizationId>/certificate/<studentId> | certificate/read | JSON, paginated |
| Download a certificate | GET /v1/<organizationId>/certificate/download/<certificateId> | certificate/read | the file |
| List a student's reports | GET /v1/<organizationId>/report/<studentId> | report/read | JSON, paginated |
| Download a report | GET /v1/<organizationId>/report/download/<reportId> | report/read | the file |
Every permission's target is the organization in the path, or *.
List a student's documents
<studentId> is the student's core id, the _id that registration returned. The lists cover every season, not just the current one, because your records outlive a season.
curl -s "https://api.main-team.org/v1/<organizationId>/certificate/652f1c9b8e4b2a0012a3c4d5?limit=100" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Certificates fetched successfully.",
"data": [
{
"_id": "6712a0b1c2d3e4f5a6b7c8d9",
"application": "6703d4e5f6a7b8c9d0e1f203",
"title": "Gold Medal",
"shortId": "K2J8X0Q4TZ",
"active": true,
"createdAt": "2026-12-01T09:00:00.000Z",
"updatedAt": "2026-12-05T12:00:00.000Z"
}
],
"pagination": { "page": 1, "limit": 100, "total": 1, "totalPages": 1 }
}
curl -s "https://api.main-team.org/v1/<organizationId>/report/652f1c9b8e4b2a0012a3c4d5" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Reports fetched successfully.",
"data": [
{
"_id": "6712a0b1c2d3e4f5a6b7c8e0",
"application": "6703d4e5f6a7b8c9d0e1f203",
"relatedApplications": [],
"shortId": "Q8ZK2M4X7P",
"isActive": true,
"isCanceled": false,
"createdAt": "2026-12-01T09:00:00.000Z",
"updatedAt": "2026-12-05T12:00:00.000Z"
}
],
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}
The examples are trimmed. Each document comes back with all its stored fields. A report also carries the result data it was generated from (resultTable, reportDetail, extras), and the shape of that data varies by organization and exam. The PDF is the authoritative copy.
| Field | Meaning |
|---|---|
_id | The document's id. Use it, or shortId, to download. |
shortId | A short 10-character id of upper-case letters and digits, accepted by the download in place of _id. Send it exactly as returned: the match is case-sensitive, so a lower-cased copy answers 404. |
application | The application the document is for: the same _id the application routes return. Use it to tell which exam a document belongs to. |
user | On some certificates only: the organization's id for the student. Certificates are listed whether they are attached to one of the student's applications or to the student directly. |
The lists are paginated with page and limit (default 20, at most 100) and have no documented order.
Refusals
| Situation | Answer |
|---|---|
| The student isn't yours, or doesn't exist | 404 not_found, Student not found! |
| The student has never signed in to this organization | 409 conflict, Student has never signed in to stem, so stem holds no record for them. … A student who never signed in there can't have sat its exams, so for a bulk job this means "nothing to collect". |
<studentId> isn't 24 hexadecimal characters | 400 bad_request |
Download a file
GET /v1/<organizationId>/certificate/download/<certificateId> and GET /v1/<organizationId>/report/download/<reportId> send the file itself, not JSON. Both accept either the document's _id or its shortId.
curl -s -D headers.txt -o gold-medal.pdf \
"https://api.main-team.org/v1/<organizationId>/certificate/download/K2J8X0Q4TZ" \
-H "Authorization: Bearer $TOKEN"
A successful download answers 200 with these headers:
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 482113
Content-Disposition: attachment; filename="Ogrenci Sukru - Gold Medal.pdf"; filename*=UTF-8''%C3%96%C4%9Frenci%20%C5%9E%C3%BCkr%C3%BC%20-%20Gold%20Medal.pdf
X-Request-Id: 7c1d6f0a-2b9e-4c3d-8f1a-5e6b7c8d9e0f
| Header | What to do with it |
|---|---|
Content-Type | The type the file was stored with. Usually application/pdf, but it can be application/octet-stream. Don't reject a file because of it. |
Content-Length | Present when the size is known. If you receive fewer bytes, the download failed. |
Content-Disposition | The file's real name, in two forms. filename* is the exact name, UTF-8 and percent-encoded (RFC 5987); prefer it. filename is an ASCII-only fallback with accents removed, for clients that don't read filename*. |
Warning
Errors always arrive before the file, as the usual JSON error envelope with the matching status. Once the file has started, nothing more can be reported: if storage fails halfway, the API closes the connection, and you are left with a truncated file and no error response. Treat any transfer that ends early, or with fewer bytes than Content-Length, as failed, and delete what you wrote.
One answer for every refusal
| Situation | Answer |
|---|---|
| No document has this id or short id | 404 not_found, Not found! |
| The document isn't released, or is canceled | 404 not_found, Not found! |
| It belongs to another account's student | 404 not_found, Not found! |
| It has no file, or its file is missing | 404 not_found, Not found! |
| File storage can't be reached | 500 internal_error. Retry later with backoff. |
The four 404s are identical on purpose, so a download never reveals whether a document exists in someone else's hands. When a download you expected to work answers 404, check that you're asking the organization the document belongs to, and that it still appears in the student's list.
Code: download to a file
import { createWriteStream } from 'node:fs';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
// kind is 'certificate' or 'report'; id is an _id or a shortId.
async function download(organizationId, kind, id, dir) {
const res = await fetch(
`https://api.main-team.org/v1/${organizationId}/${kind}/download/${encodeURIComponent(id)}`,
{ headers: { Authorization: `Bearer ${await getToken()}` } },
);
if (!res.ok) {
const { error } = await res.json(); // errors are always JSON
throw Object.assign(new Error(error.message), { status: res.status, code: error.code, requestId: error.request_id });
}
const name = path.basename(fileName(res.headers.get('content-disposition')) ?? `${id}.pdf`);
const target = path.join(dir, name);
try {
await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
} catch (err) {
await rm(target, { force: true }); // a cut-off transfer is a failed download
throw err;
}
return target;
}
function fileName(disposition) {
if (!disposition) return null;
const exact = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
if (exact) return decodeURIComponent(exact[1]);
const ascii = /filename="([^"]*)"/i.exec(disposition);
return ascii ? ascii[1] : null;
}
<?php
// $kind is 'certificate' or 'report'; $id is an _id or a shortId.
function download(string $organizationId, string $kind, string $id, string $dir): string
{
$tmp = tempnam($dir, 'dl');
$fh = fopen($tmp, 'wb');
$headers = [];
$ch = curl_init("https://api.main-team.org/v1/{$organizationId}/{$kind}/download/" . rawurlencode($id));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getToken()],
CURLOPT_FILE => $fh,
CURLOPT_TIMEOUT => 120,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$ok = curl_exec($ch); // false when the transfer is cut off
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
fclose($fh);
if (!$ok || $status !== 200) {
$error = json_decode((string) file_get_contents($tmp), true)['error'] ?? [];
unlink($tmp);
throw new RuntimeException(sprintf('download failed: %s %s %s',
$status, $error['code'] ?? 'transfer', $error['request_id'] ?? ''));
}
$name = fileName($headers['content-disposition'] ?? '') ?? "{$id}.pdf";
$target = $dir . DIRECTORY_SEPARATOR . basename($name);
rename($tmp, $target);
return $target;
}
function fileName(string $disposition): ?string
{
if (preg_match("/filename\\*=UTF-8''([^;]+)/i", $disposition, $m)) {
return rawurldecode($m[1]);
}
if (preg_match('/filename="([^"]*)"/i', $disposition, $m)) {
return $m[1];
}
return null;
}
Both examples pass the name through basename. The name comes from the organization's records, so never use it as a path without doing that.
Keeping your copies in sync
A nightly job that collects every new document looks like this, for each organization you work with:
- Page through
GET /v1/<organizationId>/studentwithlimit=100to get your students who have access to the organization. - For each student, page through their certificates and their reports.
- Compare each document's
shortIdwith the ones you already hold. Download the new ones, and remove your copy of any report that is no longer listed. - Record progress (organization, page, last student) so a stopped job can resume where it left off.
For each student:
409 conflicton a list means the student never signed in to that organization. Skip them. There is nothing to collect.404 not_foundon a download means the document was withdrawn or isn't available to you. Skip it and check the list again next run.429 too_many_requestsmeans slow down. Wait the number of seconds inRetry-After, then continue.
Budget for the rate limit. Each API account may make 100 requests per 60 seconds to each operation. Every call to the same operation shares one budget, whichever organization or student it names. Listing certificates for 1,000 students therefore takes at least 10 minutes. Listing reports, downloading certificates and downloading reports are separate operations with budgets of their own, so you can run those in parallel. Pace each operation at about one request every 0.6 seconds, rather than bursting and waiting. See Rate limits.
The Collect results tutorial builds this job in full, with checkpointing, in Node.js and PHP.
Related
- Applications: the
applicationa document points to - Collect results
- Identifiers: core ids, organization ids and short ids
- Requests and responses: downloads and
Content-Disposition - Error codes:
not_found,conflict,internal_error