Your HR, ERP or facilities system already knows your sites, branches and departments, and which sits under which. Place sync keeps PortalBase's org tree the same. It adds new places under their parents, renames and moves the ones that changed, and can make inactive the ones that closed.
Over the API, your program sends the list on its own schedule. It's the same sync as Setup, under Sync from a file, with the same mapping. Each change goes in your company's audit log as its own entry, under the key's service account.
1. Before you start
- An admin makes a service account in Setup, under Connections, with a role that has org.manage for the whole company. Admin at the top of your company has it.
- Then makes a key for it that can make changes. A key that only looks things up is refused, and so is an AI tool.
- Map your columns, level names and parent codes once in Setup, under Sync from a file, on the Places tab. The API uses that mapping. The template there shows every column.
2. Send rows as JSON
POST to https://portalbaseai.com/api/v1/org-units/sync with your key as Authorization: Bearer pbk_... One row per place.
curl https://portalbaseai.com/api/v1/org-units/sync \
-H "Authorization: Bearer $PORTALBASE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d3a7e91-2c4b-4f8e-a1d6-8b0c2e4f6a17" \
-d '{
"preview": true,
"rows": [
{ "code": "NE", "name": "North East", "parent": "N", "level": "Region" },
{ "code": "NE-01", "name": "Harbor Yard", "parent": "NE", "level": "Site", "status": "active" }
]
}'- code finds each place, then its parent and its name together. Codes are matched exactly, then ignoring case when only one place has it.
- parent is the parent's code, name or path, a code your mapping knows, or another row's code or name. A row can come before its parent in the list.
- level is a level's name, or one your mapping knows. Left out, a new place goes at the level just below its parent's.
- status: active or inactive, or a word your mapping knows. Left out, it doesn't change.
- attributes: the fields the place's level defines, by key. fields: your company fields on places, by key.
- A value left out, or empty, is never cleared. A sync never changes the company itself.
3. Or send the list as CSV
Send the same file you'd load in Setup, with Content-Type: text/csv. The options go in the address.
curl "https://portalbaseai.com/api/v1/org-units/sync?preview=true" \
-H "Authorization: Bearer $PORTALBASE_KEY" \
-H "Content-Type: text/csv" \
--data-binary @locations.csvEach column is read by its name, through your saved mapping. A column named for one of your company fields or a level's field is read as that field.
4. Check first
preview: true works it all out and writes nothing. The answer has the counts (added, changed, unchanged, retired, reinstated, refused) and a line for each row: what it would do, which fields would change, or why it can't. Send it again without preview to apply it.
In JSON, preview, retire_missing and max_retire go in the body beside rows. With CSV, they go in the address.
5. Making closed places inactive
- Nothing is made inactive unless you send retire_missing: true.
- With it, active places under each parent your rows name, at the levels your rows use, that aren't in the list are made inactive. A list of sites covers the sites under the regions it names, and leaves your crews alone.
- Say max_retire too: the most it may make inactive. If it would make more inactive, nothing is changed and the answer says how many.
- A place is made inactive only while no active person works anywhere under it and nothing there is unretired. The places under it go inactive with it.
- Inactive places keep their records, and stay on phones for history. They leave the lists people pick a place from.
- A place made inactive by hand stays inactive. A sync brings back only the places it made inactive, with the places under it.
- A sync never deletes a place.
curl "https://portalbaseai.com/api/v1/org-units/sync?retire_missing=true&max_retire=5" \
-H "Authorization: Bearer $PORTALBASE_KEY" \
-H "Content-Type: text/csv" \
-H "Idempotency-Key: 9c1e3a5b-7d2f-4a6c-8e0b-3f5a7c9e1d24" \
--data-binary @locations.csv6. Retries
Send an Idempotency-Key header, a UUID you make for each sync. If the call times out, send it again with the same key. You get the first run's answer and nothing changes twice. The same key with a different list is refused with conflict.
7. The answer
A sync answers with its run_id, the counts and a line for each row. row is the line it would be in a file, so the first JSON row is row 2. A row gives the place's id, not its name or code.
{
"ok": true,
"result": {
"run_id": "2b4d6f80-1a3c-4e5d-b7f9-0c2e4a6b8d13",
"counts": { "added": 2, "changed": 1, "unchanged": 38, "retired": 0, "reinstated": 0, "refused": 1 },
"rows": [
{ "row": 2, "unit_id": "7e9a1c3d-5f2b-4d8e-a0c6-2b4d6f8a0c35", "action": "changed", "fields": ["parent"], "reason": null },
{ "row": 3, "unit_id": null, "action": "refused", "fields": [], "reason": "No level matches \"Store\". Map it in Setup." }
]
}
}GET https://portalbaseai.com/api/v1/org-units/sync/runs/{run_id} reads a run later. Runs are kept a year and their rows 90 days. Admins see every run in Setup, under Sync from a file.
8. Limits
- Up to 2,000 rows, and 1 MB, in one call.
- Each key may make 120 requests a minute, the same as the rest of the API.
- A refusal answers ok: false with error.code and a sentence saying what to fix. Every path is listed at https://portalbaseai.com/api/v1/openapi.json.