Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: ClassQuill Public Bookings API
description: 'REST API for ClassQuill tutoring organisations. Pipe your sessions, invoices, payments, tutor earnings, students, and more into your own tools. Most endpoints are reads; a focused set of writes (each gated by a *:write scope) create or update data, including creating student/tutor/parent accounts.
Authenticate every request with an org API key:
Authorization: Token token=cq_live_...
Mint keys in the ClassQuill app under Settings → Developers.'
version: 1.0.0
security:
- ApiKeyAuth: []
tags:
- name: Bookings
paths:
/v1/booking-requests:
get:
tags:
- Bookings
summary: List Booking Requests
description: 'List incoming booking requests for the org (most recent first).
These are the "request a tutor" submissions from the org''s public page. Scoped
directly by `org_id`. Filter by status or requested subject.'
operationId: list_booking_requests_v1_booking_requests_get
parameters:
- name: status
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: pending, matched, declined, expired
title: Status
description: pending, matched, declined, expired
- name: subject_id
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Filter by requested subject id
title: Subject Id
description: Filter by requested subject id
- name: cursor
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Opaque cursor from a previous response's meta.next_cursor, or the literal 'start' to begin cursor paging from the newest record. When set, offset is ignored and results page by a stable (created_at, id) keyset — recommended for large or incremental syncs.
title: Cursor
description: Opaque cursor from a previous response's meta.next_cursor, or the literal 'start' to begin cursor paging from the newest record. When set, offset is ignored and results page by a stable (created_at, id) keyset — recommended for large or incremental syncs.
- name: limit
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
description: Records per page (default 20, max 100)
default: 20
title: Limit
description: Records per page (default 20, max 100)
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
description: Pagination offset (ignored when cursor is set)
default: 0
title: Offset
description: Pagination offset (ignored when cursor is set)
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Page_BookingRequest_'
'422':
description: Validation error — `error.details.errors` lists the fields.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'401':
description: Missing, malformed, revoked, or expired credentials.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'403':
description: The credential lacks the scope this endpoint requires.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'429':
description: Rate limit exceeded — honour Retry-After and the RateLimit-* headers.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/v1/booking-requests/{request_id}/recommended-tutors:
get:
tags:
- Bookings
summary: Recommend Tutors
description: 'Rank the org''s tutors for a booking request, best fit first.
Each tutor is scored 0–100 on subject match, availability overlap with the
requested days/times, teaching mode, spare capacity, and — for an in-person
request with a known suburb — proximity (via the shared match_org_tutors RPC,
which also powers the in-app matcher). Tutors who can''t do the requested
arrangement or are beyond their travel radius are omitted. Includes a
per-signal `breakdown`, `distance_km`/`nearby`, and human-readable `reasons`.
Read-only — this recommends, it never assigns. 404 if the request isn''t this org''s.'
operationId: recommend_tutors_v1_booking_requests__request_id__recommended_tutors_get
parameters:
- name: request_id
in: path
required: true
schema:
type: string
description: Booking request UUID
title: Request Id
description: Booking request UUID
- name: limit
in: query
required: false
schema:
type: integer
maximum: 50
minimum: 1
description: Max tutors to return (default 10, max 50)
default: 10
title: Limit
description: Max tutors to return (default 10, max 50)
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/RecommendedTutors'
'422':
description: Validation error — `error.details.errors` lists the fields.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'401':
description: Missing, malformed, revoked, or expired credentials.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'403':
description: The credential lacks the scope this endpoint requires.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'429':
description: Rate limit exceeded — honour Retry-After and the RateLimit-* headers.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'404':
description: Not found — the record doesn't exist or belongs to another organisation.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/v1/leads:
get:
tags:
- Bookings
summary: List Leads
description: 'List inbound leads captured by the org''s lead forms (most recent first).
Scoped directly by `org_id`. The free-form `payload` is NOT exposed; only a
safe subset (name/email/phone/subject) is flattened. `ip_address`,
`user_agent`, and raw `utm` are never exposed.'
operationId: list_leads_v1_leads_get
parameters:
- name: status
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: new, contacted, consultation_booked, converted, lost
title: Status
description: new, contacted, consultation_booked, converted, lost
- name: assigned_tutor_id
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Filter by assigned tutor UUID
title: Assigned Tutor Id
description: Filter by assigned tutor UUID
- name: from
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: created_at >= ISO date
title: From
description: created_at >= ISO date
- name: to
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: created_at <= ISO date
title: To
description: created_at <= ISO date
- name: cursor
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Opaque cursor from a previous response's meta.next_cursor, or the literal 'start' to begin cursor paging from the newest record. When set, offset is ignored and results page by a stable (created_at, id) keyset — recommended for large or incremental syncs.
title: Cursor
description: Opaque cursor from a previous response's meta.next_cursor, or the literal 'start' to begin cursor paging from the newest record. When set, offset is ignored and results page by a stable (created_at, id) keyset — recommended for large or incremental syncs.
- name: limit
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
description: Records per page (default 20, max 100)
default: 20
title: Limit
description: Records per page (default 20, max 100)
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
description: Pagination offset (ignored when cursor is set)
default: 0
title: Offset
description: Pagination offset (ignored when cursor is set)
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Page_Lead_'
'422':
description: Validation error — `error.details.errors` lists the fields.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'401':
description: Missing, malformed, revoked, or expired credentials.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'403':
description: The credential lacks the scope this endpoint requires.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'429':
description: Rate limit exceeded — honour Retry-After and the RateLimit-* headers.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
post:
tags:
- Bookings
summary: Create Lead
description: 'Capture an inbound lead into your org''s CRM (e.g. a website quote funnel). The
free-form details (name/phone/subject) are stored in the lead''s `payload`; `email`,
`source_url`, `notes` and a `source_form_name` snapshot are columns; `status` starts
at ''new''. `audience` (''student''/''tutor''/''parent'') defaults to ''student'' when omitted.
Honours `Idempotency-Key`. Requires `leads:write` (the coarse `write` umbrella grants
it).'
operationId: create_lead_v1_leads_post
parameters:
- name: Idempotency-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: Idempotency-Key
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LeadCreate'
responses:
'201':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Lead'
'422':
description: Validation error — `error.details.errors` lists the fields.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'401':
description: Missing, malformed, revoked, or expired credentials.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'403':
description: The credential lacks the scope this endpoint requires.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'429':
description: Rate limit exceeded — honour Retry-After and the RateLimit-* headers.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/v1/leads/{lead_id}:
get:
tags:
- Bookings
summary: Get Lead
description: Fetch a single lead by ID. 404 if it doesn't exist or belongs to another org.
operationId: get_lead_v1_leads__lead_id__get
parameters:
- name: lead_id
in: path
required: true
schema:
type: string
description: Lead UUID
title: Lead Id
description: Lead UUID
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Lead'
'422':
description: Validation error — `error.details.errors` lists the fields.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'401':
description: Missing, malformed, revoked, or expired credentials.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'403':
description: The credential lacks the scope this endpoint requires.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'429':
description: Rate limit exceeded — honour Retry-After and the RateLimit-* headers.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'404':
description: Not found — the record doesn't exist or belongs to another organisation.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
components:
schemas:
Page_BookingRequest_:
properties:
data:
items:
$ref: '#/components/schemas/BookingRequest'
type: array
title: Data
meta:
$ref: '#/components/schemas/PageMeta'
type: object
required:
- data
- meta
title: Page[BookingRequest]
ScoreBreakdown:
properties:
subject:
type: number
title: Subject
default: 0
availability:
type: number
title: Availability
default: 0
mode:
type: number
title: Mode
default: 0
capacity:
type: number
title: Capacity
default: 0
distance:
type: number
title: Distance
default: 0
type: object
title: ScoreBreakdown
description: Per-signal point contributions (sum to `score`, 0–100).
Lead:
properties:
id:
type: string
title: Id
form_id:
anyOf:
- type: string
- type: 'null'
title: Form Id
source_form_name:
anyOf:
- type: string
- type: 'null'
title: Source Form Name
name:
anyOf:
- type: string
- type: 'null'
title: Name
email:
anyOf:
- type: string
- type: 'null'
title: Email
phone:
anyOf:
- type: string
- type: 'null'
title: Phone
subject:
anyOf:
- type: string
- type: 'null'
title: Subject
audience:
anyOf:
- type: string
enum:
- student
- tutor
- parent
- type: 'null'
title: Audience
status:
anyOf:
- type: string
- type: 'null'
title: Status
assigned_tutor_id:
anyOf:
- type: string
- type: 'null'
title: Assigned Tutor Id
source_url:
anyOf:
- type: string
- type: 'null'
title: Source Url
notes:
anyOf:
- type: string
- type: 'null'
title: Notes
converted_at:
anyOf:
- type: string
- type: 'null'
title: Converted At
created_at:
anyOf:
- type: string
- type: 'null'
title: Created At
updated_at:
anyOf:
- type: string
- type: 'null'
title: Updated At
type: object
required:
- id
title: Lead
description: 'An inbound lead captured by an org lead form. The free-form `payload` is
NOT exposed; only a safe subset (name/email/phone/subject) is flattened.'
ApiErrorBody:
description: The `error` object every /v1 error response carries.
properties:
code:
description: 'Stable machine-readable error code: bad_request, unauthorized, forbidden, not_found, conflict, validation_error, rate_limited, or internal_error.'
examples:
- not_found
title: Code
type: string
message:
description: Human-readable explanation, safe to surface to end users.
examples:
- Session not found
title: Message
type: string
status:
description: The HTTP status code, repeated in the body.
examples:
- 404
title: Status
type: integer
details:
additionalProperties: true
description: Error-specific context. 422 carries the field errors under `errors`; 429 carries `retry_after_seconds`.
title: Details
type: object
required:
- code
- message
- status
title: ApiErrorBody
type: object
ApiError:
description: Canonical error envelope for every /v1 error response (4xx/5xx).
properties:
error:
$ref: '#/components/schemas/ApiErrorBody'
required:
- error
title: ApiError
type: object
RecommendedTutor:
properties:
tutor_id:
type: string
title: Tutor Id
first_name:
anyOf:
- type: string
- type: 'null'
title: First Name
last_name:
anyOf:
- type: string
- type: 'null'
title: Last Name
score:
type: number
title: Score
default: 0
breakdown:
$ref: '#/components/schemas/ScoreBreakdown'
reasons:
items:
type: string
type: array
title: Reasons
default: []
upcoming_sessions_count:
type: integer
title: Upcoming Sessions Count
default: 0
teaches_subject:
type: boolean
title: Teaches Subject
default: false
distance_km:
anyOf:
- type: number
- type: 'null'
title: Distance Km
nearby:
type: boolean
title: Nearby
default: false
road_km:
anyOf:
- type: number
- type: 'null'
title: Road Km
road_minutes:
anyOf:
- type: number
- type: 'null'
title: Road Minutes
type: object
required:
- tutor_id
- breakdown
title: RecommendedTutor
RecommendedTutors:
properties:
request_id:
type: string
title: Request Id
subject_id:
anyOf:
- type: string
- type: 'null'
title: Subject Id
data:
items:
$ref: '#/components/schemas/RecommendedTutor'
type: array
title: Data
type: object
required:
- request_id
- data
title: RecommendedTutors
LeadCreate:
properties:
name:
type: string
minLength: 1
title: Name
description: Lead's full name.
email:
type: string
title: Email
description: Lead's email address (required).
phone:
anyOf:
- type: string
- type: 'null'
title: Phone
subject:
anyOf:
- type: string
- type: 'null'
title: Subject
description: Subject of interest, e.g. 'Methods'.
audience:
anyOf:
- type: string
enum:
- student
- tutor
- parent
- type: 'null'
title: Audience
description: 'Who this lead is: ''student'' (enrolment, the default), ''tutor'' (recruitment), or ''parent'' (enrolment, on behalf of a student). Omit to use the org_leads column default (''student'').'
source_form_name:
anyOf:
- type: string
- type: 'null'
title: Source Form Name
description: Where the lead came from, e.g. 'website quote funnel'.
source_url:
anyOf:
- type: string
- type: 'null'
title: Source Url
notes:
anyOf:
- type: string
- type: 'null'
title: Notes
additionalProperties: false
type: object
required:
- name
- email
title: LeadCreate
description: 'POST /v1/leads — capture an inbound lead into the key''s org (e.g. a website quote
funnel). name/phone/subject are stored in the lead''s free-form `payload`; email,
source_url, notes and a source_form_name snapshot are columns. organization_id,
form_id, status and timestamps are server-set, never accepted; status starts ''new''.'
PageMeta:
properties:
limit:
type: integer
title: Limit
offset:
anyOf:
- type: integer
- type: 'null'
title: Offset
count:
anyOf:
- type: integer
- type: 'null'
title: Count
total:
anyOf:
- type: integer
- type: 'null'
title: Total
next_cursor:
anyOf:
- type: string
- type: 'null'
title: Next Cursor
description: 'Cursor-mode only: pass as ?cursor= to fetch the next page; null when this is the last page.'
type: object
required:
- limit
title: PageMeta
description: 'Pagination envelope metadata. `count` is the size of the returned page;
`total` (when present) is the full result-set size for offset paging.
In cursor mode `offset`/`total` are absent and `next_cursor` carries the
opaque token for the next page (null on the last page).'
Page_Lead_:
properties:
data:
items:
$ref: '#/components/schemas/Lead'
type: array
title: Data
meta:
$ref: '#/components/schemas/PageMeta'
type: object
required:
- data
- meta
title: Page[Lead]
BookingRequest:
properties:
id:
type: string
title: Id
status:
anyOf:
- type: string
- type: 'null'
title: Status
subject_id:
anyOf:
- type: string
- type: 'null'
title: Subject Id
student_id:
anyOf:
- type: string
- type: 'null'
title: Student Id
student_display_name:
anyOf:
- type: string
- type: 'null'
title: Student Display Name
preferred_mode:
anyOf:
- type: string
- type: 'null'
title: Preferred Mode
preferred_days:
items:
type: string
type: array
title: Preferred Days
default: []
preferred_times:
additionalProperties: true
type: object
title: Preferred Times
default: {}
duration_minutes:
anyOf:
- type: integer
- type: 'null'
title: Duration Minutes
message:
anyOf:
- type: string
- type: 'null'
title: Message
matched_session_id:
anyOf:
- type: string
- type: 'null'
title: Matched Session Id
created_at:
anyOf:
- type: string
- type: 'null'
title: Created At
updated_at:
anyOf:
- type: string
- type: 'null'
title: Updated At
type: object
required:
- id
title: BookingRequest
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: Authorization
description: Org API key as `Token token=cq_live_...`