Push users and permissions
Keeping the SpendOne directory in step with the system that owns it: your identity provider, your HR system, or an export from either. This is the first integration almost every customer builds, because approval routing, cost allocation and card reconciliation all read from it.
Pick the path first
Three ways in, and the right one depends on who owns the directory. They can be mixed: SCIM for the ongoing joiner/mover/leaver flow, a CSV import for the initial load, direct API calls for corrections.
| Path | Use it when | Guide |
|---|---|---|
| SCIM 2.0 | Your IdP is the system of record and can push changes | SSO and user provisioning |
| CSV via blob storage | Your ETL can drop files on a schedule; also the initial load | Data import |
| REST API | Corrections, small changes, and anything the import kinds do not cover | This page |
The order that works
Nothing here is optional and nothing tolerates being done out of sequence, because each layer references the one above it by code.
1. Legal entities POST /legal-entities
2. Permission sets POST /permission-sets
3. Organizational units POST /organizational-units (parents before children)
4. Cost centers POST /cost-centers
5. Users POST /users
6. Leadership + membership the org unit's leader_id, and /members/{user_code}
A create that names a code the tenant does not have yet is rejected, not queued. The CSV import is the exception in one narrow way: within the users file, row order does not matter, because the writer reorders each batch managers-first.
1. Permission sets
Provision a permission set, then assign users to it by code. Do not assign roles: they are derived from the set's permissions, and role on the payload is accepted for compatibility and ignored. See Roles and Permissions.
curl -X POST https://api.spendone.tech/api/v1/permission-sets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Requester",
"description": "Files purchase requests, no admin rights",
"permissions": ["can_request", "can_view_reports"]
}'
The response carries the derived roles and the code you will reference from every user. GET /permissions/catalog is the authoritative list of permission names; an unknown one is refused rather than dropped.
POST /permission-sets/{code}/set-default marks the set new users fall back to. Change a set rather than re-granting per person: every user holding it moves with it.
2. Organizational units and cost centers
Create the tree top-down. Each unit names its legal entity, optionally its parent, and optionally the users who lead it.
curl -X POST https://api.spendone.tech/api/v1/organizational-units \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"code": "OU-1010",
"name": "Accounting",
"legal_entity_code": "DE01",
"parent_id": 12,
"cost_center_code": "CC-1010"
}'
Cycles are rejected. GET /organizational-units/root and GET /organizational-units/{code}/hierarchy let you verify the shape you just built without walking it yourself.
3. Users
curl -X POST https://api.spendone.tech/api/v1/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "anna.becker@example.com",
"first_name": "Anna",
"last_name": "Becker",
"external_id": "E1001",
"permission_set_code": "requester",
"manager_code": "U-1000"
}'
email, first_name and last_name are required; everything else is optional.
Key on external_id, not on email. external_id is your stable identifier for the person, the HR employee ID, and GET /users/by-external/{external_id} is how you resolve one of your records to a SpendOne user code without keeping a mapping table. Email changes; the employee ID does not. Two writers reach this field: the CSV import, and the employee_id claim on SSO login where your IdP sends one, which wins when it is present and never clears a stored value when it is absent.
4. Leadership and membership
Two different relationships, both needed, and confusing them is the usual first-integration bug:
manager_codeon the user is the reporting line. It is the org chart, not an approval route. A cycle is rejected and rolls the whole update back.leader_id/deputy_leader_idon the org unit is who approves that unit's spend. Set it on the unit, withPUT /organizational-units/{code}.
Membership is its own call: POST /organizational-units/{code}/members/{user_code}, and DELETE to remove. GET /organizational-units/users/{user_code} reads the units a user belongs to.
5. Offboarding
PATCH /users/{id}/deactivate. Never delete. A deleted user would break the audit trail and the approval history that references them, and a deactivated user is blocked at the access gate even if their IdP login still works.
Deactivating also removes the person as leader of every unit they lead, which is why leadership has to be handed over on the successor's record, not withheld from the leaver's.
Open work does not disappear with them: GET /admin-processes lists what a departing user still owns and POST /admin-processes/reassign moves it to somebody else. Run that before the deactivation.
Verifying
GET /users?search=…&limit=…withinclude=organizational_unitsto read back what you wrote.GET /users/{id}returns the resolvedpermissionsandroles, which is the real check that a permission set landed as intended.GET /audit-logs?object_type=user&start_date=…for the trail of everything your integration changed. See Pull audit logs.
Permissions your integration needs
The user your service account resolves to must hold a permission set deriving ADMIN: user, permission-set and org-structure writes are all administrator surfaces, and every route under /admin/… is gated on that role explicitly.