Skip to content

Legal & policies

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:

SurfacePathWhat it is
REST/api/v1/<resource>The versioned read API. 51 resources, cursor-paged.
OpenAPI/api/v1/openapiThe generated contract for the above.
MCP/api/mcpModel 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…UseBecause
Ask questions in an AI assistant, in your own wordsMCPThe assistant picks the resource, the filter and the paging. You write nothing.
Read and transform records in codeREST + OpenAPIDeterministic, versioned, and described by a generated contract.
React the moment something happensWebhooksNothing to poll.
Load whole datasets into a warehouse or BI toolWarehouse exportOne 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

ParameterDetail
limitRows per page. Default 100, maximum 500. A larger value is clamped rather than rejected.
cursorThe next_cursor from the previous response. Omit for the first page.
fieldsComma-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.
sortA column name, or -column for descending. Always tie-broken by id. The default is id ascending.
counttrue answers “how many” without transferring the rows.
any filterable column?status=active, or ?column[op]=value for another comparison.

Operators

OperatorMeaning
eqEqual to. The bare form ?status=active means the same thing.
neqNot equal to.
gtGreater than.
gteGreater than or equal to.
ltLess than.
lteLess than or equal to.
inOne of a comma-separated list.
containsSubstring 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 admissions7students, enrolments, applications, enquiries, international_information, emergency_contacts, addresses
Academic structure10programmes, courses, intakes, modules, content_items, learning_outcomes, programme_terms, programme_majors, student_courses, course_programmes
Assessment and results6assessments, submissions, grades, outcome_results, completion_records, rubrics
Attendance2attendance, attendance_students
Finance9invoices, payment_transactions, payment_allocations, fee_structures, credit_notes, discount_codes, commission_agreements, commission_records, financial_records
Engagement and support5cases, case_notes, appointments, communications, notices
Compliance and operations12compliance_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

StatusCodeMeaning
400invalid_queryA filter, operator, field or sort the resource does not support. The message names what is wrong and what is legal.
401unauthorisedMissing, malformed, revoked or unknown token.
404unknown_resourceNo such resource. The response lists the available ones.
404No such record — or one withheld by record confidentiality. The two are deliberately indistinguishable.
500read_failedThe 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, token or hash; 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.

CollectionFields
studentsid, institutionStudentId, status, firstName, lastName, preferredName, academyEmail, international, citizenship, createdAt
enrolmentsid, status, startDate, endDate, enrolmentType, studyMode, eftsFactor, fundingSource, completionStatus, studentId, programmeId, intakeId, createdAt
programmesid, status, programmeName, programmeCode, programmeType, nzqaLevel, qualificationCode, totalCredits, eftsValue, durationWeeks, createdAt
coursesid, status, courseName, courseCode, creditValue, eftsFactor, nzqfLevel, nzscedCode, createdAt
intakesid, status, name, startDate, endDate, maxStudents, enrollmentType, programmeId, createdAt
assessmentsid, status, title, assessmentType, dueDate, totalMarks, passingMarks, weight, unitStandardCode, courseId, createdAt
attendanceid, date, courseId, intakeId, sessionType, startTime, endTime, createdAt
invoicesid, 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-Key is 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.

EventFires when
enquiry.createdA new enquiry was raised, whether typed by staff or posted to the API.
application.stage_changedAn application moved to a different pipeline stage.
enrolment.confirmedA learner was enrolled in a programme.
learner.status_changedA learner’s status changed.
submission.createdA learner submitted work for an assessment.
invoice.issuedAn invoice was issued to a learner.
payment.receivedA 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
TransportStreamable HTTP, POST only. GET and DELETE return 405 with Allow: POST.
Protocol version2026-07-28. 2025-06-18 is still served for older clients, including its initialize handshake.
Server identity{{ "name": "pallara", "version": "2.0.0" }}
AuthenticationThe same pallara_bulk_ token as the REST API.
SessionsNone. Every request stands alone, which is what lets it run behind more than one container.
Rate limit60 tool calls a minute per token; 10 a minute for find_learner and learner_overview.
Last tested3 September 2026, against the hosted endpoint with the official MCP client and Inspector.

The seven tools

ToolWhat it doesTakesReturns
list_resourcesEvery resource this token can read, grouped by domain.{ groups, total }
describe_resourceThe columns a resource exposes and which of them can be filtered.resource{ resource, group, columns, filterable }
query_resourceOne page of a resource.resource, and optionally filters, fields, sort, limit, cursor{ resource, count, next_cursor, data }
get_recordOne record by id, with every column.resource, idthe record, or { found: false }
count_recordsHow many records match, without transferring them.resource, and optionally filters{ resource, total }
find_learnerTurns 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_overviewOne 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 schemaVersion1.0 today — 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. documents is 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_cursor is 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=true does 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.