Integrating with OnEdHub
OnEdHub exposes four read-mostly APIs:
- Vigilo Core API — the relational core of Vigilo (persons, students, employees, schedules, …)
- OneRoster Norwegian Profile v1.0 — rostering (users, classes, enrollments, terms, orgs)
- OneRoster Gradebook v1.2 — line items (assignments) and results (grades)
- OneRoster Absence v1.0 — student absences
This page is a guide for building integrations on top of them. It is opinionated. If you read only one sentence:
Pull each API's data into your own store and join it there. Don't try to assemble cross-API views at request time.
Everything below is a consequence of that.
Two kinds of integration
Simple, single-entity integrations. "I want all students at this school in my own system." One API, one resource, paginate, done.
Cross-entity integrations. "Show this student's absences alongside the lessons they missed and the grades they got that day." Multiple APIs, multiple resources, references that have to be resolved in sequence.
The simple case is genuinely simple. The complex case is where most integration projects underestimate the work.
Why complex cases are harder than they look
Two facts about our APIs collide:
Entities are normalized. Resources reference each other through
GUIDRef—{sourcedId, type, href}— not by embedding. An absence hasclassesAffected: GUIDRef, not the class itself. To "link an absence to a schedule" you must traverse: absence → class → schedule.Entities follow a spec, not your domain model. The OneRoster Norwegian Profile dictates the shape —
users,classes,enrollments,terms. Your product probably wants something else — "absence event with student name, lesson title, teacher, and lesson time" — a single row. The mapping happens in your code.
Doing the traversal at request time — fan-out HTTP calls per absence — is technically possible and operationally bad. It is slow, fragile under retry, hard to cache, and makes incremental sync impossible. The pattern that scales is to mirror the data locally and do the joins there.
The recommended shape
Three layers in your own system:
- Raw mirror. One store per OnEdHub resource. Same field names. Append-only — keep deleted and replaced records.
- Modeled view. Joins across the raw mirror. Resolves
GUIDRefs to local foreign keys, collapsesreplaces/replacedBychains, applies your domain mapping (a "lesson", an "assessment", a "guardian relationship"). - Business view. Aggregations and reports built on the modeled view.
We deliver the raw layer. The modeled and business layers are yours. We won't pre-join entities for you because the right join depends on what your product is.
We are not prescriptive about technology — flat files, Postgres, BigQuery, a warehouse, an in-process cache are all reasonable. The principle is the only thing we insist on.
A worked example: linking an absence to its schedule
The motivating case from the introduction. A student is marked absent. You want to know which scheduled lesson they missed.
Setup:
- Base host:
https://api.staging.onedhub.io/vigilo.no - Bearer token already obtained — see Authentication.
Step 1 — Pull absences
curl -H "Authorization: Bearer $TOKEN" \
"https://api.staging.onedhub.io/vigilo.no/ims/oneroster/absences/v1p0/absences/detail?limit=1000&offset=0"For an intra-day delta — a refresh after your last pull the same day — you can filter by dateLastModified:
curl -H "Authorization: Bearer $TOKEN" \
"https://api.staging.onedhub.io/vigilo.no/ims/oneroster/absences/v1p0/absences/detail?filter=dateLastModified>'2026-04-26T12:00:00Z'&limit=1000"Read Pagination & sync cadence before relying on this — dateLastModified is not a reliable long-window high-water mark, and your sync should be designed for a daily full re-pull regardless.
A representative response (one record):
{
"sourcedId": "abs-9821",
"status": "active",
"dateLastModified": "2026-04-26T08:14:02Z",
"fromTime": "2026-04-26T08:00:00Z",
"toTime": "2026-04-26T09:30:00Z",
"absenceDurationUnit": "Minutes",
"absenceDurationValue": 90,
"student": { "sourcedId": "user-441", "type": "user", "href": "..." },
"school": [{ "sourcedId": "org-12", "type": "org", "href": "..." }],
"term": { "sourcedId": "term-2026-spring", "type": "academicSession", "href": "..." },
"classesAffected": { "sourcedId": "class-7B-math", "type": "class", "href": "..." },
"replaces": null,
"replacedBy": null
}Step 2 — Resolve the class
If classesAffected is set, you have a direct reference:
curl -H "Authorization: Bearer $TOKEN" \
"https://api.staging.onedhub.io/vigilo.no/ims/oneroster/rostering/v1p2/classes/class-7B-math"But classesAffected is 0..1. When it is missing — typically full-day or out-of-class absences — you cannot link directly. You have student and [fromTime, toTime]; resolve the class via the schedule. Step 3 covers that path.
Step 3 — Pull the schedule
The schedule lives in the Vigilo Core API:
curl -H "Authorization: Bearer $TOKEN" \
"https://api.staging.onedhub.io/vigilo.no/api/core/schedules?student=user-441&from=2026-04-26&to=2026-04-26"(Exact query parameters per the Core API OpenAPI spec at /api/core/swagger-ui/index.html.)
You now have the lessons that fall inside the absence's window. Filter to those that overlap [fromTime, toTime].
Step 4 — Link in your store
Two ways the link can be established:
classesAffected set | How you link |
|---|---|
| Yes | absence.classesAffected.sourcedId → class.sourcedId → schedule entries for that class overlapping [fromTime, toTime] |
| No | absence.student.sourcedId → student's schedule entries overlapping [fromTime, toTime] → derive the class from each schedule entry |
This logic belongs in your modeled-view layer. Don't repeat it for every read.
Practical concerns
A short tour. Each per-API page has the detail; this is the shape of what to plan for.
Authentication & token caching
OAuth 2.0 client credentials. Tokens have a non-trivial TTL — cache them in your sync worker rather than minting one per request. Details in Authentication.
Pagination & sync cadence
All collection endpoints take limit (default 100) and offset. Page until you receive fewer rows than limit.
Use a large page size. Pagination is offset-based against Postgres, and deep offsets get progressively slower — large pages reduce both the number of round trips and the total cost of getting deep into the dataset. Start at limit=1000 and only back off if you hit timeouts.
Don't override the sort. Results are sorted by id by default, which gives a stable order across pages. Adding sort=… is rarely useful and can be slower; leave it off unless you have a concrete reason.
A note on
dateLastModified— it is not what its name suggests:
- The upstream source system (Vigilo OAS) does not consistently track per-entity change timestamps. OneDhub stamps
dateLastModifiedwhen it receives an update message from OAS.- A nightly full sync between OAS and OneDhub re-stamps
dateLastModifiedon every entity to the sync time. So any high-water mark older than the most recent nightly sync will return effectively the full dataset.dateLastModifiedworks as a true delta filter only within a single day, after the nightly sync window. The practical consequence is that your integration must be designed to absorb a full re-pull every day. Make raw-layer writes idempotent (append-only with version tracking, or upsert bysourcedId+ a content hash), so a daily full pull costs only the bandwidth, not the correctness. UsedateLastModifiedfiltering only as an optional optimisation for intra-day refreshes.
Filter syntax
Filtering is server-side. Push filters to the server rather than paginating-then-filtering-locally — it costs you and us less. The two API surfaces use different filter standards:
- OneRoster (rostering, gradebook, absence) — 1EdTech filter syntax. See OneRoster filtering.
- Vigilo Core API — OData v4. See Core API filtering.
Retry
There are no published rate limits, but you should still build for transient failure — deploys, network blips, brief upstream hiccups. Treat 5xx and connection errors as retryable, with exponential backoff and jitter. Reads are idempotent and safe to retry, but cap with a circuit breaker so a longer outage doesn't burn your worker pool.
Immutability and replaces / replacedBy
Worth its own section because it is the most common source of bugs.
AbsenceDetails is immutable. Updates create a new resource and link the old via replaces/replacedBy. Deletes set deletedTime. The original records never change in place.
What this means for your sync:
- A "current absence" is the tail of a
replacedBychain. Walk forward untilreplacedByis null. - An absence with
deletedTimeset is a tombstone — don't show it. - In your raw layer, store every version. In your modeled view, expose only the current tail.
- An incremental pull on
dateLastModifiedwill deliver replacement records; you do not need to re-fetch the entire chain. But you do need to re-resolve the tail in your modeled view when a new replacement arrives.
A naive integration that "upserts by sourcedId" will corrupt history. Append to raw; resolve current state in modeled.
GDPR & minimization
You will be storing student data, including national identification numbers and birth dates (see User metadata). Store only what your product needs. Set retention. Encrypt at rest. National ID lookup tables benefit from a separate, more restricted store. Your data processing agreement with the customer is the controlling document — this guide is not.
Checklist
Before going live:
- [ ] Raw layer mirrors each OnEdHub resource separately, append-only.
- [ ] Sync designed to absorb a daily full re-pull (raw-layer writes are idempotent).
dateLastModifiedused only for optional intra-day deltas, not as a long-window high-water mark. - [ ] Filters pushed to the server, not the client.
- [ ] Token cache shared across the sync worker.
- [ ]
replaces/replacedBychains resolved in the modeled view;deletedTimehonored. - [ ] Cross-API joins (e.g. absence ↔ schedule) live in the modeled view, not in request handlers.
- [ ] Retention and access control on student data agreed with the customer.
If your integration looks like a sequence of REST calls inside a request handler, it will not survive a thousand students. If it looks like a small data pipeline with a join layer, it will.