Passdesk

passdesk

API Reference

Programmatic access for Scale-tier driving schools. v1 — read-mostly.

Download OpenAPI spec (YAML)

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 *MinorUnits holding 3500 means £35.00. Purchase.refundAmount is 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 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, 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-Remaining headers on every response; 429 with Retry-After on 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

GET
/me

Sanity-check the bearer key. Returns clientId, apiKeyId, readOnly — no PII.

Example response
{
  "data": {
    "clientId": 42,
    "apiKeyId": 7,
    "readOnly": false
  }
}

Students

GET
/students

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

GET
/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)

GET
/schedules

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

GET
/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

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 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

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 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

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. 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

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 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

GET
/employees

List office employees (non-instructor staff). Teaching staff live under /instructors.

GET
/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

GET
/webhooks

List your webhook endpoints. Active endpoints sort first.

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 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.