API Reference
Base URL: https://<your-platform-host>/api/v1/
Every request needs your organization's API key in a header:
X-API-Key: <your-key>
Authorization: Bearer <your-key> is accepted too. Responses are JSON. Create keys on
Organization dashboard → More Actions → API Keys & Integrations.
Two kinds of key. A Full access key can do everything below. A Read only key can only make GET
requests — results, roster, reports — and gets 403 "This API key is read-only" on anything that creates or
changes data (here and on the Assessment API). Give reporting tools, spreadsheets and dashboards a read-only
key. Existing keys are full access.
New to APIs? Read the API Quick Start first.
Endpoints
| Method & path | Purpose |
|---|---|
GET /ping/ |
Test your key. |
GET /organization/ |
Your organization and its onboarding defaults. |
GET /courses/ |
Course types (with your default course for each) and all your courses. |
POST /provision/student/ |
Onboard a student: create, renew, extend, add a course, resend the email — and, optionally, set their target score plan. |
GET /students/?email= |
One student's membership and courses in your organization. |
POST /roster/import/ |
Sync a whole roster on a schedule. |
GET /results/ |
Read scores: every final test, quiz and assessment result, with a cursor for nightly sync. See Results & Progress API. |
GET /progress/daily/ |
Score trend: one row per student per day per test type. See Results & Progress API. |
GET /students/ (no email) |
Your whole roster, paged, with access and courses. See Roster, Assignments & Activity API. |
GET /catalog/ |
What you can assign: language tests, quizzes, assessments, with ids. |
GET /assignments/ |
What is assigned, its status (open / completed / overdue) and the result it produced. |
POST /assignments/ |
Assign a test, quiz or assessment to a student (full-access key). |
GET /activity/daily/ |
Engagement: active minutes and tests started/finished per student per day. |
/testing-center/… |
Test links by your own student ID, no registration: POST /testing-center/attempts/ returns a personal link; status and results by webhook or GET. See Testing Center API. |
Every GET list accepts tz=<time zone> (e.g. Asia/Kolkata) to write timestamps in local time.
Prefer to be told instead of asking? Webhooks push results, new students, enrolment changes and assignments to your URL.
GET /ping/
curl -s https://<host>/api/v1/ping/ -H "X-API-Key: <key>"
{ "status": "ok", "organization": "Acme Prep", "organization_slug": "acme-prep" }
GET /organization/
curl -s https://<host>/api/v1/organization/ -H "X-API-Key: <key>"
{
"status": "ok",
"organization": { "name": "Acme Prep", "slug": "acme-prep", "language_tests_enabled": true },
"api_key": { "name": "Pabbly – production", "prefix": "2s19bl1d", "created_at": "2026-09-17T14:28:28+00:00" },
"provisioning_defaults": {
"default_access_months": 3,
"grace_period_days": 30,
"starter_tests_per_course": 3,
"visible_tests_limit": 20,
"send_welcome_email_default": true,
"set_password_link_valid_days": 3
},
"counts": { "courses": 1, "students_with_access": 0 },
"as_of": "2026-09-17"
}
| Field | Meaning |
|---|---|
organization.language_tests_enabled |
Must be true. Otherwise POST /provision/student/ returns 403. |
api_key |
The key you called with: its name, first characters and creation time. |
provisioning_defaults.default_access_months |
Access length for a course type when tenure_months is left out. |
provisioning_defaults.grace_period_days |
Days of access after end_date, before access is removed. |
provisioning_defaults.starter_tests_per_course |
Starter practice tests placed per new course (0 = switched off). None for a student on a target score plan for that exam. |
provisioning_defaults.visible_tests_limit |
How many practice tests a new student sees. |
provisioning_defaults.set_password_link_valid_days |
Lifetime of the set‑password link. |
counts.students_with_access |
Distinct students with an active, grace‑period or scheduled course. |
target_score_plans |
Which course types take a test plan (test_plan_course_types) and whether Safe Exam Browser can be required (safe_exam_browser_available). See Target Score Plans through the API. |
Defaults are set by your platform administrator.
GET /courses/
curl -s https://<host>/api/v1/courses/ -H "X-API-Key: <key>"
{
"status": "ok",
"course_types": [
{ "name": "gmat", "display_name": "GMAT", "default_course": null,
"default_access_months": 3, "starter_tests_available": 0 },
{ "name": "ielts", "display_name": "IELTS", "default_course": { "id": 372, "name": "Quick Enroll - IELTS" },
"default_access_months": 3, "starter_tests_available": 4 }
],
"courses": [
{ "id": 371, "name": "IELTS Weekend Batch", "course_type": "ielts", "course_type_display": "IELTS",
"is_default": false, "status": "active", "open_for_enrolment": true, "is_perpetual": false,
"start_date": "2026-09-01", "end_date": null,
"default_access_months": 2, "default_access_weeks": 0, "students_with_access": 0 },
{ "id": 372, "name": "Quick Enroll - IELTS", "course_type": "ielts", "course_type_display": "IELTS",
"is_default": true, "status": "active", "open_for_enrolment": true, "is_perpetual": true,
"start_date": "2026-09-17", "end_date": null,
"default_access_months": 3, "default_access_weeks": 0, "students_with_access": 1 }
]
}
course_types[] — what you can send in courses.
| Field | Meaning |
|---|---|
name |
The value to send in courses, e.g. ielts. The display_name also works. |
default_course |
Your default course for this type, or null if nobody has been granted this type yet (it is created on the first call). |
default_access_months |
Access length when tenure_months is left out. |
starter_tests_available |
How many practice tests a new student of this type can be given. |
test_plan_available |
true for the course types that take a test plan (IELTS Academic, IELTS General Training, SAT). |
target_score_min / target_score_max |
The range a target_score for this course type must fall in. |
courses[] — every course in your organization (cancelled ones are left out). Send an id in course_ids.
| Field | Meaning |
|---|---|
id |
The value to send in course_ids. |
is_default |
true for the default course of its type. |
open_for_enrolment |
Only open courses accept new or renewed students; otherwise the call is rejected with 400. Students who already have running access in a course that has since closed keep getting normal answers and can still be extended. |
default_access_months / default_access_weeks |
Access length for this course when tenure_months is left out. |
students_with_access |
Students with an active, grace‑period or scheduled enrolment in it. |
POST /provision/student/
Request body (JSON)
| Field | Required | Type | Notes |
|---|---|---|---|
email |
✅ | string | The student's identity. Case doesn't matter. |
first_name |
✅ | string | Up to 150 characters. |
last_name |
– | string | Up to 150 characters. |
courses |
– | string or list | Course type names from GET /courses/: "ielts", "ielts, gmat" or ["ielts", "gmat"]. Each goes to your default course for that type, or to the course of that type the student is already in. |
course_ids |
– | number, string or list | Specific course ids from courses[].id: 371, "371, 380" or [371, 380]. |
tenure_months |
– | whole number | 1–120. 6, "6" and 6.0 are accepted; 1.5 is rejected. Left out → the course's default, then your organization's default. |
external_ref |
– | string or number | Your reference, up to 255 characters. Required when extend_access is true. |
password_mode |
– | string | How the student gets in: link (default), set or generate. Wording from the Add Student page is accepted too: invite, direct, auto, system_generated. |
username |
– | string | Username for a new account, up to 150 characters, letters, digits and @ . + - _. Left out, it is derived from the email address. |
password |
– | string | Password for a new account. Sending it implies password_mode: "set". Must pass the platform's password rules. |
send_welcome_email |
– | boolean | Default true. false sends no email at all. |
resend_welcome_email |
– | boolean | Default false. true sends the email even if nothing changed. |
extend_access |
– | boolean | Default false. true adds tenure_months to courses that are still running. |
source |
– | string | A label for tracing, e.g. "pabbly". |
target_score_plan |
– | object | Set the student's target score and exam date, start, change or turn off their test plan, and require Safe Exam Browser, on a course in this call. Left out, nothing about targets or plans changes. See Target Score Plans through the API. |
- Booleans may be
true/false,"true"/"false","yes"/"no"or1/0. An empty string means "use the default". - Leave out both
coursesandcourse_idsto add the person as a member with no course. - The whole request is checked before anything is saved: one bad field means nothing is created.
Example
curl -s -X POST https://<host>/api/v1/provision/student/ \
-H "X-API-Key: <key>" -H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"courses": ["ielts"],
"tenure_months": 6,
"external_ref": "wc_order_1234",
"source": "pabbly"
}'
Response
201 when a new account was created, 200 when the email already had one.
{
"status": "success",
"user_id": 825,
"created_user": true,
"organization": { "name": "Acme Prep", "slug": "acme-prep" },
"membership": { "role": "member", "status": "active", "created": true, "reactivated": false },
"enrollments": [
{
"course_type": "ielts",
"course_type_display": "IELTS",
"course_id": 372,
"course_name": "Quick Enroll - IELTS",
"is_default_course": true,
"enrollment_id": 593,
"status": "active",
"start_date": "2026-09-17",
"end_date": "2027-03-17",
"grace_period_end": "2027-04-16",
"has_access": true,
"action": "created",
"already_enrolled": false,
"starter_tests_assigned": 3
}
],
"warnings": [],
"credentials": { "username": "jane", "password": null, "must_change_password": false },
"login_url": "https://<host>/login/",
"set_password_url": "https://<host>/reset/ODI1/df2fhh-3dd0ca02.../",
"set_password_expires_at": "2026-09-20T14:28:29+00:00",
"welcome_email_sent": true,
"email": { "status": "sent", "type": "welcome", "detail": "" }
}
Top‑level fields
| Field | Meaning |
|---|---|
status |
success if anything changed; already_provisioned if the call changed nothing (a safe repeat). |
warnings[] |
Plain-English notes about anything the call asked for and did not get, such as sign-in details ignored on an account already in use, or tenure_months sent for access that is still running. Empty on a clean call. |
user_id |
The student's platform id. The same email always gives the same id. |
created_user |
true if this call created the account. |
organization |
The organization your key belongs to. |
membership |
The student's role and status in your organization, and whether this call created or reactivated it. |
enrollments[] |
One entry per course named in the call (a course named twice appears once). Empty when no course was named. |
credentials.username |
The account's username, whether you chose it or it was derived from the email. |
credentials.password |
The password only when the platform generated it. null otherwise, including when you sent the password yourself. |
credentials.must_change_password |
true when the student will be asked to change the password at first sign‑in. |
credentials.note |
Present when the account already existed and its username and password were therefore left alone. |
login_url |
Where the student signs in. |
set_password_url |
A link to set a password. Returned while the student has never signed in, only for accounts your organization created through this API. Otherwise null. |
set_password_expires_at |
When that link stops working (3 days). |
welcome_email_sent |
true when email.status is sent or queued. |
email.status |
sent, queued, uncertain, suppressed, failed, not_requested or not_needed (see below). |
email.type |
welcome (set‑password button), credentials (username and password), access_update (student already signs in), or null when no email was sent. |
email.detail |
A short reason for queued, uncertain, suppressed or failed. |
target_score_plan |
Only when the call sent one: the course's target, test plan and Safe Exam Browser switch after the call, with action and changes. See Target Score Plans through the API. |
enrollments[] fields
| Field | Meaning |
|---|---|
course_type / course_type_display |
The exam, e.g. ielts / IELTS. |
course_id / course_name / is_default_course |
Which course the student is in. |
enrollment_id |
The enrolment's id. Stays the same across renewals. |
action |
created, reactivated, extended, unchanged or skipped. |
status |
active, grace_period, pending, expired, suspended, dropped or completed. |
start_date / end_date |
The access window. |
grace_period_end |
The last day of access, after the grace days. |
has_access |
Whether the student can use this course today, judged by the dates. |
starter_tests_assigned |
Practice tests placed by this call; null when the call didn't grant anything. 0 for a course whose test plan this call starts, or for a student already on a plan for that exam. |
already_enrolled |
true when action is unchanged or skipped. |
skipped_reason |
Only when action is skipped: currently always "suspended". |
note |
Only on an extension retry: "already_extended_for_external_ref". |
email.status values
| Value | Meaning | What to do |
|---|---|---|
sent |
Accepted by the mail server. | Nothing. |
queued |
Stored and retried automatically. | Nothing; it goes out shortly. |
uncertain |
The server may have received it without confirming. | Check with the student; resend if needed. |
suppressed |
The platform will not mail this address, for example a test or demo domain. | Use a real address, or send your own email. |
failed |
Not sent. | The next call for this student retries the welcome automatically, or send resend_welcome_email: true. |
not_requested |
You sent send_welcome_email: false. |
Email the student yourself, using set_password_url. |
not_needed |
Nothing changed and the student already has their email. | Nothing. |
How the student signs in
password_mode mirrors the three options on the Add Student page. It only ever applies to an account
this call creates.
password_mode |
Account gets | Response | Email the student receives |
|---|---|---|---|
link (default) |
No password; a one‑time set‑password link | set_password_url present, credentials.password null |
"Welcome — set your password" |
set |
The username and password you sent |
credentials.password null (you already have it) |
"Welcome — your sign‑in details" |
generate |
A generated password | credentials.password is the password |
"Welcome — your sign‑in details" |
{ "email": "sam@example.com", "first_name": "Sam", "courses": ["ielts"],
"tenure_months": 3, "password_mode": "generate", "external_ref": "wc_order_1700" }
{ "status": "success", "created_user": true,
"credentials": { "username": "sam", "password": "uCfkXmxRhF6L", "must_change_password": true },
"email": { "status": "sent", "type": "credentials", "detail": "" } }
Which accounts a mode applies to. set and generate always apply to an account this call creates.
For an email that already exists:
| The existing account | Result |
|---|---|
| Created by your key here and never signed in | The username and password are applied. You could already set that account's password with the set_password_url returned to you, so this only saves a round trip. |
| Has signed in, has its own password, or came from anywhere else | Left untouched. You get a warnings[] entry and credentials.note. The student uses Forgot password, or an administrator resets it in the platform. |
That is what keeps a repeated webhook from resetting a live student's password, and stops a key being used to take over an account it did not create.
Every scenario
The call is keyed on the student's email and is safe to repeat.
| Scenario | What happens | action |
HTTP |
|---|---|---|---|
| New email | Account + membership + course + welcome email. | created |
201 |
New email with username/password or password_mode: generate |
Account created with those sign‑in details, emailed to the student. | created |
201 |
Existing email you created here that has never signed in, with username/password |
Those sign-in details are applied and emailed. | – | 200 |
Existing email in use, with username/password |
Course work happens as usual; the account's own credentials are untouched and a warning explains why. | – | 200 |
| Existing account, new course | Course added; email sent. | created |
200 |
| Course type with no default course yet | Default course created, student placed in it. | created |
200/201 |
| Student already in a course of that type (default or not) | Left as it is. | unchanged |
200 |
| Course still running, same call repeated | Nothing changes; no email. | unchanged |
200 |
Course still running + extend_access + new external_ref |
Months added to the current end_date. |
extended |
200 |
Same extend_access call repeated (same external_ref) |
Nothing changes. | unchanged + note |
200 |
end_date passed (grace period, expired, dropped, completed) |
Same enrolment, fresh window from today. With courses this renews the default course; to renew a batch, send its course_ids. |
reactivated |
200 |
| Student suspended in that course | Left alone. Lift the suspension in the platform. | skipped |
200 |
| Welcome email failed last time | The next call sends it again. | – | 200 |
| Inactive member re‑granted a course | Membership reactivated. | – | 200 |
No courses and no course_ids |
Membership only. | – | 200/201 |
| Unknown course type, foreign course id, closed course for a new or renewed student, bad value | Nothing saved. | – | 400 |
Errors
Errors always look like { "status": "error", "error": "<reason>" }.
| HTTP | When | Example error |
|---|---|---|
400 |
Invalid JSON or a field fails validation. | Unknown course type 'ielts-academic'. Valid values: gmat, ielts, pte_academic. |
400 |
tenure_months is not a whole number from 1 to 120. |
'tenure_months' must be a whole number of months between 1 and 120. |
400 |
extend_access without external_ref. |
'extend_access' needs an 'external_ref' (for example your order id). … |
400 |
A course_ids course isn't yours or isn't open. |
Course 380 was not found in your organization. … |
400 |
The username is taken or has invalid characters. |
Username 'priya.n' is already taken. Send a different 'username', or leave it out to have one made from the email address. |
400 |
The password fails the platform's rules. |
That 'password' can't be used: This password is too common. This password is entirely numeric. |
400 |
password_mode is set with no password, or a password with another mode. |
'password_mode' is 'set', so send the 'password' to use. |
401 |
Missing, wrong or revoked key. | Invalid or missing API key. Send it in the X-API-Key header. |
403 |
Language tests are not enabled for your organization. | This organization is not enabled for language tests; cannot provision students. |
500 |
Unexpected server error. | Internal error while provisioning. Safe to retry. |
Retries
- Retry only
5xx. A500is safe to retry as is. - Never retry a
400: fix the data first. Pabbly, Zapier and Make treat4xxas a permanent failure by default, which is what you want. - Concurrent calls for the same student are handled one at a time on the server, so they cannot create duplicates.
GET /students/?email=
curl -s "https://<host>/api/v1/students/?email=jane@example.com" -H "X-API-Key: <key>"
{
"status": "ok",
"student": { "user_id": 825, "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe",
"has_set_password": false, "last_login": null },
"membership": { "role": "member", "status": "active" },
"enrollments": [
{ "course_type": "gmat", "course_type_display": "GMAT", "course_id": 373, "course_name": "Quick Enroll - GMAT",
"is_default_course": true, "enrollment_id": 595, "status": "active",
"start_date": "2026-09-17", "end_date": "2026-12-17", "grace_period_end": "2027-01-16", "has_access": true },
{ "course_type": "ielts", "course_type_display": "IELTS", "course_id": 372, "course_name": "Quick Enroll - IELTS",
"is_default_course": true, "enrollment_id": 593, "status": "active",
"start_date": "2026-09-17", "end_date": "2027-06-17", "grace_period_end": "2027-07-17", "has_access": true }
]
}
has_set_passwordandlast_loginshow whether the student has opened their welcome email and signed in.- Each enrolment also carries
target_score_plan(left out of the example above): its target score, exam date, Safe Exam Browser switch and test plan (nullwhen none is running). See Target Score Plans through the API. 404(No student with that email in your organization.) for anyone who is not a member of, or enrolled in, your organization, including people who have accounts elsewhere.400ifemailis missing or not an email address.
Course-type reference
Send these values in courses. GET /courses/ returns the live list; only active types are accepted.
courses value |
Exam |
|---|---|
ielts |
IELTS Academic |
ielts_general |
IELTS General Training |
toefl |
TOEFL |
pte_academic |
PTE Academic |
pte_core |
PTE Core |
oet |
OET |
gre |
GRE |
gmat |
GMAT |
sat |
SAT |
duolingo |
Duolingo English Test |
celpip |
CELPIP |
adaptive_language |
Adaptive Language Proficiency |
dmat |
dMAT |
custom |
Custom Language Test |
POST /roster/import/
Sends an entire roster in one call, rather than one student at a time. Designed to run unattended on a schedule and keep the platform in step with your own system.
Accepts a .zip containing people.csv (and optionally enrollments.csv), or a
bare people.csv. Same format as the Roster import page in the workspace.
# multipart
curl -X POST https://<host>/api/v1/roster/import/ \
-H "X-API-Key: <key>" \
-F mode=additive_only \
-F source="Nightly SIS export" \
-F archive=@roster.zip
# or pipe the file in — headers replace the form fields
curl -X POST https://<host>/api/v1/roster/import/ \
-H "X-API-Key: <key>" \
-H "X-Roster-Mode: additive_only" \
-H "X-Roster-Source: Nightly SIS export" \
-H "Content-Type: application/zip" \
--data-binary @roster.zip
people.csv
person_external_id,email,first_name,last_name,role
s1,asha@school.org,Asha,Rao,student
t1,ravi@school.org,Ravi,Kumar,teacher
role is student or teacher. person_external_id is your own stable code —
it is what makes a repeat run a no-op rather than a duplicate, so send it.
enrollments.csv (optional)
person_external_id,class_external_id,class_name
s1,c1,Class 11 Science
One row per student per class. Classes are created the first time they appear.
Modes
mode |
Behaviour |
|---|---|
additive_only (default) |
Creates and updates. Never removes anyone. Safe unattended. |
authoritative_snapshot |
The file is the complete roster; anyone missing is marked inactive (never deleted). Off until enabled per source. |
Responses
| Status | Meaning |
|---|---|
200 |
Applied. counts says what changed. |
202 |
Held for review — a safety guard fired and nothing was written. reasons explains; review_url points an administrator at the preview. |
400 |
File unreadable, or no usable people in it. Nothing written. |
403 |
Authoritative mode requested but not enabled for this source. |
413 |
Over 25 MB. |
{
"status": "applied",
"run_id": 42,
"mode": "additive_only",
"counts": {"create": 18, "update": 4, "skip": 210},
"issues": []
}
Watch for 202
Nobody is looking at a preview when a cron job runs at 2am, so the safety guards are the review. If a run would deactivate an implausible share of your roster — or the file is empty, or a section is missing — you get 202 and the run is saved as a preview for a human to confirm.
A failed sync is recoverable. A silently applied bad one is not. Alert on 202
in whatever schedules the job.
What it will never do
- Put a teacher into a class — class membership hands out learner work, so only students are added and teacher rows are reported back to you.
- Grant an administrative role — those are listed for a human to decide.
- Delete anyone. Leavers go inactive, keep their work and marks, and reactivate onto the same account.
- Touch a membership or class it did not create.
- Generate or email a password. New accounts have no usable password until the person uses the normal reset link.
