# Certificates and reports

> List a student's released certificates and result reports, download the PDFs by id or short id, and keep your copies in sync.

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](https://hub.main-team.org/api/reference/list-student-certificates) | `GET /v1/<organizationId>/certificate/<studentId>` | `certificate/read` | JSON, paginated |
| [Download a certificate](https://hub.main-team.org/api/reference/download-certificate) | `GET /v1/<organizationId>/certificate/download/<certificateId>` | `certificate/read` | the file |
| [List a student's reports](https://hub.main-team.org/api/reference/list-student-reports) | `GET /v1/<organizationId>/report/<studentId>` | `report/read` | JSON, paginated |
| [Download a report](https://hub.main-team.org/api/reference/download-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.

```bash
curl -s "https://api.main-team.org/v1/<organizationId>/certificate/652f1c9b8e4b2a0012a3c4d5?limit=100" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "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 }
}
```

```bash
curl -s "https://api.main-team.org/v1/<organizationId>/report/652f1c9b8e4b2a0012a3c4d5" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "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](https://hub.main-team.org/api/guides/applications#read-applications) 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`.

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

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 `404`s 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

```js
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
<?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:

1. Page through [`GET /v1/<organizationId>/student`](https://hub.main-team.org/api/reference/list-org-students) with `limit=100` to get your students who have access to the organization.
2. For each student, page through their certificates and their reports.
3. Compare each document's `shortId` with the ones you already hold. Download the new ones, and remove your copy of any report that is no longer listed.
4. Record progress (organization, page, last student) so a stopped job can resume where it left off.

For each student:

- `409 conflict` on a list means the student never signed in to that organization. Skip them. There is nothing to collect.
- `404 not_found` on 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_requests` means slow down. Wait the number of seconds in `Retry-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](https://hub.main-team.org/api/rate-limits).

The [Collect results](https://hub.main-team.org/api/tutorials/collect-results) tutorial builds this job in full, with checkpointing, in Node.js and PHP.

## Related

- [Applications](https://hub.main-team.org/api/guides/applications): the `application` a document points to
- [Collect results](https://hub.main-team.org/api/tutorials/collect-results)
- [Identifiers](https://hub.main-team.org/api/identifiers): core ids, organization ids and short ids
- [Requests and responses](https://hub.main-team.org/api/requests-and-responses): downloads and `Content-Disposition`
- Error codes: [`not_found`](https://hub.main-team.org/api/errors#not_found), [`conflict`](https://hub.main-team.org/api/errors#conflict), [`internal_error`](https://hub.main-team.org/api/errors#internal_error)
