The bug shows up two minutes after the customer says, "we only renamed the group."
In Okta, the admin changed Enterprise Admins to Platform Admins. WorkOS sent dsync.group.updated. Your webhook handler saw the new name. The cache updated. Everything looked normal.
Then support gets the screenshot: half the enterprise customer's users can access the gated feature. Half can't. The senior employees are the first ones broken because they sit in the group your app treats as the entitlement source.
The webhook is not the final state
The tempting handler is simple:
type WorkOSGroupUpdated = {
event: "dsync.group.updated";
data: {
id: string;
name: string;
};
};
export async function handleGroupUpdated(event: WorkOSGroupUpdated) {
await groupsCache.set(event.data.id, {
name: event.data.name,
updatedAt: new Date(),
});
}
That works only if the webhook payload is the same thing as the directory state your feature gate should trust.
It is not.
Okta sends SCIM PATCH operations to WorkOS in batches that do not necessarily match the order an admin sees in Okta's UI. WorkOS directory sync webhooks fire as individual events. They tell you something changed. They do not give you a complete, ordered snapshot of the group and every user's current membership.
If your app updates downstream feature gating from the webhook payload, it can partially reconcile. One cache entry has the new group name. Another user's group membership still reflects the old world. The feature gate now depends on which stale value a request happened to read.
Treat dsync webhooks as invalidation
For feature gating, a dsync.group.updated or dsync.user.updated webhook should invalidate local state. It should not be the state.
The safer handler does two reads after every relevant webhook:
type DSyncEvent = {
event: "dsync.group.updated" | "dsync.user.updated";
data: {
id: string;
};
};
export async function handleDirectorySyncEvent(event: DSyncEvent) {
if (event.event === "dsync.group.updated") {
const group = await workos.directorySync.getDirectoryGroup(event.data.id);
const users = await workos.directorySync.listDirectoryUsers({
directory: group.directoryId,
});
await featureGateStore.reconcileDirectoryGroup({
groupId: group.id,
groupName: group.name,
users: users.data,
});
}
if (event.event === "dsync.user.updated") {
const users = await workos.directorySync.listDirectoryUsers();
await featureGateStore.reconcileDirectoryUsers(users.data);
}
}
The important part is not the SDK shape. It is the rule: ignore the webhook payload's name and groups for downstream feature gating. Use the payload ID to know what to re-fetch. Then read from GET /directory_groups/{id} and GET /directory_users.
Those reads are the source of truth your cache reconciles against.
What to test
The test is not "did we receive dsync.group.updated?"
The test is:
- Start with a group that controls a feature gate.
- Rename the group in the identity provider.
- Receive the group update webhook.
- Re-fetch the group by ID.
- Re-fetch directory users.
- Rebuild the local entitlement view from the reads, not the webhook payload.
That is the path that catches the bug. A unit test around the webhook JSON does not.
Receipts
Here's a real WorkOS directory group reconciliation workflow run against the FetchSandbox sandbox, captured 2026-06-11:
GET /directories → 200
GET /directory_groups → 200
GET /directory_groups/54483a43-5333-489d-8930-2a549bce4de9 → 200
GET /directory_users → 200
Sandbox ID: a8d236f155
Flow run ID: run_227a570b-1c7d-42d8-ad8d-af1c09c67b60
Full timeline: fetchsandbox.com/runs/a8d236f155?flow=run_227a570b-1c7d-42d8-ad8d-af1c09c67b60
The group ID 54483a43-5333-489d-8930-2a549bce4de9 is the value the webhook should use to trigger reconciliation. The follow-up GET /directory_users is what keeps feature gating from depending on a partial webhook payload.
How to test this without waiting on Okta
For WorkOS directory sync, the finish line is not "webhook received." It is "the app rebuilt its local entitlement view from the directory state."
We use FetchSandbox to run the directory group reconciliation workflow before wiring the production handler. The point is not to replace Okta or WorkOS testing. The point is to make the cache rule obvious before a real enterprise customer renames the group your feature gate depends on.
Top comments (0)