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
| Type | Fields |
|---|---|
teachers | name, shortName, department, designation, employmentType, email |
subjects | name, shortName, color, department, courseCode, description, courseType |
rooms | name, shortName, color, spaceType, building, floor, groupName, capacity |
classes | name, shortName, color, levelLabel, order |
students | name, 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 code | Meaning |
|---|---|
EXTERNAL_ID_REQUIRED | Missing or blank externalId |
NAME_REQUIRED | Missing name |
DUPLICATE_IN_PAYLOAD | The same externalId appears twice in one request |
NAME_COLLISION | Another 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.