openapi: 3.1.0
info:
  title: Passdesk Public API
  version: 1.0.0
  summary: Programmatic access to your driving school's data on Passdesk.
  description: |
    The Passdesk API gives Scale-tier schools programmatic access to their own
    students, schedules, products, purchases, progress, instructors, and
    employees, plus full management of outbound webhook endpoints. Webhooks
    themselves start at the Team plan (managed in the app); managing them
    through this API needs a key, which is a Scale feature. v1 lists and
    reads every primary resource, and writes where an integration needs to:
    booking a learner into an open slot, cancelling a lesson, taking in and
    converting enquiries, and managing webhook endpoints.

    All requests are authenticated with a bearer API key. Keys are minted from
    the Passdesk app under **Developer → API keys** and can be rotated or
    revoked at any time. Rotation immediately invalidates the previous key.

    ### Conventions

    - **Money** is always an integer in minor currency units (pence): a field
      named `*MinorUnits` holding `3500` means £35.00. `Purchase.refundAmount`
      is also minor units despite the legacy name; read
      `Purchase.refundAmountMinorUnits`, which carries the same figure.
    - **Timestamps** are ISO 8601 UTC strings (`2026-08-14T16:00:00.000Z`).
    - **Date filters** (`?from`, `?to`) accept a bare `YYYY-MM-DD`
      (start-of-day for `from`, end-of-day for `to`) or an ISO 8601
      timestamp. Both are read in the school's timezone unless the timestamp
      carries an offset (`Z` or `+01:00`).
    - **Currency** is the school's own, returned by `GET /me` as an ISO 4217
      `currency`. Every money field is in that currency's minor units: `3500`
      is €35.00 for a school in euros.
    - **Hours and weekdays** in preference/working-pattern fields are plain
      integers: hours are 24h clock values (0–23), weekdays are ISO numbers
      (Monday = 1 … Sunday = 7).
    - **Response fields are stable**: every property documented on a resource
      schema is always present in the response. Nullable properties return
      `null` when unset — they are never omitted. The one exception is the
      sensitive `Student` fields (date of birth, provisional licence number
      and guardian contacts), which are left out unless the key holds the
      `students:sensitive:read` scope.
    - **Key scopes** are extra powers ticked when a key is created, listed by
      `GET /me`. `students:sensitive:read` adds the sensitive `Student`
      fields; `mcp:write` lets an AI assistant book and cancel lessons over
      MCP. Learners' health notes are never returned, whatever the key holds.
servers:
  - url: https://app.passdesk.co.uk/api/v1
    description: Production
  - url: http://localhost:4000/api/v1
    description: Local development

security:
  - bearerAuth: []

paths:
  /me:
    get:
      summary: Verify the bearer key
      description: |
        Lightweight pre-flight that returns the tenant id, the calling key id,
        whether the key is read-only, the key's scopes, and the currency the
        school's money fields are in. Useful for sanity-checking a key before exercising any
        of the resource endpoints. Returns no PII.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      clientId:
                        type: integer
                        description: Your school's tenant id.
                        example: 42
                      apiKeyId:
                        type: integer
                        description: Id of the key making this request.
                        example: 7
                      readOnly:
                        type: boolean
                        description: True when the key was minted read-only.
                        example: false
                      scopes:
                        type: array
                        items:
                          type: string
                          enum: ['mcp:write', 'students:sensitive:read']
                        description: Extra powers the key was created with.
                        example: []
                      currency:
                        type: string
                        description: >
                          ISO 4217 code of the school's currency. Every money
                          field in this school's data is in its minor units.
                        example: GBP
                    required: [clientId, apiKeyId, readOnly, scopes, currency]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /students:
    get:
      summary: List students
      description: Returns the tenant's learners, ordered by id ascending.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Student' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /students/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get a student by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Student' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /schedules:
    get:
      summary: List lessons
      description: |
        Returns lessons (and OPEN slots without a student), ordered by
        `startTime` descending.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: from
          schema: { type: string }
          description: >
            Lessons on or after this date: `YYYY-MM-DD` (start of day) or an
            ISO timestamp, in the school's timezone unless it carries an offset.
          example: '2026-08-01'
        - in: query
          name: to
          schema: { type: string }
          description: >
            Lessons on or before this date: `YYYY-MM-DD` (end of day) or an
            ISO timestamp, in the school's timezone unless it carries an offset.
          example: '2026-08-31'
        - in: query
          name: status
          schema:
            type: string
            enum: [OPEN, SCHEDULED, IN_PROGRESS, COMPLETED, CANCELED, NO_SHOW]
          example: COMPLETED
        - in: query
          name: studentId
          schema: { type: integer }
          example: 118
        - in: query
          name: instructorId
          schema: { type: integer }
          example: 12
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Schedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /schedules/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get a lesson by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Schedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /schedules/{id}/cancel-preview:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Preview a cancellation fee
      description: |
        Whether cancelling this lesson now would charge the learner the
        school's late-cancellation fee, and how much. Changes nothing.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      feeApplies: { type: boolean, example: true }
                      feeAmountMinorUnits: { type: integer, example: 3500 }
                      windowHours:
                        type: integer
                        description: The school's cancellation window. 0 when the lesson is already closed.
                        example: 24
                      alreadyClosed:
                        type: boolean
                        description: Present, and true, only when the lesson is already cancelled or a no-show.
                        example: true
                    required: [feeApplies, feeAmountMinorUnits, windowHours]
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /schedules/{id}/book:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    post:
      summary: Book a learner into an open slot
      description: |
        Books a learner into an OPEN slot with the same checks and side
        effects as booking in the app: the learner's lesson balance, clashes
        with their other lessons and the instructor's calendar, notifications
        and the `schedule.updated` webhook.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [studentId]
              properties:
                studentId: { type: integer, example: 118 }
                overrideBalance:
                  type: boolean
                  default: false
                  description: Book even if the learner's balance doesn't cover the lesson.
      responses:
        '200':
          description: Booked. Returns the lesson.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Schedule' }
        '400':
          description: |
            No `studentId`, the slot has already started, or the learner's
            balance doesn't cover it. A balance refusal carries
            `balanceMinutes`, or `allowedDurations` when their packages don't
            allow a lesson this long.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '404':
          description: The slot or the learner isn't in this school.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: |
            The slot is already booked, the learner has another lesson at that
            time, or the instructor is busy or off that day.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /schedules/{id}/cancel:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    post:
      summary: Cancel a lesson
      description: |
        Cancels a booked lesson the way the app does: a late cancellation
        charges the school's fee unless `waiveFee` is set, the slot is offered
        to the waitlist, and the learner is told unless `notifyStudent` is
        false. Fires `schedule.cancelled`. An OPEN slot can't be cancelled
        here: withdraw availability in Passdesk.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Shown to the learner. Stored up to 500 characters.
                  example: Instructor unwell
                waiveFee: { type: boolean, default: false }
                notifyStudent: { type: boolean, default: true }
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      schedule: { $ref: '#/components/schemas/Schedule' }
                      feeApplied: { type: boolean, example: false }
                      feeAmountMinorUnits: { type: integer, example: 0 }
                      feePurchaseId:
                        type: [integer, 'null']
                        description: The fee's Purchase row, when a fee was charged.
                        example: null
                    required: [schedule, feeApplied, feeAmountMinorUnits, feePurchaseId]
        '400':
          description: A body field has the wrong type.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The slot is OPEN, or the lesson is already cancelled, a no-show or completed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /products:
    get:
      summary: List products
      description: Returns the tenant's catalogue, ordered by id ascending.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: sellable
          schema: { type: string, enum: ['true'] }
          description: When set to `true`, only sellable-online products are returned.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Product' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /products/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get a product by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Product' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /purchases:
    get:
      summary: List purchases
      description: |
        Returns student top-ups, packages, and auto-generated fee rows,
        ordered by `createdAt` descending.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: studentId
          schema: { type: integer }
          example: 118
        - in: query
          name: status
          schema:
            type: string
            enum: [PENDING, CONFIRMED, CANCELED, FAILED, REFUNDING, REFUNDED]
          example: CONFIRMED
        - in: query
          name: kind
          schema:
            type: string
            enum:
              [PACKAGE, DEPOSIT, NO_SHOW_FEE, LATE_CANCEL_FEE, TIP, MERCHANDISE, TOP_UP, ADJUSTMENT]
          example: PACKAGE
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Purchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /purchases/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get a purchase by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Purchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /progress:
    get:
      summary: List progress entries
      description: Skill ratings logged against learners, ordered by `date` descending.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: studentId
          schema: { type: integer }
          example: 118
        - in: query
          name: from
          schema: { type: string }
          description: >
            Entries on or after this date: `YYYY-MM-DD` (start of day) or an
            ISO timestamp, in the school's timezone unless it carries an offset.
          example: '2026-08-01'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Progress' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /progress/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get a progress entry by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Progress' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /instructors:
    get:
      summary: List instructors
      description: |
        Users in the tenant who teach, ordered by id ascending. Includes ADI
        compliance fields for downstream HR / payroll integrations.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Instructor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /instructors/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get an instructor by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Instructor' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /employees:
    get:
      summary: List employees
      description: |
        EMPLOYEE-role users, ordered by id ascending. Staff who also teach are
        included, with `isInstructor: true`, and appear in `/instructors` too.
        Owners are excluded.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Employee' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /employees/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get an employee by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Employee' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /enquiries:
    get:
      summary: List enquiries
      description: |
        Returns the school's enquiries, newest first. Reachable with an API
        key or with an OAuth grant carrying `enquiries:read` or
        `enquiries:write` — the OAuth route needs no Scale plan.

        An enquiry the school deletes drops out of this list and `GET
        /enquiries/{id}` returns 404 for it. You hear about it through the
        `enquiry.deleted` webhook or `GET /enquiries/deletions`. A method or
        path under `/enquiries` that isn't listed here returns 404
        (`code: not_found`).
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: status
          schema: { $ref: '#/components/schemas/EnquiryStatus' }
        - in: query
          name: updatedSince
          schema: { type: string, format: date-time, example: '2026-10-05T09:00:00Z' }
          description: |
            Only enquiries changed at or after this instant. Poll with your last
            sync time. A date-time must carry an offset (`Z` or `+01:00`); one
            without is refused with a 400 rather than guessed. A bare
            `YYYY-MM-DD` means midnight UTC.
        - in: query
          name: externalRef
          schema: { type: string }
          description: Exact match on your own reference.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Enquiry' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      summary: Add an enquiry
      description: |
        Adds an enquiry to the school's inbox and fires the school's usual
        alerts (in-app, push, email). One of `email` or `phone` is required.
        Send your own `externalRef` and the call is idempotent per school: a
        repeat returns the original row with a 200 instead of creating a
        duplicate. The repeat is answered before the structured fields are
        checked again, so it still returns the row if an instructor or
        product it named has since been removed. A reference stays taken
        after the school deletes its enquiry: a repeat is a 410
        (`code: enquiry_deleted`) and the enquiry isn't added again. Send a
        new reference for a new lead.

        The structured fields are checked with the rules the school's learner
        records use: `preferredInstructorId` and `productId` must belong to
        the school. A postcode is never a reason to lose the lead: a full one
        is normalised and files the enquiry under the school's region for it,
        and one the school's country can't read (a partial `WR11`, a typo) is
        kept as you sent it but isn't used for routing or copied onto the
        learner.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EnquiryCreate' }
      responses:
        '200':
          description: An enquiry with this `externalRef` already exists — returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Enquiry' }
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Enquiry' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/EnquiryWriteForbidden' }
        '410':
          description: |
            The school deleted the enquiry this `externalRef` made
            (`code: enquiry_deleted`). Carries `id`, `externalRef` and
            `deletedAt`, the fields of an `enquiry.deleted` event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/EnquiryFieldTooLong' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /enquiries/deletions:
    get:
      summary: List deleted enquiries
      description: |
        The enquiries the school has deleted, oldest deletion first, so a
        partner that polls can drop its own copies. Each row carries only the
        id, your `externalRef` and when it was deleted — the school may have
        deleted it to get rid of the details. The `enquiry.deleted` webhook
        carries the same fields.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: since
          schema: { type: string, format: date-time, example: '2026-10-05T09:00:00Z' }
          description: Only deletions at or after this instant. Needs an offset, like `updatedSince`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/EnquiryDeletion' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /enquiries/availability:
    get:
      summary: Times a first lesson can be booked
      description: |
        The slots the school's public booking rules would offer: inside
        instructor working hours, on a 30-minute grid, at least 24 hours
        ahead and within 14 days. Works whether or not the school has
        published its Passdesk page, but an instructor only appears once the
        school has set their working days and hours and switched on their
        public profile — an empty `slots` list on every day usually means
        neither is set yet. Book a slot by passing its `start` as
        `lesson.startTime` on convert.

        If the school uses instructor regions, convert books the lesson with
        an instructor from the enquiry's region. Pass `enquiryId` to see the
        times convert would offer that enquiry — its region, else its
        postcode, else the `postcode` you pass — in the order convert tries
        the instructors. Without `enquiryId`, `postcode` alone narrows the
        result to that postcode's region.

        For a school in Great Britain that allows for driving time between
        pickups (the default), a postcode also drops the times an instructor
        couldn't drive to from their previous lesson, or couldn't leave in
        time for their next. Pass the learner's postcode, or an `enquiryId`
        whose enquiry has one, to see only times convert will accept:
        without one, convert can refuse a listed time with `slot_taken`.
      parameters:
        - name: durationMinutes
          in: query
          required: true
          schema: { type: integer, minimum: 30, maximum: 180, multipleOf: 30 }
        - name: from
          in: query
          schema: { type: string, format: date }
          description: School timezone. Clamped to today.
        - name: to
          in: query
          schema: { type: string, format: date }
          description: School timezone. Clamped to the 14-day window; before `from` gives no days.
        - name: instructorId
          in: query
          schema: { type: integer }
        - name: enquiryId
          in: query
          schema: { type: integer }
          description: Route the result the way convert will for this enquiry. 404 if it isn't this school's.
        - name: postcode
          in: query
          schema: { type: string, maxLength: 32, example: WR11 4AB }
          description: |
            The learner's postcode. It narrows who is offered when the school
            uses instructor regions, and drops the times an instructor
            couldn't drive to in time when the school allows for driving time.
            With `enquiryId`, it's used only when the enquiry has no region or
            postcode of its own. One too partial to route (`WR11`) is ignored,
            as it is on convert.
      responses:
        '200':
          description: Offered slots by day.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      timezone: { type: string, example: Europe/London }
                      durationMinutes: { type: integer, example: 60 }
                      minNoticeHours: { type: integer, example: 24 }
                      maxWindowDays: { type: integer, example: 14 }
                      days:
                        type: array
                        items:
                          type: object
                          properties:
                            date: { type: string, format: date }
                            slots:
                              type: array
                              items:
                                type: object
                                properties:
                                  start: { type: string, format: date-time }
                                  end: { type: string, format: date-time }
                                  instructorIds:
                                    type: array
                                    items: { type: integer }
                                    description: In the order convert tries them.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404':
          description: "The `enquiryId` is not one of this school's enquiries (`code: enquiry_not_found`)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/EnquiryFieldTooLong' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /enquiries/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: Get an enquiry by id
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Enquiry' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      summary: Update an enquiry
      description: Change the triage status and/or the school's internal notes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { $ref: '#/components/schemas/EnquiryStatus' }
                notes: { type: [string, 'null'], maxLength: 5000 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Enquiry' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/EnquiryWriteForbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/EnquiryFieldTooLong' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /enquiries/{id}/convert:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    post:
      summary: Add the enquirer as a learner
      description: |
        Creates the learner from the enquiry with the same checks the school's
        own Add Student form applies — seat cap, duplicate email, under-18
        safeguarding — then marks the enquiry WON and links it.

        A learner needs a first name, last name, email and date of birth. The
        names split from the enquiry's `name` and the email comes from the
        enquiry, so send `student.dateOfBirth` always, `student.lastName` when
        the enquiry's name is one word, and `student.email` for a phone-only
        enquiry. Postcode, transmission, the availability preferences and the
        preferred instructor also default from the enquiry. Two values are
        copied only when the learner record can take them: the enquiry's
        phone when it's a mobile number, and its preferred instructor while
        they're still at the school. Otherwise they stay on the enquiry. Any
        `student` field you send wins over the enquiry's.

        Pass `lesson` to book a first lesson at a time read from
        `/enquiries/availability`. The time is re-checked against the school's
        public booking rules and booked as a confirmed lesson; the school
        collects payment its usual way. If the school uses instructor regions,
        the lesson goes to an instructor from the enquiry's `region` or, when
        it has none, the region its postcode (or the learner's) falls in —
        read availability with `enquiryId` to see the same times. A key holder
        can instead pass `scheduleId` (an OPEN slot) with
        `overrideBalance: true` — a new learner has nothing on account, so
        without it the slot can't be booked. Send one or the other.

        The time or slot is checked before anyone is created. One the school
        isn't offering is a 409 `slot_taken`; a slot that doesn't exist, is
        taken or has passed is a 404 `slot_not_found`, 409
        `slot_already_booked` or 400 `slot_in_past`. 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 does the learner exist
        without the lesson: the enquiry is still converted, and `booking`
        carries the failure's `status`, `code` and `error`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [student]
              properties:
                student:
                  type: object
                  description: |
                    Any field the school's Add Student form accepts.
                    `lastName` and `email` are needed when the enquiry can't
                    supply them (see above).
                  required: [dateOfBirth]
                  properties:
                    firstName: { type: string }
                    lastName: { type: string }
                    email: { type: string }
                    phone: { type: string }
                    dateOfBirth: { type: string, format: date, example: '2005-04-12' }
                    licenseCategory: { type: string, example: B }
                    transmission: { type: string, enum: [MANUAL, AUTOMATIC] }
                lesson:
                  type: object
                  required: [startTime, durationMinutes]
                  properties:
                    startTime:
                      type: string
                      format: date-time
                      example: '2026-10-05T09:00:00Z'
                      description: A slot `start` from `/enquiries/availability`. Must carry an offset; one without is a 400.
                    durationMinutes: { type: integer, minimum: 30, maximum: 180, multipleOf: 30 }
                    instructorId:
                      { type: integer, description: Omit to take the first offered instructor. }
                scheduleId: { type: integer, description: An OPEN slot to book. }
                overrideBalance: { type: boolean, default: false }
      responses:
        '201':
          description: Learner created and linked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enquiry: { $ref: '#/components/schemas/Enquiry' }
                      student: { $ref: '#/components/schemas/Student' }
                      booking:
                        description: |
                          Null when neither `lesson` nor `scheduleId` was sent.
                          `status` is 200 on success. A failure here means the
                          time went after the pre-check passed: `slot_taken`
                          (409), or the booking's own refusal code, falling
                          back to `booking_failed`.
                        oneOf:
                          - type: 'null'
                          - type: object
                            required: [status, scheduleId, instructorId, startTime, endTime]
                            properties:
                              status: { type: integer, enum: [200] }
                              scheduleId: { type: integer }
                              instructorId: { type: integer }
                              startTime: { type: string, format: date-time }
                              endTime: { type: string, format: date-time }
                          - type: object
                            required: [status, code, error]
                            properties:
                              status: { type: integer, example: 409 }
                              code: { type: string, example: slot_taken }
                              error:
                                {
                                  type: string,
                                  example: That time is no longer available — pick another,
                                }
        '400':
          description: |
            A learner field is missing or invalid: no first name, last name,
            email or date of birth ("Missing required fields"), an email
            another learner at the school already has (no `code`, or
            `student_soft_deleted` when it belongs to a removed learner), or
            `scheduleId` without `overrideBalance: true`
            (`enquiry_invalid_input`). Also `slot_in_past`, and an invalid
            `lesson`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: |
            The school has reached its active-learner cap for its plan, its
            subscription is inactive, or (for an API key) it isn't on Scale.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403': { $ref: '#/components/responses/EnquiryWriteForbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Already converted (`code: enquiry_already_converted`, carries
            `studentId`), or the requested first lesson can't be booked
            (`slot_taken`, `slot_already_booked`). Nothing was created. If the
            learner an enquiry was converted to has since been removed, the
            enquiry can be converted again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks:
    get:
      summary: List webhook endpoints
      description: Active endpoints sort first, then newest-created first.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 25, default: 25 }
          description: Page size. Clamped to 1–25 rather than rejected.
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      summary: Create a webhook endpoint
      description: |
        Registers an HTTPS endpoint for event delivery. The response includes
        the plaintext `signingSecret` exactly once — store it then; it is
        never shown again (rotate via `/webhooks/{id}/rotate-secret` if lost).
        Each school can have at most 25 active endpoints.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointCreate' }
      responses:
        '201':
          description: Created. Includes the one-time plaintext signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpointWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/event-types:
    get:
      summary: List subscribable event types
      description: |
        The catalogue of event types an endpoint can subscribe to, so an
        integration can enumerate dynamically before subscribing.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      types:
                        type: array
                        items: { type: string }
                        example:
                          - student.created
                          - student.updated
                          - student.test_date_changed
                          - student.deleted
                          - instructor.created
                          - instructor.updated
                          - schedule.created
                          - schedule.updated
                          - schedule.cancelled
                          - lesson.completed
                          - lesson.no_show
                          - purchase.created
                          - purchase.refunded
                          - rubric.updated
                          - progress.test_ready_changed
                          - waitlist.offer_accepted
                          - enquiry.created
                          - enquiry.updated
                          - enquiry.deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    patch:
      summary: Update a webhook endpoint
      description: |
        Update `description`, `events`, and/or `active`. The URL is immutable —
        rotate by delete + recreate. Re-enabling a disabled endpoint respects
        the 25-active-endpoint cap.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointPatch' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      summary: Disable a webhook endpoint
      description: |
        Soft delete — sets `active: false` and stops deliveries. Idempotent;
        disabling an already-disabled endpoint returns
        `{ "data": { "ok": true, "alreadyDisabled": true } }`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean, example: true }
                      alreadyDisabled: { type: boolean, example: false }
                    required: [ok]
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/rotate-secret:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    post:
      summary: Rotate the signing secret
      description: |
        Generates a fresh signing secret and returns the plaintext exactly
        once, same one-shot reveal as create. The old secret stops verifying
        immediately.
      responses:
        '200':
          description: OK. Includes the new one-time plaintext signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookEndpointWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/deliveries:
    parameters: [{ $ref: '#/components/parameters/Id' }]
    get:
      summary: List deliveries for an endpoint
      description: Delivery log, newest first. `limit` is capped at 100 here (default 25).
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - $ref: '#/components/parameters/Offset'
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, delivered, failed, all]
            default: all
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookDelivery' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}/deliveries/{deliveryId}/retry:
    parameters:
      - $ref: '#/components/parameters/Id'
      - in: path
        name: deliveryId
        required: true
        schema: { type: integer }
    post:
      summary: Retry a delivery
      description: |
        Reschedules a failed or still-pending delivery to fire immediately. It counts as one more attempt: a failed delivery
        retried this way isn't retried again automatically.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean, example: true }
                    required: [ok]
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/ReadOnlyKey' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The delivery already succeeded, it's being sent right now, or the
            endpoint is disabled (re-enable it first).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pdsk_live_<prefix>_<secret>
      description: |
        Issue from the Passdesk app under **Developer → API keys**. The full
        token is shown once at creation. Send as
        `Authorization: Bearer <token>` on every request.

        The `/enquiries` endpoints also accept an OAuth access token
        (`pdsk_at_…`) minted through `/api/oauth/*` with an `enquiries:read`
        or `enquiries:write` scope. That path is for partners (a school's
        website or phone provider), is granted per school by its owner, and
        carries no plan requirement.

  parameters:
    Id:
      in: path
      name: id
      required: true
      schema: { type: integer, minimum: 1 }
      description: Anything but a positive whole number is a 400.
    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      description: |
        Page size. A value above the maximum is lowered to it and one below 1
        is raised to 1, rather than rejected. A missing or non-numeric value
        uses the default, and a fraction is rounded down.
    Offset:
      in: query
      name: offset
      schema: { type: integer, minimum: 0, default: 0 }
      description: Number of rows to skip.

  responses:
    BadRequest:
      description: Validation failed — the `error` message says which field and why.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, malformed, revoked, or expired API key or OAuth access token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PaymentRequired:
      description: |
        The school isn't on a plan with API access (Scale), or this is a write
        and its subscription has lapsed. Reads keep working while a
        subscription is lapsed, and an OAuth grant on `/enquiries` has no plan
        requirement. The body has no `code`: a plan refusal carries
        `requiredFeature`, `requiredTier`, `currentTier` and `upgradeUrl`, and
        a lapsed subscription carries `subscriptionStatus` and
        `gracePeriodEndsAt`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ReadOnlyKey:
      description: The API key is read-only and this is a write. The body has no `code`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    EnquiryWriteForbidden:
      description: |
        The API key is read-only, or the OAuth grant carries `enquiries:read`
        only, and this is a write (no `code`). An OAuth grant with no
        enquiries scope at all gets `code: insufficient_scope`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InsufficientScope:
      description: 'The OAuth grant does not carry a scope for this resource (`code: insufficient_scope`).'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    EnquiryFieldTooLong:
      description: |
        A value is over its cap (`code: enquiry_field_too_long`): a text field
        over its `maxLength`, more than 20 `details` items, or `details` over
        16 KB once serialised.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource does not exist or is not in the caller's tenant.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded for this key or OAuth grant. Wait `Retry-After` seconds.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
          example: Invalid API key
        code:
          type: string
          description: |
            Stable machine-readable code. The enquiry endpoints send one on
            every error; elsewhere many errors have none, so branch on the
            HTTP status first.
          example: enquiry_not_found
      required: [error]

    ListEnvelope:
      type: object
      description: |
        Every list response. The total row count is also mirrored in the
        `X-Total-Count` response header.
      properties:
        data: { type: array, items: {} }
        pagination:
          type: object
          properties:
            limit: { type: integer, example: 50 }
            offset: { type: integer, example: 0 }
            total: { type: integer, example: 137 }
          required: [limit, offset, total]
      required: [data, pagination]

    EnquiryStatus:
      type: string
      enum: [NEW, CONTACTED, WON, LOST]

    EnquiryCreate:
      type: object
      required: [name, message]
      description: |
        One of `email` or `phone` must be present. The text fields must be
        JSON strings or null: any other type is a 400 `enquiry_invalid_input`.
      properties:
        name: { type: string, maxLength: 120, example: Amy Patel }
        email: { type: string, format: email, maxLength: 200, example: amy@example.com }
        phone: { type: string, maxLength: 32, example: '07700 900123' }
        message:
          type: string
          maxLength: 5000
          description: What the enquirer said — a form message or your call summary.
          example: Wants automatic lessons, weekday evenings
        source:
          type: string
          maxLength: 120
          description: Where it came from, shown to the school. Defaults to `api`.
          example: oblyx:phone
        externalRef:
          type: string
          maxLength: 120
          description: Your id for this enquiry. Unique per school, including enquiries the school has deleted; a repeat returns the original row, or a 410 if the school deleted it.
          example: call-8f21c3
        postcode:
          type: string
          maxLength: 32
          example: G12 8QQ
          description: A full postcode is normalised and routed; anything else is kept as sent.
        transmission: { type: string, enum: [MANUAL, AUTOMATIC] }
        preferredDaysOfWeek:
          type: array
          items: { type: integer, minimum: 1, maximum: 7 }
          description: ISO weekdays, Monday = 1.
        preferredHourStart: { type: integer, minimum: 0, maximum: 23 }
        preferredHourEnd:
          type: integer
          minimum: 0
          maximum: 23
          description: Must be later than `preferredHourStart` when both are sent.
        preferredInstructorId: { type: integer, description: An instructor at this school. }
        productId: { type: integer, description: The lesson package they asked about. }
        details:
          type: array
          maxItems: 20
          description: |
            Anything else your form or call captured, shown to the school in
            order. At most 20 items and 16 KB in total; an item with an empty
            `value` is dropped.
          items: { $ref: '#/components/schemas/EnquiryDetail' }
        consent:
          type: object
          properties:
            privacyAccepted: { type: boolean }
            acceptedAt:
              type: string
              format: date-time
              description: |
                When they accepted. Defaults to now. A date-time must carry an
                offset; a bare `YYYY-MM-DD` means midnight UTC. Can't be in the
                future. Ignored unless `privacyAccepted` is true.
            marketingOptIn: { type: boolean }

    EnquiryDeletion:
      type: object
      required: [id, externalRef, deletedAt]
      properties:
        id: { type: integer, example: 311 }
        externalRef: { type: [string, 'null'], example: call-8f21c3 }
        deletedAt: { type: string, format: date-time }

    EnquiryDetail:
      type: object
      required: [label, value]
      properties:
        label: { type: string, maxLength: 80, example: Previous experience }
        value: { type: string, maxLength: 2000, example: About 10 hours elsewhere }

    Enquiry:
      type: object
      description: |
        A prospective learner's enquiry, as the school sees it in its inbox.
        All fields are always present; nullable fields return `null`.
      properties:
        id: { type: integer, example: 311 }
        name: { type: string, example: Amy Patel }
        email: { type: [string, 'null'], example: amy@example.com }
        phone: { type: [string, 'null'], example: '07700 900123' }
        message: { type: string, example: 'Wants automatic lessons, weekday evenings' }
        source: { type: [string, 'null'], example: oblyx:phone }
        status: { $ref: '#/components/schemas/EnquiryStatus' }
        notes: { type: [string, 'null'], example: 'Rang back, wants Tuesdays' }
        externalRef: { type: [string, 'null'], example: call-8f21c3 }
        studentId:
          type: [integer, 'null']
          description: Set once the enquiry has been added as a learner.
          example: null
        contactedAt: { type: [string, 'null'], format: date-time }
        resolvedAt: { type: [string, 'null'], format: date-time }
        convertedAt: { type: [string, 'null'], format: date-time }
        postcode: { type: [string, 'null'], example: G12 8QQ }
        transmission: { type: [string, 'null'], enum: [MANUAL, AUTOMATIC, null] }
        preferredDaysOfWeek: { type: array, items: { type: integer } }
        preferredHourStart: { type: [integer, 'null'] }
        preferredHourEnd: { type: [integer, 'null'] }
        preferredInstructorId: { type: [integer, 'null'] }
        productId: { type: [integer, 'null'] }
        details:
          type: [array, 'null']
          items: { $ref: '#/components/schemas/EnquiryDetail' }
        privacyConsentAt: { type: [string, 'null'], format: date-time }
        marketingOptIn: { type: [boolean, 'null'] }
        region:
          type: [object, 'null']
          description: |
            The school's instructor region for this enquiry: set from the
            postcode when it arrives, and changeable by the school. Null when
            the school doesn't use regions or no region covers the postcode.
          properties:
            id: { type: integer, example: 4 }
            name: { type: string, example: Evesham }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required:
        - id
        - name
        - email
        - phone
        - message
        - source
        - status
        - notes
        - externalRef
        - studentId
        - contactedAt
        - resolvedAt
        - convertedAt
        - postcode
        - transmission
        - preferredDaysOfWeek
        - preferredHourStart
        - preferredHourEnd
        - preferredInstructorId
        - productId
        - details
        - privacyConsentAt
        - marketingOptIn
        - region
        - createdAt
        - updatedAt

    Student:
      type: object
      description: |
        A learner in your school. Nullable fields return `null` when unset.
        `dateOfBirth`, `provisionalLicenceNumber` and the `guardian*` fields
        are only present when the key holds `students:sensitive:read`; every
        other field is always present. Learners' health notes are never
        returned.
      properties:
        id: { type: integer, example: 118 }
        firstName: { type: string, example: Amelia }
        lastName: { type: string, example: Hart }
        email:
          type: [string, 'null']
          description: Login email of the linked user account.
          example: amelia.hart@example.com
        phone: { type: [string, 'null'], example: '07700 900123' }
        address: { type: [string, 'null'], example: '14 Marsh Lane, Leeds LS1 4AB' }
        dateOfBirth:
          type: [string, 'null']
          format: date-time
          description: Only present with the `students:sensitive:read` scope.
          example: '2007-03-22T00:00:00.000Z'
        gender: { type: [string, 'null'], example: female }
        wearsGlassesOrContacts: { type: boolean, example: false }
        licenseCategory:
          type: string
          description: DVLA licence category being trained for. Defaults to `B` (car).
          example: B
        profileComplete:
          type: boolean
          description: True once the learner completed the post-signup profile form.
          example: true
        provisionalLicenceNumber:
          type: [string, 'null']
          description: Only present with the `students:sensitive:read` scope.
          example: HART9037226AM9XY
        licenceIssueDate:
          type: [string, 'null']
          format: date-time
          example: '2024-04-02T00:00:00.000Z'
        theoryTestPassDate:
          type: [string, 'null']
          format: date-time
          example: '2026-05-11T00:00:00.000Z'
        theoryTestCertificate:
          type: [string, 'null']
          description: Theory test certificate number.
          example: '155593301'
        practicalTestDate:
          type: [string, 'null']
          format: date-time
          example: '2026-09-14T00:00:00.000Z'
        practicalTestResult:
          type: [string, 'null']
          enum: [pass, fail, pending, cancelled, null]
          description: Mirrors the learner's most recent practical test attempt.
          example: pending
        testCentreId:
          type: [integer, 'null']
          description: Id of the DVSA test centre the learner is booked at.
          example: 211
        guardianName:
          type: [string, 'null']
          description: Only present with the `students:sensitive:read` scope.
          example: Simon Hart
        guardianEmail:
          type: [string, 'null']
          description: Only present with the `students:sensitive:read` scope.
          example: simon.hart@example.com
        guardianPhone:
          type: [string, 'null']
          description: Only present with the `students:sensitive:read` scope.
          example: '07700 900124'
        guardianRelationship:
          type: [string, 'null']
          description: Only present with the `students:sensitive:read` scope.
          example: father
        transmission:
          type: [string, 'null']
          enum: [MANUAL, AUTOMATIC, null]
          example: MANUAL
        defaultMeetingMode:
          type: string
          enum: [PICKUP, MEET_AT_LOCATION]
          description: >
            Where lessons begin by default — instructor picks the learner up,
            or the learner meets at a saved location.
          example: PICKUP
        preferredDaysOfWeek:
          type: array
          items: { type: integer, minimum: 1, maximum: 7 }
          description: ISO weekday numbers (Monday = 1 … Sunday = 7). Empty when no preference.
          example: [1, 3, 6]
        preferredHourStart:
          type: [integer, 'null']
          minimum: 0
          maximum: 23
          description: Earliest preferred lesson start, 24h clock.
          example: 16
        preferredHourEnd:
          type: [integer, 'null']
          minimum: 0
          maximum: 23
          example: 19
        preferredInstructorId:
          type: [integer, 'null']
          description: Soft preference; scheduling does not enforce it.
          example: 12
      required:
        - id
        - firstName
        - lastName
        - email
        - phone
        - address
        - gender
        - wearsGlassesOrContacts
        - licenseCategory
        - profileComplete
        - licenceIssueDate
        - theoryTestPassDate
        - theoryTestCertificate
        - practicalTestDate
        - practicalTestResult
        - testCentreId
        - transmission
        - defaultMeetingMode
        - preferredDaysOfWeek
        - preferredHourStart
        - preferredHourEnd
        - preferredInstructorId

    Schedule:
      type: object
      description: |
        A lesson slot. OPEN slots have no student yet. All timestamps are UTC.
      properties:
        id: { type: integer, example: 5804 }
        date:
          type: string
          format: date-time
          description: |
            The lesson's day: UTC midnight of that day for most lessons, or the
            lesson's start instant for ones created as part of a series, moved,
            or booked by a guest. Use `startTime` for the time, and the `from`
            and `to` filters to select by day.
          example: '2026-08-14T00:00:00.000Z'
        startTime:
          type: string
          format: date-time
          example: '2026-08-14T16:00:00.000Z'
        endTime:
          type: string
          format: date-time
          example: '2026-08-14T18:00:00.000Z'
        studentId:
          type: [integer, 'null']
          description: Null on OPEN slots that no student has booked.
          example: 118
        instructorId: { type: [integer, 'null'], example: 12 }
        status:
          type: string
          enum: [OPEN, SCHEDULED, IN_PROGRESS, COMPLETED, CANCELED, NO_SHOW]
          example: COMPLETED
        notes: { type: [string, 'null'], example: Focus on roundabouts }
        lessonType:
          type: [string, 'null']
          enum: [practical, theory, mock_test, pretest, test, null]
          example: practical
        vehicleReg:
          type: [string, 'null']
          description: Registration plate of the assigned vehicle, snapshotted at booking.
          example: AB12 XYZ
        canceledAt:
          type: [string, 'null']
          format: date-time
          example: null
        canceledReason: { type: [string, 'null'], example: null }
        rescheduleOfId:
          type: [integer, 'null']
          description: When this lesson replaced a cancelled one, the original schedule id.
          example: null
        startedAt:
          type: [string, 'null']
          format: date-time
          description: When the instructor actually started the lesson.
          example: '2026-08-14T16:02:11.000Z'
        endedAt:
          type: [string, 'null']
          format: date-time
          example: '2026-08-14T17:58:40.000Z'
        billableMinutes:
          type: [integer, 'null']
          description: Minutes deducted from the learner's balance.
          example: 120
        distanceMiles:
          type: [integer, 'null']
          description: Miles driven during the lesson, when recorded.
          example: 23
      required:
        - id
        - date
        - startTime
        - endTime
        - studentId
        - instructorId
        - status
        - notes
        - lessonType
        - vehicleReg
        - canceledAt
        - canceledReason
        - rescheduleOfId
        - startedAt
        - endedAt
        - billableMinutes
        - distanceMiles

    Product:
      type: object
      description: |
        A catalogue item — usually a lesson package. All money fields are
        integer pence.
      properties:
        id: { type: integer, example: 31 }
        name: { type: string, example: 10-hour lesson package }
        description:
          type: [string, 'null']
          example: Ten hours of one-to-one tuition, bookable in 1-2 hour slots.
        kind:
          type: string
          enum: [LESSON_PACKAGE, MERCHANDISE]
          description: |
            LESSON_PACKAGE adds lesson minutes to the learner's balance.
            MERCHANDISE is anything else the school sells (a mock test, a
            theory course); its minute and duration fields carry defaults and
            mean nothing.
          example: LESSON_PACKAGE
        priceMinorUnits:
          type: integer
          description: Headline price in pence (£320.00 = 32000).
          example: 32000
        priceWasMinorUnits:
          type: [integer, 'null']
          description: Strike-through "was" price in pence, when the product is discounted.
          example: 35000
        totalMinutes:
          type: integer
          description: Lesson minutes the package adds to the learner's balance. Ignore on MERCHANDISE.
          example: 600
        allowedDurations:
          type: array
          items: { type: integer }
          description: Bookable lesson lengths, in minutes.
          example: [60, 90, 120]
        defaultLessonMinutes: { type: [integer, 'null'], example: 120 }
        depositMinorUnits:
          type: [integer, 'null']
          description: Upfront deposit in pence, when the school takes one.
          example: 5000
        cancelFeeMinorUnits:
          type: [integer, 'null']
          description: Late-cancellation fee in pence.
          example: 2500
        noShowFeeMinorUnits:
          type: [integer, 'null']
          description: No-show fee in pence.
          example: 3200
        sellableOnline:
          type: boolean
          description: Whether learners can buy this themselves from the school's public page.
          example: true
        transmission:
          type: [string, 'null']
          enum: [MANUAL, AUTOMATIC, null]
          description: Null when the product applies to both transmissions.
          example: MANUAL
        displayOrder:
          type: [integer, 'null']
          description: Sort position in the school's catalogue. Lower shows first.
          example: 1
      required:
        - id
        - name
        - description
        - kind
        - priceMinorUnits
        - priceWasMinorUnits
        - totalMinutes
        - allowedDurations
        - defaultLessonMinutes
        - depositMinorUnits
        - cancelFeeMinorUnits
        - noShowFeeMinorUnits
        - sellableOnline
        - transmission
        - displayOrder

    Purchase:
      type: object
      description: |
        A billing event against a learner — package purchase, top-up, fee,
        or manual adjustment. All money fields are integer pence.
      properties:
        id: { type: integer, example: 902 }
        studentId: { type: integer, example: 118 }
        productId:
          type: [integer, 'null']
          description: Null on ad-hoc rows (fees, adjustments) with no catalogue product.
          example: 31
        description:
          type: [string, 'null']
          description: The line item, on a row with no catalogue product.
          example: null
        amountMinorUnits:
          type: integer
          description: Amount charged in pence (£320.00 = 32000).
          example: 32000
        notes: { type: [string, 'null'], example: null }
        status:
          type: string
          enum: [PENDING, CONFIRMED, CANCELED, FAILED, REFUNDING, REFUNDED]
          example: CONFIRMED
        kind:
          type: string
          enum:
            [PACKAGE, DEPOSIT, NO_SHOW_FEE, LATE_CANCEL_FEE, TIP, MERCHANDISE, TOP_UP, ADJUSTMENT]
          description: >
            PACKAGE = lesson package; TOP_UP = extra minutes on an existing
            package; DEPOSIT / NO_SHOW_FEE / LATE_CANCEL_FEE are auto-created
            by the booking flows and point back via `parentScheduleId`;
            MERCHANDISE = non-lesson charge; ADJUSTMENT = manual hour-bank
            correction at £0.
          example: PACKAGE
        adjustmentMinutes:
          type: [integer, 'null']
          description: |
            Minutes added to (positive) or taken from (negative) the learner's
            balance, on ADJUSTMENT rows. Null on every other kind.
          example: null
        parentScheduleId:
          type: [integer, 'null']
          description: The lesson that generated this fee row, on fee kinds.
          example: null
        refundedAt:
          type: [string, 'null']
          format: date-time
          example: null
        refundAmountMinorUnits:
          type: [integer, 'null']
          description: Refunded amount in pence (minor units). Null unless refunded.
          example: null
        refundAmount:
          type: [integer, 'null']
          deprecated: true
          description: >
            The same figure as `refundAmountMinorUnits`, kept for existing
            integrations. Minor units despite the name.
          example: null
        confirmedAt:
          type: [string, 'null']
          format: date-time
          example: '2026-08-01T09:14:03.000Z'
        createdAt:
          type: string
          format: date-time
          example: '2026-08-01T09:13:41.000Z'
      required:
        - id
        - studentId
        - productId
        - description
        - amountMinorUnits
        - notes
        - status
        - kind
        - adjustmentMinutes
        - parentScheduleId
        - refundedAt
        - refundAmountMinorUnits
        - refundAmount
        - confirmedAt
        - createdAt

    Progress:
      type: object
      description: A single skill rating logged against a learner.
      properties:
        id: { type: integer, example: 44712 }
        studentId: { type: integer, example: 118 }
        date:
          type: string
          format: date-time
          example: '2026-08-14T17:58:40.000Z'
        competency:
          type: string
          description: Skill key, e.g. `vehicle_control`, `roundabouts`, `road_awareness`.
          example: roundabouts
        rating:
          type: integer
          minimum: 1
          maximum: 5
          description: Proficiency on a 1–5 scale.
          example: 4
        category:
          type: string
          enum: [theory, practical, exam]
          example: practical
        notes:
          type: [string, 'null']
          example: Confident on mini-roundabouts; multi-lane still needs prompting.
      required: [id, studentId, date, competency, rating, category, notes]

    Instructor:
      type: object
      description: |
        A teaching member of staff. Money is integer pence; hours are 24h
        clock integers; weekdays are ISO numbers (Monday = 1).
      properties:
        id: { type: integer, example: 12 }
        email: { type: string, example: dan.okafor@example.com }
        name: { type: [string, 'null'], example: Dan Okafor }
        role:
          type: string
          enum: [CLIENT_OWNER, EMPLOYEE, STUDENT, APP_SUPPORT]
          description: Owners who teach appear here as CLIENT_OWNER.
          example: EMPLOYEE
        isInstructor: { type: boolean, example: true }
        hourlyRateMinorUnits:
          type: [integer, 'null']
          description: Hourly rate in pence (£36.00 = 3600).
          example: 3600
        employmentType:
          type: [string, 'null']
          enum: [FULL_TIME, PART_TIME, CONTRACTOR, null]
          example: FULL_TIME
        contractedHoursPerWeek: { type: [integer, 'null'], example: 38 }
        workingDays:
          type: array
          items: { type: integer, minimum: 1, maximum: 7 }
          description: ISO weekday numbers (Monday = 1 … Sunday = 7).
          example: [1, 2, 3, 4, 5]
        workingHourStart:
          type: [integer, 'null']
          minimum: 0
          maximum: 23
          example: 8
        workingHourEnd:
          type: [integer, 'null']
          minimum: 0
          maximum: 23
          example: 18
        adiBadgeNumber: { type: [string, 'null'], example: '345678' }
        adiBadgeType:
          type: [string, 'null']
          enum: [ADI, PDI, null]
          example: ADI
        adiExpiryDate:
          type: [string, 'null']
          format: date-time
          example: '2027-01-31T00:00:00.000Z'
      required:
        - id
        - email
        - name
        - role
        - isInstructor
        - hourlyRateMinorUnits
        - employmentType
        - contractedHoursPerWeek
        - workingDays
        - workingHourStart
        - workingHourEnd
        - adiBadgeNumber
        - adiBadgeType
        - adiExpiryDate

    Employee:
      type: object
      description: A staff member with permission-driven access. `isInstructor` is true for staff who also teach.
      properties:
        id: { type: integer, example: 27 }
        email: { type: string, example: priya.shah@example.com }
        name: { type: [string, 'null'], example: Priya Shah }
        role: { type: string, enum: [EMPLOYEE], example: EMPLOYEE }
        permissions:
          type: array
          items:
            type: string
            enum:
              - MANAGE_SCHOOL_INFO
              - MANAGE_PUBLIC_PAGE
              - MANAGE_SIGNUP_FORM
              - MANAGE_STUDENTS
              - VIEW_STUDENTS
              - MANAGE_INSTRUCTORS
              - VIEW_INSTRUCTORS
              - MANAGE_SCHEDULES
              - VIEW_SCHEDULES
              - MANAGE_PROGRESS
              - VIEW_PROGRESS
              - MANAGE_PRODUCTS
              - VIEW_PRODUCTS
              - MANAGE_PURCHASES
              - VIEW_PURCHASES
              - INVITE_STAFF
              - MANAGE_OWN_PROGRESS
              - MANAGE_PRACTICE_ROUTES
              - MANAGE_SKILLS
          example: [VIEW_STUDENTS, MANAGE_SCHEDULES, VIEW_PURCHASES]
        isInstructor: { type: boolean, example: false }
      required: [id, email, name, role, permissions, isInstructor]

    WebhookEndpointCreate:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: Destination for event POSTs. Must be `https://`.
          example: https://example.com/hooks/passdesk
        events:
          type: array
          items: { type: string }
          minItems: 1
          maxItems: 50
          description: >
            Event types to subscribe to (see `/webhooks/event-types`), or
            `["*"]` for everything.
          example: [student.created, lesson.completed]
        description:
          type: [string, 'null']
          maxLength: 200
          description: Optional label shown in the workspace.
          example: CRM sync
      required: [url, events]

    WebhookEndpointPatch:
      type: object
      description: At least one field must be provided.
      properties:
        description:
          type: [string, 'null']
          maxLength: 200
          example: CRM sync (staging)
        events:
          type: array
          items: { type: string }
          minItems: 1
          maxItems: 50
          example: ['*']
        active:
          type: boolean
          description: Set false to pause deliveries, true to resume.
          example: true

    WebhookEndpoint:
      type: object
      properties:
        id: { type: integer, example: 3 }
        url: { type: string, example: https://example.com/hooks/passdesk }
        description: { type: [string, 'null'], example: CRM sync }
        events:
          type: array
          items: { type: string }
          description: Subscribed event types, or `["*"]` for everything.
          example: [student.created, lesson.completed]
        active: { type: boolean, example: true }
        disabledAt:
          type: [string, 'null']
          format: date-time
          example: null
        disabledReason:
          type: [string, 'null']
          description: |
            Why the endpoint was disabled: `manual` (the school or an API
            call), `platform` (Passdesk support switched it off) or `Tenant
            archived`. Deliveries that keep failing don't disable an endpoint.
          example: null
        createdById:
          type: integer
          description: User id that created the endpoint.
          example: 4
        createdAt:
          type: string
          format: date-time
          example: '2026-07-30T10:00:00.000Z'
        updatedAt:
          type: string
          format: date-time
          example: '2026-07-30T10:00:00.000Z'
      required:
        - id
        - url
        - description
        - events
        - active
        - disabledAt
        - disabledReason
        - createdById
        - createdAt
        - updatedAt

    WebhookEndpointWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          properties:
            signingSecret:
              type: string
              description: >
                64-hex-char HMAC secret. Returned exactly once, at create and
                rotate — store it immediately.
              example: 4f6a2c9e8b1d3f5a7c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d0f1a3c5e7b9d1f3a
          required: [signingSecret]

    WebhookDelivery:
      type: object
      description: One attempt-tracked dispatch of an event to one endpoint.
      properties:
        id: { type: integer, example: 991 }
        eventId:
          type: string
          description: >
            Matches the envelope `id` and the `Idempotency-Key` header —
            stable across retries, so dedupe on it.
          example: evt_9f2c41d68a0b73e5c1d24f80
        eventType: { type: string, example: lesson.completed }
        payloadBytes:
          type: integer
          description: |
            Size of the JSON envelope in bytes. An event whose envelope would
            be over 256 KB isn't delivered and has no delivery row.
          example: 412
        attempts:
          type: integer
          description: |
            Delivery attempts made so far. Automatic retries stop at 7; a
            manual retry adds one more.
          example: 1
        nextAttemptAt:
          type: [string, 'null']
          format: date-time
          description: When the next retry fires; null once delivered or failed.
          example: null
        lastStatus:
          type: [integer, 'null']
          description: HTTP status of the most recent attempt.
          example: 200
        lastError: { type: [string, 'null'], example: null }
        lastResponse:
          type: [string, 'null']
          description: Truncated response body of the most recent attempt.
          example: ok
        lastAttemptAt:
          type: [string, 'null']
          format: date-time
          example: '2026-08-14T18:00:05.000Z'
        deliveredAt:
          type: [string, 'null']
          format: date-time
          description: Set on a 2xx response. Terminal.
          example: '2026-08-14T18:00:05.000Z'
        failedAt:
          type: [string, 'null']
          format: date-time
          description: Set after the final attempt fails. Retry manually via the API or delivery log.
          example: null
        createdAt:
          type: string
          format: date-time
          example: '2026-08-14T18:00:04.000Z'
      required:
        - id
        - eventId
        - eventType
        - payloadBytes
        - attempts
        - nextAttemptAt
        - lastStatus
        - lastError
        - lastResponse
        - lastAttemptAt
        - deliveredAt
        - failedAt
        - createdAt

    WebhookEnvelope:
      type: object
      description: |
        The JSON body POSTed to your endpoint for every event. Sent with
        `Idempotency-Key`, `Passdesk-Event`, and `Passdesk-Signature`
        headers. `data` is an event-specific payload — inspect real payloads
        in the delivery log. For `enquiry.created` and `enquiry.updated` it is
        the full Enquiry object plus `clientId`; `enquiry.updated` fires when
        the learner link or region changes and on any update that sends
        status or notes, even unchanged ones. `enquiry.deleted`
        carries only `id`, `externalRef`, `clientId` and `deletedAt`.
        `progress.test_ready_changed` fires when a learner's readiness band
        moves into or out of READY, with `studentId`, `testReady`, `band` and
        `previousBand`. Passing the test isn't a crossing and fires nothing.
      properties:
        id:
          type: string
          description: Unique event id (`evt_` + 24 hex chars). Stable across retries.
          example: evt_9f2c41d68a0b73e5c1d24f80
        type: { type: string, example: lesson.completed }
        createdAt:
          type: string
          format: date-time
          example: '2026-08-14T18:00:04.000Z'
        tenantId:
          type: integer
          description: Your school's tenant id.
          example: 42
        apiVersion: { type: string, example: v1 }
        data:
          type: object
          description: Event-specific payload.
      required: [id, type, createdAt, tenantId, apiVersion, data]

x-rate-limits:
  description: |
    Per-key buckets, refreshed every 60 seconds:
      - 600 reads/min on GET / HEAD
      - 120 writes/min on POST / PUT / PATCH / DELETE
    Each Passdesk-issued key gets its own bucket — partners can be
    isolated by minting one key per integration.
    Responses that reach the limiter carry the IETF draft `RateLimit-Policy`,
    `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers;
    a 401, 402 or read-only 403 is refused before it and carries none.
    Overage produces a 429 with `Retry-After`. OAuth grants on `/enquiries`
    get their own buckets on the same terms.

x-pagination:
  description: |
    All list endpoints accept `?limit` (max 200; webhook endpoints max 25,
    deliveries max 100) and
    `?offset` (default 0). The total count is included in `pagination.total`
    of the response body and as the `X-Total-Count` response header.
