This is the developer contract for Folio's read-only institution API and outbound webhooks. For key and endpoint setup in the administrator console, see Institution API and webhooks.
Base URL and authentication
The API version is part of the path:
https://www.usefolio.co/api/v1/institutions/{institution_id}
Send an institution API key in the Authorization header on every request. Keys begin with fk_inst_, belong to one institution, and are shown only when created or rotated.
curl --fail-with-body \
-H "Authorization: Bearer $FOLIO_API_KEY" \
"$FOLIO_ORIGIN/api/v1/institutions/$FOLIO_INSTITUTION_ID/roster?limit=2"
The institution id in the path must be the institution that issued the key. A key for a different institution receives 401, even if it has the required scope.
Version lifecycle and compatibility
Institution API v1 is generally available, supported, and not currently deprecated. Every success and error response carries Folio-API-Version: 1. While v1 remains supported, responses do not carry Deprecation or Sunset headers.
Within v1, Folio can add endpoints, optional query parameters, and optional or nullable response fields without advance notice. Clients must ignore response fields they do not recognize. Folio can also fix defects or security issues while preserving the documented request, response, authorization, and tenant-isolation contract, and can raise a documented limit.
A change is breaking when an existing conforming client must change to preserve the same documented behavior. Examples include removing or renaming an endpoint, parameter, or response field; requiring an existing optional input; changing a field's type or documented meaning; changing an enum that is not explicitly open-ended; narrowing a scope's documented access; lowering a documented limit; or changing pagination, authentication, or error semantics. Breaking changes require a new API version and owner approval.
If Folio deprecates v1, it will publish the deprecation date and provide at least 365 days before the earliest sunset date. For that entire notice period, Folio will keep v1 operational with security and correctness fixes, keep a generally available replacement available, and publish a migration guide and lifecycle dates in this reference and the changelog. Every v1 success and error response will then carry:
| Header | Value during the deprecation window |
|---|---|
Deprecation | @<unix_seconds> for the published deprecation date |
Sunset | <HTTP-date> for the earliest retirement date |
Link | <...>; rel="deprecation"; type="text/html" linking to the migration guidance |
Folio can retire v1 only after the full notice window has elapsed, the generally available replacement and migration guide have remained available throughout it, and the owner has approved retirement. The response headers and published reference are the canonical client notifications; an email to an administrator is only an additional courtesy. Reaching the announced sunset date permits retirement but does not require Folio to switch v1 off that day.
Scope checks and institution isolation are security boundaries, not compatibility promises. Folio can tighten enforcement immediately when needed to restore the documented boundary; it will not broaden a scope or weaken tenant isolation as a v1-compatible change.
Scopes
| Scope | Required for | Data granted |
|---|---|---|
roster.read | GET /roster | Admitted-student identity, status, program and directory-restriction state |
enrollments.read | GET /enrollments | Course memberships for students and teaching staff |
grades.read | Enrollment response field | Adds final_grade to each enrollment row; it does not grant /enrollments by itself |
audit.read | GET /audit | Institution audit events for a SIEM or data warehouse |
scim.write | SCIM 2.0 provisioning | Creates, updates and deactivates institution users through the separate SCIM API; it grants no /api/v1 access |
Grant only the scopes a connector needs. grades.read is deliberately additive: a key needs both enrollments.read and grades.read to receive grades. Keep scim.write on a separate key from read-only integrations so each credential has one job.
Pagination
roster and enrollments use offset pagination. Pass the returned next_offset as the next request's offset; stop when it is null.
audit uses an exclusive time cursor. Pass the returned next_since as the next request's since; stop when it is null. Rows with created_at equal to the cursor are not returned again.
All three endpoints default to 200 rows and cap limit at 500. Responses are JSON and have a top-level data array.
Get the roster
GET /api/v1/institutions/{institution_id}/roster
Requires roster.read. Rows are ordered by admitted_at, oldest first. Institution connectors receive directory-restricted students because the institution controls its own roster; downstream public directories must honor directory_restricted.
| Query parameter | Meaning |
|---|---|
limit | Page size from 1 to 500; default 200 |
offset | Zero-based row offset; default 0 |
curl --fail-with-body \
-H "Authorization: Bearer $FOLIO_API_KEY" \
"$FOLIO_ORIGIN/api/v1/institutions/$FOLIO_INSTITUTION_ID/roster?limit=2&offset=0"
{
"data": [
{
"user_id": "7fd34ff7-7fb1-440f-a456-13bc29901133",
"student_id": "20260041",
"name": "Leila Haddad",
"email": "leila.haddad@example.edu",
"status": "active",
"program": "Computer Science",
"directory_restricted": false,
"admitted_at": "2026-08-18T09:30:00.000Z"
}
],
"next_offset": null
}
student_id, name, email, status, and program can be null. directory_restricted is always a boolean.
Get enrollments
GET /api/v1/institutions/{institution_id}/enrollments
Requires enrollments.read. Rows are ordered by joined_at, oldest first. Template courses are excluded.
| Query parameter | Meaning |
|---|---|
term | Exact course-term value; omit it to include every term |
limit | Page size from 1 to 500; default 200 |
offset | Zero-based row offset; default 0 |
curl --fail-with-body \
-H "Authorization: Bearer $FOLIO_API_KEY" \
"$FOLIO_ORIGIN/api/v1/institutions/$FOLIO_INSTITUTION_ID/enrollments?term=Fall%202026&limit=2&offset=0"
With enrollments.read and grades.read:
{
"data": [
{
"id": "26f7bf06-73e4-4a20-90f4-6613a02692e2",
"course_id": "1d7f20f9-e198-49b1-b554-f2970474028d",
"course_code": "CS-204",
"course_title": "Data Structures",
"term": "Fall 2026",
"course_status": "active",
"user_id": "7fd34ff7-7fb1-440f-a456-13bc29901133",
"role": "student",
"status": "active",
"joined_at": "2026-08-20T11:00:00.000Z",
"ended_at": null,
"final_grade": "A-"
}
],
"next_offset": null
}
Without grades.read, the final_grade property is omitted rather than returned as null. course_code, course_title, term, course_status, ended_at, and—when present—final_grade can be null.
Get the audit trail
GET /api/v1/institutions/{institution_id}/audit
Requires audit.read. Rows are ordered by created_at, oldest first, which makes the endpoint suitable for incremental ingestion.
| Query parameter | Meaning |
|---|---|
since | Valid ISO-8601 timestamp; only rows strictly newer than it are returned |
limit | Page size from 1 to 500; default 200 |
curl --fail-with-body \
-H "Authorization: Bearer $FOLIO_API_KEY" \
"$FOLIO_ORIGIN/api/v1/institutions/$FOLIO_INSTITUTION_ID/audit?since=2026-09-01T00%3A00%3A00.000Z&limit=2"
{
"data": [
{
"id": "0a4f6778-187c-4da4-b20f-b7082cd9d848",
"action": "integration.key_rotated",
"actor_user_id": "61ca5287-eafa-478f-a03b-452682b41921",
"target_type": "api_key",
"target_id": "608a1bbe-45a9-4ebd-b980-80229a6d52ed",
"summary": "Rotated API key \"Warehouse sync\"",
"created_at": "2026-09-03T08:12:00.000Z"
}
],
"next_since": null
}
target_type, target_id, and summary can be null. An absent or invalid since value currently starts from the oldest available audit event; validate stored cursors before sending them.
Errors and rate limits
JSON errors use the shape { "error": "Human-readable message" }.
| Status | Meaning |
|---|---|
401 | Missing or malformed bearer key, unknown key, revoked key, or key issued by a different institution |
403 | The key is valid for this institution but lacks the endpoint's required scope |
429 | This key has used its 1,000-request hourly budget |
500 | Folio could not read the requested institution data |
The limit is 1,000 requests per hour per API key, shared by all three endpoints. A 429 response does not currently include a Retry-After header. Back off until the next hourly window rather than retrying immediately.
{
"error": "This key lacks the audit.read scope."
}
Webhook events
Folio sends one delivery for each enabled endpoint subscribed to the event.
| Event | data payload |
|---|---|
enrollment.changed | course_id, course_code, course_title, user_id, status, self_service, actor_user_id |
grade.posted | course_id, course_code, course_title, user_id, final_grade, actor_user_id |
request.submitted | request_id, request_type_id, request_type_key, request_type_name, student_user_id |
hold.placed | hold_id, user_id, type, blocks, reason, actor_user_id |
ping | message, sent_by; emitted only by Send test |
Every delivery is an HTTP POST with Content-Type: application/json and User-Agent: Folio-Webhooks/1:
{
"id": "739eec89-48a8-489a-ae73-2ef0a82486a7",
"event": "grade.posted",
"created_at": "2026-09-03T08:12:00.000Z",
"institution_id": "32d1297a-fb8e-4558-aace-cf7feb8495b5",
"data": {
"course_id": "1d7f20f9-e198-49b1-b554-f2970474028d",
"course_code": "CS-204",
"course_title": "Data Structures",
"user_id": "7fd34ff7-7fb1-440f-a456-13bc29901133",
"final_grade": "A-",
"actor_user_id": "61ca5287-eafa-478f-a03b-452682b41921"
}
}
The request also carries:
| Header | Value |
|---|---|
Folio-Event | Event name, such as grade.posted |
Folio-Delivery-Id | Stable delivery UUID; use it as the idempotency key |
Folio-Signature | t=<unix_seconds>,v1=<lowercase_hex_hmac> |
Verify a webhook signature
Compute HMAC-SHA256 over the UTF-8 bytes of "<timestamp>.<raw request body>". Verify the signature before parsing or re-serializing JSON, reject timestamps more than five minutes away from your clock, and compare digests in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyFolioWebhook(secret, signatureHeader, rawBody, now = Date.now()) {
if (!signatureHeader) return false
const parts = Object.fromEntries(
signatureHeader.split(',').map(part => part.trim().split('='))
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp) || !parts.v1) return false
if (Math.abs(now - timestamp * 1000) > 5 * 60 * 1000) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest()
const received = Buffer.from(parts.v1, 'hex')
return received.length === expected.length && timingSafeEqual(received, expected)
}
Keep the raw body available in your framework. Calling a JSON body parser first and then signing JSON.stringify(parsedBody) can change whitespace or key order and reject a genuine delivery.
Delivery and retry semantics
Return any 2xx status within 8 seconds. Redirects and every non-2xx response count as failures. Folio records at most the first 300 characters of the response body in the institution's delivery log.
| Attempt | When it runs |
|---|---|
| 1 | Immediately when the event is emitted |
| 2 | 1 minute after the first failure |
| 3 | 5 minutes after the second failure |
| 4 | 30 minutes after the third failure |
| 5 | 2 hours after the fourth failure |
| 6 | 12 hours after the fifth failure |
After the sixth failure the delivery is marked dead. An administrator can manually retry it from the delivery log. Pausing a webhook leaves pending deliveries recorded but prevents scheduled attempts until the endpoint is enabled again.
Delivery is at least once. Store Folio-Delivery-Id before applying the event and return success for a duplicate id. Do not use the timestamp or event name as a deduplication key.
Webhook endpoints must use https, resolve to a public hostname, contain no URL credentials, and respond directly; Folio does not follow redirects.