Skip to main content

Entity sync

Your ERP is the system of record for people and academic structure. You push that data to us; we own only the schedule. Nothing is written back to your system by this API.

Endpoints

PUT   /api/partner/v1/entities/{type}     full snapshot: retires anything absent
PATCH /api/partner/v1/entities/{type} delta: creates and updates only, never retires
GET /api/partner/v1/entities/{type} read back what we hold
GET /api/partner/v1/sync-status last result per entity type

{type} is one of teachers, subjects, rooms, classes, students.

Query parameters: ?dryRun=true, ?allowMassRetire=true, ?includeInactive=true (on GET).

Request

Send an array, either bare or wrapped in records:

PUT /api/partner/v1/entities/teachers
Authorization: Bearer ttm_<your api key>
Content-Type: application/json

[
{ "externalId": "EMP-1001", "name": "Edna Krabappel", "shortName": "EK", "department": "Science" },
{ "externalId": "EMP-1002", "name": "Seymour Skinner", "department": "Administration" }
]

externalId and name are required on every record. Everything else is optional, and a field you omit is left untouched. A sparse payload never blanks data.

Fields by type

TypeFields
teachersname, shortName, department, designation, employmentType, email
subjectsname, shortName, color, department, courseCode, description, courseType
roomsname, shortName, color, spaceType, building, floor, groupName, capacity
classesname, shortName, color, levelLabel, order
studentsname, shortName, email, phone, color

courseType is subject, course or activity. spaceType is classroom, lab, hall, outdoor or other. Unrecognised values fall back to the default rather than failing the record.

Fields not listed here (roles, substitution settings, and anything an organisation configures inside the product) cannot be changed by sync. That is deliberate: their own configuration survives you overwriting everything else.

Classes are a flat list. Send 9A, 10B and so on. If your ERP models Grade → Section, flatten it on your side; that is what your users already see in our UI.

Response

{
"success": true,
"data": {
"type": "teachers",
"mode": "replace",
"dryRun": false,
"received": 120,
"created": 3,
"updated": 8,
"restored": 1,
"retired": 2,
"unchanged": 105,
"failed": [
{ "externalId": "EMP-9", "code": "NAME_COLLISION", "reason": "another teacher is already named \"John Smith\"" }
]
}
}

One bad record never abandons the rest. A 2,000-record sync with one malformed row imports 1,999 and reports the one. Always read failed[]: the HTTP status is 200 when the sync ran.

Failure codeMeaning
EXTERNAL_ID_REQUIREDMissing or blank externalId
NAME_REQUIREDMissing name
DUPLICATE_IN_PAYLOADThe same externalId appears twice in one request
NAME_COLLISIONAnother entity in this organisation already has that name

Names are unique per organisation and compared case-insensitively. Unlike a launch, sync never auto-renames. It reports, so you can fix the source data.

What "retire" means

A PUT is declarative: anything absent from the payload is deactivated, not deleted.

  • Deactivated entities disappear from the product's pickers.
  • Their history is preserved. Published timetables keep rendering.
  • Sending them again restores them, with the same underlying record, so they re-attach to the timetables that already used them.

Entities created by hand inside the product (with no externalId) are never retired by your snapshot. A partner snapshot has no authority over data it did not create.

PATCH never retires anything.

The mass-retire guard

A snapshot that would retire more than 20% of your synced entities of that type (and at least 5) is refused:

{
"success": false,
"error": {
"code": "MASS_RETIRE_BLOCKED",
"message": "This snapshot would retire 120 of 130 synced teachers (over 20%). If that is intended, resend with allowMassRetire=true.",
"details": { "wouldRetire": 120, "activeSynced": 130 }
}
}

This exists because the realistic cause of an empty payload is a failed query on your side, not an organisation dismissing its staff. If the retirement is genuine, resend with ?allowMassRetire=true.

Dry run

Add ?dryRun=true to any PUT or PATCH. You get the same summary, computed by the same code path, with nothing written. Useful during onboarding and before a large change.

Cadence and concurrency

A nightly full PUT plus event-driven PATCHes on change works well.

Only one sync per organisation per entity type runs at a time; an overlapping one gets SYNC_IN_PROGRESS (409) and should be retried shortly. Two overlapping full snapshots would otherwise retire each other's records. Different entity types sync independently, and a dryRun never blocks a real sync.

Checking status

GET /api/partner/v1/sync-status
{
"success": true,
"data": {
"teachers": { "at": "2026-09-14T02:00:00.000Z", "received": 120, "created": 3, "updated": 8,
"restored": 1, "retired": 2, "unchanged": 105, "failedCount": 1,
"failedSample": [ ] },
"subjects": null
}
}

null means that type has never been synced.