Institution administrators manage integrations from the console under Security & IT → Integrations. Everything on that page is audited under integration.* and reaches the institution's security alert subscribers.
Building the receiving system? Use the Institution API v1 and webhook reference for endpoint schemas, pagination, errors, event payloads, signature verification, and retry behavior.
API keys
A key is scoped to the institution it was created in and to the scopes you pick. It is shown once; Folio stores only a hash. Rotate a key to mint a replacement with the same scopes and revoke the old one in the same step.
| Scope | Grants |
|---|---|
roster.read | Every admitted student: id, name, email, status, track, directory-restriction flag |
enrollments.read | Every course membership (students and teaching staff) with status and dates |
grades.read | Adds final_grade to enrollment rows |
audit.read | The institution audit trail, for a SIEM or warehouse |
Send the key as a bearer token:
curl -H "Authorization: Bearer fk_inst_..." \
"https://www.usefolio.co/api/v1/institutions/<institution-id>/roster?limit=200"
Endpoints (all GET, JSON, limit up to 500):
/api/v1/institutions/{id}/roster?limit=&offset=→{ data: [...], next_offset }/api/v1/institutions/{id}/enrollments?term=&limit=&offset=→{ data: [...], next_offset }/api/v1/institutions/{id}/audit?since=<ISO>&limit=→{ data: [...], next_since }(oldest first; passnext_sinceback to continue)
Each key is limited to 1,000 requests per hour. A 401 means the key is unknown or revoked; a 403 means it lacks the scope; a 429 means the hour's budget is spent.
Webhooks
Register an https URL on a public host and pick the events you want. Folio tries each delivery once immediately, then retries failures after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the sixth failure the delivery is marked dead; you can retry it by hand from the delivery log.
| Event | Fires when |
|---|---|
enrollment.changed | Staff change a course membership's status or a student leaves a course |
grade.posted | A final grade is posted on a course membership |
certificate.issued | A course-completion certificate is issued to a student |
request.submitted | A student submits an institution request (waiver, appeal, form) |
hold.placed | The records office places a hold on a student |
A delivery is a POST with a JSON body:
{
"id": "<delivery id>",
"event": "grade.posted",
"created_at": "2026-09-03T08:12:00.000Z",
"institution_id": "<institution id>",
"data": { "course_id": "…", "user_id": "…", "final_grade": "A-" }
}
Headers: Folio-Event, Folio-Delivery-Id, and Folio-Signature: t=<unix seconds>,v1=<hex>.
Verifying the signature
The signature is HMAC-SHA256 over "<t>.<raw body>" with the webhook's secret. Reject anything older than five minutes and compare in constant time:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('=')))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
Respond with any 2xx within 8 seconds. Deliveries are at-least-once: use Folio-Delivery-Id to ignore a repeat.
The secret is shown once when the webhook is created and again only if you rotate it. Send test posts a signed ping delivery so you can confirm your receiver before real events flow.
CRM sync
Webhooks give you the raw event feed. If what you want instead is the same lifecycle as CRM contacts — one record per person, showing where they came from and how far they got — connect a CRM on the same Integrations page. HubSpot is supported today.
Create a private app in HubSpot with contact read and write scopes and paste its pat-… token. Folio verifies it before storing it, keeps it encrypted, and never shows it again; replace it any time without re-creating the connection.
Before you connect, create these contact properties in HubSpot:
| Property | Holds |
|---|---|
folio_institution | The institution's name |
folio_course_code | The course the last lifecycle event was about |
folio_status | lead, enrolled, completed or dropped |
folio_signup_source | The channel that first brought this person to Folio — the UTM source when the link was tagged, otherwise the referring site or landing page |
folio_last_synced_at | When Folio last wrote to the contact |
Contacts are matched by email address; a person Folio holds no address for is skipped. Folio writes only the properties above and the contact's first and last name — never your own lifecycle stages, contact owners or deals.
Stages move like this:
| Stage | Set when |
|---|---|
lead | Someone joins the institution at sign-in — claiming an invitation, or admitted by your email domain |
enrolled | A course membership becomes active |
completed | A membership is marked completed, or a completion certificate is issued |
dropped | A membership is dropped or withdrawn |
Pushes are queued and sent by a sweep that runs every 15 minutes, with the same retry ladder as webhooks; Sync now drains your queue immediately. A rejected token stops retrying at once rather than burning through the ladder, and shows on the connection so you can replace it. Connecting, pausing, re-keying and disconnecting are all audited under integration.crm_sync.