The organization API
Gretlab’s web app and other systems use the same API, at /api/v1. This guide covers what another system needs:
- a key to call an organization’s API;
- the endpoints it’s most likely to want;
- webhooks, which tell it what changed.
Keys and webhooks are under Manage › Integrations. Only people who manage the organization’s settings across the whole organization see that page.
API keys
- One system, one key. Make a key for each system that connects, so you can revoke one without the others. A key:
- belongs to one organization, and only works on its addresses (
/api/v1/orgs/<slug>/…); - acts as the person who made it, but only with the permissions chosen for it, and only those that person holds across the organization;
- is shown once, when made. Gretlab keeps only a fingerprint (SHA-256), so a lost key can’t be recovered: make a new one;
- can be revoked at any time, and stops working at once;
- appears in the activity log by name, for every change it makes. Its maker’s name is there too (“Name
, API key “SIS”“).
- belongs to one organization, and only works on its addresses (
- Sending it. Every request carries it as a bearer token:
GET /api/v1/orgs/greenwood/members?page=1Authorization: Bearer glk_3fa9c01b2e_…- What a key can’t do:
- use the platform’s addresses, anyone’s own account addresses (
/me), or another organization’s; - make or change keys and webhooks, which needs a signed-in person.
- use the platform’s addresses, anyone’s own account addresses (
- Answers:
| Status | Means |
|---|---|
401 invalid_api_key |
Not a key, or it was revoked. |
| 403 | The key may not do this. |
| 404 | Not found, or another organization’s. |
Errors
Every error is JSON:
{ "error": { "code": "forbidden", "message": "This needs the \"See members\" permission." } }An unexpected failure is a 500 with a short ref. Quote it to the people who run Gretlab: the same ref is in the server log.
Endpoints to start with
All under /api/v1/orgs/<slug>. Each needs the permission in brackets.
| Method and path | What |
|---|---|
GET /members?page=1&kind=learner |
Members, 100 a page, with counts by kind (staff, learner, parent, other) [See members] |
GET /members |
Everyone at once [See members] |
GET /groups |
Classes, sections, teams and how they nest [See members] |
GET /students?page=1 |
Student records with admission numbers [See student records] |
GET /attendance/overview?month=2026-11 |
Every class’s month: days taken, share present, how many below the minimum [See attendance] |
GET /attendance/month?groupId=…&month=2026-11 |
One class’s month, mark by mark [See attendance] |
GET /holidays |
Days the organization is closed (anyone in it) |
GET /reports?part=figures |
The figures: summary, assignments, teaching [See reports] |
GET /reports?page=1&inactive=true |
Students’ activity and assignment results, 100 a page [See reports] |
GET /reports |
The whole report at once [See reports] |
GET /assignments/<id>/gradebook?format=csv |
A gradebook as CSV [See grades] |
Paged lists
Lists that can grow long take ?page= (from 1), with ?q= to search and ?perPage= (up to 200) to change the page size. A paged answer always has:
{ "items": […], "total": 180, "page": 1, "pages": 2, "perPage": 100 }A page past the end answers with the last page. Some lists also repeat items under an older name (members, links, students). Without ?page, they answer the whole list. A page or perPage that isn’t a number in range is a 400 invalid_query.
The same paths change things too (POST, PUT, PATCH, DELETE), with the matching permissions. A key may change whatever its permissions allow.
Webhooks
- What gets sent. A webhook posts each change you choose to an address of yours, as it happens. Changes are what the activity log records:
leave.decide,member.add,exam.publish,holiday.addand so on;- “Everything” (
*), or whole areas (leave.*,member.*).
- The address. It must be
httpsand public, not one on the server’s own machine or a private network.
A delivery
POST https://example.com/hooks/gretlabContent-Type: application/jsonGretlab-Event: leave.decideGretlab-Delivery: 6f1c…Gretlab-Signature: t=1794631200,v1=9e2b…
{ "id": "6f1c…", "event": "leave.decide", "at": "2026-11-14T10:00:00.000Z", "organization": "c41d…", "summary": "Approved Imran Naidu's leave (sick, 1 day)", "actor": { "id": "a7e0…", "label": "Shreya Shah <shreya.shah@…>" }, "detail": { "…": "what the activity log has" }}- Answering. Answer with any 2xx within 10 seconds; do slow work afterwards.
- Retries. Anything else, or no answer, is tried again after 1, 5 and 15 minutes, then 1, 3, 8 and 24 hours. After eight tries Gretlab gives up and shows “Gave up”.
- Duplicates. A delivery can arrive more than once. Use
Gretlab-Delivery(the same asid) to skip one you’ve already handled. - Checking the setup. “Send a test” on the Integrations page sends an
event: "ping"delivery.
Checking the signature
- The signing secret. Each webhook has one (
whsec_…), shown once when it’s added. - The signature.
v1is the HMAC-SHA256, in hex, of<t>.<raw body>, keyed with the secret. Recompute it and compare in constant time. - The time. Refuse a
tmore than five minutes from now.
import { createHmac, timingSafeEqual } from "node:crypto"
function fromGretlab(secret, rawBody, header) { const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("="))) if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest() const given = Buffer.from(v1 ?? "", "hex") return given.length === expected.length && timingSafeEqual(given, expected)}