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.
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/jsonand are limited to 4 KB. - Authentication: the school key in the
X-School-Keyheader. - 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.
| Method | URL | Purpose |
|---|---|---|
| GET | /courses | List the school's courses and their default access period |
| POST | /students | Find a student by phone or create one |
| POST | /enrollments | Enroll 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
- Check your plan. The API is available on Pro (annual), Lifetime and Enterprise.
- 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. - Send your first request. List the school's courses; the response contains course
idvalues you need for enrollment.
export KEY="cs_live_..."
export BASE="https://api.courstore.com/v1/school-api"curl -s "$BASE/courses" \
-H "X-School-Key: $KEY"[
{
"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 -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"}'{
"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 -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:
{
"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
/coursesReturns 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 -s "$BASE/courses" \
-H "X-School-Key: $KEY"Response fields
| Field | Type | Description |
|---|---|---|
id | integer | Course identifier; pass it as courseId |
title | string | Course title |
slug | string | Short course name used in the URL |
status | string | publish | private | draft | pending. Students can only be enrolled in a published course |
access.mode | string | days | months | unlimited |
access.amount | integer | null | Number of days or months; null for unlimited |
Example response
[
{
"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
| HTTP | Code | Meaning |
|---|---|---|
| 401 | bad_key | Key is missing, wrong or revoked |
| 401 | key_expired | The key has expired |
| 403 | tariff_not_allowed | The school's plan does not include the API |
| 429 | rate_limited | Limit of 60 requests per minute exceeded |
Find or create a student
/studentsLooks 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)
| Parameter | Type | Required | Description |
|---|---|---|---|
phone | string | yes | Phone in any format |
name | string | no | Name, up to 200 characters |
email | string | no | Email, up to 200 characters |
lang | string | no | Student language: ru | kk | en | uz. Default ru |
Example request
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
| Field | Type | Description |
|---|---|---|
studentId | integer | Student identifier |
created | boolean | true: the student was not yet in your school (response 201); false: already studying (response 200) |
Example response
{
"studentId": 118350,
"created": true
}{
"studentId": 118347,
"created": false
}Method errors
| HTTP | Code | Meaning |
|---|---|---|
| 401 | bad_key | Key is missing, wrong or revoked |
| 401 | key_expired | The key has expired |
| 403 | tariff_not_allowed | The school's plan does not include the API |
| 409 | student_limit_reached | The plan's student limit has been reached |
| 422 | validation_error | Invalid data, for example a phone number; also invalid_json and payload_too_large |
| 429 | rate_limited | Limit of 60 requests per minute exceeded |
Enroll in a course
/enrollmentsEnrolls 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
| Parameter | Type | Required | Description |
|---|---|---|---|
Idempotency-Key | header, string | yes | Unique operation key (for example, the order number in your CRM), up to 128 characters: letters, digits, . _ : - |
phone | string | yes | Student phone in any format |
courseId | integer | yes | Course identifier from GET /courses |
name | string | no | Name, up to 200 characters (used for a new student) |
email | string | no | Email, up to 200 characters |
access | object | no | Access period. If omitted, taken from the course settings |
access.mode | string | no | days | months |
access.amount | integer | no | days: 1…3650; months: 1…120 |
notify | boolean | no | Send the student enrollment notifications. Default true |
orderRef | string | no | Your order number, up to 128 characters (for your own records) |
Example request
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
| Field | Type | Description |
|---|---|---|
requestId | string | Request identifier, for GET /enrollments/{requestId} |
status | string | queued: accepted into the queue; done: completed |
studentId | integer | null | Student identifier |
enrollmentId | integer | null | Enrollment identifier; null while the request is queued |
expireDate | date | null | Access cutoff; null means no expiry or not yet known |
lastDay | date | null | Last day of access |
Example response
{
"requestId": "req_882",
"status": "queued",
"studentId": 118350,
"enrollmentId": null,
"expireDate": null,
"lastDay": null
}{
"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
| HTTP | Code | Meaning |
|---|---|---|
| 401 | bad_key | Key is missing, wrong or revoked |
| 401 | key_expired | The key has expired |
| 403 | tariff_not_allowed | The school's plan does not include the API |
| 404 | course_not_found | Course not found in your school (including another school's course) |
| 409 | idempotency_conflict | This Idempotency-Key was already used with a different body |
| 409 | idempotency_in_progress | A request with this key is still being processed |
| 409 | course_locked | The course is frozen |
| 409 | course_closed | The course is not published (for example, a draft) |
| 409 | student_limit_reached | The plan's student limit has been reached |
| 422 | idempotency_key_required | The Idempotency-Key header is missing; also validation_error, invalid_json, payload_too_large |
| 429 | rate_limited | Limit of 60 requests per minute exceeded |
| 429 | daily_limit | Limit of 300 enrollments per day per school exceeded |
Enrollment status
/enrollments/{requestId}Returns the state of an enrollment accepted by POST /enrollments.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
requestId | string | yes | Identifier from the enrollment response, for example req_882 |
Example request
curl -s "$BASE/enrollments/req_882" \
-H "X-School-Key: $KEY"Response fields
| Field | Type | Description |
|---|---|---|
requestId | string | Request identifier |
status | string | queued: waiting to be processed; done: the student is enrolled; failed: the enrollment was not confirmed within 15 minutes |
studentId | integer | null | Student identifier |
courseId | integer | null | Course identifier |
expireDate | date | null | Access cutoff |
lastDay | date | null | Last day of access |
notified | boolean | true: the student notification has been queued for sending (delivery is not confirmed) |
error | string | null | Reason when failed: not_confirmed |
Example response
{
"requestId": "req_882",
"status": "done",
"studentId": 118350,
"courseId": 1234,
"expireDate": "2027-01-20",
"lastDay": "2027-01-19",
"notified": true,
"error": null
}Method errors
| HTTP | Code | Meaning |
|---|---|---|
| 401 | bad_key | Key is missing, wrong or revoked |
| 401 | key_expired | The key has expired |
| 403 | tariff_not_allowed | The school's plan does not include the API |
| 404 | — | No request with this requestId was found |
| 429 | rate_limited | Limit of 60 requests per minute exceeded |
Check access
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
phone | string | yes | Student phone. Send the + sign in the URL as %2B |
courseId | integer | yes | Course identifier |
Example request
curl -s "$BASE/enrollments?phone=%2B77001234567&courseId=1234" \
-H "X-School-Key: $KEY"Response fields
| Field | Type | Description |
|---|---|---|
active | boolean | Whether the student has active access |
expireDate | date | null | Access cutoff |
lastDay | date | null | Last day of access |
progress | integer | Course progress, % |
Example response
{
"active": true,
"expireDate": "2026-12-01",
"lastDay": "2026-11-30",
"progress": 35
}Method errors
| HTTP | Code | Meaning |
|---|---|---|
| 401 | bad_key | Key is missing, wrong or revoked |
| 401 | key_expired | The key has expired |
| 403 | tariff_not_allowed | The school's plan does not include the API |
| 404 | course_not_found | Course not found in your school |
| 422 | validation_error | phone / courseId is missing or invalid |
| 429 | rate_limited | Limit 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.
/students/login-linkRequest body
| Parameter | Type | Required | Description |
|---|---|---|---|
phone | string | yes | Student’s phone in any format |
redirect | integer | no | Course id of your school: the course page opens after sign-in. Without it — “My courses” |
ttlSeconds | integer | no | Link lifetime, 30 to 86400 seconds, default 120 |
Example request
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
{
"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
| Scenario | ttlSeconds | Why |
|---|---|---|
| “Go to Courstore” button on your site: get the link and redirect right away | 120 (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 Telegram | up to 86400 (24 h) | The student may open the message later. The link still works only once |
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
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
| HTTP | Code | Meaning |
|---|---|---|
| 404 | student_not_found | The 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 |
| 404 | course_not_found | The course in redirect does not belong to your school |
| 422 | validation_error | Invalid phone, redirect or ttlSeconds outside 30…86400 |
| 429 | daily_limit | More than 2000 links per day per school; see the Retry-After header |
| 429 | rate_limited | More 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.testevent. 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.
type | When it is sent | accessUntil |
|---|---|---|
enrollment.created | A student is enrolled in a course: payment, manual enrollment in the dashboard, School API | Last day of access or null |
access.extended | Access is extended or the course is purchased again | New last day |
access.expired | The access period has ended. Sent only for periods ending after the webhook was enabled | Last day of access |
access.revoked | The author closed the student’s access to the course | No field |
webhook.test | “Send test” button in the dashboard | — |
accessUntil — last day of access, inclusive (YYYY-MM-DD), null — unlimited.
Request body
{
"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"
}
}{
"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
| Field | Type | Description |
|---|---|---|
id | string | Unique event id evt_…; equals X-Courstore-Delivery. Idempotency key |
type | string | Event type |
createdAt | date-time | When the event happened (UTC) |
schoolId | integer | Your school id on Courstore |
data.enrollmentId | integer | Enrollment id |
data.student | object | Student’s id, name, phone, email |
data.course | object | Course id and title |
data.accessUntil | date | null | Last day of access; null — unlimited. Absent in access.revoked |
Headers and signature
| Header | Description |
|---|---|
X-Courstore-Event | Event type |
X-Courstore-Delivery | Event id (same as id in the body) — to protect against duplicates |
X-Courstore-Signature | t=<unix time>,v1=<hex>, where v1 = HMAC-SHA256(secret, t + "." + raw body) |
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…- Take the raw request body — before JSON parsing: any reformatting breaks the signature.
- Compute HMAC-SHA256 with the
whsec_…secret overt + "." + body, in hex. - Compare it with
v1using a constant-time comparison (timingSafeEqual,hash_equals,compare_digest). - Reject requests whose
tis older than 5 minutes — this protects against replay of an intercepted request.
Signature verification example
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
$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);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 "", 200Delivery and retries
- Respond with
2xxwithin 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.
| Attempt | When |
|---|---|
| 1 | Right after the event |
| 2 | After 1 minute |
| 3 | After 5 minutes |
| 4 | After 30 minutes |
| 5 | After 2 hours |
| 6 | After 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.
| Situation | Result |
|---|---|
Same Idempotency-Key and same body | The same response, no repeated action |
Same Idempotency-Key but a different body | 409 idempotency_conflict |
| A request with this key is still being processed | 409 idempotency_in_progress: wait and retry |
| Header is missing | 422 idempotency_key_required |
{
"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
accessobject, the period comes from it:mode=days(amountfrom 1 to 3650) ormonths(amountfrom 1 to 120). - If
accessis omitted, the period comes from the course settings. They are visible in theaccessfield ofGET /courses:mode=days/monthswithamount, orunlimited(no expiry,amount=null).
{
"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
notifyparameter (defaulttrue);falseturns 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: truemeans the notification has been queued for sending. The service does not confirm delivery to WhatsApp/Telegram.
{
"phone": "+77001234567",
"courseId": 1234,
"notify": false
}Limits
Limits protect the platform and your students from accidental loops in an integration.
| Limit | Value | When exceeded |
|---|---|---|
| Requests | 60 per minute per key | 429 rate_limited and a Retry-After header (seconds until retry) |
| Enrollments | 300 per day per school | 429 daily_limit |
| Request body size | up to 4 KB | 422 payload_too_large |
| Active keys | up to 3 per school | Revoke 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:
{
"error": "validation_error",
"message": "phone: некорректный номер телефона"
}Rely on the HTTP status and the error field (machine code); the message text is meant for logs.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
bad_key | 401 | Key is missing, wrong or revoked | Check the X-School-Key header; create a new key in the dashboard if needed |
key_expired | 401 | The key has expired (its valid-until date has passed) | Issue a new key in the dashboard |
tariff_not_allowed | 403 | The school's plan does not include the API (Pro annual, Lifetime or Enterprise is required). Checked on every request | Switch to a suitable plan |
course_not_found | 404 | Course not found in your school, including a course that belongs to another school | Compare courseId with the GET /courses response |
validation_error | 422 | Invalid request data (the field is named in message) | Fix the request according to message |
invalid_json | 422 | The request body is not valid JSON | Check the JSON and the Content-Type header |
payload_too_large | 422 | The request body is larger than 4 KB | Shorten the request body |
idempotency_key_required | 422 | POST /enrollments was sent without Idempotency-Key | Add the header, for example with the order number |
idempotency_conflict | 409 | This Idempotency-Key was already used with a different request body | Use a new key for a new operation, or send the original body |
idempotency_in_progress | 409 | A request with this key is still being processed | Wait and retry with the same key |
course_locked | 409 | The course is frozen | Unfreeze the course in the dashboard or choose another |
course_closed | 409 | The course is not published (for example, a draft) | Publish the course |
student_limit_reached | 409 | The plan's student limit has been reached | Free up a seat or switch to a plan with a higher limit |
rate_limited | 429 | More than 60 requests per minute per key | Wait Retry-After seconds and retry |
daily_limit | 429 | More than 300 enrollments per day per school | Retry 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: truein thePOST /studentsresponse 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: falsemeans 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 /coursesreturns only the courses of the key owner's school; another school's course returns404 course_not_found.