WorkOS Directory Sync
Step 1: Fetch Documentation (BLOCKING)
STOP. Do not proceed until complete.
WebFetch these URLs in order — they are the source of truth:
- •
https://workos.com/docs/directory-sync/quick-start - •
https://workos.com/docs/directory-sync/index - •
https://workos.com/docs/directory-sync/understanding-events - •
https://workos.com/docs/directory-sync/handle-inactive-users - •
https://workos.com/docs/directory-sync/attributes - •
https://workos.com/docs/directory-sync/identity-provider-role-assignment - •
https://workos.com/docs/directory-sync/example-apps
If this skill conflicts with fetched docs, follow the docs.
Step 2: Pre-Flight Validation
Environment Variables
Check .env or .env.local for:
- •
WORKOS_API_KEY- starts withsk_ - •
WORKOS_CLIENT_ID- starts withclient_ - •
WORKOS_WEBHOOK_SECRET- provided by WorkOS Dashboard (required for event verification)
WorkOS Dashboard Setup
Confirm in WorkOS Dashboard before coding:
- •Directory connection exists for test organization
- •Webhook endpoint is registered (you'll create this in Step 5)
- •Directory events are enabled in webhook settings
Project Detection
# Detect project type ls package.json 2>/dev/null && echo "Node.js project" || echo "Check for other runtime" # Check SDK installation grep -E "@workos-inc/node|workos" package.json || echo "SDK not found"
Step 3: Install SDK (If Missing)
Detect package manager from lockfile:
Lockfile present? | +-- package-lock.json --> npm install @workos-inc/node | +-- yarn.lock --> yarn add @workos-inc/node | +-- pnpm-lock.yaml --> pnpm add @workos-inc/node | +-- bun.lockb --> bun add @workos-inc/node
Verify: Run ls node_modules/@workos-inc/node before continuing.
Step 4: Initialize SDK Client
Create SDK client module (e.g., lib/workos.ts or utils/workos.ts):
import { WorkOS } from '@workos-inc/node';
export const workos = new WorkOS(process.env.WORKOS_API_KEY);
Critical: Never hardcode API key. Always use environment variable.
Verify: Import this module in another file without errors.
Step 5: Create Webhook Endpoint
Webhooks are mandatory for Directory Sync — you cannot poll for events.
Framework Detection
Framework? | +-- Next.js (app/) --> Create app/api/webhooks/workos/route.ts | +-- Next.js (pages/) --> Create pages/api/webhooks/workos.ts | +-- Express --> Add POST /webhooks/workos route | +-- Fastify --> Add POST /webhooks/workos route | +-- Other --> Create POST endpoint at /webhooks/workos
Webhook Implementation Pattern
Critical security steps:
- •Verify signature using
WORKOS_WEBHOOK_SECRET— reject unsigned requests - •Parse event payload from request body
- •Handle idempotently — same event may arrive multiple times
- •Return 200 immediately — process async if needed
Example structure (adapt to your framework):
import { workos } from '@/lib/workos';
export async function POST(request: Request) {
const payload = await request.text();
const signature = request.headers.get('workos-signature');
// 1. CRITICAL: Verify webhook signature
let event;
try {
event = workos.webhooks.constructEvent({
payload,
signature,
secret: process.env.WORKOS_WEBHOOK_SECRET!,
});
} catch (err) {
return new Response('Invalid signature', { status: 401 });
}
// 2. Route event to handler
await handleDirectorySyncEvent(event);
// 3. Return 200 immediately
return new Response('OK', { status: 200 });
}
If signature verification fails: WorkOS will retry. Check WORKOS_WEBHOOK_SECRET matches Dashboard value.
Register Webhook in Dashboard
- •Go to WorkOS Dashboard → Webhooks
- •Add endpoint URL:
https://your-domain.com/api/webhooks/workos - •Enable these event types:
- •
dsync.activated - •
dsync.deleted - •
dsync.user.created - •
dsync.user.updated - •
dsync.user.deleted - •
dsync.group.created - •
dsync.group.updated - •
dsync.group.deleted - •
dsync.group.user_added - •
dsync.group.user_removed
- •
- •Copy webhook secret → set as
WORKOS_WEBHOOK_SECRET
Test webhook: Use Dashboard "Send test event" feature before deploying.
Step 6: Implement Event Handlers (Decision Tree)
Create event routing logic:
event.event (type)?
|
+-- dsync.activated
| |
| +-> Save directory_id → Link to organization_id in your DB
| +-> Mark directory as "active" state
|
+-- dsync.deleted
| |
| +-> Find organization by directory_id
| +-> Mark all users from this directory as deleted OR hard delete
| +-> Remove directory association
| +-> DO NOT wait for individual dsync.user.deleted events
|
+-- dsync.user.created
| |
| +-> event.data: Extract user attributes (email, first_name, last_name, etc.)
| +-> Create user in your DB, link to organization via directory_id
| +-> Check event.data.state: "active" vs "inactive"
| +-> Optional: Send welcome email if state = "active"
|
+-- dsync.user.updated
| |
| +-> event.data: New user state
| +-> event.data.previous_attributes: What changed
| +-> Update user record in your DB
| +-> CRITICAL: Check if state changed to "inactive" (soft deletion)
| +-> If state = "inactive" → Revoke access, suspend user, or delete per your policy
|
+-- dsync.user.deleted
| |
| +-> Hard delete user from directory
| +-> Remove from your DB OR mark as permanently deleted
| +-> Rare: Most providers use dsync.user.updated with state="inactive" instead
|
+-- dsync.group.created
| |
| +-> Save group (id, name, directory_id) to your DB
| +-> DO NOT assign users yet — wait for dsync.group.user_added events
|
+-- dsync.group.updated
| |
| +-> Update group name or attributes
|
+-- dsync.group.deleted
| |
| +-> Remove group from your DB
| +-> DO NOT remove user-group memberships manually
| +-> WorkOS sends dsync.group.user_removed events first
|
+-- dsync.group.user_added
| |
| +-> event.data.user.id: User to add
| +-> event.data.group.id: Group to add them to
| +-> Create membership record in your DB
| +-> Apply group-based permissions/roles
|
+-- dsync.group.user_removed
|
+-> Remove user from group membership
+-> Revoke group-based permissions/roles
Handling Inactive Users (CRITICAL)
Policy decision required:
When user state becomes "inactive"? | +-- Option A: Soft delete (recommended for compliance) | | | +-> Mark user.deleted_at = now() | +-> Revoke all active sessions | +-> Keep user data for audit trail | +-- Option B: Hard delete (WorkOS default for new environments after Oct 2023) | | | +-> Delete user record entirely | +-> WorkOS may auto-delete inactive users | +-> Contact WorkOS support to change this behavior
Check your WorkOS environment settings — newer environments delete inactive users automatically.
Event Ordering Guarantees
WorkOS guarantees:
- •Events arrive in causal order per resource (per user, per group)
- •No guarantee of global ordering across resources
Example initial sync flow:
- •
dsync.activated(directory connected) - •Multiple
dsync.user.created(all existing users, any order) - •Multiple
dsync.group.created(all existing groups, any order) - •Multiple
dsync.group.user_added(memberships, any order)
Handle idempotently: Same event may arrive twice if webhook retry occurs.
Step 7: Store Directory Data (Schema Guide)
Recommended database schema:
Directories Table
CREATE TABLE directories ( id UUID PRIMARY KEY, workos_directory_id TEXT UNIQUE NOT NULL, -- from event.data.id organization_id UUID REFERENCES organizations(id), state TEXT NOT NULL, -- 'active', 'deleting', 'invalid_credentials' provider TEXT, -- 'okta', 'azure', 'google', etc. created_at TIMESTAMP, updated_at TIMESTAMP );
Users Table (Add Directory Columns)
ALTER TABLE users ADD COLUMN workos_user_id TEXT UNIQUE; ALTER TABLE users ADD COLUMN directory_id UUID REFERENCES directories(id); ALTER TABLE users ADD COLUMN directory_state TEXT; -- 'active', 'inactive' ALTER TABLE users ADD COLUMN directory_raw_attributes JSONB; -- custom attributes
Groups Table
CREATE TABLE directory_groups ( id UUID PRIMARY KEY, workos_group_id TEXT UNIQUE NOT NULL, directory_id UUID REFERENCES directories(id), name TEXT NOT NULL, raw_attributes JSONB, created_at TIMESTAMP, updated_at TIMESTAMP );
Group Memberships Table
CREATE TABLE directory_group_memberships ( user_id UUID REFERENCES users(id), group_id UUID REFERENCES directory_groups(id), created_at TIMESTAMP, PRIMARY KEY (user_id, group_id) );
Index requirements:
- •
workos_user_id(unique, for lookups) - •
workos_group_id(unique, for lookups) - •
directory_id(for org-scoped queries)
Step 8: Fetch Directory Data (On-Demand API Calls)
Webhooks are primary data source, but SDK provides query APIs for:
- •Initial data loading (if not using webhook backfill)
- •Manual sync triggers
- •Displaying directory status in admin UI
List Directories
import { workos } from '@/lib/workos';
const { data: directories } = await workos.directorySync.listDirectories({
organization: 'org_123', // Optional: filter by organization
});
List Users in Directory
const { data: users } = await workos.directorySync.listUsers({
directory: 'directory_123',
limit: 100, // Paginate if >100 users
});
// Check for pagination
if (users.list_metadata.after) {
// Fetch next page with after: users.list_metadata.after
}
List Groups in Directory
const { data: groups } = await workos.directorySync.listGroups({
directory: 'directory_123',
});
Get User Details
const user = await workos.directorySync.getUser({
user: 'directory_user_123',
});
console.log(user.emails[0].value); // Primary email
console.log(user.state); // 'active' or 'inactive'
console.log(user.custom_attributes); // Provider-specific attributes
When to use API calls vs webhooks:
- •Webhooks: Real-time updates (required)
- •API calls: Initial sync, status checks, admin UI display
Verification Checklist (ALL MUST PASS)
Run these commands in your project directory:
# 1. Check environment variables grep -E "WORKOS_API_KEY|WORKOS_CLIENT_ID|WORKOS_WEBHOOK_SECRET" .env* || echo "FAIL: Missing env vars" # 2. Check webhook endpoint exists find . -path "*/api/webhooks/workos*" -o -path "*/webhooks/workos*" | head -1 || echo "FAIL: No webhook endpoint" # 3. Check SDK is installed ls node_modules/@workos-inc/node/package.json 2>/dev/null || echo "FAIL: SDK not installed" # 4. Check signature verification in webhook grep -r "constructEvent\|verifySignature" . --include="*.ts" --include="*.js" || echo "WARN: No signature verification found" # 5. Check event handlers exist grep -r "dsync\.user\.created\|dsync\.user\.updated" . --include="*.ts" --include="*.js" || echo "FAIL: No event handlers" # 6. Build succeeds npm run build || yarn build || pnpm build
Manual verification:
- •Go to WorkOS Dashboard → Directories
- •Send test
dsync.user.createdevent - •Check webhook endpoint logs — should receive and process event
- •Check database — test user should appear
Production readiness:
- • Webhook endpoint is publicly accessible (not localhost)
- • HTTPS enabled on webhook endpoint
- • Signature verification is NOT commented out
- • Event handlers are idempotent (safe to run twice)
- • Database has indexes on
workos_user_idandworkos_group_id
Error Recovery
"Invalid signature" (401 from webhook)
Root cause: Signature verification failing.
Fixes:
- •Check
WORKOS_WEBHOOK_SECRETexactly matches Dashboard value (no extra spaces) - •Check you're passing raw request body to
constructEvent(not parsed JSON) - •For Next.js: Ensure
export const config = { api: { bodyParser: false } }in pages/api - •For Express: Use
express.raw({ type: 'application/json' })middleware
"User already exists" on dsync.user.created
Root cause: Event arrived twice (webhook retry) or initial sync collision.
Fix:
// Use upsert pattern
await db.users.upsert({
where: { workos_user_id: event.data.id },
update: { /* updated fields */ },
create: { /* new user fields */ },
});
"Directory not found" in event handler
Root cause: dsync.activated event not processed yet, or directory deleted.
Fix:
- •Check directory exists in your DB before processing user/group events
- •If missing, fetch from WorkOS API:
workos.directorySync.getDirectory() - •Handle race condition: queue event for retry if directory not yet synced
Events arrive out of order
Example: dsync.group.user_added before dsync.group.created.
Root cause: Network timing, not WorkOS ordering issue.
Fix:
// Check if group exists before adding user
const group = await db.groups.findUnique({
where: { workos_group_id: event.data.group.id }
});
if (!group) {
// Fetch group from API and create
const groupData = await workos.directorySync.getGroup({
group: event.data.group.id
});
await db.groups.create({ data: groupData });
}
//