Skip to content
API documentation
View as MarkdownOpen in Claude

Tutorials

Send a student into the panel

Your students already sign in to your own site. In this tutorial you add an Open my exam panel button that takes a student straight into an organization's panel, signed in as themselves, with no password to remember. You build it twice, as an Express route and as a plain PHP script, and handle every way the API can refuse.

The API provides a sign-in link. This tutorial covers the one safe way to hand that link to a student.

PropertyValue
RequestPOST /v1/<organizationId>/auth/signin with { "studentId": "…", "redirect": "…" }
Permissionauth/signin on that organization
Lifetime120 seconds from the moment it is issued (expiresIn in the response)
UsesOne. The first request that opens it spends it
Who it signs inWhoever opens it, as the student
What the student needsAccess to that organization. No password, and no confirmed email address
First useCreates the organization's own copy of the student

That last row matters beyond this button. Applications, supervisor links, certificates and reports on an organization all need the organization's copy of the student, which exists only after their first sign-in there. See Organizations.

The pattern: mint on click, redirect at once

  1. The student presses a button on your page. The button submits a form to your server with POST.
  2. Your server works out who the student is from your own session, and looks up the core student id you stored when you registered them.
  3. Your server asks the API for a link.
  4. Your server answers 303 See Other with the link in Location, and Cache-Control: no-store.
  5. The browser follows it at once. After a short pass through the Main Team sign-in address, the student lands in the organization's panel, signed in.

Why each part matters:

  • Mint on click, not on page load. A link minted when your page renders is useless two minutes later, and until then it sits in your HTML as a working credential.
  • POST, not a plain link. Browsers, chat and email link previews, and security scanners fetch plain links ahead of time. If one of them triggers your route, it mints a link and may spend it before the student arrives. A form sent with POST goes only when the student presses the button.
  • Redirect, don't display. A URL shown on screen gets copied into chats and emails, and any preview of it spends it.
  • Never log, email or store the URL. Anyone who opens it within the 120 seconds is signed in as the student. Log the request_id instead; it is all support needs.

Security

Take the student id from your session, never from the request. If your route accepted ?studentId= from the browser, any signed-in student could sign in as any other student you registered. The routes below have no student id in their URL at all.

Before you start

  • Permissions: auth/signin on each organization you send students to. To diagnose a refusal (below) you also need student/read on mto, and to fix one, student/update on the organization (or on mto for the flat route). See Permissions.
  • The core student id for each of your users, stored when you registered them with POST /v1/student. See Register and apply.
  • The organization ids, from GET /v1/organization. They never change, so load them once.
  • The helper file mainteam.mjs or mainteam.php from Register and apply. It signs tokens, retries a 429 after Retry-After, and turns refusals into an ApiError with status, code and requestId.
  • Node.js 20 or newer with Express (npm install express), or PHP 8.1 or newer with sessions.

The request

curl -s -X POST https://api.main-team.org/v1/<organizationId>/auth/signin \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "studentId": "66e6a3f5c1d2b30012a4f9c1" }'
{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=q8x2…&redirectUrl=…",
    "organization": "stem",
    "studentId": "66e6a3f5c1d2b30012a4f9c1",
    "expiresIn": 120
  }
}

Treat url as opaque: redirect to it exactly as it came, and never build or change one yourself. organization is the organization's slug and studentId is the id you sent.

Choosing where the student lands

redirect is optional. Leave it out and the student lands on the organization's home page. To land somewhere else in the organization's panel, send a path on that panel:

redirectResult
(absent)The organization's home page
/The root of the organization's panel
/dashboardAccepted
/exams?tab=upcomingAccepted
dashboardRefused: no leading /
https://example.com/Refused: not a path
//example.comRefused: a second / would leave the site
/\example.comRefused: backslashes are not allowed anywhere
/my examsRefused: whitespace is not allowed

A refused value answers 400 bad_request with redirect must be a site-relative path starting with "/" (e.g. "/dashboard"). The rule keeps every link inside the organization's own panel; a link can never send the student to another site.

Straight to a group challenge. A group challenge's page is where the student's group uploads its work. getGroupChallengeStudent and the student list give its path as panelPath, with {userId} left in it for the link to fill. Send it as is:

{
  "studentId": "<studentId>",
  "redirect": "/stadia/{userId}/group-challenges/6650a1b2c3d4e5f6a7b8c9eb"
}

See Group challenges.

The Express route

Save this next to mainteam.mjs as server.mjs. Replace the stand-in authentication with your own.

// server.mjs: an "Open my exam panel" button for your own students.
// Node.js 20 or newer, Express 4 or 5 (npm install express), and mainteam.mjs next to it.
import express from 'express';
import { api, ApiError } from './mainteam.mjs';

const app = express();

// Replace this with your real authentication. It must put the signed-in user
// on req.user, including the core student id you stored at registration.
app.use((req, res, next) => {
  req.user = { mainTeamStudentId: '66e6a3f5c1d2b30012a4f9c1' };
  next();
});

// Organization ids never change: load them once at start-up.
const organizationIds = new Map(); // slug -> _id

async function loadOrganizations() {
  const res = await api('GET', '/organization?limit=100');
  for (const org of res.data) organizationIds.set(org.slug, org._id);
}

app.get('/', (req, res) => {
  res.type('html').send(`<!doctype html>
<form method="post" action="/panel/stem">
  <button type="submit">Open my STEM exam panel</button>
</form>`);
});

app.post('/panel/:slug', async (req, res) => {
  const organizationId = organizationIds.get(req.params.slug);
  if (!organizationId) return res.status(404).type('text').send('Unknown organization.');

  // From your session, never from the request.
  const studentId = req.user.mainTeamStudentId;

  try {
    const link = await api('POST', `/${organizationId}/auth/signin`, { studentId });
    res.set('Cache-Control', 'no-store');
    return res.redirect(303, link.data.url); // never log link.data.url
  } catch (error) {
    const message = await explainRefusal(error, studentId, req.params.slug);
    const status = error instanceof ApiError && error.status === 403 ? 403 : 502;
    return res.status(status).type('text').send(message);
  }
});

// Turns a refusal into a message for the student, and logs what your staff need.
async function explainRefusal(error, studentId, slug) {
  if (!(error instanceof ApiError)) {
    console.error(`sign-in link failed: ${error.message}`);
    return 'The exam panel cannot be reached right now. Please try again in a minute.';
  }
  console.error(`sign-in link refused: ${error.status} ${error.code} request ${error.requestId}`);

  if (error.status === 403) {
    // Two causes share this status: the student has no access to this organization,
    // or this API account lacks auth/signin there. The student's record tells them apart.
    const student = (await api('GET', `/student/${studentId}`).catch(() => null))?.data;
    const platforms = student?.activatedPlatformsThisSeason ?? [];
    if (student && !platforms.includes('common') && !platforms.includes(slug)) {
      return 'You are not entered for this organization yet. Please contact your school.';
    }
    return 'The exam panel is not available from this site at the moment. We have been notified.';
  }
  if (error.status === 404) {
    return 'We could not find your exam account. Please contact your school.';
  }
  return 'The exam panel cannot be reached right now. Please try again in a minute.';
}

await loadOrganizations();
app.listen(3000, () => console.log('Listening on port 3000'));

Run it with node server.mjs, open port 3000 of that machine in your browser and press the button. Your browser's network tab shows one POST /panel/stem answered 303, then the sign-in hand-off, then the panel.

The plain PHP route

Two files: the page with the button, and the script it posts to. Both assume your own login has already put the student's core id in the session as mainteam_student_id.

<?php
// index.php: the page with the button. Your own login has already run.
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(16));
?>
<!doctype html>
<form method="post" action="/panel.php?org=stem">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <button type="submit">Open my STEM exam panel</button>
</form>
<?php
// panel.php: POST /panel.php?org=stem signs the current student into that organization's panel.
declare(strict_types=1);
require __DIR__ . '/mainteam.php';

session_start();

// Organization ids never change: keep the slug => _id map in your configuration.
const ORGANIZATION_IDS = [
    'stem' => '64b7f1c2a9e3d45f10c2b7a1',
];

function fail(int $status, string $message): never
{
    http_response_code($status);
    header('Content-Type: text/plain; charset=utf-8');
    echo $message;
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    fail(405, 'Please use the button.');
}
$csrf = $_POST['csrf'] ?? null;
if (!is_string($csrf) || !hash_equals($_SESSION['csrf'] ?? '', $csrf)) {
    fail(400, 'Please reload the page and try again.');
}

// From your session, never from the request.
$studentId = $_SESSION['mainteam_student_id'] ?? null;
if (!is_string($studentId)) {
    fail(401, 'Please sign in first.');
}

$slug = (string) ($_GET['org'] ?? '');
$organizationId = ORGANIZATION_IDS[$slug] ?? null;
if ($organizationId === null) {
    fail(404, 'Unknown organization.');
}

$api = MainTeam::fromEnv();

try {
    $link = $api->call('POST', "/$organizationId/auth/signin", ['studentId' => $studentId]);
    header('Cache-Control: no-store');
    header('Location: ' . $link['data']['url'], true, 303); // never log this URL
    exit;
} catch (ApiError $e) {
    error_log("sign-in link refused: {$e->status} {$e->errorCode} request {$e->requestId}");

    if ($e->status === 403) {
        // Two causes share this status: the student has no access to this organization,
        // or this API account lacks auth/signin there. The student's record tells them apart.
        try {
            $student = $api->call('GET', "/student/$studentId")['data'] ?? null;
        } catch (ApiError) {
            $student = null;
        }
        $platforms = $student['activatedPlatformsThisSeason'] ?? [];
        if ($student !== null && !in_array('common', $platforms, true) && !in_array($slug, $platforms, true)) {
            fail(403, 'You are not entered for this organization yet. Please contact your school.');
        }
        fail(403, 'The exam panel is not available from this site at the moment. We have been notified.');
    }
    if ($e->status === 404) {
        fail(404, 'We could not find your exam account. Please contact your school.');
    }
    fail(502, 'The exam panel cannot be reached right now. Please try again in a minute.');
} catch (RuntimeException $e) {
    error_log('sign-in link failed: ' . $e->getMessage());
    fail(502, 'The exam panel cannot be reached right now. Please try again in a minute.');
}

The hidden csrf field is ordinary cross-site request protection for a form that acts on the signed-in user. If your framework already protects forms, use its mechanism instead.

StatusMessageCauseWhat to do
403 forbiddenStudent is not activated for organization stem.The student's activatedPlatformsThisSeason holds neither common nor this organization's slugGrant access (below), then let the student press the button again
403 forbiddenInsufficient role permissionsYour account lacks auth/signin on this organizationAsk your operator for the role. Retrying does not help
404 not_foundStudent not found!The id is not one of your students. A student of another account, a mistyped id and a missing student all answer the sameCheck the id stored against this user
404 not_foundOrganization not found!The organization id in the path is wrongReload your organization ids
400 bad_requeststudentId must be a mongodb idThe stored id is not a 24-character hex idFix the stored id
400 bad_requestredirect must be a site-relative path…The redirect value breaks the rule aboveSend a path, or leave it out
429 too_many_requestsToo many requests to this operation. Wait the number of seconds in Retry-After, then try again.More than 100 links in 60 seconds across your accountWait Retry-After seconds; the helper does this for you
500 internal_errorCould not issue a sign-in token, please retry.A rare fault while issuing the linkRetry once

Both 403 answers carry the code forbidden, so the routes above do not read the message to tell them apart. They read the student instead. If the student's list of organizations lacks this one, access is the problem. Otherwise it is your account's permission. See Errors.

Granting access to an organization

activatedPlatformsThisSeason lists the organizations a student may use: common means every organization, and otherwise it holds organization slugs. A student you register without the field gets ["common"], so you see this refusal only if you set the list yourself.

The simplest fix adds one organization and touches nothing else: send the organization route an empty body. Here the student was on ["neo"], and <organizationId> is stem's:

curl -s -X PUT https://api.main-team.org/v1/<organizationId>/student/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
{
  "success": true,
  "message": "Student updated successfully.",
  "data": {
    "_id": "66e6a3f5c1d2b30012a4f9c1",
    "username": "XXB1045",
    "firstName": "Jane",
    "lastName": "Doe",
    "activatedPlatformsThisSeason": ["neo", "stem"]
  }
}

Without activatedPlatformsThisSeason in the body, this route puts the organization in the path into the list, keeps the entries already there, and leaves a list that holds common as it is. Profile fields you include are updated too. It needs student/update on that organization.

To add several organizations at once, send them in activatedPlatformsThisSeason, on either route. They are added to the student's list, and nothing already on it is removed:

curl -s -X PUT https://api.main-team.org/v1/student/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "activatedPlatformsThisSeason": ["stem", "gmath"] }'

This needs student/update on mto. Either route accepts common and the slugs stem, hilingua, neo, gmath and coding; any other value, mto included, is a 400. No update removes an organization from the list. See Students.

What the student sees

  1. The panel, signed in. The browser passes briefly through the Main Team sign-in address, then lands in the organization's panel as the student, on the page redirect named.
  2. On the first visit to an organization, that organization creates its own copy of the student. From then on your applications, supervisor links, certificates and reports on that organization work for this student.
  3. If their email address is not confirmed, the student still gets in, but the panel asks them to confirm the address with a 6-digit code, which it sends from no-reply@main-team.org. The prompt appears on every page until the address is confirmed, My Exams included, so they must confirm before they can start an exam. They can't put it off; the only way out is to sign out. A student already inside an exam room is not interrupted. A code is valid for 15 minutes, and they can ask for a new one after 60 seconds. Tell your students to check their spam folder for the code, and to confirm well before an exam day, for example right after their first sign-in.
  4. If the link was already used or has expired, the browser shows an error saying the access token is invalid or expired. Nothing is wrong with the account. Pressing your button again mints a fresh link.

Note

Changing a student's email address through the API withdraws its confirmation, because nobody has yet proved they receive mail at the new address. The next time the student uses the panel, it asks them to confirm the new address and keeps asking until they do. Sending the address they already have changes nothing.

Test it

  • Press the button: you land in the organization's panel, signed in.
  • In the browser's network tab, copy the Location of your 303 and open it in a private window. You get the invalid-or-expired error: the link was spent by the first visit.
  • Mint a link with curl, wait more than two minutes, then open it. Same error: it expired.
  • Register a test student with activatedPlatformsThisSeason: ["neo"] and press the stem button: your page shows the "not entered" message, not a raw error. An update can't take an organization away, so use a new student for this.
  • Search your logs for accessToken=. You should find nothing.

Next steps

Search the API documentation

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