WorkOS Events
Step 1: Fetch Documentation (BLOCKING)
STOP. Do not proceed until complete.
WebFetch these docs in order:
- •https://workos.com/docs/events/index
- •https://workos.com/docs/events/data-syncing/webhooks
- •https://workos.com/docs/events/data-syncing/events-api
- •https://workos.com/docs/events/data-syncing/data-reconciliation
- •https://workos.com/docs/events/observability/datadog
The docs are the source of truth. If this skill conflicts with docs, follow docs.
Step 2: Pre-Flight Validation
Environment Variables
Check .env or .env.local for:
- •
WORKOS_API_KEY- starts withsk_ - •
WORKOS_WEBHOOK_SECRET- if using webhooks (generated in dashboard)
Project Requirements
- •Confirm WorkOS SDK installed: Check
package.jsonor language-specific manifest - •Confirm you have an active SSO or Directory Sync connection (required to generate events)
Verify in dashboard: https://dashboard.workos.com/
- •At least one connection exists (SSO or Directory)
- •Connection status is "Active" or "Linked"
Step 3: Choose Data Sync Strategy (Decision Tree)
Event data consumption pattern? | +-- Real-time push --> Use Webhooks (Step 4) | +-- Poll on-demand --> Use Events API (Step 5) | +-- Both needed --> Implement both + reconciliation (Step 6)
Decision factors:
- •Webhooks: Instant delivery, requires public endpoint, WorkOS retries failures
- •Events API: Poll at your pace, no endpoint needed, paginated results
- •Both: Webhooks for speed + Events API to catch missed webhooks (recommended for critical data)
Step 4: Webhook Implementation
4.1 Create Webhook Endpoint
Determine endpoint path based on your framework:
Framework/Language --> Typical path pattern Express.js --> /webhooks/workos or /api/webhooks/workos Next.js API Routes --> app/api/webhooks/workos/route.ts Flask/Django --> /webhooks/workos/ Rails --> /webhooks/workos
CRITICAL: Endpoint must:
- •Accept POST requests
- •Return
HTTP 200 OKwithin 5 seconds (WorkOS timeout) - •Return 200 even if processing fails (acknowledge receipt, process async)
4.2 Parse Raw Request Body
Before processing: Capture raw request body as string/bytes.
This is required for signature validation. Do NOT parse JSON first.
Framework-specific patterns: | +-- Express.js --> Use express.raw() middleware | +-- Next.js 13+ --> await request.text() | +-- Flask --> request.data or request.get_data() | +-- Django --> request.body
4.3 Validate Webhook Signature
BLOCKING: Do not process events until signature validated.
Option A: Use SDK (RECOMMENDED)
Check SDK docs for method name (varies by language):
- •Node.js:
workos.webhooks.verifyEvent(payload, headers, secret) - •Python:
workos.webhooks.verify_event(payload, headers, secret) - •Ruby:
WorkOS::Webhooks.verify_event(payload, headers, secret)
SDK handles timestamp validation and HMAC verification.
Option B: Manual Validation
If SDK unavailable, implement this flow:
1. Extract WorkOS-Signature header Format: "t=<timestamp>, v1=<signature>" 2. Parse timestamp and signature: timestamp = value after "t=" signature = value after "v1=" 3. Validate timestamp: current_time - timestamp < 300 seconds (5 min tolerance) Reject if outside window (replay attack protection) 4. Compute expected signature: message = timestamp + "." + raw_payload expected = HMAC_SHA256(webhook_secret, message) 5. Compare signatures (constant-time comparison): if expected != signature: reject with 401
On validation failure: Return 401 Unauthorized. Do NOT return 200.
4.4 Process Event
Parse validated payload as JSON. Event structure:
{
"id": "event_01...",
"event": "dsync.user.created", // event type
"data": { /* event-specific payload */ },
"created_at": "2024-01-15T10:30:00.000Z"
}
Implement event type routing:
event type --> action dsync.user.created --> Create user in your DB dsync.user.updated --> Update user in your DB dsync.user.deleted --> Soft delete or mark inactive dsync.group.created --> Create group/team authentication.email_verified --> Mark email as verified connection.activated --> Log connection event
Check WebFetch docs for complete event type list and data schemas.
4.5 Respond Quickly
CRITICAL: Respond with 200 within 5 seconds, then process async.
Pattern for async processing:
1. Validate signature (fast, <100ms) 2. Store event in queue/DB (fast, <500ms) 3. Return HTTP 200 OK 4. Process event in background job
WorkOS will retry if you don't return 200:
- •Retry schedule: exponential backoff
- •Max retries: 6 attempts over 3 days
- •Endpoint marked unhealthy after repeated failures
4.6 Register Endpoint in Dashboard
Navigate to: https://dashboard.workos.com/webhooks
- •Click "Add Endpoint"
- •Enter endpoint URL (must be HTTPS for production)
- •Select event types to receive (or "All events")
- •Copy webhook secret to
.envasWORKOS_WEBHOOK_SECRET
For local dev: Use ngrok or similar tunnel:
ngrok http 3000 # Use ngrok HTTPS URL in dashboard
4.7 IP Allowlist (OPTIONAL)
Restrict endpoint to WorkOS IPs only:
52.21.191.26 18.205.106.236 3.217.146.166
Implementation varies by infrastructure:
- •AWS WAF/CloudFront: IP set rules
- •Nginx:
allowdirectives - •Express/middleware: IP check before route handler
Step 5: Events API Implementation (Polling)
5.1 Choose Polling Strategy
Data freshness needs? | +-- Near real-time (1-5 min) --> Frequent polling + cursor pagination | +-- Batch sync (hourly/daily) --> Scheduled job + date range queries | +-- On-demand (user action) --> Query when user requests data
5.2 Fetch Events with SDK
Check SDK docs for method signature. Typical pattern:
// Cursor-based pagination (recommended)
const events = await workos.events.list({
events: ['dsync.user.created', 'dsync.user.updated'], // filter by type
after: lastCursor, // resume from previous fetch
limit: 100 // max events per page
});
// Date range query
const events = await workos.events.list({
range_start: '2024-01-01T00:00:00.000Z',
range_end: '2024-01-02T00:00:00.000Z',
limit: 100
});
Pagination loop:
cursor = null
all_events = []
while True:
response = fetch_events(after=cursor, limit=100)
all_events.extend(response.data)
if not response.metadata.has_more:
break
cursor = response.metadata.after
5.3 Store Cursor Position
Persist metadata.after cursor to resume on next poll:
- •Database:
polling_statetable withcursorcolumn - •Redis:
SET workos:events:cursor "<cursor_value>" - •File:
.workos_cursorin secure location
On first run: Omit cursor to start from oldest available events (7 day retention).
5.4 Handle Rate Limits
Events API rate limit: Check docs for current limits (typically 100 req/min).
Implement exponential backoff on 429 responses:
if response.status == 429:
retry_after = response.headers.get('Retry-After', 60)
sleep(retry_after)
retry_request()
Step 6: Hybrid Approach (Webhooks + Reconciliation)
6.1 When to Use Hybrid
Use both webhooks AND Events API when:
- •Critical data that cannot be missed (user provisioning, access control)
- •Webhook endpoint might experience downtime
- •Need to backfill historical events
6.2 Reconciliation Pattern
Implement periodic reconciliation job (e.g., every 6 hours):
1. Query Events API for events in last 24 hours 2. Compare event IDs with processed webhook events 3. Process any events missing from webhook flow 4. Log discrepancies for monitoring
Storage schema:
CREATE TABLE processed_events ( event_id VARCHAR PRIMARY KEY, event_type VARCHAR, received_at TIMESTAMP, source VARCHAR, -- 'webhook' or 'api' processed BOOLEAN );
Check for duplicates before processing:
SELECT 1 FROM processed_events WHERE event_id = ?
6.3 Deduplication Strategy
Events may arrive via both webhook and API:
Before processing event:
|
+-- Check if event_id exists in DB
|
+-- EXISTS --> Skip (already processed)
|
+-- NOT EXISTS --> Process + insert event_id
Use database constraints or atomic operations to prevent race conditions.
Step 7: Datadog Integration (Observability)
7.1 Enable in Dashboard
Navigate to: https://dashboard.workos.com/integrations
- •Find Datadog integration
- •Click "Configure"
- •Enter Datadog API key
- •Select event types to stream
- •Save configuration
STOP: Events will now stream automatically. No code changes needed.
7.2 View Events in Datadog
Events appear in Datadog Logs with:
- •Source:
workos - •Service:
workos-events - •Tags:
event_type:<type>,connection_id:<id>,organization_id:<id>
Query examples:
# All WorkOS events source:workos # Failed authentication attempts source:workos event_type:authentication.failed # Events for specific organization source:workos @organization_id:org_123
7.3 Create Monitors (OPTIONAL)
Example monitor for failed logins:
source:workos event_type:authentication.failed # Alert if count > 100 in 5 minutes
Use Datadog monitor UI or Terraform/API for setup.
Verification Checklist (ALL MUST PASS)
Run these checks to confirm implementation:
For Webhooks:
# 1. Check endpoint exists (adjust path for your project)
grep -r "webhooks/workos" . --include="*.ts" --include="*.js" --include="*.py"
# 2. Test endpoint responds
curl -X POST http://localhost:3000/api/webhooks/workos \
-H "Content-Type: application/json" \
-d '{"event":"test"}' \
# Should return 401 (signature validation) not 404
# 3. Verify webhook secret is set
grep WORKOS_WEBHOOK_SECRET .env || echo "FAIL: Missing webhook secret"
# 4. Check dashboard registration
# Manual: Visit https://dashboard.workos.com/webhooks
# Should see your endpoint listed
For Events API:
# 1. Test API credentials curl https://api.workos.com/events \ -u "$WORKOS_API_KEY:" \ -G --data-urlencode "limit=1" # Should return 200, not 401 # 2. Check cursor persistence (adjust for your storage) grep -r "workos.*cursor" . --include="*.sql" --include="*.ts" # Or check DB: SELECT cursor FROM polling_state; # 3. Verify polling job exists (cron/scheduler) crontab -l | grep workos || \ grep -r "schedule.*workos" . --include="*.yml" --include="*.js"
For Datadog (if configured):
# Check Datadog integration enabled # Manual: Visit https://dashboard.workos.com/integrations # Datadog should show "Connected" # Query Datadog for recent events (requires Datadog CLI) datadog logs query "source:workos" --from "5m"
Error Recovery
"Webhook signature validation failed"
Root cause: Signature mismatch or timestamp outside tolerance window.
Fix:
- •Verify
WORKOS_WEBHOOK_SECRETmatches dashboard value exactly (no extra whitespace) - •Check system clock:
date -u(must be within 5 min of actual time) - •Confirm you're validating against RAW payload, not parsed JSON
- •Check WorkOS-Signature header is being passed to validator
Debug pattern:
console.log('Raw payload:', payload);
console.log('Header:', request.headers['workos-signature']);
console.log('Secret (first 10 chars):', secret.substring(0, 10));
"Events API returns 401 Unauthorized"
Root cause: Invalid or missing API key.
Fix:
- •Verify
WORKOS_API_KEYstarts withsk_(notpk_) - •Check key hasn't been rotated in dashboard
- •Confirm key is being passed correctly:
- •SDK: Should auto-read from env
- •Manual: Use HTTP Basic Auth with key as username, empty password
Test directly:
curl -u "YOUR_API_KEY:" https://api.workos.com/events
# Should return JSON, not {"message": "Unauthorized"}
"No events returned from API"
Root cause: No events in time window or missing connection.
Fix:
- •Check date range: Events older than 7 days are purged
- •Verify connection exists: https://dashboard.workos.com/sso or /directory-sync
- •Trigger test event: Perform SSO login or Directory Sync to generate event
- •Query without filters:
workos.events.list({limit: 1})to check any events exist
"Webhook endpoint returning 200 but events not processing"
Root cause: Silent failure in async processing or DB writes.
Fix:
- •Check background job queue: Are jobs being enqueued?
- •Check job logs: Are async handlers throwing exceptions?
- •Add logging before return 200:
console.log('Event received:', event.id) - •Verify DB writes:
SELECT COUNT(*) FROM processed_events WHERE created_at > NOW() - INTERVAL 1 HOUR
Pattern for debugging:
app.post('/webhooks/workos', async (req, res) => {
console.log('[WEBHOOK] Received:', req.body.id);
try {
validateSignature(req); // Should throw if invalid
console.log('[WEBHOOK] Signature valid');
await enqueueJob(req.body);
console.log('[WEBHOOK] Job enqueued');
res.status(200).send('OK');
} catch (err) {
console.error('[WEBHOOK] Error:', err);
res.status(401).send('Invalid signature');
}
});
"Duplicate events being processed"
Root cause: Race condition between webhook and API polling, or retry logic.
Fix:
- •Implement idempotency check: Query
event_idbefore processing - •Use database unique constraint:
UNIQUE(event_id) - •Catch duplicate key errors gracefully:
try:
db.execute("INSERT INTO processed_events (event_id, ...) VALUES (?, ...)", event_id)
except IntegrityError:
logger.info(f"Event {event_id} already processed,