Install our app for a better experience!

API Endpoints & Payloads

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" or 1/0. An empty string means "use the default".
  • Leave out both courses and course_ids to 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. A 500 is safe to retry as is.
  • Never retry a 400: fix the data first. Pabbly, Zapier and Make treat 4xx as 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_password and last_login show 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.
  • 400 if email is 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.