Read-only REST API for Nine Pebbles tenants and partners. Authenticate with an API key, pull data over HTTPS, get JSON back. Useful for accounting integrations, data warehouses, in-house dashboards, and parent-portal extensions.
For event-driven integrations (we POST you when something happens), see Webhooks.
Machine-readable spec: /api/v1/openapi.json (OpenAPI 3.0).
https://app.ninepebbles.com/api/v1/api/v1/). Breaking changes ship under a new prefix; v1 won't mutate.POST/PATCH/DELETE on tenant data yet — get in touch if you have a write use case.API keys are minted by org admins in the app. Sign in as an admin and go to Integrations → API keys. You'll see the key once at creation time — copy it to your secret manager; we won't show it again. API key management is gated by the api_access plan feature (Enterprise tier).
Pass the key on every request, in either of two equivalent headers:
Authorization: Bearer np_live_<your-key>
# or
x-api-key: np_live_<your-key>Calls with no key, an invalid key, or a key without the right scope are rejected with a JSON error and the appropriate HTTP status code (see Errors).
Each key is minted with a fixed set of scopes. A request to an endpoint whose required scope isn't on the key returns 403.
read:students — list and read individual studentsread:guardians — list guardians (parents) and their contact detailsread:invoices — list and read invoices, with line items and paymentsread:attendance — list attendance events (check-in/check-out)Mint multiple keys with narrow scopes rather than one key with everything — easier to rotate and easier to audit which integration touched what. Keys can be revoked at any time from the same UI; revocation is instant.
List endpoints are page-based:
page — 1-indexed (default 1)per_page — items per page (default 50, max 100)The response body always includes a pagination block:
{
"data": [ ... ],
"pagination": {
"page": 1,
"per_page": 50,
"total": 138,
"has_more": true
}
}For incremental sync (e.g. polling for newly-updated rows since your last poll), use updated_since=<ISO-8601 datetime> on any list endpoint. Combine with pagination to walk the full set.
Errors are returned as JSON with a single error string:
{
"error": "Missing API key. Pass via \"Authorization: Bearer <key>\" or \"x-api-key\" header."
}HTTP status codes:
200 — success400 — bad request (malformed query params, etc.)401 — missing or invalid API key403 — valid key but missing scope, or IP not in allow-list404 — entity not found, or not accessible with this key429 — organisation suspended (not a per-request rate limit; see below)500 — server error; transient, retry with backoffNo request-rate limit in v1. The only 429 path today is for suspended organisations (closed account, billing failure, etc.) — they receive 429 on every request until the suspension is lifted. Expect a published per-key rate limit before general availability; integrations should already be tolerant of 429 with exponential backoff.
Liveness check. No scope required; the only endpoint that accepts an unauthenticated request.
curl https://app.ninepebbles.com/api/v1/health// Node (built-in fetch, Node 18+)
const res = await fetch('https://app.ninepebbles.com/api/v1/health');
const json = await res.json();
console.log(json.status); // "ok"# Python (requests)
import requests
r = requests.get('https://app.ninepebbles.com/api/v1/health')
print(r.json()['status']) # "ok"{
"status": "ok",
"service": "ninepebbles-api",
"api_version": "v1",
"timestamp": "2026-05-17T20:30:00Z"
}List students at the centres your key has access to. Each row includes ids, name, DOB, status, year group, and timestamps. Use updated_since for incremental sync.
Scope: read:students
Query parameters:
page, per_page — see Paginationstatus — active | inactive | all (default active)centre_id — restrict to a single centre (must be on your key)updated_since — ISO-8601; only rows updated at or after this timestampcurl -H "Authorization: Bearer $NP_KEY" \
"https://app.ninepebbles.com/api/v1/students?page=1&per_page=50&status=active"// Node
const res = await fetch(
'https://app.ninepebbles.com/api/v1/students?status=active',
{ headers: { 'Authorization': `Bearer ${process.env.NP_KEY}` } }
);
const { data, pagination } = await res.json();# Python
import os, requests
r = requests.get(
'https://app.ninepebbles.com/api/v1/students',
headers={'Authorization': f"Bearer {os.environ['NP_KEY']}"},
params={'status': 'active', 'per_page': 50},
)
data = r.json()['data']{
"data": [
{
"id": "s2...",
"full_name": "Ava Carter",
"first_name": "Ava",
"last_name": "Carter",
"date_of_birth": "2018-03-12",
"centre_id": "c8...",
"status": "active",
"year_group": "Year 2",
"created_at": "2025-09-04T10:30:00Z",
"updated_at": "2026-05-12T09:00:00Z"
}
],
"pagination": { "page": 1, "per_page": 50, "total": 84, "has_more": true }
}Fetch a single student by id, with the list of guardians attached. 404 if the id doesn't exist or the student belongs to a centre your key can't see.
Scope: read:students
curl -H "Authorization: Bearer $NP_KEY" \
https://app.ninepebbles.com/api/v1/students/s2abc...// Node
const res = await fetch(
`https://app.ninepebbles.com/api/v1/students/${id}`,
{ headers: { 'Authorization': `Bearer ${process.env.NP_KEY}` } }
);
if (res.status === 404) { /* not found */ }
const student = await res.json();# Python
r = requests.get(
f'https://app.ninepebbles.com/api/v1/students/{student_id}',
headers={'Authorization': f"Bearer {os.environ['NP_KEY']}"},
)
r.raise_for_status()
student = r.json(){
"id": "s2...",
"full_name": "Ava Carter",
"first_name": "Ava",
"last_name": "Carter",
"date_of_birth": "2018-03-12",
"centre_id": "c8...",
"status": "active",
"year_group": "Year 2",
"guardians": [
{ "id": "g4...", "full_name": "Lily Carter", "relationship": "mother" }
],
"created_at": "2025-09-04T10:30:00Z",
"updated_at": "2026-05-12T09:00:00Z"
}List guardians (parents/carers) with contact details. The relationship between a guardian and a student lives on the student detail endpoint — this endpoint returns guardian rows unattached.
Scope: read:guardians
Query parameters:
page, per_pagecentre_id — restrict to a single centreupdated_since — ISO-8601curl -H "Authorization: Bearer $NP_KEY" \
"https://app.ninepebbles.com/api/v1/guardians?updated_since=2026-05-01T00:00:00Z"// Node
const res = await fetch(
'https://app.ninepebbles.com/api/v1/guardians?updated_since=2026-05-01T00:00:00Z',
{ headers: { 'Authorization': `Bearer ${process.env.NP_KEY}` } }
);
const { data } = await res.json();# Python
r = requests.get(
'https://app.ninepebbles.com/api/v1/guardians',
headers={'Authorization': f"Bearer {os.environ['NP_KEY']}"},
params={'updated_since': '2026-05-01T00:00:00Z'},
)
guardians = r.json()['data']{
"data": [
{
"id": "g4...",
"full_name": "Lily Carter",
"email": "[email protected]",
"phone": "+44 7700 900000",
"centre_id": "c8...",
"created_at": "2025-09-04T10:30:00Z",
"updated_at": "2026-05-12T09:00:00Z"
}
],
"pagination": { "page": 1, "per_page": 50, "total": 64, "has_more": true }
}List invoices. The most common endpoint for accounting-system integrations: filter by status=paid and updated_since to pull newly-paid invoices on a cron.
Scope: read:invoices
Query parameters:
page, per_pagestatus — draft | sent | paid | partial | overdue | void | all (default all)centre_id — restrict to a single centreissued_since / issued_before — ISO dates on the invoice issued_atupdated_since — ISO-8601 on updated_atcurl -H "Authorization: Bearer $NP_KEY" \
"https://app.ninepebbles.com/api/v1/invoices?status=paid&updated_since=2026-05-17T00:00:00Z"// Node — polling cron, walks pagination
let page = 1;
const all = [];
while (true) {
const res = await fetch(
`https://app.ninepebbles.com/api/v1/invoices?status=paid&updated_since=${since}&page=${page}`,
{ headers: { 'Authorization': `Bearer ${process.env.NP_KEY}` } },
);
const { data, pagination } = await res.json();
all.push(...data);
if (!pagination.has_more) break;
page += 1;
}# Python
page = 1
all_invoices = []
while True:
r = requests.get(
'https://app.ninepebbles.com/api/v1/invoices',
headers={'Authorization': f"Bearer {os.environ['NP_KEY']}"},
params={'status': 'paid', 'updated_since': since, 'page': page},
)
body = r.json()
all_invoices.extend(body['data'])
if not body['pagination']['has_more']: break
page += 1{
"data": [
{
"id": "9a1f...",
"invoice_number": "INV-2026-0123",
"status": "paid",
"centre_id": "c8...",
"student_id": "s2...",
"guardian_id": "g4...",
"issued_at": "2026-05-01",
"due_date": "2026-05-15",
"total_amount": 120.00,
"amount_paid": 120.00,
"currency": "GBP",
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-05-10T14:21:00Z"
}
],
"pagination": { "page": 1, "per_page": 50, "total": 138, "has_more": true }
}Fetch a single invoice with line_items and payments embedded. Use this after polling the list endpoint to grab full detail for invoices you haven't seen before.
Scope: read:invoices
curl -H "Authorization: Bearer $NP_KEY" \
https://app.ninepebbles.com/api/v1/invoices/9a1f...// Node
const res = await fetch(
`https://app.ninepebbles.com/api/v1/invoices/${id}`,
{ headers: { 'Authorization': `Bearer ${process.env.NP_KEY}` } }
);
const invoice = await res.json();# Python
r = requests.get(
f'https://app.ninepebbles.com/api/v1/invoices/{invoice_id}',
headers={'Authorization': f"Bearer {os.environ['NP_KEY']}"},
)
r.raise_for_status()
invoice = r.json(){
"id": "9a1f...",
"invoice_number": "INV-2026-0123",
"status": "paid",
"centre_id": "c8...",
"student_id": "s2...",
"guardian_id": "g4...",
"issued_at": "2026-05-01",
"due_date": "2026-05-15",
"total_amount": 120.00,
"amount_paid": 120.00,
"currency": "GBP",
"line_items": [
{ "description": "Tuition — May", "quantity": 1, "unit_price": 120.00, "amount": 120.00 }
],
"payments": [
{ "id": "p1...", "amount": 120.00, "method": "stripe", "paid_at": "2026-05-10T14:20:00Z" }
],
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-05-10T14:21:00Z"
}List attendance events (check-in/check-out per student per session). Each row is a single attendance record; multiple rows per student per day if they're checked in and out more than once.
Scope: read:attendance
Query parameters:
page, per_pagecentre_id — restrict to a single centrestudent_id — restrict to a single studentdate_from / date_to — ISO dates on the attendance dateupdated_since — ISO-8601curl -H "Authorization: Bearer $NP_KEY" \
"https://app.ninepebbles.com/api/v1/attendance?date_from=2026-05-01&date_to=2026-05-31"// Node
const res = await fetch(
'https://app.ninepebbles.com/api/v1/attendance?date_from=2026-05-01&date_to=2026-05-31',
{ headers: { 'Authorization': `Bearer ${process.env.NP_KEY}` } }
);
const { data } = await res.json();# Python
r = requests.get(
'https://app.ninepebbles.com/api/v1/attendance',
headers={'Authorization': f"Bearer {os.environ['NP_KEY']}"},
params={'date_from': '2026-05-01', 'date_to': '2026-05-31'},
)
events = r.json()['data']{
"data": [
{
"id": "a9...",
"student_id": "s2...",
"centre_id": "c8...",
"class_id": "k1...",
"status": "present",
"checked_in_at": "2026-05-15T09:01:23Z",
"checked_out_at": "2026-05-15T15:30:01Z",
"date": "2026-05-15",
"created_at": "2026-05-15T09:01:23Z"
}
],
"pagination": { "page": 1, "per_page": 50, "total": 412, "has_more": true }
}A full OpenAPI 3.0 spec is served at /api/v1/openapi.json. Use it to generate typed clients (openapi-typescript, openapi-generator), render docs (Redoc, Swagger UI, Stoplight), or drive contract tests. The spec is authoritative when this page lags behind reality.
For server-to-server event notifications instead of polling, see Webhooks. Webhook subscriptions are managed in the same place as API keys — Settings → Webhooks.
Questions about integrating? Get in touch via the in-app Help & support form (signed-in users), or email [email protected].
Found a bug in the API or these docs? Same channels — we triage and reply on business days.