Developers
Build on Auditori
Read your data out, write learners and outcomes back in, receive events as they happen, and deliver generated content in the LMS you already run. Included in every plan.
REST API
Students, enrolments and outcomes, programs, trainers, venues, employers and invoices, with incremental sync.
Writes
Create and update learners, enrol them in sessions, and record unit outcomes.
Webhooks and Zapier
Signed webhooks for six events, and a Zapier app on top of them.
MCP server
Point an AI assistant at your own data. Read tools, and write tools if you grant them.
Getting a key
In Auditori, go to Settings → API keys and create one. Give each integration its own key so you can revoke it without disturbing the others. The key is shown once and stored as a hash — if you lose it, create another.
A key carries scopes, and a scope can never exceed what your plan includes. A key with students:read on a plan without student management returns feature_not_enabled, not data.
Making a request
Send the key as a bearer token. Everything is JSON; there is nothing to configure.
curl https://app.auditori.com.au/api/v1/students \
-H "Authorization: Bearer auditori_sk_live_…"Rate limits
120 requests per key per minute, across both the REST API and the MCP server. Every response tells you where you stand, so you should never have to discover the limit by being refused:
x-ratelimit-limit: 120
x-ratelimit-remaining: 118
x-ratelimit-reset: 1787910411Go over and you get a 429 with a retry-after header. If you are hitting it during a sync, you almost certainly want updated_since rather than a faster loop. Need a higher limit for a real integration? Ask us — it is a number, not a policy.
Pagination
Lists are cursor-paged. Each response carries next_cursor; pass it back as cursor to get the next page, and stop when has_more is false.
Cursors describe a position rather than a distance, so records being edited while you walk will not cause you to skip or repeat rows. Treat the cursor as opaque.
{
"data": [ … ],
"next_cursor": "MjAyNi0wOC0yOFQwODo0ODowNVo…",
"has_more": true
}limitHow many records to return. 1–200, default 50.
cursorThe `next_cursor` from your previous response. Omit for the first page.
updated_sinceISO 8601 timestamp. Returns only records changed at or after it — this is how you sync incrementally instead of re-reading everything.
To sync incrementally, store the highest updated_at you have seen and pass it as updated_since next time. That is far cheaper than re-reading everything, and it is what the field is for.
Reading data
Every resource below, documented in full in the reference — every parameter, every field, every response shape. It is generated from our OpenAPI specification, so it cannot describe an endpoint that does not exist or omit a field a route returns.
- studentsread + write
students:read - enrolmentsread + write
enrolments:read - courses
courses:read - trainers
trainers:read - venues
venues:read - employers
employers:read - invoicesread + write
invoices:read - sessionsread + write
calendar:read - webhooksread + write
Errors
Errors carry a stable code you can branch on and a message meant for a human. We refuse malformed requests rather than guessing at them — a clamped limit would tell you your request worked when it did not.
401 · unauthorizedNo key, or a key that is unknown or revoked. All three answer identically.
403 · insufficient_scopeThe key is valid but does not carry the scope this endpoint needs.
403 · feature_not_enabledYour plan does not include this part of the product. Scopes cannot exceed your plan.
400 · invalid_requestA parameter was out of range or malformed. We refuse rather than guess.
429 · rate_limitedMore than 120 requests from one key in a minute. Wait for the window in retry_after, or read x-ratelimit-remaining and pace yourself.
500 · internal_errorSomething went wrong our end. The request is logged; contact us with the time it happened.
Writing data
Create and update learners, enrol them into a scheduled session, and record unit outcomes. That last one is what most integrations are here for: it is the write that feeds AVETMISS, and it is the reason a marking tool or an LMS wants to talk to us at all.
curl -X PATCH https://app.auditori.com.au/api/v1/enrolments/{id} \
-H "Authorization: Bearer auditori_sk_live_…" \
-H "Content-Type: application/json" \
-d '{"units": [{"id": "…", "outcome": "competent", "assessment_date": "2026-08-20"}]}'Idempotency
Send an Idempotency-Key when you create something — any string unique to that request. A retry with the same key returns the original response instead of creating a second record. Without it, a connection that drops after we commit but before the response reaches you leaves you with a duplicate learner to find and merge, which is the ordinary behaviour of every HTTP client there is.
Reusing a key for a different request is refused rather than answered — you would otherwise believe your second write happened. Updates need no key: setting named fields to given values is already safe to repeat.
Validation
An outcome must be one of the six we report on. A unit that is not on the enrolment you named comes back as an error listing it, not as a silent skip — reporting success for an outcome that was never recorded is the worst possible answer for compliance data. Plan limits apply here exactly as they do in the app, so an integration is never a way around a cap you would hit in the UI.
Webhooks
Rather than polling for changes, have us tell you. Add an endpoint under Settings → Webhooks and we will POST a signed JSON body the moment something happens.
document.approvedA document was approved. Carries the document id, kind and who approved it.
document.generatedA generation finished and the document is ready to read. Sent when it becomes readable, which for a standalone document is after the pre-release check, not when the model finished.
enrolment.outcome_recordedAn outcome was recorded against a unit in an enrolment.
invoice.paidAn invoice was marked paid.
student.usi_invalidA student was saved with a missing or badly-formatted USI. Sent on the save itself, and only when the USI was the thing that changed.
trainer.credential_expiringA trainer credential is within 60 days of its expiry date. Unlike the others this one has no user action behind it, so it comes from a daily sweep — once per credential, not once a day.
Verifying the signature
Every request carries x-auditori-signature, an HMAC-SHA256 of timestamp.bodyusing your endpoint’s signing secret. The timestamp is signed along with the body, so reject anything more than a few minutes old and a captured request cannot be replayed at you later.
const ts = req.headers['x-auditori-timestamp']
const sig = req.headers['x-auditori-signature']
const expected = crypto
.createHmac('sha256', process.env.AUDITORI_WEBHOOK_SECRET)
.update(`${ts}.${rawBody}`)
.digest('hex')
// Constant-time, and check the age.
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(400).end()
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).end()Managing subscriptions
An integration can manage its own subscriptions with an API key — POST /api/v1/webhooks to subscribe and DELETE /api/v1/webhooks/{id} to stop. The signing secret comes back once, on creation.
Each event needs the read permission for the data it carries: a key that cannot read invoices cannot arrange to be sent them. Omit events and you get everything that key is allowed — which is not necessarily everything. GET /api/v1/webhooks tells you which events the key may have, so you can offer a picker rather than guess and be refused.
Zapier
Those two endpoints are what a Zapier app subscribes and unsubscribes with, which is why our integration triggers within seconds rather than on a polling interval. Triggers for every event above; actions to create and update learners, enrol them and record outcomes; and a search so a Zap can find a learner before acting on one.
Retries
Answer with any 2xx and we consider it delivered. Anything else is retried with backoff — roughly a minute, then five, twenty-five, two hours, ten hours — for six attempts. An endpoint that keeps failing is switched off and shown as such in your settings, rather than us retrying a dead URL forever. Turn it back on once your receiver is healthy.
Deliveries are at-least-once. Treat x-auditori-delivery as an idempotency key if processing the same event twice would matter to you.
MCP server
Auditori speaks the Model Context Protocol, so an AI assistant can answer questions against your own data — “which students are missing a USI, and which trainers have credentials expiring before the next scheduled session?” — without you opening three screens to piece it together.
It uses the same API key, the same scopes and the same plan limits as the REST API. An assistant can only ever see what that key allows.
{
"mcpServers": {
"auditori": {
"url": "https://app.auditori.com.au/api/mcp",
"headers": { "Authorization": "Bearer auditori_sk_live_…" }
}
}
}One read tool per endpoint above — list_students, list_enrolments and so on. Results are capped and say so when they are truncated, so an assistant knows when it is looking at part of the picture rather than reasoning confidently over a subset.
Write tools
Four write tools — create_student, update_student, enrol_student and record_outcomes — each doing one thing to one record. There is no bulk update and no delete, because the way an assistant goes wrong is rarely one bad field: it is a confident sweep across a hundred rows.
You decide whether this is on at all. The write tools refuse unless the key carries students:write or enrolments:write. Mint a read-only key for your assistant and it can answer questions and change nothing. Every call — read or write — is logged against that key and visible under Settings → API keys, so you can see afterwards exactly what it did.
Values are checked rather than trusted. An outcome must be one of the six we report on, and a unit that is not on the enrolment is refused rather than skipped — a model offering a plausible-sounding "passed" gets an error, not a row in your AVETMISS file.
Course packages
A generated learner guide can be exported as a SCORM 1.2 package and imported into Moodle, or whatever LMS you already run. One page per section, so your LMS builds its own contents list, and the pages load nothing from the network — they work inside a sandboxed course player.
GET https://app.auditori.com.au/api/generate/{session_id}/scorm1.2 rather than 2004 on purpose: every LMS imports it, and 2004’s sequencing model buys nothing for a linear guide. Requires an active plan or credits — a package is delivered to learners, so unlike a PDF there is no watermarked version that makes sense.
xAPI
The same guide is also available as an xAPI (Tin Can)package. The difference is not the content, it is where the record lands: SCORM reports into whichever LMS is playing the guide, while xAPI posts statements to a Learning Record Store that may be somewhere else entirely. That is usually the reason to want it — completions from several vendors’ content arriving in one store.
curl https://app.auditori.com.au/api/generate/{id}/xapi -o guide-xapi.zipBoth are in the download menu next to the PDF and DOCX exports once a learner guide has been generated. Activity ids are stable across exports, so two deliveries of the same guide report against the same activity rather than looking like unrelated content.
What’s next
Live USI verification against the registry, so a USI that is shaped correctly but belongs to nobody — or to somebody else — is caught here rather than at NCVER. It is built, and answering correctly against the Commonwealth’s test environment. What is left is onboarding rather than code: our own machine credential, and your authorisation for us to ask on your behalf.
Attendance and invoices have since landed: you can mark a whole class in one call, and raise an invoice against an enrolment with an idempotency key so a retry never bills somebody twice. Trainers are next, and after that whatever people actually ask for. If you are building something and need one sooner, tell us. What gets built next is decided by what someone is genuinely blocked on, not by what rounds out a list.