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.
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. |
GET /students/?email= |
One student's membership and courses in your organization. |
POST /roster/import/ |
Sync a whole roster on a schedule. |
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). |
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. |
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. |
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". |
- 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. |
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. |
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.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.
