Debugging with SCIM Logs
SCIM directories now have a Logs tab with every provisioning request your IdP sent, the exact payloads, and a debug message explaining what failed and why.
"Why didn't this user get provisioned?" has been one of our most common enterprise support escalations, and until now the answer lived in server logs only we could see. The customer controls the identity provider. The failure lives in the traffic between the two, and the person who noticed it, usually an IT admin whose new hire can't log in, could see neither end.
SCIM directories now have a Logs tab showing every provisioning request the identity provider sent us over the last 30 days. Customers can see exactly what their IdP sent and what we returned.
Provisioning is a protocol nobody can see all of
SCIM is unusual among the protocols we run. With most integrations, whoever is debugging owns at least one end of the wire. With provisioning, the customer configures Okta or Entra ID, we accept whatever those systems decide to send, and the interesting question, did the request arrive, and what did we say back, sits in a place neither side controls.
That asymmetry is what generated the tickets. An admin adds a user to a group in their IdP, nothing shows up in the app, and the only party who can see whether a request was ever made is us. So the admin files a ticket, a support engineer reads server logs, and a fact that takes four seconds to look up takes a day and a half to communicate. We support 12+ directory providers, and in my experience no two of them send quite the same payload for the same admin action, which makes that queue a permanent tax on a feature that should otherwise run itself.
What's in the Logs tab
Every request from the last 30 days, with its status, and the exact request and response payloads as they were received. For failures, there's a debug message that names what went wrong and how to fix it.
Here is a single entry, start to finish: an admin deactivates a user in Okta, Okta sends one PATCH replacing active with false, and we answer 200 with the updated record.
{
"request": {
"method": "PATCH",
"path": "/scim/v2.0/FjxM6eP7j9t3zwt1/Users/scim_user_0111111111111111111111",
"contentLength": 118,
"userAgent": "Okta SCIM Client 1.0.0",
"body": {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "replace", "value": { "active": false } }
]
}
},
"response": {
"statusCode": 200,
"body": {
"name": { "givenName": "ScimTest", "familyName": "User01" },
"active": false,
"emails": [
{ "type": "work", "value": "scim-test-01@workos-test.com", "primary": true }
],
"groups": [],
"locale": "en-US",
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
],
"userName": "scim-test-01@workos-test.com",
"externalId": "00u111111111111111111",
"displayName": "ScimTest User01",
"id": "scim_user_0111111111111111111111",
"meta": {
"resourceType": "User",
"created": "2026-08-13T17:11:58.036Z",
"lastModified": "2026-08-13T17:29:42.238Z"
}
}
}
}That deactivation succeeded, so there's nothing on it to debug. It's still worth reading, because it holds everything a customer used to have to ask us for: the userAgent naming which SCIM client sent the request, the externalId tying our record back to the one in their IdP, and a lastModified timestamp that settles on its own whether the deactivation landed.
The debug message is the part that matters. Raw payloads are necessary, but they're also easy to misread. Say a POST /Users comes back 409: that looks like a bug until you notice the user already exists under a different external ID, at which point it's a configuration question about the customer's IdP. Handing a customer a status code and a JSON blob moves the ticket rather than resolving it. Handing them the exchange plus a sentence about what to change is what actually closes it.
Filters turn a log into an answer
Logs are filterable by action, status, method, email, external ID, group, and time range down to the minute.
That list reads like a feature checklist, but every filter maps to a sentence a customer actually says. "Rachel can't log in" is an email filter. "Everything broke after we changed our group mappings on Tuesday" is an action-plus-time-range filter. "Our IdP admin says she pushed the change at 9:14" is why the time range goes down to the minute rather than the hour. A log you can only scroll is an archive. A log you can narrow to the four requests involving one person's email address in a ten-minute window is a diagnosis.
Cause and effect, navigable both ways
The logs are linked with WorkOS events in both directions. From an event, you can jump to the requests that produced it. From a request, you can jump to the events it caused.
This is the design decision I'd steal for any other product. Directory Sync already emitted events for the things customers care about, dsync.user.created, dsync.user.deleted, dsync.group.user_added, dsync.group.user_removed, and the rest, and events are how customers respond to activity in WorkOS and in third-party providers programmatically. But an event stream and a request log answer different halves of the same question. The event says a user was removed from a group. The request log says which PATCH from which IdP, at which second, carrying which payload, caused it.
Linking them one way gives you a trail. Linking them both ways gives you an investigation you can run from either end: start from the effect a customer noticed and walk back to the cause, or start from a suspicious request and walk forward to what it changed in their tenant.
The question that used to open a support ticket now has an answer the customer can reach without us. Pull up the request the identity provider sent, read what we returned, narrow by email or by a ten-minute window when you know roughly when it happened, and follow the link through to the events it produced. When a request failed, the debug message says what to change. Thirty days of that history sits in the dashboard next to the directory it belongs to, which is where someone debugging a provisioning problem was always going to look first.