Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/workos/workos-node/llms.txt

Use this file to discover all available pages before exploring further.

This guide covers receiving, verifying, and processing webhook events from WorkOS to keep your application in sync with changes in WorkOS.

Prerequisites

npm install @workos-inc/node
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS(process.env.WORKOS_API_KEY);

Understanding Webhooks

WorkOS sends webhook events to notify your application about important changes:
  • User and group changes in Directory Sync
  • Authentication events
  • Organization updates
  • Connection status changes
Each webhook includes a signature header for verification to ensure events are genuinely from WorkOS.

Setting Up Webhook Endpoint

1

Create Webhook Endpoint

Create an endpoint to receive webhook events:
import express from 'express';
import { WorkOS } from '@workos-inc/node';

const app = express();
const workos = new WorkOS(process.env.WORKOS_API_KEY);

// Important: Use express.json() middleware
app.use(express.json());

app.post('/webhooks/workos', async (req, res) => {
  try {
    const webhook = await workos.webhooks.constructEvent({
      payload: req.body,
      sigHeader: req.headers['workos-signature'] as string,
      secret: process.env.WORKOS_WEBHOOK_SECRET
    });

    console.log('Webhook event:', webhook.event);
    console.log('Event data:', webhook.data);

    // Process the event
    await handleWebhookEvent(webhook);

    res.sendStatus(200);
  } catch (error) {
    console.error('Webhook verification failed:', error);
    res.sendStatus(400);
  }
});

app.listen(3000);
2

Verify Webhook Signatures

Always verify webhook signatures to ensure authenticity:
const webhook = await workos.webhooks.constructEvent({
  payload: req.body,
  sigHeader: req.headers['workos-signature'] as string,
  secret: process.env.WORKOS_WEBHOOK_SECRET,
  tolerance: 180000  // Optional: 180 seconds (default)
});
The constructEvent method:
  • Verifies the signature hash
  • Checks the timestamp is within tolerance
  • Deserializes the event data
  • Throws SignatureVerificationException if verification fails
3

Handle Different Event Types

Process different webhook events:
async function handleWebhookEvent(webhook: Event) {
  switch (webhook.event) {
    case 'dsync.user.created':
      await handleUserCreated(webhook.data);
      break;

    case 'dsync.user.updated':
      await handleUserUpdated(webhook.data);
      break;

    case 'dsync.user.deleted':
      await handleUserDeleted(webhook.data);
      break;

    case 'dsync.group.user_added':
      await handleUserAddedToGroup(webhook.data);
      break;

    case 'dsync.group.user_removed':
      await handleUserRemovedFromGroup(webhook.data);
      break;

    default:
      console.log('Unhandled event type:', webhook.event);
  }
}

Common Webhook Events

Directory Sync Events

User Events

// dsync.user.created
await workos.webhooks.constructEvent({
  payload: req.body,
  sigHeader: req.headers['workos-signature'],
  secret: process.env.WORKOS_WEBHOOK_SECRET
});

if (webhook.event === 'dsync.user.created') {
  console.log('New user:', webhook.data.id);
  console.log('Email:', webhook.data.email);
  console.log('First name:', webhook.data.firstName);
  console.log('Last name:', webhook.data.lastName);
  console.log('State:', webhook.data.state);
  console.log('Groups:', webhook.data.groups);
  console.log('Directory:', webhook.data.directoryId);
  console.log('Organization:', webhook.data.organizationId);
}

Group Events

switch (webhook.event) {
  case 'dsync.group.created':
    console.log('Group created:', webhook.data.name);
    break;

  case 'dsync.group.updated':
    console.log('Group updated:', webhook.data.name);
    break;

  case 'dsync.group.deleted':
    console.log('Group deleted:', webhook.data.id);
    break;

  case 'dsync.group.user_added':
    console.log('User added to group');
    console.log('User ID:', webhook.data.id);
    console.log('Groups:', webhook.data.groups);
    break;

  case 'dsync.group.user_removed':
    console.log('User removed from group');
    console.log('User ID:', webhook.data.id);
    break;
}

Connection Events

if (webhook.event === 'connection.activated') {
  console.log('Connection activated:', webhook.data.id);
  console.log('Connection type:', webhook.data.connectionType);
  console.log('Organization:', webhook.data.organizationId);
}

if (webhook.event === 'connection.deactivated') {
  console.log('Connection deactivated:', webhook.data.id);
}

Advanced Verification

Manual Signature Verification

If you need more control over verification:
try {
  await workos.webhooks.verifyHeader({
    payload: req.body,
    sigHeader: req.headers['workos-signature'] as string,
    secret: process.env.WORKOS_WEBHOOK_SECRET,
    tolerance: 180000
  });

  // Signature is valid, process the webhook
  const webhook = req.body;
  await handleWebhookEvent(webhook);
} catch (error) {
  console.error('Signature verification failed:', error);
  res.sendStatus(400);
}

Compute Signature

Compute a signature for testing:
const timestamp = Date.now();
const signature = await workos.webhooks.computeSignature(
  timestamp,
  req.body,
  process.env.WORKOS_WEBHOOK_SECRET
);

console.log('Expected signature:', signature);

Parse Signature Header

Extract timestamp and signature from header:
const { timestamp, signatureHash } = 
  workos.webhooks.getTimestampAndSignatureHash(
    req.headers['workos-signature'] as string
  );

console.log('Timestamp:', timestamp);
console.log('Signature:', signatureHash);

Error Handling

import { SignatureVerificationException } from '@workos-inc/node';

app.post('/webhooks/workos', async (req, res) => {
  try {
    const webhook = await workos.webhooks.constructEvent({
      payload: req.body,
      sigHeader: req.headers['workos-signature'] as string,
      secret: process.env.WORKOS_WEBHOOK_SECRET
    });

    await processWebhook(webhook);
    res.sendStatus(200);
  } catch (error) {
    if (error instanceof SignatureVerificationException) {
      console.error('Invalid webhook signature');
      return res.sendStatus(401);
    }

    console.error('Webhook processing error:', error);
    res.sendStatus(500);
  }
});

Best Practices

1. Respond Quickly

Respond with 200 OK as soon as you receive the webhook. Process events asynchronously:
app.post('/webhooks/workos', async (req, res) => {
  try {
    const webhook = await workos.webhooks.constructEvent({
      payload: req.body,
      sigHeader: req.headers['workos-signature'] as string,
      secret: process.env.WORKOS_WEBHOOK_SECRET
    });

    // Respond immediately
    res.sendStatus(200);

    // Process asynchronously
    processWebhookAsync(webhook).catch(console.error);
  } catch (error) {
    res.sendStatus(400);
  }
});

2. Handle Duplicates

Webhook events may be delivered more than once. Use the event ID to track processed events:
const processedEvents = new Set();

async function processWebhook(webhook: Event) {
  if (processedEvents.has(webhook.id)) {
    console.log('Duplicate event, skipping:', webhook.id);
    return;
  }

  processedEvents.add(webhook.id);

  // Process the event
  await handleWebhookEvent(webhook);
}

3. Implement Retries

If processing fails, WorkOS will retry with exponential backoff. Ensure your endpoint is idempotent:
async function handleUserCreated(user: any) {
  // Check if user already exists
  const existing = await db.users.findOne({ directoryUserId: user.id });

  if (existing) {
    // Update instead of creating
    return db.users.update(existing.id, { ...user });
  }

  // Create new user
  return db.users.create({ directoryUserId: user.id, ...user });
}

4. Secure Your Endpoint

Always verify signatures and use HTTPS in production:
if (process.env.NODE_ENV === 'production' && !req.secure) {
  return res.status(403).send('HTTPS required');
}

try {
  await workos.webhooks.verifyHeader({
    payload: req.body,
    sigHeader: req.headers['workos-signature'] as string,
    secret: process.env.WORKOS_WEBHOOK_SECRET
  });
} catch (error) {
  return res.status(401).send('Invalid signature');
}

Testing Webhooks

Test webhook handling locally:
import crypto from 'crypto';

// Generate test webhook
function generateTestWebhook() {
  const timestamp = Date.now();
  const payload = {
    id: 'wh_test_123',
    event: 'dsync.user.created',
    data: {
      id: 'directory_user_123',
      email: 'test@example.com',
      firstName: 'Test',
      lastName: 'User',
      state: 'active',
      directoryId: 'directory_123',
      organizationId: 'org_123',
      groups: []
    }
  };

  const secret = process.env.WORKOS_WEBHOOK_SECRET;
  const unhashedString = `${timestamp}.${JSON.stringify(payload)}`;
  const signatureHash = crypto
    .createHmac('sha256', secret)
    .update(unhashedString)
    .digest('hex');

  return {
    payload,
    headers: {
      'workos-signature': `t=${timestamp}, v1=${signatureHash}`
    }
  };
}

// Test your webhook handler
const { payload, headers } = generateTestWebhook();
const webhook = await workos.webhooks.constructEvent({
  payload,
  sigHeader: headers['workos-signature'],
  secret: process.env.WORKOS_WEBHOOK_SECRET
});

console.log('Test webhook verified:', webhook.event);

Complete Example

import express from 'express';
import { WorkOS, SignatureVerificationException } from '@workos-inc/node';

const app = express();
const workos = new WorkOS(process.env.WORKOS_API_KEY);

app.use(express.json());

app.post('/webhooks/workos', async (req, res) => {
  try {
    const webhook = await workos.webhooks.constructEvent({
      payload: req.body,
      sigHeader: req.headers['workos-signature'] as string,
      secret: process.env.WORKOS_WEBHOOK_SECRET
    });

    // Respond immediately
    res.sendStatus(200);

    // Process asynchronously
    processWebhook(webhook).catch(error => {
      console.error('Webhook processing failed:', error);
    });
  } catch (error) {
    if (error instanceof SignatureVerificationException) {
      console.error('Invalid webhook signature');
      return res.sendStatus(401);
    }

    console.error('Webhook error:', error);
    res.sendStatus(400);
  }
});

const processedEvents = new Set<string>();

async function processWebhook(webhook: any) {
  // Prevent duplicate processing
  if (processedEvents.has(webhook.id)) {
    console.log('Duplicate event:', webhook.id);
    return;
  }

  processedEvents.add(webhook.id);

  // Handle different event types
  switch (webhook.event) {
    case 'dsync.user.created':
      await db.users.create({
        directoryUserId: webhook.data.id,
        email: webhook.data.email,
        firstName: webhook.data.firstName,
        lastName: webhook.data.lastName,
        state: webhook.data.state,
        organizationId: webhook.data.organizationId
      });
      console.log('User created:', webhook.data.email);
      break;

    case 'dsync.user.updated':
      await db.users.update(
        { directoryUserId: webhook.data.id },
        {
          email: webhook.data.email,
          firstName: webhook.data.firstName,
          lastName: webhook.data.lastName,
          state: webhook.data.state
        }
      );
      console.log('User updated:', webhook.data.email);
      break;

    case 'dsync.user.deleted':
      await db.users.delete({ directoryUserId: webhook.data.id });
      console.log('User deleted:', webhook.data.id);
      break;

    default:
      console.log('Unhandled event type:', webhook.event);
  }
}

app.listen(3000, () => {
  console.log('Webhook server running on port 3000');
});

Build docs developers (and LLMs) love