Reconcile people and endpoints against your system of record
Focus knows about people (individuals in your tenant), their endpoints (phone numbers, emails, Teams user ids) and which retention group each person falls under. Your HR or identity system is the authority on who works there. The two drift — leavers stay active, new starters go unprovisioned, a new number records nothing because nobody owns it.
This example produces a drift report: a list of what disagrees, ready for someone to act on.
The public API exposes read and search operations only, so this example finds drift, it does not fix it. Apply the corrections in the portal, or through first-party tooling. Treat the output as a work list, not a sync job. Contact support if your use case genuinely needs write access.
Every step is a curl call. $BASE_URL is your regional API host (see Base URLs) and $TOKEN is a bearer token from POST /authWithApiKey.
Rights your service user needs
People.View for people, Endpoints.View for endpoint search, and RetentionGroup.View to resolve retention group names.
Step 1 — Authenticate
TOKEN=$(curl -s -X POST "$BASE_URL/authWithApiKey" \
-H "Content-Type: application/json" \
-d '{"clientId":1234567,"apiKey":"YOUR-API-KEY-XYZ","username":"YOUR-INTEGRATION-USERNAME"}' \
| jq -r .token)
Step 2 — Pull the Focus side
Take active staff, page by page. Every person comes back with their endpoints, retention group and data-group assignments, which is everything the report needs:
curl -X POST "$BASE_URL/client/people.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "personType": "staff", "isActive": true, "page": 1, "pageSize": 200 }'
{
"persons": [
{
"id": 42,
"fullName": "Jane Smith",
"email": "jane.smith@example.com",
"thirdPartyId": "AAD-1f2c",
"personType": "staff",
"isActive": true,
"retentionGroupId": 12,
"mostRecentCommAt": "2026-09-08T14:14:21.000Z",
"endpoints": [
{ "id": 81, "type": "phone", "endpoint": "447896423595", "ownedByPersonId": 42, "isRecorded": true },
{ "id": 82, "type": "email", "endpoint": "jane.smith@example.com", "ownedByPersonId": 42, "isRecorded": false }
],
"autoGroupAssignment": [
{ "groupName": "Department", "groupValue": "Support", "groupNameId": 3, "groupValueId": 17 }
]
}
],
"total": 137,
"page": 1,
"pageSize": 200
}
Match on thirdPartyId, not on name or email. It holds the source-system id — the Entra ID object id, typically — which is the only value stable across a rename, a marriage, or a second Jane Smith. Email is a moving target and names are not unique.
Step 3 — Resolve retention groups to names
retentionGroupId on a person is a number. This gives you the names, the durations behind them, and the people assigned to each:
curl -H "Authorization: Bearer $TOKEN" "$BASE_URL/admin/retentionGroup"
{
"accountDefault": { "default": 2555, "phone": 2555, "teams": 2555, "sms": 1095 },
"retentionGroups": [
{ "id": 12, "name": "Standard 7 years", "retentionGroup": { "default": 2555 }, "peopleIds": [42, 58, 91] },
{ "id": 13, "name": "Short 90 days (training)", "retentionGroup": { "default": 90 }, "peopleIds": [120, 121] }
]
}
Durations are in days. Note what retentionGroupId: null on a person actually means: not "no retention", but "the accountDefault above applies". That distinction is worth getting right in the report — a "missing retention group" line that turns out to be the intended default just wastes the reader's time.
Step 4 — Run the checks
Each check below is one query, and each answers a question someone will actually ask.
Leavers still active in Focus
Diff the thirdPartyId values from Step 2 against your system of record. Anyone active in Focus and gone upstream is the finding — they may still be recording.
New starters not yet in Focus
The other direction of the same diff. Look them up in bulk to confirm before reporting, since provisioning may lag by design:
curl -X POST "$BASE_URL/client/people.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "thirdPartyIds": ["AAD-9f3a", "AAD-2b71"], "page": 1, "pageSize": 100 }'
Staff on the account default rather than a group
Ask for exactly those people — null is a real filter value here:
curl -X POST "$BASE_URL/client/people.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "personType": "staff", "isActive": true, "retentionGroupId": null, "page": 1, "pageSize": 200 }'
People with no portal user
Useful when everyone in a team is supposed to be able to log in:
curl -X POST "$BASE_URL/client/people.search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "personType": "staff", "onlyReturnNonAssociated": true, "page": 1, "pageSize": 200 }'
Endpoints nobody owns
An unowned endpoint is the classic cause of "why wasn't that call recorded?" — recording follows the person, so a number with no owner has nothing to attach to:
curl -X POST "$BASE_URL/client/people/endpoint/search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "type": "phone", "onlyReturnNonOwned": true, "page": 1, "pageSize": 200 }'
This endpoint returns a plain JSON array — no total / page wrapper, unlike the people searches. Page until you get a short result set.
[
{
"id": 92,
"type": "phone",
"endpoint": "447896000999",
"ownedByFullName": "",
"isRecorded": false,
"isCurrentlyRecorded": false,
"mostRecentCommAt": null
}
]
Endpoints that should be recording but aren't
isCurrentlyRecorded is the live state; isRecorded is whether it ever has been. An owned endpoint with isRecorded: false and a mostRecentCommAt in the past is worth a look — it has carried traffic without being captured:
curl -X POST "$BASE_URL/client/people/endpoint/search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "type": "phone", "onlyReturnOwned": true, "isRecorded": false, "page": 1, "pageSize": 200 }'
The report
| Check | Query | Why it matters |
|---|---|---|
| Leaver still active | people.search diffed on thirdPartyId | Someone who has left may still be recorded |
| New starter missing | people.search with thirdPartyIds | Communications are going uncaptured |
| On the account default | retentionGroupId: null | Retention may not match the person's role |
| No portal user | onlyReturnNonAssociated: true | Staff who cannot access their own recordings |
| Unowned endpoint | onlyReturnNonOwned: true | Recording has no person to attach to |
| Owned but never recorded | onlyReturnOwned + isRecorded: false | Configured, but not actually capturing |
| Wrong data group | autoGroupAssignment vs upstream department | Search, reporting and access rules key off these |
Each row wants a person id or endpoint id, the Focus value, the upstream value, and which side you believe. Resist the urge to output only a count — the reader's next question is always "which ones?".
Practical guidance
- Run it on a schedule, not on demand. Daily or weekly is plenty; drift accumulates slowly and a report nobody reads is worse than no report.
- Report deltas as well as totals. "Nine unowned numbers, four of them new since last week" tells the reader where to start; "nine unowned numbers" doesn't.
- Treat
isActive: falseas a state, not a deletion. Inactive people keep their recordings and their retention position. - Don't reconcile external people.
personType: "external"covers the other party on a communication and is created from traffic, not provisioning. There is nothing upstream to reconcile it against. - Page everything. People searches return
total; endpoint search does not, so page until the array comes back short.
Related
- Search people · Search endpoints · List retention groups — schemas and interactive consoles.
- Full local sync with the Cursor API — for keeping a complete copy of communications, rather than checking configuration.
- Endpoints admin — where the fixes this report identifies actually get applied.