passdesk
API Reference
Programmatic access for Scale-tier driving schools. v1 — read-mostly.
Getting started
Mint a key in Passdesk under Your school → 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>"
Connecting an AI assistant instead of writing code? The same key powers our MCP server — Claude, ChatGPT, Codex, Cursor, and friends, no integration work needed.
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. - 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,402= not on Scale or subscription inactive,403= read-only key on a write,404= not found in your tenant,409= conflict (e.g. retrying an already-delivered webhook),429= rate limited. - Rate limits:
per-key, 600 reads/min and 120 writes/min. Standard
RateLimit-Limit/RateLimit-Remainingheaders on every response;429withRetry-Afteron overage.
Endpoints & objects
Each group lists its endpoints, any query parameters, and the full field reference for the object it returns — with a type and an example for every field.
Auth
/me Sanity-check the bearer key. Returns clientId, apiKeyId, readOnly — no PII.
Example response
{
"data": {
"clientId": 42,
"apiKeyId": 7,
"readOnly": false
}
} Students
/students List students, ordered by id ascending. Supports ?limit and ?offset.
/students/{id} Get a single student.
The student object
| Field | Type | Example | Description |
|---|---|---|---|
id | integer | 118 | |
firstName | string | "Amelia" | |
lastName | string | "Hart" | |
email | string | null | "amelia.hart@example.com" | 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" | |
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" | |
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. |
testCentreId | integer | null | 211 | DVSA test centre the learner is booked at. |
medicalConditions | string | null | null | Free-text, self-declared. |
guardianName | string | null | "Simon Hart" | |
guardianEmail | string | null | "simon.hart@example.com" | |
guardianPhone | string | null | "07700 900124" | |
guardianRelationship | string | null | "father" | |
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": "amelia.hart@example.com",
"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,
"medicalConditions": null,
"guardianName": "Simon Hart",
"guardianEmail": "simon.hart@example.com",
"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.
Query parameters
| Parameter | Type | Description |
|---|---|---|
from | string | Lessons on or after this date. Full ISO 8601 timestamp, or YYYY-MM-DD interpreted in your school’s timezone. |
to | string | Lessons on or before this date. Full ISO 8601 timestamp, or YYYY-MM-DD interpreted in your school’s timezone. |
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 calendar day. |
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. |
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 pence: 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." | |
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.",
"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 pence — including refundAmount, despite the legacy name.
| 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. |
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. |
parentScheduleId | integer | null | null | The lesson that generated this fee row, on fee kinds. |
refundedAt | datetime | null | null | |
refundAmount | integer | null | null | Refunded amount in pence. |
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,
"amountMinorUnits": 32000,
"notes": null,
"status": "CONFIRMED",
"kind": "PACKAGE",
"parentScheduleId": null,
"refundedAt": 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. Full ISO 8601 timestamp, or YYYY-MM-DD interpreted in your school’s timezone. |
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 pence; hours are 24h clock integers; weekdays are ISO numbers (Monday = 1).
| Field | Type | Example | Description |
|---|---|---|---|
id | integer | 12 | |
email | string | "dan.okafor@example.com" | |
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": "dan.okafor@example.com",
"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 office employees (non-instructor staff). Teaching staff live under /instructors.
/employees/{id} Get a single employee.
The employee object
| Field | Type | Example | Description |
|---|---|---|---|
id | integer | 27 | |
email | string | "priya.shah@example.com" | |
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": "priya.shah@example.com",
"name": "Priya Shah",
"role": "EMPLOYEE",
"permissions": [
"VIEW_STUDENTS",
"MANAGE_SCHEDULES",
"VIEW_PURCHASES"
],
"isInstructor": false
}
} Webhook endpoints
/webhooks List your webhook endpoints. Active endpoints sort first.
/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 delivery immediately. 409 if already delivered.
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”, or set automatically after sustained delivery failure. |
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 (capped at 256 KB). |
attempts | integer | 1 | Delivery attempts made so far (max 7). |
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 at creation time for sync / reporting integrations that don't
need to mutate. A read-only key is a hard 403 on any
POST, PUT,
PATCH, or DELETE across the entire
surface, regardless of which endpoint is called.
Webhooks
Subscribe an HTTPS endpoint to domain events and receive HMAC-signed JSON payloads
when things happen. No polling required. Manage endpoints in the workspace under
Webhooks, or 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 a compact event-specific summary — inspect
real payloads for your events in the delivery log.
{
"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 fee status)
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 crossed the test-ready threshold (either direction)
waitlist.offer_accepted A waitlist offer was claimed
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.
Questions? Drop a note via the in-app feedback widget — we read every report.