Install our app for a better experience!

API Endpoints & Payloads

API Reference

Base URL: https://<your-platform-host>/api/v1/

All requests authenticate with your per-organization API key, sent on every request:

X-API-Key: <your-raw-key>

(Authorization: Bearer <your-raw-key> is also accepted — the same org key.)

Endpoints

Method & path Purpose
GET /ping/ Test your key — returns your organization name.
GET /courses/ List the valid courses values (course-type names).
POST /provision/student/ Create + enrol a student.
POST /roster/import/ Sync a whole roster — people, roles, class placements. Built for a nightly job.

GET /ping/

curl -s https://<host>/api/v1/ping/ -H "X-API-Key: <key>"
{ "status": "ok", "organization": "Acme Prep", "organization_slug": "acme-prep" }

GET /courses/

curl -s https://<host>/api/v1/courses/ -H "X-API-Key: <key>"
{ "status": "ok", "course_types": [ { "name": "ielts", "display_name": "IELTS" } ] }

POST /provision/student/

Request body (JSON):

Field Required Type Notes
email string The student's email — used as the unique identity.
first_name string
last_name string
courses string or array One or more course-type names (see table below). e.g. "ielts" or ["ielts","gmat"]. Omit it to add the user as a bare org member with no enrollment (e.g. someone who will only use custom tests).
tenure_months integer How many months of access (1–120). Access starts immediately (today) and ends this many months later. Omit to use your organization's configured default (typically 3 months).
external_ref string Your reference (e.g. the WooCommerce order id). Stored for traceability.
send_welcome_email boolean Default true. Set false to email the student yourself.
source string Optional label ("pabbly", "zapier", …) recorded in the audit log.

Example:

{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "courses": ["ielts", "gmat"],
  "tenure_months": 6,
  "external_ref": "wc_order_1234"
}

Success response (201 for a new account, 200 if the student already existed):

{
  "status": "success",
  "user_id": 4821,
  "created_user": true,
  "enrollments": [
    { "course_type": "ielts", "course_id": 12, "enrollment_id": 5567,
      "status": "active", "start_date": "2026-06-04", "end_date": "2026-12-04",
      "already_enrolled": false }
  ],
  "login_url": "https://<host>/login/",
  "set_password_url": "https://<host>/reset/MzQ/abc.../",
  "welcome_email_sent": true
}

Response fields:

Field Meaning
status success if anything new was created/changed; already_provisioned if the call was a no-op (a safe repeat).
user_id The platform user id for this student (stable across calls — same email always maps to the same id).
created_user true if this call created a brand-new account, false if the email already existed.
enrollments[] One entry per requested course — see the enrollment fields below. Empty [] when courses was omitted.
enrollments[].course_type The course-type name you requested (e.g. ielts).
enrollments[].course_id Your organization's default course for that course type — auto-created (a perpetual "Quick Enroll" course) if you didn't already have one.
enrollments[].enrollment_id The student's enrollment id for that course.
enrollments[].status Usually active. suspended if an admin had suspended this student (see scenarios).
enrollments[].start_date / end_date The access window — start_date is today, end_date is start_date + tenure_months.
enrollments[].already_enrolled true if the student already had access to this course (nothing changed); false if this call granted/renewed it.
enrollments[].skipped_reason Present only when a course was intentionally left untouched — currently "suspended".
login_url Where the student logs in after setting a password.
set_password_url A one-time "set your password" link — returned only for brand-new accounts. Email it yourself if you set send_welcome_email: false. null otherwise.
welcome_email_sent true if the platform emailed the student (new account → welcome + set-password; newly enrolled existing student → enrollment notice).

Error responses:

HTTP When Body
400 Bad payload (missing email/first_name/courses, unknown course type, bad tenure_months, invalid JSON) { "status": "error", "error": "<reason>" }
401 Missing/invalid/revoked API key { "status": "error", "error": "Invalid or missing API key…" }
403 The organization is not enabled for language tests { "status": "error", "error": "This organization is not enabled…" }
500 Unexpected server error — safe to retry { "status": "error", "error": "Internal error while provisioning. Safe to retry." }

How enrolment works — course & duration

For each name in courses, one call does the following:

  1. Places the student in your organization's default course for that course type. Every course type (IELTS, GMAT, …) has one default course per organization, and that's what students are enrolled into:
  2. If you've already set up a default course for that type, they join it.
  3. If you haven't, the platform creates one automatically — a perpetual "Quick Enroll" course — and uses it as your default for that type from then on. So you never have to set courses up in advance, and every student of that type lands in the same shared course.

The course itself is ongoing; how long each student keeps access is set by their own enrolment window (below), not by the course. 2. Sets the access window from tenure_months. start_date is today; end_date is start_date + tenure_months. Omit tenure_months and your organization's configured default is used (typically 3 months). After end_date there's a short grace period, then access ends automatically — no action needed on your side. 3. Grants full practice access to that course (reading, listening, speaking, writing, plus the AI features your organization has enabled) and assigns a few starter practice tests so the student has something to do on day one.

New accounts also get a one-time set-password link (returned as set_password_url and, unless you opt out, emailed to the student).

Every scenario — what the API does

The endpoint is idempotent and keyed on the student's email, so the same call is always safe to repeat. Here is exactly what happens in each case:

Scenario What the API does How you can tell (response)
New email Creates the account, enrols it, sends a set-password link 201, created_user: true, already_enrolled: false, set_password_url present
Existing student, not yet in this course Enrols them into the course 200, created_user: false, already_enrolled: false
No default course for that type in your org Auto-creates a default "Quick Enroll" course, then enrols into it Normal success — already_enrolled: false (transparent to you)
Student already has active access to this course Nothing — leaves their current access as-is already_enrolled: true (dates unchanged)
Returning student whose access had lapsed (expired / dropped) Re-activates it in place with a fresh window — never a duplicate already_enrolled: false, status: active, new end_date
Student is suspended in this course Leaves the suspension untouched (never silently overridden) already_enrolled: true, skipped_reason: "suspended"
courses omitted Creates the account + org membership only, no enrolment enrollments: []
Several courses at once Handles each course independently by the rules above one enrollments[] entry per course
Nothing left to do (repeat of a completed call) No changes top-level status: already_provisioned
Unknown course type Rejects the whole call — nothing is created 400 with the list of valid values

A suspended student is re-granted access only by lifting the suspension in the platform (an admin action) — the API deliberately won't do it, so an automated webhook can't undo a manual decision.

Retries & safety

  • Repeats never duplicate. Same email → same account, same enrolment; a repeat just reports already_provisioned / already_enrolled: true.
  • Concurrent calls for the same student are serialized server-side — no double memberships or enrolments.
  • external_ref is stored for traceability/correlation only; it is not the idempotency key (email is).
  • A 500 is the only response you should retry. Never retry a 400 — fix the payload first (Pabbly / Zapier / Make treat 4xx as a permanent failure by default, which is correct here).

Course-type reference

Use these exact values in courses. (Call GET /courses/ for the live, org-specific list — only active types are provisionable.)

courses value Test
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
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.