For developers

Ecolia School API

Connect your school’s other programs to Ecolia: read students, classes, the timetable, attendance and published grades over a simple REST API, and get webhooks when something changes. Read only, one school per key.

A school admin creates keys and webhooks in Settings → API & connections.

Authentication

Every request carries an API key in the Authorization header: Authorization: Bearer eco_xxxxxxxx_… . A school admin creates the key and chooses what it may read (its scopes). The key is shown only once; Ecolia keeps only a hash of it.

A key reads only its own school. It can be given an end date and revoked at any time — it stops working at once. Keep keys on your server, never in a web page or an app.

Base address

All endpoints are under /api/v1 of your Ecolia address. Answers are JSON (UTF-8). Dates are YYYY-MM-DD, times of day HH:MM in the school’s time zone, timestamps ISO 8601 in UTC. Ids are stable UUIDs. https://ecolia.eu/api/v1

Endpoints

All endpoints are GET. Unknown parameters are refused, so typos show up at once.

GET /api/v1

Which school the key belongs to and its scopes.

Scope
—
Parameters
none

GET /api/v1/students

Current students: id, class, level, names, student number.

Scope
read:students
Parameters
  • class_id — only this class
  • include_left — true: also students who left
  • limit — 1–200, default 50
  • cursor — next page

GET /api/v1/classes

Classes of the current school year, with their year and class teacher id.

Scope
read:classes
Parameters
  • school_year_id — a given year
  • all_years — true: every year
  • limit — 1–200
  • cursor — next page

GET /api/v1/timetable

Weekly lessons: weekday (1 = Monday … 7 = Sunday), start, end, subject, teacher id, room.

Scope
read:timetable
Parameters
  • class_id — only this class
  • limit — 1–200
  • cursor — next page

GET /api/v1/attendance

Absences and late arrivals: status absent or late, minutes late, excused, reported by a parent.

Scope
read:attendance
Parameters
  • from, to — required, at most 366 days
  • class_id — only this class
  • student_id — only this student
  • limit — 1–200
  • cursor — next page

GET /api/v1/grades

Published grades only (what parents see): test, subject, date, term, score, maximum, weight.

Scope
read:grades
Parameters
  • from, to — dates of the tests
  • term — 1–4
  • class_id — only this class
  • student_id — only this student
  • limit — 1–200
  • cursor — next page

Examples

curl
curl https://ecolia.eu/api/v1/students?limit=200 \
  -H "Authorization: Bearer $ECOLIA_KEY"

# next page
curl "https://ecolia.eu/api/v1/students?limit=200&cursor=NEXT_CURSOR" \
  -H "Authorization: Bearer $ECOLIA_KEY"

# absences of October
curl "https://ecolia.eu/api/v1/attendance?from=2026-10-01&to=2026-10-31" \
  -H "Authorization: Bearer $ECOLIA_KEY"
200 OK · application/json
{
  "data": [
    { "id": "6b1f…", "class_id": "a3c2…", "level_id": null, "first_name": "Sami",
      "middle_name": null, "last_name": "Example", "last_initial": "E",
      "student_number": "2026-014", "left_at": null, "created_at": "2026-09-01T07:12:44.000Z" }
  ],
  "next_cursor": "NmIxZi4uLg"
}

Pages

Lists answer { "data": [ … ], "next_cursor": "…" }. While next_cursor is not null, ask again with cursor=<next_cursor> (and the same filters) to get the next page.

The order is stable (by id, attendance by date then id), so no row is skipped or repeated between pages.

Errors

Errors answer { "error": { "code": "…", "message": "…" } } with a matching HTTP status.

StatusCodeMeaning
400invalid_parameterA parameter is missing, unknown or not valid.
401unauthorizedNo key, an unknown key, or a revoked or expired one.
403insufficient_scopeThe key may not read this.
403module_offThe school does not use this part of Ecolia.
404not_foundNo such endpoint.
429rate_limitedToo many requests; wait for the time in Retry-After.
500server_errorSomething went wrong on our side; try again later.

Rate limits

Each key may make 60 requests per minute and 10,000 per day. Above that, the answer is 429 with a Retry-After header. Ask for 200 rows per page to need fewer requests.

Webhooks

A school admin registers an https address and chooses events. When one happens, Ecolia sends a POST with a JSON body to that address. Private, internal and loopback addresses are refused.

The body holds only ids and a few fields — never names, notes or health data. Read the details with the API.

Events

student.created
A student was added (student_id, class_id, level_id).
student.updated
A student’s name, class, level, number or leaving date changed.
attendance.recorded
An absence or late arrival was recorded or changed (attendance_id, student_id, date, status, excused).
grades.published
A test’s grades were published to parents (assessment_id, class_id, term, date).
ping
Sent by the “Send a test” button.
POST · application/json
{
  "id": "0d8e…",
  "type": "attendance.recorded",
  "school_id": "5f20…",
  "created_at": "2026-10-31T08:15:02.120Z",
  "data": { "attendance_id": "91ab…", "student_id": "6b1f…", "date": "2026-10-31", "status": "absent", "excused": false }
}

Headers

Ecolia-Webhook-Id
the event id (the same for every retry: use it to ignore duplicates)
Ecolia-Event
the event type
Ecolia-Timestamp
when it was sent, in seconds since 1970 (UTC)
Ecolia-Signature
v1= followed by the hex HMAC-SHA256 of "<timestamp>.<body>" with your signing secret
Ecolia-Attempt
1 for the first try, then 2, 3 …

Answers and retries

Answer with any 2xx status within 10 seconds. Otherwise Ecolia tries again after 1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours (7 attempts in all). Redirects are not followed.

After 15 failed attempts in a row the webhook is turned off; an admin can turn it back on. The last 50 deliveries are shown in the settings.

Checking the signature

The signing secret (whsec_…) is shown once when the webhook is created. Compute the HMAC over the raw body exactly as received, compare in constant time, and refuse timestamps older than 5 minutes (against replays).

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

// body: the raw request body as a string (before JSON.parse)
export function isFromEcolia(secret, body, signature, timestamp) {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const expected = 'v1=' + createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(signature ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: app.post('/ecolia', express.text({ type: 'application/json' }), (req, res) => {
//   if (!isFromEcolia(process.env.ECOLIA_WEBHOOK_SECRET, req.body,
//       req.get('Ecolia-Signature'), req.get('Ecolia-Timestamp'))) return res.sendStatus(400);
//   const event = JSON.parse(req.body); /* … */ res.sendStatus(204);
// });
Python
import hashlib, hmac, time

def is_from_ecolia(secret: str, body: bytes, signature: str, timestamp: str) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

# Flask:
# @app.post("/ecolia")
# def ecolia():
#     if not is_from_ecolia(SECRET, request.get_data(), request.headers.get("Ecolia-Signature"),
#                           request.headers.get("Ecolia-Timestamp")):
#         abort(400)
#     event = request.get_json(); ...; return "", 204

Data protection

The API is read-only. Health information, support plans, private notes, parents’ notes on absences and grade comments are never returned. Every key and webhook change is written to the school’s activity log (ids only).

The school remains the controller of its data: share keys only with processors it has a contract with.