DocsDeveloper

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:

VariableUsed for
OPTICRM_API_KEYBearer token on every /api/lms/* call. Must match the value OptiCRM is configured to accept.
NEXTAUTH_SECRETJWT 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:

VariableDefaultNotes
OPTICRM_BASE_DOMAINopticrm.appOverride for staging (opticrm-staging.app) or local Docker (opticrm.local)
OPTICRM_PROTOCOLhttpsSet to http for local Docker
OPTICRM_API_URLLegacy single-tenant — peeled apart at boot for backward-compat
Warning

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

PatternCacheRationale
Names + class metadatarevalidate=300 (5 min)Rarely change, hot path on every leaderboard / forum render
Class cataloguerevalidate=600 (10 min)Class structure changes rarely
Fee statusno-storeFees clear in seconds; stale data is a support call
Section rosterno-storeStale roster = wrong access
Parent childrenno-storeNewly linked children must appear immediately
Parent lookupsno-storeSame — 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.