Skip to main content

Developers

Build on the data your district already runs on.

A REST API with 558 endpoints, district-scoped API keys, scopes mapped to permissions, and an OpenAPI 3.1 spec you can import into any client generator.

Quickstart

  1. 1. Sign in to your district admin account.

    Navigate to /admin/api-keys — only roles with the api-key.manage permission see this page (district_admin has it by default).

  2. 2. Generate a key.

    Pick the scopes your integration needs — every scope corresponds 1:1 with a platform permission code (see the table below). Optionally set a rate limit, IP allowlist, and expiry. The plaintext key is shown once — copy it somewhere safe.

  3. 3. Call the API.

    curl

    curl -H "Authorization: Bearer fsis_secret_<your-key>" \
      https://omniforgesis.click/__api/v1/persons?personType=STUDENT&limit=10

    JavaScript (Node 20+)

    const res = await fetch(
      'https://omniforgesis.click/__api/v1/persons?personType=STUDENT&limit=10',
      { headers: { Authorization: `Bearer ${process.env.FORGE_API_KEY}` } },
    );
    if (!res.ok) {
      const { error } = await res.json();
      throw new Error(`${error.code}: ${error.message}`);
    }
    const { data } = await res.json();
    console.log(`fetched ${data.length} students`);
  4. 4. Handle errors.

    Every error response is a typed JSON envelope with a stable error.code field. See the table further down for the full list of API-key-specific codes.

Authentication

Two equivalent ways to pass an API key on every request:

Bearer (recommended)

Authorization: Bearer fsis_secret_<64-hex>

Custom header

X-API-Key: fsis_secret_<64-hex>

Keys are scoped to a single district by issuance. They cannot cross tenants. We store SHA-256 of the key — the plaintext is unrecoverable if you lose it. Rotate by issuing a new key and revoking the old one.

Common scopes

A scope is the permission code the API checks on a route. Pick the narrowest set your integration needs. Custom permission codes (e.g. fees.assess) are accepted as scopes too if your route requires them.

ScopeGrants
student.readList + read student records
attendance.readDaily + period attendance
attendance.writeSubmit attendance marks
grade.readSection grades + report cards
assignment.readAssignment definitions + scores
guardian.readParent/guardian records
enrollment.readRoster + active enrollments
fees.readStudent fees + payments
incident.readDiscipline + HIB incidents
health.readVisit log, medications, screenings
transportation.readBus routes + assignments

Rate limits

Each key has an optional rateLimit.requestsPerMinute ceiling set at issuance. Enforcement is per-key, fixed one-minute windows.

When you exceed the ceiling, you get:

HTTP/2 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{
  "error": {
    "code": "auth.api_key_rate_limited",
    "message": "API key exceeded 60 requests/minute.",
    "traceId": "cor_..."
  }
}

Honor the Retry-After header. There is also a baseline IP-level rate limit (600/min) applied across all unauthenticated traffic — keyed requests count against the per-key ceiling instead.

Error codes

CodeHTTPWhen
auth.api_key_malformed401Key doesn't match the fsis_<type>_<hex> shape.
auth.api_key_unknown401Key not found in the platform registry.
auth.api_key_revoked401Key has been revoked from the district admin panel.
auth.api_key_expired401Key passed its expiresAt timestamp.
auth.api_key_ip_not_allowed403Caller IP not in the key’s ipAllowlist.
auth.api_key_rate_limited429Exceeded requestsPerMinute. Retry-After header set.
auth.forbidden403Key valid but its scopes don’t cover the route’s required permission.

Webhooks

Subscribe a URL to one or more event types. We POST a signed envelope when those events fire — usually within a second of the underlying mutation.

1. Create the subscription

curl

curl -X POST https://omniforgesis.click/__api/v1/webhooks \
  -H "Authorization: Bearer fsis_secret_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/forge/webhook",
    "events": ["IncidentReported", "AttendanceMarked"],
    "description": "Reporting integration"
  }'

The signingSecret in the response is shown only once.

2. Receive a delivery

POST to your URL

POST /forge/webhook HTTP/1.1
Content-Type: application/json
User-Agent: forge-sis-webhooks/1
X-Forge-Event: IncidentReported
X-Forge-Event-Id: 47053415-9408-4fd8-9713-50972a5c95fb
X-Forge-District-Id: 0a6e77d3-d626-4b23-8b13-760eb1badec4
X-Forge-Signature: sha256=eeb89bfc7a106df5e71b4db75db12474f38791174cfd2edd1c833fbf5190381a

{
  "eventId": "47053415-9408-4fd8-9713-50972a5c95fb",
  "eventType": "IncidentReported",
  "districtId": "0a6e77d3-d626-4b23-8b13-760eb1badec4",
  "payload": { "incidentId": "...", "participants": [...] },
  "sentAt": "2026-05-21T09:18:11.338Z"
}

3. Verify the signature

Node 20+

import crypto from 'node:crypto';

export function verifyForgeSignature(rawBody, headerValue, signingSecret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', signingSecret)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(headerValue ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Use crypto.timingSafeEqual (or equivalent) to avoid timing side channels.

Retry policy

5xx and network failures are retried with exponential backoff. 4xx responses are recorded but not retried — fix the receiver before re-enabling. Past attempts are visible at /admin/webhooks (per subscription).

Event catalog

Every event you can subscribe a webhook to, with the exact payload shape your receiver will get. Each event also flows through audit + the realtime hub — your webhook delivery wraps the same body in the standard envelope{ eventId, eventType, districtId, payload, sentAt }.

AssessmentResultsImported

A batch of state assessment results was imported into the district (NJSLA / SBAC / STAAR).

Example payload

{
  "districtId": "00000000-0000-0000-0000-000000000000",
  "importId": "00000000-0000-0000-0000-000000000000",
  "adapter": "njsla",
  "filename": "string",
  "successRows": 0,
  "errorRows": 0,
  "importedByUserId": "string"
}

AssignmentCreated

A teacher created a new assignment in the gradebook.

Example payload

{
  "assignmentId": "00000000-0000-0000-0000-000000000000",
  "sectionId": "00000000-0000-0000-0000-000000000000",
  "name": "string",
  "category": "string",
  "pointsPossible": 0,
  "dueAt": "2026-05-21T12:00:00.000Z"
}

AssignmentScored

A student was assigned a score on an assignment.

Example payload

{
  "assignmentId": "00000000-0000-0000-0000-000000000000",
  "studentId": "00000000-0000-0000-0000-000000000000",
  "points": 0,
  "percent": 0,
  "submittedAt": "2026-05-21T12:00:00.000Z"
}

AttendanceMarked

Attendance for a student was recorded (daily or period).

Example payload

{
  "studentId": "00000000-0000-0000-0000-000000000000",
  "sectionId": "00000000-0000-0000-0000-000000000000",
  "period": "string",
  "date": "2026-05-21",
  "status": "present",
  "code": "string"
}

CommunicationSent

A communication was delivered (or failed to deliver).

Example payload

{
  "channel": "email",
  "recipients": [
    "string"
  ],
  "templateId": "string",
  "status": "queued"
}

CounselorAssignment.Created

A student was assigned (or re-assigned) to a counselor. Reassignments end-date the prior row first.

Example payload

{
  "assignmentId": "00000000-0000-0000-0000-000000000000",
  "counselorPersonId": "00000000-0000-0000-0000-000000000000",
  "studentPersonId": "00000000-0000-0000-0000-000000000000",
  "schoolId": "00000000-0000-0000-0000-000000000000",
  "isPrimary": false
}

CounselorAssignment.Ended

A counselor assignment was end-dated.

Example payload

{
  "assignmentId": "00000000-0000-0000-0000-000000000000",
  "endDate": "2026-05-21"
}

FeeAssessed

A fee was charged to a specific student (registrar assigning registration, teacher flagging field-trip opt-in, etc.).

Example payload

{
  "studentFeeId": "00000000-0000-0000-0000-000000000000",
  "studentPersonId": "00000000-0000-0000-0000-000000000000",
  "feeCatalogId": "00000000-0000-0000-0000-000000000000",
  "code": "string",
  "description": "string",
  "amountCents": 0,
  "dueOn": "2026-05-21",
  "schoolYearId": "00000000-0000-0000-0000-000000000000",
  "assessedByUserId": "00000000-0000-0000-0000-000000000000"
}

FeePaid

Money was applied to a student fee (online via Stripe, in-office check/cash, or an adjustment).

Example payload

{
  "paymentId": "00000000-0000-0000-0000-000000000000",
  "studentFeeId": "00000000-0000-0000-0000-000000000000",
  "amountCents": 0,
  "paymentMethod": "stripe",
  "processor": "stripe",
  "processorIntentId": "string",
  "processorChargeId": "string",
  "processorStatus": "succeeded",
  "referenceId": "string",
  "paidByUserId": "00000000-0000-0000-0000-000000000000",
  "balanceAfterCents": 0
}

IncidentActionAssigned

A consequence (ISS, OSS, detention, referral) was assigned for an incident.

Example payload

{
  "incidentId": "00000000-0000-0000-0000-000000000000",
  "studentId": "00000000-0000-0000-0000-000000000000",
  "actionType": "string",
  "durationDays": 0
}

IncidentReported

A discipline incident was reported.

Example payload

{
  "incidentId": "00000000-0000-0000-0000-000000000000",
  "date": "2026-05-21T12:00:00.000Z",
  "location": "string",
  "offenseCode": "string",
  "participants": [
    {
      "studentId": "00000000-0000-0000-0000-000000000000",
      "role": "offender"
    }
  ]
}

M365SyncCompleted

A Microsoft 365 user or Teams sync completed for a district.

Example payload

{
  "districtId": "00000000-0000-0000-0000-000000000000",
  "created": 0,
  "updated": 0,
  "failed": 0,
  "syncType": "users"
}

MessageBounced

A message bounced or was rejected. Hard bounces suppress future sends.

Example payload

{
  "communicationId": "00000000-0000-0000-0000-000000000000",
  "districtId": "00000000-0000-0000-0000-000000000000",
  "channel": "string",
  "provider": "string",
  "recipientAddress": "string",
  "bounceType": "hard",
  "reason": "string"
}

MessageDelivered

A message was successfully delivered to a recipient by the provider.

Example payload

{
  "communicationId": "00000000-0000-0000-0000-000000000000",
  "districtId": "00000000-0000-0000-0000-000000000000",
  "channel": "string",
  "provider": "string",
  "recipientAddress": "string",
  "providerMessageId": "string",
  "deliveredAt": "2026-05-21T12:00:00.000Z"
}

OneRosterExportGenerated

A OneRoster 1.2 CSV ZIP export was generated for a district.

Example payload

{
  "districtId": "00000000-0000-0000-0000-000000000000",
  "asOf": "string",
  "fileCount": 0,
  "rowCount": 0
}

ReportCardPublished

A report card was published for a student for a term.

Example payload

{
  "studentId": "00000000-0000-0000-0000-000000000000",
  "termId": "00000000-0000-0000-0000-000000000000",
  "url": "https://example.com"
}

Section504Plan.Created

A new Section 504 plan was drafted for a student following an eligibility determination.

Example payload

{
  "planId": "00000000-0000-0000-0000-000000000000",
  "studentId": "00000000-0000-0000-0000-000000000000",
  "disability": "string",
  "majorLifeActivity": "string",
  "planStartDate": "2026-05-21",
  "planEndDate": "2026-05-21"
}

SectionGradePosted

A final section grade was posted for a student for a term.

Example payload

{
  "sectionId": "00000000-0000-0000-0000-000000000000",
  "studentId": "00000000-0000-0000-0000-000000000000",
  "termId": "00000000-0000-0000-0000-000000000000",
  "letterGrade": "string",
  "percent": 0,
  "gpaValue": 0
}

StudentEnrolled

A student began an enrollment at an organization.

Example payload

{
  "personId": "00000000-0000-0000-0000-000000000000",
  "organizationId": "00000000-0000-0000-0000-000000000000",
  "gradeLevel": "string",
  "startDate": "2026-05-21"
}

StudentWithdrawn

A student withdrew from an organization.

Example payload

{
  "personId": "00000000-0000-0000-0000-000000000000",
  "exitReason": "string",
  "exitCode": "string",
  "endDate": "2026-05-21"
}

TranscriptGenerated

A transcript was generated for a student.

Example payload

{
  "studentId": "00000000-0000-0000-0000-000000000000",
  "generatedByUserId": "00000000-0000-0000-0000-000000000000",
  "purpose": "string"
}

Need an event we don't expose yet? Open a ticket — the underlying event log captures more than is currently subscribable.