OptiCRM Integration
Cross-service auth, multi-tenant URL routing, the /api/lms/* surface
OptiLearn delegates anything user-related to OptiCRM. Names, emails, parent links, classes, sections, fee status, attendance — none of it lives in OptiLearn's database. The integration is a thin HTTP client (src/lib/opticrm-api.ts) calling /api/lms/* endpoints on the OptiCRM side, plus an outbound completion push.
Authentication
Two secrets:
| Variable | Used for |
|---|---|
OPTICRM_API_KEY | Bearer token on every /api/lms/* call. Must match the value OptiCRM is configured to accept. |
NEXTAUTH_SECRET | JWT signing — must match OptiCRM exactly. Drives shared SSO via cookie on .opticrm.app. |
Per-request headers:
Authorization: Bearer <OPTICRM_API_KEY>
x-institution-slug: <slug>
Content-Type: application/json
The slug header is belt-and-braces — OptiCRM's subdomain routing already resolves the tenant from the URL.
Multi-tenant URL routing
OptiCRM is multi-tenant via subdomain. Every institution lives at <slug>.opticrm.app; the apex opticrm.app returns 404. So every cross-service call must be routed to the tenant's own subdomain.
OPTICRM_BASE_DOMAIN defines the parent domain. The client builds:
https://<institutionSlug>.<OPTICRM_BASE_DOMAIN><path>
Defaults and overrides:
| Variable | Default | Notes |
|---|---|---|
OPTICRM_BASE_DOMAIN | opticrm.app | Override for staging (opticrm-staging.app) or local Docker (opticrm.local) |
OPTICRM_PROTOCOL | https | Set to http for local Docker |
OPTICRM_API_URL | — | Legacy single-tenant — peeled apart at boot for backward-compat |
Calling OptiCRM without an institutionSlug will hit a non-existent host. The client throws if OPTICRM_API_KEY is missing; everything else assumes the slug is present in the JWT.
Fetch helpers
Three helpers live in src/lib/opticrm-api.ts:
lmsFetch<T>(path, slug, opts)
Authed /api/lms/* calls. Unwraps the { data, error } envelope and throws on any non-2xx. Defaults to Next.js revalidate=300 (5 min) — passing cache: "no-store" opts out for fresh-data paths.
publicFetch<T>(path, slug, opts)
Unauthed /api/portal/* (institution lookup). Same per-tenant subdomain rule, no envelope unwrap.
getOptional* variants
Wrap the throwing helpers and return null on failure. Use these in render paths so one missing name doesn't blow up an entire screen:
const contact = await getOptionalContact(id, institutionSlug);
const name = contact?.firstName ?? "Student";
The 9 /api/lms/* endpoints
OptiCRM exposes these for OptiLearn to consume. Path, cache, and response sketch:
GET /api/lms/contact/[id]
Generic Contact lookup. Cache revalidate=300.
{ "data": { "id", "firstName", "lastName", "email", "contactType", "status" } }
GET /api/lms/student/[id]
Student lookup keyed by Contact id. Includes currentEnrolment (class + section) used by class-targeting. Cache revalidate=300.
{ "data": { "id", "contactId", "firstName", "lastName", "studentCode",
"currentEnrolment": { "classId", "className", "sectionId", "sectionName" } } }
GET /api/lms/student/[id]/parents
Every parent Contact linked to this student via ContactStudentLink. Cache no-store.
{ "data": [ { "parentContactId", "email", "name", "relation", "mobile", "isPrimary" } ] }
GET /api/lms/user/[id]
Staff User lookup. Cache revalidate=300.
{ "data": { "id", "firstName", "lastName", "email", "role" } }
GET /api/lms/classes?yearId=...
Class catalogue with sections. Cache revalidate=600 (class structure changes rarely).
{ "data": [ { "id", "name", "year", "sections": [ { "id", "name" } ] } ] }
GET /api/lms/sections/[id]/students
Live roster of a section. Cache no-store — stale rosters cause wrong access.
{ "data": [ { "studentContactId", "name", "rollNumber" } ] }
GET /api/lms/students/[id]/fee-status
Track D fee status. Cache no-store.
{ "data": { "status": "PAID" | "PARTIAL" | "OVERDUE", "outstanding": 12500, "dueDate": "..." } }
GET /api/lms/students/[id]/attendance?from=&to=
Attendance feed for parent dashboards. Cache revalidate=300.
GET /api/lms/parent/[id]/children
Children linked to a parent. Cache no-store — newly added children must appear immediately.
{ "data": [ { "studentId", "contactId", "name", "email", "studentCode",
"class", "section", "photoUrl", "status", "relation", "isPrimary" } ] }
Outbound — pushCompletion
When a student completes a course, OptiLearn pushes an event to OptiCRM:
await pushCompletion(
{
studentContactId,
courseId,
courseTitle,
completedAt,
finalScore,
certificateNumber,
},
institutionSlug,
);
POST <slug>.opticrm.app/api/lms/completion with the same Bearer + slug headers. OptiCRM uses this to update its own academic records and surface the completion in the student's CRM profile.
Caching strategy
| Pattern | Cache | Rationale |
|---|---|---|
| Names + class metadata | revalidate=300 (5 min) | Rarely change, hot path on every leaderboard / forum render |
| Class catalogue | revalidate=600 (10 min) | Class structure changes rarely |
| Fee status | no-store | Fees clear in seconds; stale data is a support call |
| Section roster | no-store | Stale roster = wrong access |
| Parent children | no-store | Newly linked children must appear immediately |
| Parent lookups | no-store | Same — new parent must see grades immediately |
Failure handling
The throwing variants log to console with [opticrm] prefix and bubble. The optional variants swallow and return null. Routes that absolutely need the data should use the throwing variant inside a try/catch and return a friendly error. Routes whose UI can degrade (a missing name shows "User abc123") should use the optional variant.
The fee-gating helper specifically degrades open when OptiCRM is unreachable — better to let an unpaid student through for an hour than to brick a school during a transient outage.
Related
- Architecture Overview — overall data flow
- Parent Portal — admin-facing setup
- Fee Gating — Track D operational notes