Skip to main content

REST API reference

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).


Overview

Authentication

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).

Scopes

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.

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.

Pagination

List endpoints are page-based:

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

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:

Rate limits

No 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.


Endpoint reference

GET /health

Liveness check. No scope required; the only endpoint that accepts an unauthenticated request.

Example

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"

Response

{
  "status": "ok",
  "service": "ninepebbles-api",
  "api_version": "v1",
  "timestamp": "2026-05-17T20:30:00Z"
}

GET /students

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:

Example

curl -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']

Response

{
  "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 }
}

GET /students/{id}

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

Example

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()

Response

{
  "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"
}

GET /guardians

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:

Example

curl -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']

Response

{
  "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 }
}

GET /invoices

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:

Example: poll for newly-paid invoices

curl -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

Response

{
  "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 }
}

GET /invoices/{id}

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

Example

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()

Response

{
  "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"
}

GET /attendance

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:

Example

curl -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']

Response

{
  "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 }
}

OpenAPI specification

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.

Webhooks

For server-to-server event notifications instead of polling, see Webhooks. Webhook subscriptions are managed in the same place as API keys — Settings → Webhooks.

Support

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.