passdesk
API Reference
Programmatic access for Scale-tier driving schools.
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
*MinorUnitsholding3500means £35.00.Purchase.refundAmountis also pence, despite the legacy name. For a school outside the UK the units are its own currency’s (cents for euros):GET /mereturns 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 bareYYYY-MM-DD, which is interpreted in your school’s timezone — start-of-day forfrom, end-of-day forto. - Stable fields: every field documented on an object below is always
present in the response. Nullable fields return
nullwhen unset — they are never omitted. - Pagination:
?limit(max 200, default 50) +?offset. Values above the max are clamped, not rejected. Total count is inpagination.totaland theX-Total-Countheader. - 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-Resetheaders on every response that reaches the limiter (a 401, 402 or read-only 403 is refused before it);429withRetry-Afteron 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
/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
/students List students, ordered by id ascending. Supports ?limit and ?offset.
/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)
/schedules List lessons, newest start time first. OPEN slots without a student are included.
/schedules/{id} Get a single lesson.
/schedules/{id}/cancel-preview Whether cancelling now with cancelledBy: student would charge the late-cancellation fee: { feeApplies, feeAmountMinorUnits, windowHours }. Changes nothing.
/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.
/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
/products List the catalogue, ordered by id ascending.
/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
/purchases List purchases, newest first — packages, top-ups, fees, and adjustments.
/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
/progress List skill ratings logged against learners, newest first.
/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
/instructors List teaching staff, with ADI compliance fields for HR / payroll integrations.
/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
/employees List EMPLOYEE-role staff. Staff who also teach are included with isInstructor: true, and appear under /instructors too. Owners aren’t listed.
/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
/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.
/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.
/enquiries/{id} Get a single enquiry.
/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 }] }] }.
/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.
/enquiries/{id} Update status (NEW, CONTACTED, WON, LOST) and internal notes.
/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
/webhooks List your webhook endpoints. Active endpoints sort first. ?limit caps at 25 (default 25).
/webhooks Create an endpoint. Returns the plaintext signing secret exactly once. Max 25 active endpoints.
/webhooks/event-types Enumerate the subscribable event types.
/webhooks/{id} Update description, events, or active. The URL is immutable — rotate by delete + recreate.
/webhooks/{id} Disable the endpoint (soft delete — sets active: false and stops deliveries).
/webhooks/{id}/rotate-secret Generate a fresh signing secret; the plaintext is returned exactly once.
/webhooks/{id}/deliveries Delivery log, newest first. ?status filters pending / delivered / failed. ?limit caps at 100 (default 25).
/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:readadds 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:writelets 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.