Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: ClassQuill Public Billing 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: Billing
paths:
/v1/payments:
get:
tags:
- Billing
summary: List Payments
description: 'List payments received by the org (most recent first).
Scoped directly by `organization_id`. Filter by student, tutor, status, or
date range.'
operationId: list_payments_v1_payments_get
parameters:
- name: student_id
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Filter by student UUID
title: Student Id
description: Filter by student UUID
- name: tutor_id
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Filter by tutor UUID
title: Tutor Id
description: Filter by tutor UUID
- name: status
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: succeeded, pending, refunded, failed, ...
title: Status
description: succeeded, pending, refunded, failed, ...
- 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_Payment_'
'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:
- Billing
summary: Create Payment
description: 'Record a MANUAL / EXTERNAL payment (cash, bank transfer, off-platform).
This endpoint NEVER initiates a real charge. `payment_type` must be `external`
— any Stripe/processor value (single_session, group_session, package_redemption)
is rejected with 422, because a real charge must flow through the Stripe
PaymentIntent + webhook that keeps `session_ledger` consistent; the Python
backend does not own money flows.
`organization_id` and `created_at` are server-set; `stripe_payment_intent_id`
is always null. The referenced `session_id` (and any
`student_id`/`tutor_id`) must be org-scoped (404 otherwise). Honours
`Idempotency-Key`.'
operationId: create_payment_v1_payments_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/PaymentCreate'
responses:
'201':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Payment'
'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/payments/{payment_id}:
get:
tags:
- Billing
summary: Get Payment
description: Fetch a single payment by ID. 404 if it doesn't exist or belongs to another org.
operationId: get_payment_v1_payments__payment_id__get
parameters:
- name: payment_id
in: path
required: true
schema:
type: string
description: Payment UUID
title: Payment Id
description: Payment UUID
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Payment'
'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/invoices:
get:
tags:
- Billing
summary: List Invoices
description: 'List invoices for the org (most recent first), each with its line items.
Scoped by `tutor_invoices.organization_id`, kept reliable by the auto-draft +
line-item triggers (WS-3). Filter by student, status, or date range.'
operationId: list_invoices_v1_invoices_get
parameters:
- name: student_id
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Filter by student UUID
title: Student Id
description: Filter by student UUID
- name: status
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: draft, open, paid, void, uncollectible
title: Status
description: draft, open, paid, void, uncollectible
- 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_Invoice_'
'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/invoices/{invoice_id}:
get:
tags:
- Billing
summary: Get Invoice
description: 'Fetch a single invoice (with line items) by ID. 404 unless its
`organization_id` is this org (same scoping as list_invoices).'
operationId: get_invoice_v1_invoices__invoice_id__get
parameters:
- name: invoice_id
in: path
required: true
schema:
type: string
description: Invoice UUID
title: Invoice Id
description: Invoice UUID
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Invoice'
'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/payouts:
get:
tags:
- Billing
summary: List Payouts
description: 'List completed tutor payouts for the org (most recent first) — the wage-payment
ledger for payroll export.
Scoped directly by `organization_id`. Notification-plumbing columns are not
exposed; the table carries no bank details.'
operationId: list_payouts_v1_payouts_get
parameters:
- name: tutor_id
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: Filter by tutor UUID
title: Tutor Id
description: Filter by tutor UUID
- name: from
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: paid_at >= ISO date
title: From
description: paid_at >= ISO date
- name: to
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
description: paid_at <= ISO date
title: To
description: paid_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_Payout_'
'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/payouts/{payout_id}:
get:
tags:
- Billing
summary: Get Payout
description: Fetch a single payout by ID. 404 if it doesn't exist or belongs to another org.
operationId: get_payout_v1_payouts__payout_id__get
parameters:
- name: payout_id
in: path
required: true
schema:
type: string
description: Payout UUID
title: Payout Id
description: Payout UUID
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Payout'
'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:
LineItem:
properties:
description:
anyOf:
- type: string
- type: 'null'
title: Description
quantity:
anyOf:
- type: integer
- type: 'null'
title: Quantity
unit_amount_cents:
anyOf:
- type: integer
- type: 'null'
title: Unit Amount Cents
amount_cents:
anyOf:
- type: integer
- type: 'null'
title: Amount Cents
item_type:
anyOf:
- type: string
- type: 'null'
title: Item Type
session_id:
anyOf:
- type: string
- type: 'null'
title: Session Id
type: object
title: LineItem
Page_Invoice_:
properties:
data:
items:
$ref: '#/components/schemas/Invoice'
type: array
title: Data
meta:
$ref: '#/components/schemas/PageMeta'
type: object
required:
- data
- meta
title: Page[Invoice]
Payment:
properties:
id:
type: string
title: Id
session_id:
anyOf:
- type: string
- type: 'null'
title: Session Id
student_id:
anyOf:
- type: string
- type: 'null'
title: Student Id
tutor_id:
anyOf:
- type: string
- type: 'null'
title: Tutor Id
amount_cents:
anyOf:
- type: integer
- type: 'null'
title: Amount Cents
refund_amount_cents:
anyOf:
- type: integer
- type: 'null'
title: Refund Amount Cents
currency:
anyOf:
- type: string
- type: 'null'
title: Currency
payment_type:
anyOf:
- type: string
- type: 'null'
title: Payment Type
status:
anyOf:
- type: string
- type: 'null'
title: Status
stripe_payment_intent_id:
anyOf:
- type: string
- type: 'null'
title: Stripe Payment Intent Id
created_at:
anyOf:
- type: string
- type: 'null'
title: Created At
type: object
required:
- id
title: Payment
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
Page_Payment_:
properties:
data:
items:
$ref: '#/components/schemas/Payment'
type: array
title: Data
meta:
$ref: '#/components/schemas/PageMeta'
type: object
required:
- data
- meta
title: Page[Payment]
Invoice:
properties:
id:
type: string
title: Id
invoice_number:
anyOf:
- type: string
- type: 'null'
title: Invoice Number
tutor_id:
anyOf:
- type: string
- type: 'null'
title: Tutor Id
student_id:
anyOf:
- type: string
- type: 'null'
title: Student Id
status:
anyOf:
- type: string
- type: 'null'
title: Status
amount_due_cents:
anyOf:
- type: integer
- type: 'null'
title: Amount Due Cents
amount_paid_cents:
anyOf:
- type: integer
- type: 'null'
title: Amount Paid Cents
currency:
anyOf:
- type: string
- type: 'null'
title: Currency
due_date:
anyOf:
- type: string
- type: 'null'
title: Due Date
sent_at:
anyOf:
- type: string
- type: 'null'
title: Sent At
paid_at:
anyOf:
- type: string
- type: 'null'
title: Paid At
created_at:
anyOf:
- type: string
- type: 'null'
title: Created At
line_items:
items:
$ref: '#/components/schemas/LineItem'
type: array
title: Line Items
default: []
type: object
required:
- id
title: Invoice
Payout:
properties:
id:
type: string
title: Id
tutor_id:
anyOf:
- type: string
- type: 'null'
title: Tutor Id
session_id:
anyOf:
- type: string
- type: 'null'
title: Session Id
amount_cents:
anyOf:
- type: integer
- type: 'null'
title: Amount Cents
currency:
anyOf:
- type: string
- type: 'null'
title: Currency
is_group:
anyOf:
- type: boolean
- type: 'null'
title: Is Group
paid_at:
anyOf:
- type: string
- type: 'null'
title: Paid At
created_at:
anyOf:
- type: string
- type: 'null'
title: Created At
type: object
required:
- id
title: Payout
description: 'A completed tutor payout (wage-payment ledger). Notification-plumbing
columns (ledger_id, digest_sent_at, immediate_sent_at) are not exposed; the
table carries no bank fields.'
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).'
PaymentCreate:
properties:
session_id:
type: string
title: Session Id
student_id:
anyOf:
- type: string
- type: 'null'
title: Student Id
tutor_id:
anyOf:
- type: string
- type: 'null'
title: Tutor Id
amount_cents:
type: integer
minimum: 0.0
title: Amount Cents
description: Cents. Must be >= 0.
currency:
type: string
title: Currency
default: aud
payment_type:
type: string
title: Payment Type
description: Only 'external' is accepted (manual/off-platform). Stripe types are rejected.
default: external
status:
type: string
title: Status
description: succeeded | pending | failed
default: succeeded
additionalProperties: false
type: object
required:
- session_id
- amount_cents
title: PaymentCreate
description: 'POST /v1/payments → session_payments. MANUAL / EXTERNAL payments ONLY.
`payment_type` is restricted to ''external'' — this endpoint NEVER initiates a
real charge. Stripe/processor payment_types (single_session, group_session,
package_redemption) are rejected with 422 because the Python backend does not
own money flows: a real charge must go through the Stripe PaymentIntent +
webhook that drives session_ledger. The referenced session/student/tutor must
be org-scoped.'
Page_Payout_:
properties:
data:
items:
$ref: '#/components/schemas/Payout'
type: array
title: Data
meta:
$ref: '#/components/schemas/PageMeta'
type: object
required:
- data
- meta
title: Page[Payout]
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: Authorization
description: Org API key as `Token token=cq_live_...`