APIs
Resorcerer has two programmatic surfaces. They cover much the same ground and differ in who they’re for:
| For | Endpoint | |
|---|---|---|
| REST API | Scripts, integrations, and anything that wants a stable HTTP contract. | /api/rest/v1 |
| MCP server | AI assistants. See Connect AI agents. | /api/mcp |
On the hosted app, prefix both with https://app.resorcerer.ai.
Authentication
Section titled “Authentication”Both surfaces take a personal access token as a bearer token:
Authorization: Bearer <your-token>Create one under My Settings → Access Tokens. The token is shown once, at creation — store it then, because it can’t be retrieved afterwards. Administrators can review and revoke every token in the organization from the Access Tokens page.
A token carries a scope, chosen when you create it: which categories of resource it reaches and whether it may write. It never has more access than the person who created it, and per-model permissions still apply — a token can’t read a private model its owner can’t. Revoking a token takes effect immediately.
A token belongs to one organization, and every call is interpreted within it. There’s no cross-organization call.
A personal access token isn’t the only bearer token both surfaces accept:
- A registered app signs your members in with OAuth and calls the API as them, with permissions an administrator fixed when registering it. See Register an app.
- An AI assistant gets its token the same way when a member connects it. See Connect AI agents.
- A service account acts as itself, for automation with nobody behind it.
Whichever way it was issued, a token is checked on every call, so revoking it, or removing the member it acts for, takes effect at once.
Interactive reference
Section titled “Interactive reference”The REST surface publishes an OpenAPI document and a browsable reference page. Reach them from the API button in the app header — that’s the authoritative, always-current description of every endpoint, parameter, and response shape, generated from the running service. Each operation lists the status codes it answers with and the schema of its body, so you can generate a typed client from the document.
Every response body is an envelope. A single resource comes back as { "data": … }. A
list comes back as { "data": [ … ], "pagination": { "cursor": …, "hasMore": … } }:
pass cursor back as the cursor query parameter, with an optional limit of up to
100, to read the next page. Errors are
problem details with a detail message and,
where there is one, a code.
A resource has the same shape everywhere it appears: in its list, its detail, and the
response to a write. A list never leaves out fields you’d need a second call to fetch.
A relationship is an id field where there is one of it (parentUnitId, managerId)
and a list of small references where there are several (a position’s units, a
model’s labels, a person’s groups).
What follows is the map, not the specification.
Resources
Section titled “Resources”Everything lives under /api/rest/v1.
| Resource | Operations |
|---|---|
/models |
List, read, create, update, delete. The list leaves out snapshots and archived models unless you pass includeSnapshots=true or includeArchived=true, and labelId lists the models carrying a label. Create takes protected, private and archived; update also takes benchIsIntentional. Each model carries its labels, private, archived, benchIsIntentional (unit-less slots are intentional bench state, not a problem), lastChangedAt, your myAccessLevel on a private model, and liveSync (its unit source and pending changes) when it is live; a snapshot carries snapshotOfModelId, snapshotOf (the source’s id and name, or null once the source is trashed), snapshotTakenAt and snapshotReason (PRE_MERGE, SCHEDULED or MANUAL). |
/models/generate |
Generate a model from the people directory — units, one filled position per active person the units have a place for, and reporting lines — in one call. Takes the unit source (and the departments, reporting trees or root groups to build from), a people filter, and optionally keepLive with its review settings. |
/models/{id}/copy |
Copy a model, including its units, positions and who is assigned to them. Omitted fields are inherited from the source; protected, private and labelIds override it. |
/models/{id}/timeline |
Read the model’s timeline: the models that are its points, ordered by effective date. |
/models/{id}/labels |
List the model’s labels; POST with labelIds attaches more; DELETE /models/{id}/labels/{labelId} detaches one. |
/models/{id}/table |
Read the whole model as one flat table. Takes unitLabelFilter, includePeople, limit (up to 500 positions) and cursor. |
/models/{id}/units |
List and create units within a model. parentUnitId lists one unit’s children; labelId lists the units carrying a label. |
/models/{id}/slots |
List and create positions within a model. unitId lists one unit’s positions, personId the position a person holds, and reportsToSlotId the positions reporting to one. |
/models/{id}/live-sync |
Read a model’s live sync settings; PUT or PATCH turns it live or changes its settings (PATCH takes a partial body); DELETE turns live sync off. Changing settings needs an organization admin. |
/models/{id}/live-sync/proposal |
Read the live model’s pending sync proposal; POST /models/{id}/live-sync/proposal/apply applies operations from it, optionally saving a snapshot first (takeSnapshot; placeSnapshotOnTimeline, on by default, puts it on the model’s timeline). If the proposal was recomputed after you read it, the apply changes nothing and returns 409 with code LIVE_SYNC_PROPOSAL_STALE: read the proposal again and apply from that. |
/models/{id}/live-sync/recompute |
POST to recompute the model’s proposal now — a live model’s, or its pending one-time update’s. |
/models/{id}/sync-request |
A one-time update from your people directory for a model that is not live. PUT asks for one (unit source, root groups, group and people filters, and which change kinds to include) and builds its proposal; GET reads what is pending; DELETE discards it. Review and apply the proposal through /models/{id}/live-sync/proposal exactly as for a live model; a fully applied update clears itself. Needs edit access to the model. |
/models/{id}/auto-snapshot |
Read a model’s automatic snapshot schedule; PUT or PATCH sets it up or changes it (enabled, a five-field cron schedule in UTC, snapshotWhenUnchanged, and placeOnTimeline, off by default, to put each snapshot on the model’s timeline). POST /models/{id}/auto-snapshot/run snapshots the model now and reports whether a copy was filed. |
/models/{id}/snapshots |
List the model’s snapshots, newest first; POST takes one now (placeOnTimeline required, effectiveDate optional) and returns 201 with it. PUT /models/{id}/snapshots/{snapshotId}/timeline with { "onTimeline": true } or false puts a snapshot on the model’s timeline or takes it off. |
/units/{id} |
Read, update, delete. A unit carries its labels; PATCH with parentUnitId moves it. A unit also carries its planned startDate and endDate, the startDatePrecision of the start date (DAY, MONTH or QUARTER; the date is always the first day of that period) and its rollout state (PLANNED, LIVE, ENDING, ENDED or TBD; see Rollout state). Create and PATCH take the dates and the precision. |
/units/{id}/children, /units/{id}/slots |
The units directly under a unit, and the positions that belong to it. |
/units/{id}/labels |
List the unit’s labels; POST with labelIds attaches more; DELETE /units/{id}/labels/{labelId} detaches one. |
/slots/{id} |
Read, update, delete. A position carries the units it belongs to (id and name), and its derived status: FILLED, PLACEHOLDER (vacant, with a placeholderLabel) or VACANT. A vacant position can also carry a placeholderLabel, a hiringStage (NONE, PLANNED, APPROVED, OPEN or OFFER_ACCEPTED) and a requisitionRef; create and PATCH take them, a filled position refuses new values for them, and assigning a person clears them (a PATCH that assigns a person may restate the current values; they are cleared too). |
/slots/{id}/units |
PUT with unitIds replaces the units a position belongs to. |
/people |
List, read, create, update, delete. Filter the list with search, isActive, groupId (repeat it for several groups) or managerId. A person carries their groups. |
/people/{id}/groups |
The groups a person belongs to. |
/groups |
List, read, create, update, delete. parentGroupId lists one group’s children. |
/groups/{id}/members |
Read a group’s members; replace the full member set. |
/roles, /levels, /locations, /companies |
List, read, create, update, delete. A level carries its rank, a company isContractor, and each its usageCount. |
/levels/swap-ranks |
POST with firstId and secondId swaps two levels’ ranks. |
/labels |
List, read, create, update, delete. A label carries displayText and how many models, units and unit templates use it, plus compositionRuleCount and checklistTemplateCount: the composition rules and checklists that target it, which deleting the label deletes. |
/composition-rules |
List and read the composition rules. A rule carries its targetLabelId, includeDescendants, and its requirements, each with a roleId or a roleCategory and its min and max. Rules are read-only here; manage them in the app or through an AI agent. |
Units and positions are created within a model and addressed directly thereafter.
Reading uses GET, creating POST, partial updates PATCH, and removal DELETE.
A deletion is recoverable from Trash exactly as it is in
the app — DELETE is not destructive.
A PATCH changes only the fields you send. Leave a field out to keep it, or send
null to clear one that can be empty. The interactive reference lists the fields each
PATCH accepts.
Snapshots
Section titled “Snapshots”A snapshot is a read-only copy of a model taken at a moment. It is taken when you press Snapshot now (or call the API below), on an automatic snapshot schedule, or by Save a snapshot first when you apply a sync proposal. A snapshot may or may not be a point on the model’s timeline. Each snapshot has two dates, and they mean different things:
| Field | Meaning |
|---|---|
snapshotTakenAt |
When the copy was captured. Always the moment it was taken. |
effectiveDate |
The date the snapshot is filed under, which is where it sits when it is on the timeline. By default it is the model’s own effective date (a snapshot of the January plan is still filed under January). For an undated model it is the day of the model’s last change. You can choose another date when you take the snapshot, but not a future one. |
snapshotReason |
MANUAL, SCHEDULED or PRE_MERGE. |
onTimeline |
REST only. true when the snapshot is on the model’s timeline (temporalGroupId is set). |
The same operations exist on every surface:
| GraphQL | REST | MCP | |
|---|---|---|---|
| List a model’s snapshots, newest first | modelSnapshots(modelId) |
GET /models/{id}/snapshots |
ListModelSnapshots |
| Take one now | createModelSnapshot(input: { modelId, placeOnTimeline, effectiveDate }) |
POST /models/{id}/snapshots |
CreateModelSnapshot |
| Put one on the timeline or take it off | setModelSnapshotTimelinePlacement(input: { snapshotModelId, onTimeline }) |
PUT /models/{id}/snapshots/{snapshotId}/timeline |
SetModelSnapshotTimelinePlacement |
Taking a snapshot always files a copy, even when nothing has changed since the last one, and
needs no schedule. It needs edit access to the model, and you always choose whether the copy
goes on the timeline. Archived models and snapshots can’t be snapshotted. A
snapshot only ever joins the timeline of the model it was taken of, and keeps its effective
date when you put it on or take it off. If the model has no timeline yet, placing
the first snapshot starts one. Listing needs view access to the model: a private model you
can’t see lists no snapshots (REST answers 404). To export a snapshot, pass its id as the
model to export, the same as for any other model.
The model table
Section titled “The model table”When you want a whole model at once, for a report, a spreadsheet or an agent building a view of it, read it as one flat table instead of walking its units and positions. There is a row for each position in each unit it belongs to, so a position in two units is two rows, and a position in no unit is one row with the unit columns empty.
| Column | Meaning |
|---|---|
slotId, unitId |
The position and the unit the row is for. |
unitPath |
The unit and the units above it, top first: R&I / Advisor Agent / AA01. |
unitLabels |
The unit’s labels, as Key: Value (or the value alone). |
unitStartDate, unitStartDatePrecision, unitEndDate, unitState |
The unit’s planned dates and rollout state. |
role, roleCategory, level, location, company |
The position’s attributes, by name. |
personName, personTitle |
Who holds the position. Left out of a table read without people. |
status |
FILLED or VACANT. |
notes |
The position’s notes. |
openCommentCount |
How many open comment threads the position has. |
problemTypes |
The problems the position is part of. |
REST and MCP return it the same compact way: a columns list and a rows list, each row
its cells in column order. A page holds up to 500 whole positions, so most models come
back in one call; for a larger one pass nextCursor back as cursor. Set
includePeople to false for a table with no names in it, fit for sharing. A
credential without read access to people always gets that table, with a note saying
why. unitLabelFilter keeps only the rows in units carrying a label: give its value
(Pod), Key: Value (Type: Pod) or its id.
| GraphQL | REST | MCP | |
|---|---|---|---|
| Read the table | modelTable(modelId, unitLabelFilter, includePeople, first, after) |
GET /models/{id}/table |
GetModelSnapshotTable |
In the app, the Download model table button on the Slots view saves the same table as CSV, with or without names.
People and their attributes
Section titled “People and their attributes”A person’s role, level, location and company are derived from match expressions, so the API exposes them as what they are — attributes, each with a flag saying whether it was pinned by hand:
| Field | Meaning |
|---|---|
company, location |
Free-text fields, alongside title and department. These are what the rules match on. |
roleAttributeId, levelAttributeId, locationAttributeId, companyAttributeId |
The attribute in effect: derived, or overridden. GraphQL also resolves each as an object: roleAttribute { id name }. |
derivedRoleAttribute, … |
What the rules give (id and name), whether or not it is overridden. |
isRoleAttributeOverride, … |
true when the attribute has been pinned and the rules leave it alone. |
To pin an override when creating or updating, send roleAttributeId (and the
others) on POST /people, or roleAttribute: { id } on PATCH /people/{id} and the
GraphQL createPerson/updatePerson mutations. On an update the field is
tri-state: leave it out to change nothing, send null to return the attribute to
automatic, { id: "…" } to pin a row, or { id: null } to pin an explicit none.
Roles, levels, locations and companies carry matchExpression, matchPriority and
isDefault. Saving an expression that doesn’t compile is rejected with the position
of the problem; saving a rule change re-derives every person in the organization.
GraphQL adds personAttributeMatchPreview and personQueryFields for trying an
expression, and recomputePersonAttributes for re-deriving on demand.
Positions are unchanged: a slot’s attributes are still roleId, levelId,
locationId and companyId, because a slot’s attributes are chosen, not derived.
The person fields were previously named roleId, levelId, locationId and
companyId, and role, level, location and company resolved to the attribute
objects. Scripts and webhook consumers written against those names need updating —
company and location now return the free-text fields. Export archives use the
new names; archives made before the change still import, with each person’s old
attribute assignments kept as overrides.
Groups
Section titled “Groups”A group nests under at most one parent, so parentGroupId (parentGroupId on REST,
parentGroupId/parent on GraphQL) forms a tree. An update that would make a group
its own ancestor — directly or through a chain of parents — is rejected with a
validation error rather than silently accepted, on every surface.
Group membership is a separate call: GET /groups/{id}/members reads the current
members, and PUT /groups/{id}/members replaces the full set in one request (send
every member you want the group to end up with, not just the ones you’re adding).
GraphQL exposes the same idea as three mutations — setGroupMembers (replace),
addGroupMembers, and removeGroupMembers — for callers that would rather send a
diff than a full set. A person can belong to any number of groups.
From the person’s side, createPerson and updatePerson take groupIds, which sets
the person’s full list of groups (on an update, leave it out to change nothing).
updatePerson also takes addGroupIds and removeGroupIds to change specific groups
and leave the rest alone. They apply to the person’s groups as they are when the update
is saved, so a group someone else added in the meantime is kept. Adding a group the
person is already in, or removing one they aren’t in, does nothing, even when two
updates make the same change at the same moment. Removing a group that no longer exists
does nothing too, while adding one is rejected as not found. Send either groupIds or
the add/remove lists, not both: mixing them, or listing the same group in both lists,
is rejected with a validation error.
Like a person, a group can be owned by an integration: sourceProviderId and
syncedFields name which fields sync automatically and are blocked from hand-edits.
Membership is never one of them — it can always be edited by hand. See Managed
fields.
Deleting a group is recoverable from Trash, the same as any other resource. Its child groups are re-parented to its own parent (or promoted to root groups) rather than deleted with it, and its memberships are kept intact so they come back if the group is restored.
Outgoing webhooks
Section titled “Outgoing webhooks”An outgoing webhook sends a CloudEvents message to your endpoint when something in the organization changes (Pro and Enterprise plans). Set them up under Organization settings → Outgoing Webhooks; each can be limited to certain entity types and actions, and can carry custom headers.
The event type names the entity and what happened, as
resorcerer.<entity>.<action>:
| Entity | Example type |
|---|---|
| Model | resorcerer.model.updated |
| Unit | resorcerer.unit.created |
| Slot (position) | resorcerer.slot.deleted |
| Person | resorcerer.person.updated |
| Role, Level, Location, Company | resorcerer.role.created, resorcerer.level.updated, … |
| Label | resorcerer.label.deleted |
| Cost profile | resorcerer.cost-profile.updated |
| People sync run | resorcerer.people-sync-run.created, sent once when a directory sync finishes, with its counts |
The action is created, updated or deleted. Route on the type, or on the
entity type and action inside the event.
Events sent before 0.40 used resorcerer.entity.<action> for every entity; a
consumer written against that format needs updating.
Versioning
Section titled “Versioning”The path carries the major version (v1), and responses echo the version they were
served by in an X-API-Version header. Additive changes — new fields, new endpoints —
happen within a version, so parse defensively and ignore fields you don’t recognize.
Rate limits
Section titled “Rate limits”Calls are rate-limited per token. A client that exceeds its allowance is told so with
a 429; back off and retry rather than tightening the loop.
Which surface to use
Section titled “Which surface to use”Reach for REST when you know what you want to do: syncing people from an HR system, exporting headcount into a dashboard, creating models from another tool.
Reach for MCP when something needs to explore — an assistant that has to look around before it knows what to ask for. See Connect AI agents.
Related
Section titled “Related”- Connect AI agents — the MCP surface in detail.
- Register an app — let an app sign your members in.
- Match people to attributes — the expression language behind
matchExpression. - Work with your team — tokens, members, and permissions.