Skip to main content

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.

This is read-only, by design

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​

CheckQueryWhy it matters
Leaver still activepeople.search diffed on thirdPartyIdSomeone who has left may still be recorded
New starter missingpeople.search with thirdPartyIdsCommunications are going uncaptured
On the account defaultretentionGroupId: nullRetention may not match the person's role
No portal useronlyReturnNonAssociated: trueStaff who cannot access their own recordings
Unowned endpointonlyReturnNonOwned: trueRecording has no person to attach to
Owned but never recordedonlyReturnOwned + isRecorded: falseConfigured, but not actually capturing
Wrong data groupautoGroupAssignment vs upstream departmentSearch, 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: false as 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.
Docs AssistantAsk anything about our products