Courstore API

School API

Enroll students into your school's courses from your CRM or any other system. Payment stays on your side; Courstore opens access and notifies the student.

Base URL: https://api.courstore.com/v1/school-apiSwagger specification →

Overview

The School API lets your system (CRM, payment service, your own website) enroll students into your school's courses on Courstore and check their access. You collect payments yourself; the API only opens access to the course.

  • Base URL: https://api.courstore.com/v1/school-api
  • Format: JSON. Request bodies use Content-Type: application/json and are limited to 4 KB.
  • Authentication: the school key in the X-School-Key header.
  • Availability: Pro (annual), Lifetime and Enterprise plans. On other plans the API responds with 403 tariff_not_allowed; the plan is checked on every request.
  • Errors are always JSON: {"error": "code", "message": "text"}.

Full machine-readable specification: Swagger.

MethodURLPurpose
GET/coursesList the school's courses and their default access period
POST/studentsFind a student by phone or create one
POST/enrollmentsEnroll a student into a course (creates the student if missing)
GET/enrollments/{requestId}Enrollment status by requestId
GET/enrollments?phone=&courseId=A student's current access to a course

Quick start

  1. Check your plan. The API is available on Pro (annual), Lifetime and Enterprise.
  2. Get a key. In your school dashboard open “Integrations” → “School API” → “Generate key”. A key like cs_live_… is shown only once, so store it somewhere safe right away. You can have up to 3 active keys and revoke any of them.
  3. Send your first request. List the school's courses; the response contains course id values you need for enrollment.
bash
export KEY="cs_live_..."
export BASE="https://api.courstore.com/v1/school-api"
curl
curl -s "$BASE/courses" \
  -H "X-School-Key: $KEY"
JSON · 200
[
  {
    "id": 1234,
    "title": "Основы маркетинга",
    "slug": "osnovy-marketinga",
    "status": "publish",
    "access": {
      "mode": "months",
      "amount": 3
    }
  },
  {
    "id": 1301,
    "title": "Мастер-класс",
    "slug": "master-klass",
    "status": "publish",
    "access": {
      "mode": "unlimited",
      "amount": null
    }
  }
]

Then enroll a student into a course. Details are in “Enroll in a course”:

curl
curl -s -X POST "$BASE/enrollments" \
  -H "X-School-Key: $KEY" \
  -H "Idempotency-Key: order-0042" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+77001234567","courseId":1234,"access":{"mode":"days","amount":30},"notify":true,"orderRef":"CRM-0042"}'
JSON · 202
{
  "requestId": "req_882",
  "status": "queued",
  "studentId": 118350,
  "enrollmentId": null,
  "expireDate": null,
  "lastDay": null
}

A 202 response with status: queued means the enrollment was accepted into the queue. Get the result from “Enrollment status” (usually ready within seconds).

Authentication

Every request must include the X-School-Key header with the school key:

curl
curl -s "https://api.courstore.com/v1/school-api/courses" \
  -H "X-School-Key: cs_live_..."
  • The key is created in the dashboard: “Integrations” → “School API”. The format is cs_live_…; it is shown once.
  • A school can have up to 3 active keys. Any key can be revoked in the same card; integrations using it will stop working.
  • Keep the key on your server. Do not put it in mobile apps, browser code or public repositories.
  • An expiry date is optional: when issuing a key you may set a "valid until" date (inclusive). After it, the key answers 401 key_expired — issue a new one.
  • When the school's plan ends, all its keys are revoked automatically and permanently; after renewing the plan, issue a new key. A revoked key gets 401 bad_key.
  • If the key is missing, wrong or revoked, the response is 401:
JSON · 401
{
  "error": "bad_key",
  "message": "Неверный ключ API"
}

If the key is valid but the school's plan does not include the API, you get 403 tariff_not_allowed. The “School API” card shows today's key usage and the latest errors.

Endpoints

The examples below use the $BASE and $KEY variables from “Quick start”. Paths are relative to the base URL https://api.courstore.com/v1/school-api.

School courses

GET/courses

Returns the courses of the school that owns the key. The access field is the default access period from the course settings (used when access is not passed on enrollment).

No parameters.

Example request

curl
curl -s "$BASE/courses" \
  -H "X-School-Key: $KEY"

Response fields

FieldTypeDescription
idintegerCourse identifier; pass it as courseId
titlestringCourse title
slugstringShort course name used in the URL
statusstringpublish | private | draft | pending. Students can only be enrolled in a published course
access.modestringdays | months | unlimited
access.amountinteger | nullNumber of days or months; null for unlimited

Example response

JSON · 200
[
  {
    "id": 1234,
    "title": "Основы маркетинга",
    "slug": "osnovy-marketinga",
    "status": "publish",
    "access": {
      "mode": "months",
      "amount": 3
    }
  },
  {
    "id": 1301,
    "title": "Мастер-класс",
    "slug": "master-klass",
    "status": "publish",
    "access": {
      "mode": "unlimited",
      "amount": null
    }
  }
]

Method errors

HTTPCodeMeaning
401bad_keyKey is missing, wrong or revoked
401key_expiredThe key has expired
403tariff_not_allowedThe school's plan does not include the API
429rate_limitedLimit of 60 requests per minute exceeded

Find or create a student

POST/students

Looks up a student by phone and returns them; if there is no such student in your school, creates one. The phone is normalized (+7 / 8 / spaces / brackets). The name and email of an existing student are not overwritten. A new student is created without a password and signs in with a code.

Request body (JSON)

ParameterTypeRequiredDescription
phonestringyesPhone in any format
namestringnoName, up to 200 characters
emailstringnoEmail, up to 200 characters
langstringnoStudent language: ru | kk | en | uz. Default ru

Example request

curl
curl -s -X POST "$BASE/students" \
  -H "X-School-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+7 (700) 123-45-67","name":"Анна","email":"anna@example.com","lang":"ru"}'

Response fields

FieldTypeDescription
studentIdintegerStudent identifier
createdbooleantrue: the student was not yet in your school (response 201); false: already studying (response 200)

Example response

JSON · 201
{
  "studentId": 118350,
  "created": true
}
JSON · 200
{
  "studentId": 118347,
  "created": false
}

Method errors

HTTPCodeMeaning
401bad_keyKey is missing, wrong or revoked
401key_expiredThe key has expired
403tariff_not_allowedThe school's plan does not include the API
409student_limit_reachedThe plan's student limit has been reached
422validation_errorInvalid data, for example a phone number; also invalid_json and payload_too_large
429rate_limitedLimit of 60 requests per minute exceeded

Enroll in a course

POST/enrollments

Enrolls a student into a course. If there is no student with this phone, one is created. The access period comes from access if passed, otherwise from the course settings. The Idempotency-Key header is required.

Header and request body

ParameterTypeRequiredDescription
Idempotency-Keyheader, stringyesUnique operation key (for example, the order number in your CRM), up to 128 characters: letters, digits, . _ : -
phonestringyesStudent phone in any format
courseIdintegeryesCourse identifier from GET /courses
namestringnoName, up to 200 characters (used for a new student)
emailstringnoEmail, up to 200 characters
accessobjectnoAccess period. If omitted, taken from the course settings
access.modestringnodays | months
access.amountintegernodays: 1…3650; months: 1…120
notifybooleannoSend the student enrollment notifications. Default true
orderRefstringnoYour order number, up to 128 characters (for your own records)

Example request

curl
curl -s -X POST "$BASE/enrollments" \
  -H "X-School-Key: $KEY" \
  -H "Idempotency-Key: order-0042" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+77001234567","name":"Анна","courseId":1234,"orderRef":"CRM-2026-0042"}'

Response fields

FieldTypeDescription
requestIdstringRequest identifier, for GET /enrollments/{requestId}
statusstringqueued: accepted into the queue; done: completed
studentIdinteger | nullStudent identifier
enrollmentIdinteger | nullEnrollment identifier; null while the request is queued
expireDatedate | nullAccess cutoff; null means no expiry or not yet known
lastDaydate | nullLast day of access

Example response

JSON · 202
{
  "requestId": "req_882",
  "status": "queued",
  "studentId": 118350,
  "enrollmentId": null,
  "expireDate": null,
  "lastDay": null
}
JSON · 200
{
  "requestId": "req_881",
  "status": "done",
  "studentId": 118347,
  "enrollmentId": 902113,
  "expireDate": "2026-12-01",
  "lastDay": "2026-11-30"
}

A new enrollment returns 202 with status: queued. Enrollment and notifications are carried out within about a minute (in practice, seconds); the result is available at GET /enrollments/{requestId}.

A repeat purchase (the student is already enrolled in the course) extends access immediately and returns 200 with status: done.

Method errors

HTTPCodeMeaning
401bad_keyKey is missing, wrong or revoked
401key_expiredThe key has expired
403tariff_not_allowedThe school's plan does not include the API
404course_not_foundCourse not found in your school (including another school's course)
409idempotency_conflictThis Idempotency-Key was already used with a different body
409idempotency_in_progressA request with this key is still being processed
409course_lockedThe course is frozen
409course_closedThe course is not published (for example, a draft)
409student_limit_reachedThe plan's student limit has been reached
422idempotency_key_requiredThe Idempotency-Key header is missing; also validation_error, invalid_json, payload_too_large
429rate_limitedLimit of 60 requests per minute exceeded
429daily_limitLimit of 300 enrollments per day per school exceeded

Enrollment status

GET/enrollments/{requestId}

Returns the state of an enrollment accepted by POST /enrollments.

Path parameters

ParameterTypeRequiredDescription
requestIdstringyesIdentifier from the enrollment response, for example req_882

Example request

curl
curl -s "$BASE/enrollments/req_882" \
  -H "X-School-Key: $KEY"

Response fields

FieldTypeDescription
requestIdstringRequest identifier
statusstringqueued: waiting to be processed; done: the student is enrolled; failed: the enrollment was not confirmed within 15 minutes
studentIdinteger | nullStudent identifier
courseIdinteger | nullCourse identifier
expireDatedate | nullAccess cutoff
lastDaydate | nullLast day of access
notifiedbooleantrue: the student notification has been queued for sending (delivery is not confirmed)
errorstring | nullReason when failed: not_confirmed

Example response

JSON · 200
{
  "requestId": "req_882",
  "status": "done",
  "studentId": 118350,
  "courseId": 1234,
  "expireDate": "2027-01-20",
  "lastDay": "2027-01-19",
  "notified": true,
  "error": null
}

Method errors

HTTPCodeMeaning
401bad_keyKey is missing, wrong or revoked
401key_expiredThe key has expired
403tariff_not_allowedThe school's plan does not include the API
404—No request with this requestId was found
429rate_limitedLimit of 60 requests per minute exceeded

Check access

GET/enrollments?phone=&courseId=

Shows a student's current access to a course of your school. A student with no link to your school answers the same way as a non-existent one: active: false.

Query parameters

ParameterTypeRequiredDescription
phonestringyesStudent phone. Send the + sign in the URL as %2B
courseIdintegeryesCourse identifier

Example request

curl
curl -s "$BASE/enrollments?phone=%2B77001234567&courseId=1234" \
  -H "X-School-Key: $KEY"

Response fields

FieldTypeDescription
activebooleanWhether the student has active access
expireDatedate | nullAccess cutoff
lastDaydate | nullLast day of access
progressintegerCourse progress, %

Example response

JSON · 200
{
  "active": true,
  "expireDate": "2026-12-01",
  "lastDay": "2026-11-30",
  "progress": 35
}

Method errors

HTTPCodeMeaning
401bad_keyKey is missing, wrong or revoked
401key_expiredThe key has expired
403tariff_not_allowedThe school's plan does not include the API
404course_not_foundCourse not found in your school
422validation_errorphone / courseId is missing or invalid
429rate_limitedLimit of 60 requests per minute exceeded

Student sign-in link

Your service can issue a student a one-time link that opens your school’s dashboard on Courstore without a password. The token in the link is 32 random bytes, only its hash is stored on our side, and the link stops working after first use.

Sign-in link to Courstore

The student is already signed in to your service — give them a link that opens your school’s dashboard on Courstore without a password. The link is single-use.

POST/students/login-link

Request body

ParameterTypeRequiredDescription
phonestringyesStudent’s phone in any format
redirectintegernoCourse id of your school: the course page opens after sign-in. Without it — “My courses”
ttlSecondsintegernoLink lifetime, 30 to 86400 seconds, default 120

Example request

curl
curl -s -X POST "$BASE/students/login-link" \
  -H "X-School-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+77001234567","redirect":1234,"ttlSeconds":120}'

Example response

JSON · 200
{
  "url": "https://myschool.courstore.com/ru/login-via-token?token=csl_...&returnUrl=courses%2Fosnovy-marketinga",
  "expiresAt": "2026-10-16T10:02:00Z"
}

expiresAt — until when (UTC) the link is valid.

Which lifetime to choose

ScenariottlSecondsWhy
“Go to Courstore” button on your site: get the link and redirect right away120 (default)The link is needed for a few seconds; a short lifetime means less risk if the URL leaks
Link in an email, WhatsApp or Telegramup to 86400 (24 h)The student may open the message later. The link still works only once
curl
curl -s -X POST "$BASE/students/login-link" \
  -H "X-School-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+77001234567","ttlSeconds":86400}'

If the link has expired or was already opened, the student does not see an error: they land on your school’s regular sign-in page with the hint “The link has expired — sign in with your phone number”.

Server example

Node.js (Express)
app.get("/go-to-courstore", requireLogin, async (req, res) => {
  const r = await fetch(`${API}/students/login-link`, {
    method: "POST",
    headers: { "X-School-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ phone: req.user.phone, redirect: 1234 }),
  });
  if (r.status !== 200) return res.redirect("https://myschool.courstore.com/ru/login");
  const { url } = await r.json();
  res.redirect(url);
});

Method errors

HTTPCodeMeaning
404student_not_foundThe number does not belong to a student of your school. Sign-in links are never issued to the author, staff or administrators — they get this response too
404course_not_foundThe course in redirect does not belong to your school
422validation_errorInvalid phone, redirect or ttlSeconds outside 30…86400
429daily_limitMore than 2000 links per day per school; see the Retry-After header
429rate_limitedMore than 60 requests per minute per key (general API limit)

Security. The link equals signing in to the student’s account: do not log it, do not forward it to third parties and give it only to the student who is signed in to your service.

Webhooks

Webhooks notify your system about enrollments and access changes right away, without polling the API: Courstore sends a POST to your URL.

Setup

  • In the dashboard: “Integrations” → “School API” → “Webhooks” → URL. Only https:// and only a public address: internal and local addresses and IP addresses are not allowed.
  • On the first save you get a signing secret whsec_…. It is shown once — save it right away. The secret can be rotated: the old one stops working immediately.
  • The “Send test” button sends a webhook.test event. The log shows the last 20 deliveries: event, response code, attempt, time.

Events

The event type comes in the type field and in the X-Courstore-Event header.

typeWhen it is sentaccessUntil
enrollment.createdA student is enrolled in a course: payment, manual enrollment in the dashboard, School APILast day of access or null
access.extendedAccess is extended or the course is purchased againNew last day
access.expiredThe access period has ended. Sent only for periods ending after the webhook was enabledLast day of access
access.revokedThe author closed the student’s access to the courseNo field
webhook.test“Send test” button in the dashboard—

accessUntil — last day of access, inclusive (YYYY-MM-DD), null — unlimited.

Request body

JSON · enrollment.created
{
  "id": "evt_8f3a1c0e2b7d4a91",
  "type": "enrollment.created",
  "createdAt": "2026-10-16T10:00:00Z",
  "schoolId": 7,
  "data": {
    "enrollmentId": 501,
    "student": {
      "id": 118347,
      "name": "Анна",
      "phone": "+77001234567",
      "email": null
    },
    "course": {
      "id": 1234,
      "title": "Основы маркетинга"
    },
    "accessUntil": "2026-12-31"
  }
}
JSON · access.revoked
{
  "id": "evt_1b9e44d07c2f8e30",
  "type": "access.revoked",
  "createdAt": "2026-10-20T08:15:00Z",
  "schoolId": 7,
  "data": {
    "enrollmentId": 501,
    "student": {
      "id": 118347,
      "name": "Анна",
      "phone": "+77001234567",
      "email": null
    },
    "course": {
      "id": 1234,
      "title": "Основы маркетинга"
    }
  }
}

Response fields

FieldTypeDescription
idstringUnique event id evt_…; equals X-Courstore-Delivery. Idempotency key
typestringEvent type
createdAtdate-timeWhen the event happened (UTC)
schoolIdintegerYour school id on Courstore
data.enrollmentIdintegerEnrollment id
data.studentobjectStudent’s id, name, phone, email
data.courseobjectCourse id and title
data.accessUntildate | nullLast day of access; null — unlimited. Absent in access.revoked

Headers and signature

HeaderDescription
X-Courstore-EventEvent type
X-Courstore-DeliveryEvent id (same as id in the body) — to protect against duplicates
X-Courstore-Signaturet=<unix time>,v1=<hex>, where v1 = HMAC-SHA256(secret, t + "." + raw body)
HTTP
POST /courstore/webhook HTTP/1.1
Content-Type: application/json
X-Courstore-Event: enrollment.created
X-Courstore-Delivery: evt_8f3a1c0e2b7d4a91
X-Courstore-Signature: t=1792144800,v1=5f0c2b…
  1. Take the raw request body — before JSON parsing: any reformatting breaks the signature.
  2. Compute HMAC-SHA256 with the whsec_… secret over t + "." + body, in hex.
  3. Compare it with v1 using a constant-time comparison (timingSafeEqual, hash_equals, compare_digest).
  4. Reject requests whose t is older than 5 minutes — this protects against replay of an intercepted request.

Signature verification example

Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();

app.post("/courstore/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = Object.fromEntries(
    String(req.get("X-Courstore-Signature") || "").split(",").map((p) => p.split("="))
  );
  const expected = crypto
    .createHmac("sha256", process.env.COURSTORE_WEBHOOK_SECRET)
    .update(sig.t + "." + req.body.toString("utf8"))
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  const ok = fresh && typeof sig.v1 === "string" && sig.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  // event.id — idempotency key; event.type — enrollment.created | access.extended | ...
  res.sendStatus(200);
});
PHP
<?php
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_COURSTORE_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, getenv('COURSTORE_WEBHOOK_SECRET'));

if (abs(time() - (int)($sig['t'] ?? 0)) > 300 || !hash_equals($expected, (string)($sig['v1'] ?? ''))) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
// $event['id'] — idempotency key; $event['type'] — event type
http_response_code(200);
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)

@app.post("/courstore/webhook")
def courstore_webhook():
    body = request.get_data()
    header = request.headers.get("X-Courstore-Signature", "")
    sig = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    expected = hmac.new(os.environ["COURSTORE_WEBHOOK_SECRET"].encode(),
                        sig.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
    t = sig.get("t", "")
    fresh = t.isdigit() and abs(time.time() - int(t)) <= 300
    if not fresh or not hmac.compare_digest(expected, sig.get("v1", "")):
        return "", 401
    event = request.get_json()
    # event["id"] — idempotency key; event["type"] — event type
    return "", 200

Delivery and retries

  • Respond with 2xx within 10 seconds. Do heavy processing after responding, in your own queue.
  • Any other response, a timeout or a connection error — delivery is retried on the schedule below, 6 attempts in total.
  • Redirects (3xx) are not followed and count as a failure — use the final URL.
  • Event order is not guaranteed and an event may arrive twice — process idempotently by id.
  • Events arrive with a delay of up to 1–2 minutes; access expiry (access.expired) — up to 30 minutes.
AttemptWhen
1Right after the event
2After 1 minute
3After 5 minutes
4After 30 minutes
5After 2 hours
6After 6 hours

The result of each attempt is shown in the delivery log in the “School API” card.

Idempotency

POST /enrollments requires the Idempotency-Key header: a unique key for the operation, for example the order number in your CRM (up to 128 characters: letters, digits, . _ : -). It makes retries safe: if a response was lost to a timeout, repeat the request with the same key and the student will not be enrolled twice.

SituationResult
Same Idempotency-Key and same bodyThe same response, no repeated action
Same Idempotency-Key but a different body409 idempotency_conflict
A request with this key is still being processed409 idempotency_in_progress: wait and retry
Header is missing422 idempotency_key_required
JSON · 409
{
  "error": "idempotency_conflict",
  "message": "Этот Idempotency-Key уже использован с другим телом запроса"
}

Do not confuse this with a repeat purchase: if the student is already enrolled in the course and you send a new operation (a different Idempotency-Key), access is extended. See “Access period”.

Access period

The access period is determined as follows:

  • If the request contains the access object, the period comes from it: mode = days (amount from 1 to 3650) or months (amount from 1 to 120).
  • If access is omitted, the period comes from the course settings. They are visible in the access field of GET /courses: mode = days / months with amount, or unlimited (no expiry, amount = null).
JSON
{
  "phone": "+77001234567",
  "courseId": 1234,
  "access": {
    "mode": "months",
    "amount": 3
  }
}

In responses and in the enrollment status: lastDay is the last day of access, expireDate is the access cutoff; null means no expiry or a date that is not known yet.

Repeat purchase. If the student has already been enrolled in this course, access is extended immediately, counting from the later of two dates: today or the current end of access. The response is 200 with status: done (no queue).

Student notifications

Enrollment through the API goes through the same queue as manual enrollment from the dashboard. The student receives the same messages: WhatsApp / WABA / Telegram / email.

  • The notify parameter (default true); false turns notifications off for this enrollment.
  • On a repeat purchase (renewal) the student gets a Telegram notification, just like with “Extend access” in the dashboard.
  • In the enrollment status, notified: true means the notification has been queued for sending. The service does not confirm delivery to WhatsApp/Telegram.
JSON
{
  "phone": "+77001234567",
  "courseId": 1234,
  "notify": false
}

Limits

Limits protect the platform and your students from accidental loops in an integration.

LimitValueWhen exceeded
Requests60 per minute per key429 rate_limited and a Retry-After header (seconds until retry)
Enrollments300 per day per school429 daily_limit
Request body sizeup to 4 KB422 payload_too_large
Active keysup to 3 per schoolRevoke an unused key and create a new one

When the request limit is exceeded, the response includes a Retry-After header: the number of seconds until you can retry. Today's usage is shown in the “School API” card in the dashboard.

Errors

All errors are returned as JSON with a code and a description:

JSON · 422
{
  "error": "validation_error",
  "message": "phone: некорректный номер телефона"
}

Rely on the HTTP status and the error field (machine code); the message text is meant for logs.

CodeHTTPMeaningWhat to do
bad_key401Key is missing, wrong or revokedCheck the X-School-Key header; create a new key in the dashboard if needed
key_expired401The key has expired (its valid-until date has passed)Issue a new key in the dashboard
tariff_not_allowed403The school's plan does not include the API (Pro annual, Lifetime or Enterprise is required). Checked on every requestSwitch to a suitable plan
course_not_found404Course not found in your school, including a course that belongs to another schoolCompare courseId with the GET /courses response
validation_error422Invalid request data (the field is named in message)Fix the request according to message
invalid_json422The request body is not valid JSONCheck the JSON and the Content-Type header
payload_too_large422The request body is larger than 4 KBShorten the request body
idempotency_key_required422POST /enrollments was sent without Idempotency-KeyAdd the header, for example with the order number
idempotency_conflict409This Idempotency-Key was already used with a different request bodyUse a new key for a new operation, or send the original body
idempotency_in_progress409A request with this key is still being processedWait and retry with the same key
course_locked409The course is frozenUnfreeze the course in the dashboard or choose another
course_closed409The course is not published (for example, a draft)Publish the course
student_limit_reached409The plan's student limit has been reachedFree up a seat or switch to a plan with a higher limit
rate_limited429More than 60 requests per minute per keyWait Retry-After seconds and retry
daily_limit429More than 300 enrollments per day per schoolRetry later, using Retry-After as a guide

For an enrollment with status failed, the error field contains not_confirmed: the enrollment was not confirmed within 15 minutes.

Privacy

The API exposes only your school's data.

  • created: true in the POST /students response means the student was not yet in your school, even if their account already exists on the platform in another school. A link is created for your school; other schools' data is not disclosed.
  • created: false means the student already studies in your school. The name and email of an existing student are not overwritten.
  • Checking access for a student with no link to your school answers the same way as for a non-existent one: active: false.
  • GET /courses returns only the courses of the key owner's school; another school's course returns 404 course_not_found.