API reference
A tenant-scoped HTTP API for your institution's data — 51 resources to read over REST, a Model Context Protocol server for your own AI assistants, signed event webhooks, scheduled warehouse exports, and a documented write API starting with enquiries. One token for all of it.
Last updated 3 September 2026
Scope, stated plainly. Reading is open across
51 resources, over REST, over MCP and as a scheduled
warehouse export. Writing is deliberately narrow — one documented action,
enquiries, with more added as actions rather than as a generic table writer; the
reasoning is under Writing and it is worth reading before you plan around it.
Signed event webhooks are available. Everything on this page is what ships
today; where this page and another page on this site disagree, this one is correct.
Base URL
The API is served from your own tenant host:
https://<your-slug>-staff.pallara.app/api/v1
Three surfaces share that host and one credential:
| Surface | Path | What it is |
|---|---|---|
| REST | /api/v1/<resource> | The versioned read API. 51 resources, cursor-paged. |
| OpenAPI | /api/v1/openapi | The generated contract for the above. |
| MCP | /api/mcp | Model Context Protocol, for AI assistants. POST only. |
The original /api/bulk surface is still served, unchanged.
Authentication
Every request carries a bearer token that your own administrator mints in the staff portal under Administration → Settings → API access. The same token authenticates REST, MCP and the warehouse exporter — a second credential would be a second thing to revoke, and a second thing to forget to revoke.
Authorization: Bearer pallara_bulk_<secret>
Four things worth knowing about the token:
- It is shown once. Only a SHA-256 hash is stored. If it is lost, revoke it and mint another — we cannot recover it, and would not want to be able to.
- It resolves to exactly one institution. The tenant is not a parameter on any endpoint and it is not taken from the hostname either, so there is no request you can construct that widens the scope. Everything after authentication runs under database row-level security for that tenant.
- Revocation is immediate — it takes effect on the next request, not at an expiry. There is no expiry, which is exactly why a rotation belongs in your calendar.
- A token is nobody. See what a token cannot reach.
REST, MCP, webhooks or warehouse
Four ways to get data out, one credential, and the same limits behind all of them. They differ in who is asking and how often.
| If you want to… | Use | Because |
|---|---|---|
| Ask questions in an AI assistant, in your own words | MCP | The assistant picks the resource, the filter and the paging. You write nothing. |
| Read and transform records in code | REST + OpenAPI | Deterministic, versioned, and described by a generated contract. |
| React the moment something happens | Webhooks | Nothing to poll. |
| Load whole datasets into a warehouse or BI tool | Warehouse export | One scheduled snapshot beats several million API calls. |
Making a request
GET /api/v1/enrolments?status=active&limit=200 HTTP/1.1
Host: your-slug-staff.pallara.app
Authorization: Bearer pallara_bulk_····
Response
{
"resource": "enrolments",
"count": 200,
"next_cursor": "0f8a1c34-…",
"data": [ { "id": "…", "status": "active", … } ]
}
When next_cursor is null you have reached the end.
One record
GET /api/v1/enrolments/0f8a1c34-…
Returns the record itself with every column, or 404.
A count without the rows
GET /api/v1/invoices?status=overdue&count=true
{ "resource": "invoices", "total": 37 }
Filtering, sorting and paging
| Parameter | Detail |
|---|---|
limit | Rows per page. Default 100, maximum 500. A larger value is clamped rather than rejected. |
cursor | The next_cursor from the previous response. Omit for the first page. |
fields | Comma-separated columns to return. id is always included — it is the cursor. An unknown field is an error listing the available ones, never silently dropped. |
sort | A column name, or -column for descending. Always tie-broken by id. The default is id ascending. |
count | true answers “how many” without transferring the rows. |
| any filterable column | ?status=active, or ?column[op]=value for another comparison. |
Operators
| Operator | Meaning |
|---|---|
eq | Equal to. The bare form ?status=active means the same thing. |
neq | Not equal to. |
gt | Greater than. |
gte | Greater than or equal to. |
lt | Less than. |
lte | Less than or equal to. |
in | One of a comma-separated list. |
contains | Substring match. |
GET /api/v1/invoices?status[in]=sent,overdue&dueDate[lte]=2026-09-30&sort=-dueDate
Only columns a resource declares filterable can be filtered — ids, statuses,
foreign keys and dates. That is not an oversight: those are the indexed columns, and a
LIKE over an unindexed text column is how an API becomes slow for everybody using it.
GET /api/v1/<resource> in the OpenAPI document lists each resource’s
filterable set, and an unknown filter is a 400 that names the legal ones rather than a
silently ignored parameter.
Paging is by cursor, not offset — and that is deliberate. An
offset over a table that is being written to while you sweep it silently skips and repeats rows,
and a nightly warehouse sync has no way to detect that it happened. Ordering by primary key with a
cursor gives a stable sweep. The cursor is the last row’s id; treat it as a value
to hand back rather than one to construct.
Resources
51 resources in seven domains, 785 columns between them. Each exposes a fixed, server-side column allow-list — requesting a column that is not on the list is an error, and the list is not a parameter. The exact columns and filters for every one of them are in the OpenAPI document, which is generated from that same list.
| Domain | # | Resources |
|---|---|---|
| Learners and admissions | 7 | students, enrolments, applications, enquiries, international_information, emergency_contacts, addresses |
| Academic structure | 10 | programmes, courses, intakes, modules, content_items, learning_outcomes, programme_terms, programme_majors, student_courses, course_programmes |
| Assessment and results | 6 | assessments, submissions, grades, outcome_results, completion_records, rubrics |
| Attendance | 2 | attendance, attendance_students |
| Finance | 9 | invoices, payment_transactions, payment_allocations, fee_structures, credit_notes, discount_codes, commission_agreements, commission_records, financial_records |
| Engagement and support | 5 | cases, case_notes, appointments, communications, notices |
| Compliance and operations | 12 | compliance_items, itr_submissions, documents, locations, staff, course_staff, events, assets, homestay_placements, awards, form_submissions, training_agreements |
documents is listed and describable but always returns zero rows: its owner is a
polymorphic link that cannot yet be guarded safely, so returning any document would risk returning
one belonging to a protected learner. It is left in the list rather than removed so that nothing
breaks when it is opened up.
Dates that are a day, not an instant
Most date columns are full ISO-8601 instants. A few are calendar dates — a
date of birth, an enrolment start, end, census or completion date, a programme term, an intake, an
attendance date — and those are documented format: date in the OpenAPI document
and written as YYYY-MM-DD in New Zealand time in a warehouse export. A date of birth
read as an instant and truncated in UTC comes out a day early, which is the sort of thing that is
only ever noticed by the agency it was reported to.
The OpenAPI document
A machine-readable OpenAPI 3.1 description is served at:
GET /api/v1/openapi
It is generated from the same registry the endpoints read, not maintained by hand. A hand-written spec drifts the first time somebody adds a column, and a contract that lies about its own shape is worse than no contract at all. Point your client generator at it.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_query | A filter, operator, field or sort the resource does not support. The message names what is wrong and what is legal. |
| 401 | unauthorised | Missing, malformed, revoked or unknown token. |
| 404 | unknown_resource | No such resource. The response lists the available ones. |
| 404 | — | No such record — or one withheld by record confidentiality. The two are deliberately indistinguishable. |
| 500 | read_failed | The read could not be completed. The message carries a reference id; quote it to support. Retry with backoff. |
Responses are sent with Cache-Control: private, no-store. Do not cache them in a
shared cache.
What a token cannot reach
A machine credential is not a member of staff. Every one of these applies to REST, MCP and warehouse exports alike, because all three read through one registry — two implementations would be two answers, and the one that fell behind would be the one still returning a confidential learner months after the other stopped.
- Another institution. Row-level security, not a filter.
- Confidential learners. A learner your institution has marked confidential is excluded outright. There is no access list a token could be added to, and a restricted learner reads as not found rather than forbidden — a 403 would confirm the record exists.
- Test records. Excluded unconditionally.
- Notes written for a narrower audience. A case note scoped to pastoral staff, to its author and managers, or to chosen roles is not returned at all. Not redacted — not returned, so it cannot leak through a count or a gap in the ordering either.
- Excluded columns. Anything matching
password,secret,tokenorhash; the tenant id; IRD and passport numbers; criminal conviction fields; medical notes; learning accommodations; and every JSON column. A test fails the build if one appears in the registry.
Learner records do include personal information the allow-list permits — NSN, date of birth, gender, home address, citizenship, language spoken at home, and the equity fields your institution reports on. Treat a token as you would a database credential, because for those columns it is one.
The original /api/bulk surface
Everything published before /api/v1 still works, unchanged, and will keep working.
An integrator already consuming it does not have to do anything.
GET /api/bulk/<collection>
GET /api/bulk/openapi
POST /api/bulk/enquiries
It serves eight collections with their original field lists, and answers
{ "collection": … } rather than { "resource": … }. It is
frozen on purpose: changing a warehouse export must not silently change an API somebody built
against.
| Collection | Fields |
|---|---|
students | id, institutionStudentId, status, firstName, lastName, preferredName, academyEmail, international, citizenship, createdAt |
enrolments | id, status, startDate, endDate, enrolmentType, studyMode, eftsFactor, fundingSource, completionStatus, studentId, programmeId, intakeId, createdAt |
programmes | id, status, programmeName, programmeCode, programmeType, nzqaLevel, qualificationCode, totalCredits, eftsValue, durationWeeks, createdAt |
courses | id, status, courseName, courseCode, creditValue, eftsFactor, nzqfLevel, nzscedCode, createdAt |
intakes | id, status, name, startDate, endDate, maxStudents, enrollmentType, programmeId, createdAt |
assessments | id, status, title, assessmentType, dueDate, totalMarks, passingMarks, weight, unitStandardCode, courseId, createdAt |
attendance | id, date, courseId, intakeId, sessionType, startTime, endTime, createdAt |
invoices | id, invoiceNumber, studentId, enrolmentId, amount, gstAmount, totalAmount, status, invoiceStatus, dueDate, paidDate, invoiceDate, amountPaid, amountDue, createdAt |
New work should use /api/v1, which is where the other 43 resources,
field selection, sorting, filtering and counting are.
Writing
There is one write endpoint today, and the narrowness is a design decision rather than an unfinished edge.
Writes are not reads with a different verb. Reading a column
is the same operation whatever the column is, which is why a column allow-list is a sound way to
open up GET. Writing is not. Creating an enrolment has to pass seat limits, the Public
Trust payment gate and portal provisioning. Recording a payment has to go through the ledger, or
the invoice’s paid amount becomes a number nobody can explain. A generic table writer walks
past all of that, and the damage is silent — the row is there and the rule never ran. So the
write surface is actions with their rules intact, added one at a time.
Create an enquiry
POST /api/bulk/enquiries
Authorization: Bearer pallara_bulk_····
Idempotency-Key: 7c1f-website-form-84213
Content-Type: application/json
{ "firstName": "Aroha", "lastName": "Ngata",
"email": "[email protected]", "phone": "021 555 0134",
"message": "Asking about the March intake" }
- An enquiry needs an email address or a phone number. One with neither is
refused with
no_contact— a lead nobody can reply to is not a lead. Idempotency-Keyis honoured. If your request times out and you send it again with the same key, the original enquiry is replayed rather than a duplicate created. Use something stable and specific to the submission.- Leads are attributed to the credential that sent them, so you can tell website traffic from an agent’s feed without asking the sender to say which they are.
What else becomes writable is a product decision, not a gap. If a particular action is on your critical path, tell us which one — that is what sets the order.
Webhooks
Subscribe an endpoint to events and Pallara posts to it as they happen. Manage subscriptions in the staff portal under Administration → Settings → Webhooks. Subscribe to nothing to receive every event, including ones added later.
| Event | Fires when |
|---|---|
enquiry.created | A new enquiry was raised, whether typed by staff or posted to the API. |
application.stage_changed | An application moved to a different pipeline stage. |
enrolment.confirmed | A learner was enrolled in a programme. |
learner.status_changed | A learner’s status changed. |
submission.created | A learner submitted work for an assessment. |
invoice.issued | An invoice was issued to a learner. |
payment.received | A payment was recorded against an invoice. |
That is the whole catalogue. Events are added to it rather than invented per subscription.
Verifying a delivery
Every request carries a signature over the timestamp and the raw body, keyed by the subscription secret shown once when you create it:
X-Pallara-Signature: t=1755500000,v1=<hex hmac-sha256>
Recompute HMAC-SHA256(secret, "<t>.<raw body>") and compare in constant
time. Reject a delivery whose timestamp is outside your tolerance window — that is what stops
a captured request being replayed at you later.
Retries
A failed delivery is retried six times with exponential backoff — roughly one minute, five minutes, thirty minutes, two hours, six hours, then twenty-four hours. A 4xx response is treated as understood and refused, and is not retried: retrying a request the receiver has rejected is noise, not resilience. Return 2xx quickly and do your work asynchronously; every attempt and response is recorded and inspectable per subscription.
MCP server
Pallara speaks the Model Context Protocol, so your own AI assistants and agents — Claude, ChatGPT or in-house — can query your Pallara data under the same tenant isolation and the same limits as everything else. The administrator guide is the step-by-step; this is the contract.
POST /api/mcp
Authorization: Bearer pallara_bulk_····
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
| Detail | |
|---|---|
| Transport | Streamable HTTP, POST only. GET and DELETE return 405 with Allow: POST. |
| Protocol version | 2026-07-28. 2025-06-18 is still served for older clients, including its initialize handshake. |
| Server identity | {{ "name": "pallara", "version": "2.0.0" }} |
| Authentication | The same pallara_bulk_ token as the REST API. |
| Sessions | None. Every request stands alone, which is what lets it run behind more than one container. |
| Rate limit | 60 tool calls a minute per token; 10 a minute for find_learner and learner_overview. |
| Last tested | 3 September 2026, against the hosted endpoint with the official MCP client and Inspector. |
The seven tools
| Tool | What it does | Takes | Returns |
|---|---|---|---|
list_resources | Every resource this token can read, grouped by domain. | — | { groups, total } |
describe_resource | The columns a resource exposes and which of them can be filtered. | resource | { resource, group, columns, filterable } |
query_resource | One page of a resource. | resource, and optionally filters, fields, sort, limit, cursor | { resource, count, next_cursor, data } |
get_record | One record by id, with every column. | resource, id | the record, or { found: false } |
count_records | How many records match, without transferring them. | resource, and optionally filters | { resource, total } |
find_learner | Turns a name, email or your own student id into the learner id the other tools need. | query, and optionally limit (default 10, maximum 50) | { query, count, total_matches, searched, truncated, search_complete, matched_on, matches } |
learner_overview | One learner in a single call: record, enrolments, recent grades, invoices, outstanding balance and open cases. | student_id | { found, student, enrolments, recent_grades, invoices, outstanding_balance, outstanding_invoice_count, balance_complete, open_cases } |
Every tool declares itself read-only, non-destructive and idempotent, validates its input against
a strict schema, and returns structuredContent against a published output schema
and the same JSON as plain text, so a client that does not read structured output still
works. Query semantics — operators, sorting, field selection, page sizes, cursors — are
exactly those above, because both surfaces call one implementation.
A minimal exchange
POST /api/mcp
MCP-Protocol-Version: 2025-06-18
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": { "name": "example", "version": "1.0.0" } } }
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "count_records",
"arguments": { "resource": "enrolments",
"filters": { "status": { "eq": "active" } } } } }
{ "jsonrpc": "2.0", "id": 2, "result": {
"structuredContent": { "resource": "enrolments", "total": 412 },
"content": [ { "type": "text", "text": "{\"resource\":\"enrolments\",\"total\":412}" } ]
} }
Recovering from an error
A mistake you can fix — an unknown resource, an unfilterable column — comes back as a
successful call with isError: true and text saying what was wrong, so
an assistant can correct itself and try again rather than giving up. Protocol-level problems come
back as ordinary JSON-RPC errors: -32600 invalid request, -32601 method
not found, -32602 invalid params or unknown tool, -32603 internal,
-32020 header mismatch, -32022 unsupported protocol version,
-32029 rate limited.
An internal failure answers “That request could not be completed.” rather than the underlying database error — an error string that names columns is a description of your schema handed to whoever triggered it.
The MCP server is read-only, and a test enforces it. All seven tools read; none writes. That is asserted at source level in the test suite rather than left to review, because an agent that can enrol a learner is a different product decision, and it must not arrive by accident inside a read integration. Token validation deliberately does not even update the token’s last-used timestamp.
Browser callers
A request from a browser must come from your own staff origin. Command-line and desktop clients
send no Origin header and are unaffected; anything else is refused with
403. That is a DNS-rebinding defence, and Host is deliberately not trusted
as an origin authority.
Warehouse exports
For whole datasets on a schedule, an administrator configures an export set in the staff portal under Administration → Data Warehouse rather than calling an API. The setup guide covers it; what an integrator needs to know is what arrives.
dw-exports/<export-set-id>/<run-id>/
manifest.json
students/part-000001.csv
students/part-000002.csv
enrolments/part-000001.csv
- One file set per resource, plus a manifest — not one enormous joined CSV.
- CSV, UTF-8, RFC 4180 quoting, 500 rows a part. A resource with no rows still gets a header-only file, so a missing file means something went wrong.
- The manifest is written last, and names every part with its row count and
SHA-256 checksum. A manifest you can read is a run whose files are all there. Read it first. It
carries a
schemaVersion—1.0today — so a loader can fail loudly on a shape it was not written for instead of guessing. - It names the calendar-date columns per resource, so your loader never has to infer which columns are days and which are instants.
- Full snapshots. Every run sends the whole dataset, swept end to end with cursor paging — no row ceiling. There is no incremental feed and no deletion tombstone, so replace rather than merge.
- Destinations are Pallara-managed storage or your own S3-compatible bucket — AWS S3, Cloudflare R2, MinIO, Backblaze B2. A destination that has not passed a connection test cannot run an export.
- Schedules are manual, hourly or daily, at a local time in a named IANA timezone — not an arbitrary cron expression. Every run gets its own folder, so runs never overwrite each other.
- Fifty of the fifty-one resources are exportable.
documentsis the exception and does not appear in the picker, for the same reason it returns no rows over REST and MCP.
Using it well
- Sweep with the cursor until
next_cursoris null, and store the last one if you want to resume rather than restart. - Ask for the columns you need.
fields=is the difference between a sync that costs a second and one that costs a minute. - Count before you page when you only need a number.
count=truedoes not transfer the rows. - Mint one token per consumer. A separate token for your warehouse and your BI tool means revoking one does not break the other, and the last-used column tells you which is still running.
- Treat the token as a credential. Put it in your secret manager, not in a repository or a scheduled-job definition in plain text.
- Handle 500 with backoff, and quote the reference id in the message if it persists.
- Use the right surface. Millions of API calls to rebuild a dataset every night is a warehouse export wearing a disguise.
Not available
Stated so nobody plans around something that is not there:
- Write access beyond enquiries. Deliberate — see Writing. Ask for the action you need.
- Write tools over MCP. The server reads only, by design and by test.
- MCP Apps, prompts, resources, subscriptions and durable tasks. Tools only.
- JSON-RPC batching. Removed from both supported protocol revisions; a batch array is refused.
- Incremental warehouse feeds and deletion tombstones. Full snapshots only in v1.
- Parquet, and native Snowflake, BigQuery, Redshift or Azure connectors. CSV to S3-compatible storage, and load from there.
- Arbitrary SQL, joins or transformations over the API or the export. Shape it in your warehouse.
- Public unauthenticated download URLs for export files.
If any of these is on your critical path, tell us at Contact — knowing who needs what changes the order we build them in.