Passdesk

passdesk

API Reference

Programmatic access for Scale-tier driving schools.

Download OpenAPI spec (YAML)

Getting started

Mint a key in Passdesk under Settings → API keys. The plaintext is shown once at creation — copy it then; we don’t store it. Pass the token as a Bearer header on every request:

curl https://app.passdesk.co.uk/api/v1/students \
  -H "Authorization: Bearer pdsk_live_<prefix>_<secret>"

The same key also works with the Passdesk MCP server, which connects AI assistants such as Claude, ChatGPT, Codex and Cursor without any integration code.

Partner access for enquiries

If you build websites or phone-answering services for driving schools, you don’t need each school’s API key, and the schools don’t need the Scale plan. Register your app once with POST /api/oauth/register, then send each school owner to https://app.passdesk.co.uk/oauth/authorize with scope=enquiries:write (or enquiries:read). They click Allow, you exchange the code at POST /api/oauth/token, and the resulting pdsk_at_… bearer works on the /enquiries endpoints below for that school only. It reaches nothing else. The flow is standard OAuth 2.1 with PKCE — the MCP guide walks through the same steps. Schools can disconnect you at any time from their API keys page.

The redirect back to you carries iss alongside the code or error. Check it matches the issuer in /.well-known/oauth-authorization-server. If the authorisation link includes resource, send the same value at the token exchange or leave it out. A different one is refused with invalid_target.

The learner that /enquiries/{id}/convert returns leaves out their date of birth, licence number and guardian details. To get them back, add students:sensitive:read to the scope; the school owner then sees a separate, unticked box for it on the consent screen, and you only get the scope if they tick it. Check the scope in the token response.

curl -X POST https://app.passdesk.co.uk/api/v1/enquiries \
  -H "Authorization: Bearer pdsk_at_<token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"Amy Patel","phone":"07700 900123",
       "message":"Wants automatic lessons, weekday evenings",
       "source":"phone","externalRef":"call-8f21c3"}'

Webhook delivery is available from the Team plan. The school adds the endpoint in the app — an enquiries grant can’t register one — so for Pro and Instructor schools, or until the school has set one up, poll GET /enquiries?updatedSince=… with your last sync time instead. Timestamps you send need an offset (Z or +01:00); one without is refused rather than guessed.

To book a first lesson, read GET /enquiries/availability for the times the school offers, then pass one as lesson.startTime when you convert the enquiry. If the school uses instructor regions, add enquiryId to the availability call so you see the instructors convert will book. The time is checked again at booking, so a slot taken in between comes back as a 409 slot_taken before anyone is created, and you can offer another time. A read grant is enough for availability; converting needs enquiries:write. When a school deletes an enquiry you hear about it through the enquiry.deleted webhook or GET /enquiries/deletions.

Conventions

  • Base URL: https://app.passdesk.co.uk/api/v1
  • Auth: Authorization: Bearer pdsk_live_…
  • Money: always an integer in minor currency units (pence). A field named *MinorUnits holding 3500 means £35.00. Purchase.refundAmount is also pence, despite the legacy name. For a school outside the UK the units are its own currency’s (cents for euros): GET /me returns the school’s currency as an ISO code.
  • Timestamps: ISO 8601 UTC strings, e.g. 2026-08-14T16:00:00.000Z. Date filters (?from, ?to) also accept a bare YYYY-MM-DD, which is interpreted in your school’s timezone — start-of-day for from, end-of-day for to.
  • Stable fields: every field documented on an object below is always present in the response. Nullable fields return null when unset — they are never omitted.
  • Pagination: ?limit (max 200, default 50) + ?offset. Values above the max are clamped, not rejected. Total count is in pagination.total and the X-Total-Count header.
  • Response envelope: lists are { data: [...], pagination: { limit, offset, total } }; detail responses are { data: { ... } }.
  • Errors: { error: string, code?: string } with HTTP status. 400 = validation failed (the message says which field), 401 = missing/invalid key or access token, 402 = any request while the subscription has lapsed, or an API key on a school that isn’t on Scale, 403 = read-only key or grant on a write, or an OAuth grant without a scope for this endpoint (code: insufficient_scope), 404 = not found in your tenant, 409 = conflict (e.g. booking a slot that’s already taken), 410 = an enquiry retried after the school deleted it, 413 = an enquiry field over its cap, 429 = rate limited.
  • Rate limits: per-key, 600 reads/min and 120 writes/min. RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers on every response that reaches the limiter (a 401, 402 or read-only 403 is refused before it); 429 with Retry-After on overage.

Endpoints & objects

Each group lists its endpoints, any query parameters, and every field of the object it returns, with a type and an example.

Auth

GET
/me

Sanity-check the bearer key. Returns clientId, apiKeyId, readOnly, the key’s scopes and the school’s currency — no PII.

Example response
{
  "data": {
    "clientId": 42,
    "apiKeyId": 7,
    "readOnly": false,
    "scopes": [],
    "currency": "GBP"
  }
}

Students

GET
/students

List students, ordered by id ascending. Supports ?limit and ?offset.

GET
/students/{id}

Get a single student.

The student object

Learners’ health notes are never returned. dateOfBirth, provisionalLicenceNumber and the guardian fields are only present when the key holds the students:sensitive:read scope; without it they are left out, not null.

Field Type Example Description
id integer 118
firstName string "Amelia"
lastName string "Hart"
email string | null "[email protected]" Login email of the linked user account.
phone string | null "07700 900123"
address string | null "14 Marsh Lane, Leeds LS1 4AB"
dateOfBirth datetime | null "2007-03-22T00:00:00.000Z" Needs students:sensitive:read.
gender string | null "female"
wearsGlassesOrContacts boolean false
licenseCategory string "B" DVLA licence category being trained for. Defaults to B (car).
profileComplete boolean true True once the learner completed the post-signup profile form.
provisionalLicenceNumber string | null "HART9037226AM9XY" Needs students:sensitive:read.
licenceIssueDate datetime | null "2024-04-02T00:00:00.000Z"
theoryTestPassDate datetime | null "2026-05-11T00:00:00.000Z"
theoryTestCertificate string | null "155593301" Theory test certificate number.
practicalTestDate datetime | null "2026-09-14T00:00:00.000Z"
practicalTestResult enum | null "pending" One of pass, fail, pending, cancelled. Mirrors the latest practical test attempt.
testCentreId integer | null 211 DVSA test centre the learner is booked at.
guardianName string | null "Simon Hart" Needs students:sensitive:read.
guardianEmail string | null "[email protected]" Needs students:sensitive:read.
guardianPhone string | null "07700 900124" Needs students:sensitive:read.
guardianRelationship string | null "father" Needs students:sensitive:read.
transmission enum | null "MANUAL" MANUAL or AUTOMATIC.
defaultMeetingMode enum "PICKUP" PICKUP (instructor collects the learner) or MEET_AT_LOCATION.
preferredDaysOfWeek integer[] [1, 3, 6] ISO weekday numbers, Monday = 1 … Sunday = 7. Empty when no preference.
preferredHourStart integer | null 16 Earliest preferred lesson start, 24h clock (0–23).
preferredHourEnd integer | null 19 Latest preferred lesson end, 24h clock (0–23).
preferredInstructorId integer | null 12 Soft preference; scheduling does not enforce it.
Example response
{
  "data": {
    "id": 118,
    "firstName": "Amelia",
    "lastName": "Hart",
    "email": "[email protected]",
    "phone": "07700 900123",
    "address": "14 Marsh Lane, Leeds LS1 4AB",
    "dateOfBirth": "2007-03-22T00:00:00.000Z",
    "gender": "female",
    "wearsGlassesOrContacts": false,
    "licenseCategory": "B",
    "profileComplete": true,
    "provisionalLicenceNumber": "HART9037226AM9XY",
    "licenceIssueDate": "2024-04-02T00:00:00.000Z",
    "theoryTestPassDate": "2026-05-11T00:00:00.000Z",
    "theoryTestCertificate": "155593301",
    "practicalTestDate": "2026-09-14T00:00:00.000Z",
    "practicalTestResult": "pending",
    "testCentreId": 211,
    "guardianName": "Simon Hart",
    "guardianEmail": "[email protected]",
    "guardianPhone": "07700 900124",
    "guardianRelationship": "father",
    "transmission": "MANUAL",
    "defaultMeetingMode": "PICKUP",
    "preferredDaysOfWeek": [
      1,
      3,
      6
    ],
    "preferredHourStart": 16,
    "preferredHourEnd": 19,
    "preferredInstructorId": 12
  }
}

Schedules (lessons)

GET
/schedules

List lessons, newest start time first. OPEN slots without a student are included.

GET
/schedules/{id}

Get a single lesson.

GET
/schedules/{id}/cancel-preview

Whether cancelling now with cancelledBy: student would charge the late-cancellation fee: { feeApplies, feeAmountMinorUnits, windowHours }. Changes nothing.

POST
/schedules/{id}/book

Book a learner into an OPEN slot. Body: { studentId, overrideBalance? }. The same checks as the app: the learner’s balance (400, with balanceMinutes or allowedDurations), and clashes with their other lessons or the instructor’s calendar (409). Returns the lesson.

POST
/schedules/{id}/cancel

Cancel a booked lesson. Body: { reason?, cancelledBy?, waiveFee?, notifyStudent? }. Set cancelledBy to student or school. A late cancellation by the student charges the school’s fee unless waiveFee is true, and the learner is asked in the app to confirm it was them. A late one by the school gives the learner free lesson time worth at least that fee. Leave cancelledBy out and nobody is charged or credited. The learner is told unless notifyStudent is false. Returns { schedule, feeApplied, feeAmountMinorUnits, feePurchaseId, creditMinutes }. An OPEN slot is a 409: withdraw availability in Passdesk.

Query parameters

Parameter Type Description
from string Lessons on or after this date. YYYY-MM-DD or an ISO 8601 timestamp, read in your school’s timezone unless the timestamp carries an offset (Z or +01:00).
to string Lessons on or before this date. YYYY-MM-DD or an ISO 8601 timestamp, read in your school’s timezone unless the timestamp carries an offset (Z or +01:00).
status enum OPEN, SCHEDULED, IN_PROGRESS, COMPLETED, CANCELED, or NO_SHOW.
studentId integer Only this learner’s lessons.
instructorId integer Only this instructor’s lessons.

The schedule object

Field Type Example Description
id integer 5804
date datetime "2026-08-14T00:00:00.000Z" The lesson’s day: UTC midnight for most lessons, or the start instant for lessons from a series, moved or booked by a guest. Use startTime for the time.
startTime datetime "2026-08-14T16:00:00.000Z"
endTime datetime "2026-08-14T18:00:00.000Z"
studentId integer | null 118 Null on OPEN slots that no student has booked.
instructorId integer | null 12
status enum "COMPLETED" OPEN, SCHEDULED, IN_PROGRESS, COMPLETED, CANCELED, or NO_SHOW.
notes string | null "Focus on roundabouts"
lessonType enum | null "practical" One of practical, theory, mock_test, pretest, test (a driving test day held in the diary).
vehicleReg string | null "AB12 XYZ" Registration plate of the assigned vehicle, snapshotted at booking.
canceledAt datetime | null null
canceledReason string | null null
rescheduleOfId integer | null null When this lesson replaced a cancelled one, the original schedule id.
startedAt datetime | null "2026-08-14T16:02:11.000Z" When the instructor actually started the lesson.
endedAt datetime | null "2026-08-14T17:58:40.000Z"
billableMinutes integer | null 120 Minutes deducted from the learner’s balance.
distanceMiles integer | null 23 Miles driven during the lesson, when recorded.
Example response
{
  "data": {
    "id": 5804,
    "date": "2026-08-14T00:00:00.000Z",
    "startTime": "2026-08-14T16:00:00.000Z",
    "endTime": "2026-08-14T18:00:00.000Z",
    "studentId": 118,
    "instructorId": 12,
    "status": "COMPLETED",
    "notes": "Focus on roundabouts",
    "lessonType": "practical",
    "vehicleReg": "AB12 XYZ",
    "canceledAt": null,
    "canceledReason": null,
    "rescheduleOfId": null,
    "startedAt": "2026-08-14T16:02:11.000Z",
    "endedAt": "2026-08-14T17:58:40.000Z",
    "billableMinutes": 120,
    "distanceMiles": 23
  }
}

Products

GET
/products

List the catalogue, ordered by id ascending.

GET
/products/{id}

Get a single product.

Query parameters

Parameter Type Description
sellable string Pass true to return only sellable-online products.

The product object

All money fields are integer minor units (pence for GBP): 32000 means £320.00.

Field Type Example Description
id integer 31
name string "10-hour lesson package"
description string | null "Ten hours of one-to-one tuition."
kind enum "LESSON_PACKAGE" LESSON_PACKAGE adds lesson minutes to the balance. MERCHANDISE is anything else the school sells (a mock test, a theory course); ignore its minute and duration fields.
priceMinorUnits integer 32000 Headline price in pence.
priceWasMinorUnits integer | null 35000 Strike-through “was” price in pence, when discounted.
totalMinutes integer 600 Lesson minutes the package adds to the learner’s balance.
allowedDurations integer[] [60, 90, 120] Bookable lesson lengths, in minutes.
defaultLessonMinutes integer | null 120
depositMinorUnits integer | null 5000 Upfront deposit in pence, when the school takes one.
cancelFeeMinorUnits integer | null 2500 Late-cancellation fee in pence.
noShowFeeMinorUnits integer | null 3200 No-show fee in pence.
sellableOnline boolean true Whether learners can buy this themselves from your public page.
transmission enum | null "MANUAL" MANUAL or AUTOMATIC. Null when the product applies to both.
displayOrder integer | null 1 Sort position in the catalogue. Lower shows first.
Example response
{
  "data": {
    "id": 31,
    "name": "10-hour lesson package",
    "description": "Ten hours of one-to-one tuition, bookable in 1-2 hour slots.",
    "kind": "LESSON_PACKAGE",
    "priceMinorUnits": 32000,
    "priceWasMinorUnits": 35000,
    "totalMinutes": 600,
    "allowedDurations": [
      60,
      90,
      120
    ],
    "defaultLessonMinutes": 120,
    "depositMinorUnits": 5000,
    "cancelFeeMinorUnits": 2500,
    "noShowFeeMinorUnits": 3200,
    "sellableOnline": true,
    "transmission": "MANUAL",
    "displayOrder": 1
  }
}

Purchases

GET
/purchases

List purchases, newest first — packages, top-ups, fees, and adjustments.

GET
/purchases/{id}

Get a single purchase.

Query parameters

Parameter Type Description
studentId integer Only this learner’s purchases.
status enum PENDING, CONFIRMED, CANCELED, FAILED, REFUNDING, or REFUNDED.
kind enum PACKAGE, DEPOSIT, NO_SHOW_FEE, LATE_CANCEL_FEE, TIP, MERCHANDISE, TOP_UP, or ADJUSTMENT.

The purchase object

All money fields are integer minor units (pence for GBP). refundAmount is too, despite the legacy name; read refundAmountMinorUnits, which carries the same figure.

Field Type Example Description
id integer 902
studentId integer 118
productId integer | null 31 Null on ad-hoc rows (fees, adjustments) with no catalogue product.
description string | null null The line item, on a row with no catalogue product.
amountMinorUnits integer 32000 Amount charged in pence.
notes string | null null
status enum "CONFIRMED" PENDING, CONFIRMED, CANCELED, FAILED, REFUNDING, or REFUNDED.
kind enum "PACKAGE" PACKAGE = lesson package; TOP_UP = extra minutes on one; DEPOSIT / NO_SHOW_FEE / LATE_CANCEL_FEE are auto-created by booking flows; MERCHANDISE = non-lesson charge; ADJUSTMENT = manual hour-bank correction at £0.
adjustmentMinutes integer | null null Minutes added (positive) or taken (negative) on ADJUSTMENT rows. Null on every other kind.
parentScheduleId integer | null null The lesson that generated this fee row, on fee kinds.
refundedAt datetime | null null
refundAmountMinorUnits integer | null null Refunded amount in minor units. Null unless refunded.
refundAmount integer | null null Deprecated: the same figure as refundAmountMinorUnits, kept for existing integrations.
confirmedAt datetime | null "2026-08-01T09:14:03.000Z"
createdAt datetime "2026-08-01T09:13:41.000Z"
Example response
{
  "data": {
    "id": 902,
    "studentId": 118,
    "productId": 31,
    "description": null,
    "amountMinorUnits": 32000,
    "notes": null,
    "status": "CONFIRMED",
    "kind": "PACKAGE",
    "adjustmentMinutes": null,
    "parentScheduleId": null,
    "refundedAt": null,
    "refundAmountMinorUnits": null,
    "refundAmount": null,
    "confirmedAt": "2026-08-01T09:14:03.000Z",
    "createdAt": "2026-08-01T09:13:41.000Z"
  }
}

Progress

GET
/progress

List skill ratings logged against learners, newest first.

GET
/progress/{id}

Get a single progress entry.

Query parameters

Parameter Type Description
studentId integer Only this learner’s entries.
from string Entries on or after this date. YYYY-MM-DD or an ISO 8601 timestamp, read in your school’s timezone unless the timestamp carries an offset (Z or +01:00).

The progress entry object

Field Type Example Description
id integer 44712
studentId integer 118
date datetime "2026-08-14T17:58:40.000Z"
competency string "roundabouts" Skill key, e.g. vehicle_control, roundabouts, road_awareness.
rating integer 4 Proficiency on a 1–5 scale.
category enum "practical" One of theory, practical, exam.
notes string | null "Confident on mini-roundabouts."
Example response
{
  "data": {
    "id": 44712,
    "studentId": 118,
    "date": "2026-08-14T17:58:40.000Z",
    "competency": "roundabouts",
    "rating": 4,
    "category": "practical",
    "notes": "Confident on mini-roundabouts; multi-lane still needs prompting."
  }
}

Instructors

GET
/instructors

List teaching staff, with ADI compliance fields for HR / payroll integrations.

GET
/instructors/{id}

Get a single instructor.

The instructor object

Money is integer minor units (pence for GBP); hours are 24h clock integers; weekdays are ISO numbers (Monday = 1).

Field Type Example Description
id integer 12
email string "[email protected]"
name string | null "Dan Okafor"
role enum "EMPLOYEE" Owners who teach appear here as CLIENT_OWNER.
isInstructor boolean true
hourlyRateMinorUnits integer | null 3600 Hourly rate in pence (£36.00 = 3600).
employmentType enum | null "FULL_TIME" FULL_TIME, PART_TIME, or CONTRACTOR.
contractedHoursPerWeek integer | null 38
workingDays integer[] [1, 2, 3, 4, 5] ISO weekday numbers, Monday = 1 … Sunday = 7.
workingHourStart integer | null 8 24h clock (0–23).
workingHourEnd integer | null 18 24h clock (0–23).
adiBadgeNumber string | null "345678"
adiBadgeType enum | null "ADI" ADI or PDI.
adiExpiryDate datetime | null "2027-01-31T00:00:00.000Z"
Example response
{
  "data": {
    "id": 12,
    "email": "[email protected]",
    "name": "Dan Okafor",
    "role": "EMPLOYEE",
    "isInstructor": true,
    "hourlyRateMinorUnits": 3600,
    "employmentType": "FULL_TIME",
    "contractedHoursPerWeek": 38,
    "workingDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "workingHourStart": 8,
    "workingHourEnd": 18,
    "adiBadgeNumber": "345678",
    "adiBadgeType": "ADI",
    "adiExpiryDate": "2027-01-31T00:00:00.000Z"
  }
}

Employees

GET
/employees

List EMPLOYEE-role staff. Staff who also teach are included with isInstructor: true, and appear under /instructors too. Owners aren’t listed.

GET
/employees/{id}

Get a single employee.

The employee object

Field Type Example Description
id integer 27
email string "[email protected]"
name string | null "Priya Shah"
role enum "EMPLOYEE" Always EMPLOYEE here.
permissions string[] ["VIEW_STUDENTS", "MANAGE_SCHEDULES"] Permission keys such as VIEW_STUDENTS, MANAGE_STUDENTS, MANAGE_SCHEDULES, VIEW_PURCHASES, INVITE_STAFF. The full set is enumerated in the OpenAPI spec.
isInstructor boolean false
Example response
{
  "data": {
    "id": 27,
    "email": "[email protected]",
    "name": "Priya Shah",
    "role": "EMPLOYEE",
    "permissions": [
      "VIEW_STUDENTS",
      "MANAGE_SCHEDULES",
      "VIEW_PURCHASES"
    ],
    "isInstructor": false
  }
}

Enquiries

GET
/enquiries

List enquiries, newest first. Supports ?status, ?updatedSince, ?externalRef, ?limit and ?offset. An enquiry the school deletes drops out of the list; see /enquiries/deletions.

GET
/enquiries/deletions

Enquiries the school has deleted, oldest first, as { id, externalRef, deletedAt } — so a partner that polls can drop its own copies. Supports ?since (with an offset), ?limit and ?offset. The enquiry.deleted webhook carries the same fields. Deleted enquiries stay on this list for 30 days, then are removed for good. One removed because the learner’s account was erased is listed with externalRef null; the enquiry.deleted webhook carries it.

GET
/enquiries/{id}

Get a single enquiry.

GET
/enquiries/availability

The times a first lesson can be booked: ?durationMinutes (required; 30–180 in steps of 30), ?from and ?to (YYYY-MM-DD, school timezone), ?instructorId, ?enquiryId, ?postcode. Follows the school’s public booking rules — instructor working hours, 24 hours’ notice, 14 days ahead — and works whether or not the school has published its Passdesk page. An instructor only appears once the school has set their working hours and switched on their public profile, so empty days usually mean neither is set. If the school uses instructor regions, pass ?enquiryId to see the times convert would offer that enquiry, in the order it tries the instructors; a postcode too partial to route is ignored. Returns { timezone, durationMinutes, minNoticeHours, maxWindowDays, days: [{ date, slots: [{ start, end, instructorIds }] }] }.

POST
/enquiries

Add an enquiry to the school’s inbox. 201 on create; 200 with the existing row when externalRef matches one you already sent, even if an instructor or product it named has since been removed, or 410 enquiry_deleted if the school deleted it in the last 30 days (after that, or once the learner’s account is erased, the same externalRef makes a new enquiry; send a new externalRef for a new lead). The school gets its usual in-app and email alert.

PATCH
/enquiries/{id}

Update status (NEW, CONTACTED, WON, LOST) and internal notes.

POST
/enquiries/{id}/convert

Add the enquirer as a learner and optionally book their first lesson. Body: { student: { dateOfBirth, lastName?, email?, licenseCategory?, … }, lesson?: { startTime, durationMinutes, instructorId? } }. A learner needs a first name, last name, email and date of birth: the names and email come from the enquiry, so send student.lastName when its name is one word and student.email for a phone-only enquiry. Postcode, transmission and the availability preferences default from the enquiry too; its phone is copied only when it’s a mobile number, and its preferred instructor only while they’re still at the school. lesson.startTime must be a slot start from /enquiries/availability, with an offset; the lesson is confirmed and the school collects payment its usual way. A key holder can pass scheduleId + overrideBalance: true for an OPEN slot instead; from an OAuth connection, scheduleId is a 403 insufficient_scope. The time or slot is checked before anyone is created: one the school isn’t offering is a 409 slot_taken (a slot: 404 slot_not_found, 409 slot_already_booked or 400 slot_in_past), and nothing changes, so you can offer another time and convert again. Only if the time goes in the moment between that check and the booking is the learner created without it; booking is then { status, code, error }, and on success { status: 200, scheduleId, instructorId, startTime, endTime }. 409 enquiry_already_converted once converted.

Query parameters

Parameter Type Description
status enum NEW | CONTACTED | WON | LOST
updatedSince datetime Only enquiries changed at or after this instant — poll with your last sync time. A date-time needs an offset (Z or +01:00); one without is refused rather than guessed. A bare YYYY-MM-DD means midnight UTC.
externalRef string Exact match on your own reference.

The enquiry object

One of email or phone is required on create; everything from postcode down is optional. Caps: name 120, email 200, phone 32, message 5000, source and externalRef 120 characters; details up to 20 items and 16 KB in all, label 80 and value 2000 characters (an item with an empty value is dropped). Over a cap is a 413 with code enquiry_field_too_long, and a text field that isn’t a string is a 400. A preferred instructor or product must belong to the school. A postcode never costs the lead: a full one is normalised and files the enquiry under its region; one the school’s country can’t read (a partial WR11, a typo) is kept as sent, but isn’t routed or copied onto the learner. On create, send consent as { privacyAccepted: true, acceptedAt?, marketingOptIn? }; acceptedAt needs an offset.

Field Type Example Description
id integer 311
name string "Amy Patel"
email string | null "[email protected]"
phone string | null "07700 900123"
message string "Wants automatic lessons, weekday evenings" What the enquirer said — a form message or your call summary.
source string | null "oblyx:phone" Where it came from. Free text you choose; shown to the school. Defaults to "api".
status enum "NEW" NEW | CONTACTED | WON | LOST
notes string | null "Rang back, wants Tuesdays"
externalRef string | null "call-8f21c3" Your id for this enquiry. Unique per school for each app that sends enquiries (a school’s API keys count as one), so a repeat POST from you with the same value returns the original row, and another app’s matching id is never mistaken for yours.
studentId integer | null 118 Set once the enquiry has been added as a learner.
contactedAt datetime | null "2026-09-12T10:04:00.000Z"
resolvedAt datetime | null null
convertedAt datetime | null null
postcode string | null "G12 8QQ"
transmission "MANUAL" | "AUTOMATIC" | null "AUTOMATIC"
preferredDaysOfWeek integer[] [2, 6] ISO weekdays, Monday = 1 to Sunday = 7.
preferredHourStart integer | null 17 0–23.
preferredHourEnd integer | null 20 0–23, after the start.
preferredInstructorId integer | null 41 An instructor at this school.
productId integer | null 7 The lesson package they asked about.
details { label, value }[] | null [{ "label": "Previous experience", "value": "10 hours" }] Anything else your form asks, shown to the school as a labelled list in the order sent.
privacyConsentAt datetime | null "2026-09-12T09:57:40.000Z" When the learner accepted the school’s privacy notice on your form.
marketingOptIn boolean | null false
region { id, name } | null null The school’s instructor region for this enquiry, set from the postcode and changeable by the school. Null when the school doesn’t use regions or none covers the postcode.
createdAt datetime "2026-09-12T09:58:12.000Z"
updatedAt datetime "2026-09-12T10:04:00.000Z"
Example response
{
  "data": {
    "id": 311,
    "name": "Amy Patel",
    "email": "[email protected]",
    "phone": "07700 900123",
    "message": "Wants automatic lessons, weekday evenings",
    "source": "oblyx:phone",
    "status": "NEW",
    "notes": null,
    "externalRef": "call-8f21c3",
    "studentId": null,
    "contactedAt": null,
    "resolvedAt": null,
    "convertedAt": null,
    "postcode": "G12 8QQ",
    "transmission": "AUTOMATIC",
    "preferredDaysOfWeek": [
      2,
      6
    ],
    "preferredHourStart": 17,
    "preferredHourEnd": 20,
    "preferredInstructorId": null,
    "productId": null,
    "details": [
      {
        "label": "Previous experience",
        "value": "About 10 hours elsewhere"
      }
    ],
    "privacyConsentAt": "2026-09-12T09:57:40.000Z",
    "marketingOptIn": false,
    "region": null,
    "createdAt": "2026-09-12T09:58:12.000Z",
    "updatedAt": "2026-09-12T09:58:12.000Z"
  }
}

Webhook endpoints

GET
/webhooks

List your webhook endpoints. Active endpoints sort first. ?limit caps at 25 (default 25).

POST
/webhooks

Create an endpoint. Returns the plaintext signing secret exactly once. Max 25 active endpoints.

GET
/webhooks/event-types

Enumerate the subscribable event types.

PATCH
/webhooks/{id}

Update description, events, or active. The URL is immutable — rotate by delete + recreate.

DELETE
/webhooks/{id}

Disable the endpoint (soft delete — sets active: false and stops deliveries).

POST
/webhooks/{id}/rotate-secret

Generate a fresh signing secret; the plaintext is returned exactly once.

GET
/webhooks/{id}/deliveries

Delivery log, newest first. ?status filters pending / delivered / failed. ?limit caps at 100 (default 25).

POST
/webhooks/{id}/deliveries/{deliveryId}/retry

Re-send a failed or pending delivery immediately. 409 if it was already delivered, is being sent right now, or the endpoint is disabled.

The webhook endpoint object

The create body takes url (string, required — must be https://), events (string[], required — event types or ["*"], max 50), and description (string, optional, max 200 characters). PATCH accepts any of description, events, active (boolean).

Field Type Example Description
id integer 3
url string "https://example.com/hooks/passdesk" Destination for event POSTs. Always https.
description string | null "CRM sync"
events string[] ["student.created", "lesson.completed"] Subscribed event types, or ["*"] for everything.
active boolean true
disabledAt datetime | null null
disabledReason string | null null “manual” (the school or an API call), “platform” (Passdesk support switched it off) or “Tenant archived”. Failing deliveries don’t disable an endpoint.
createdById integer 4 User id that created the endpoint.
createdAt datetime "2026-07-30T10:00:00.000Z"
updatedAt datetime "2026-07-30T10:00:00.000Z"
signingSecret string "4f6a2c9e8b1d…" 64-hex-char HMAC secret. Only present on create and rotate-secret responses — store it immediately.
Example response
{
  "data": {
    "id": 3,
    "url": "https://example.com/hooks/passdesk",
    "description": "CRM sync",
    "events": [
      "student.created",
      "lesson.completed"
    ],
    "active": true,
    "disabledAt": null,
    "disabledReason": null,
    "createdById": 4,
    "createdAt": "2026-07-30T10:00:00.000Z",
    "updatedAt": "2026-07-30T10:00:00.000Z",
    "signingSecret": "4f6a2c9e8b1d3f5a7c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d0f1a3c5e7b9d1f3a"
  }
}

Webhook deliveries

The delivery object

Returned by GET /webhooks/{id}/deliveries — one row per dispatch attempt chain.

Field Type Example Description
id integer 991
eventId string "evt_9f2c41d68a0b73e5c1d24f80" Matches the envelope id and the Idempotency-Key header. Stable across retries — dedupe on it.
eventType string "lesson.completed"
payloadBytes integer 412 Size of the JSON envelope in bytes. An event over 256 KB isn’t delivered and gets no delivery row.
attempts integer 1 Delivery attempts made so far. Automatic retries stop at 7; a manual retry adds one more.
nextAttemptAt datetime | null null When the next retry fires; null once delivered or failed.
lastStatus integer | null 200 HTTP status of the most recent attempt.
lastError string | null null
lastResponse string | null "ok" Truncated response body of the most recent attempt.
lastAttemptAt datetime | null "2026-08-14T18:00:05.000Z"
deliveredAt datetime | null "2026-08-14T18:00:05.000Z" Set on a 2xx response. Terminal.
failedAt datetime | null null Set after the final attempt fails; re-send via the retry endpoint.
createdAt datetime "2026-08-14T18:00:04.000Z"

Read-only keys

Tick the read-only box when you create a key for a sync or reporting integration that doesn’t write. A read-only key gets a 403 on any POST, PUT, PATCH, or DELETE to the REST endpoints. Over MCP it gets the read-only tools.

Key scopes

Two extra powers are separate boxes on the key dialog, both off unless you tick them. GET /me lists the ones a key holds under scopes. Rotating a key keeps them, and a key can’t gain one later: create a new key instead.

  • students:sensitive:read adds learners’ date of birth, provisional licence number and parent or guardian contacts to student records, here and over MCP. Without it those fields are left out of the response.
  • mcp:write lets an AI assistant connected with the key book and cancel lessons over MCP. It needs a full-access key. The REST write endpoints only need full access.

Learners’ health notes are never returned by the API or MCP, whatever the key holds.

Webhooks

Subscribe an HTTPS endpoint to domain events to receive HMAC-signed JSON payloads as they happen. Available from the Team plan: manage endpoints in the workspace under Webhooks, or on Scale programmatically via the /webhooks endpoints above.

Envelope

Every dispatch posts JSON of this shape, with Idempotency-Key, Passdesk-Event, and Passdesk-Signature headers. The data object is event-specific — inspect real payloads for your events in the delivery log. For the two enquiry events it is the whole enquiry object above, plus clientId; enquiry.deleted carries only its id, externalRef, clientId and deletedAt.

{
  "id": "evt_9f2c41d68a0b73e5c1d24f80",  // string — "evt_" + 24 hex chars; doubles as Idempotency-Key
  "type": "lesson.completed",            // string — one of the event types below
  "createdAt": "2026-08-14T18:00:04Z",   // ISO 8601 UTC
  "tenantId": 42,                        // integer — your school's id
  "apiVersion": "v1",                    // string
  "data": { ... }                        // object — shape depends on type
}

Event types

student.created

New student added to your school

student.updated

Student profile changed

student.test_date_changed

Practical test date set or moved

student.deleted

Student removed

instructor.created

Instructor added

instructor.updated

Instructor profile changed

schedule.created

Lesson booked or slot opened

schedule.updated

Booking changed

schedule.cancelled

Booking cancelled (carries who it was down to, the fee and any credit)

lesson.completed

Instructor closed the lesson with a rubric

lesson.no_show

Student marked as a no-show

purchase.created

Cash sale or Stripe checkout completion

purchase.refunded

Purchase refunded

rubric.updated

Per-skill rating change

progress.test_ready_changed

Learner’s readiness band moved into or out of READY (carries studentId, testReady, band, previousBand)

waitlist.offer_accepted

A waitlist offer was claimed

enquiry.created

A new enquiry landed in the inbox (public page, discover routes, or the API)

enquiry.updated

Enquiry status, notes, learner link or region changed

enquiry.deleted

The school deleted an enquiry (carries only its id, externalRef and deletedAt)

Verifying signatures

The Passdesk-Signature header has the form t=<unix>,v1=<hex>. The signed string is `${t}.${rawBody}`, HMAC-SHA256 with your endpoint’s 64-hex-char signing secret. Compare in constant time. Reject anything where |now - t| exceeds 5 minutes.

// Node.js (no dependencies)
import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=')),
  );
  const t = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - t) > 5 * 60) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(parts.v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retry behaviour

The first attempt fires immediately; failures retry on an exponential schedule: 30s, 2m, 10m, 1h, 6h, 24h. After seven attempts the row is marked failed; you can re-send it from the delivery log or via POST /webhooks/{id}/deliveries/{deliveryId}/retry. We deliver at-least-once — dedupe on the envelope’s id (it’s stable across retries). Ordering is not guaranteed; reconcile on createdAt.


Send questions through the in-app feedback widget.