Driving the emulator from a test suite: starting it from Node, pointing other languages' SDKs at it, receiving webhooks, forcing failures with error hooks, and serving a real login page for browser tests.
import { createEmulator } from '@workos/emulate';
const emulator = await createEmulator({
port: 0,
seed: {
users: [{ email: 'test@example.com', password: 'secret' }],
},
});
const res = await fetch(`${emulator.url}/user_management/users`, {
headers: { Authorization: `Bearer ${emulator.apiKey}` },
});
emulator.reset();
await emulator.close();The emulator's request budget defaults to 1,000 per API key in each 60-second window. Larger
integration suites that share an API key can set requestsPerMinute to a positive safe integer,
for example createEmulator({ port: 0, requestsPerMinute: 10_000 }). The full configured budget is
accepted, so a budget of 1 allows one request before HTTP 429 responses with Retry-After. The
last accepted request has X-RateLimit-Remaining: 0; the budget resets 60 seconds after the first
request in the window. Anonymous requests share a separate budget.
From the CLI, --requests-per-minute 10000 sets the same budget, and
WORKOS_EMULATE_REQUESTS_PER_MINUTE=10000 is the environment equivalent, for a compose file or the
Docker image. The flag wins over the environment.
The reset() method clears all data and re-seeds from the original config, but route-level authentication events will not work after reset. This is because Hono's router cannot be modified after it's built, so the EventBus cannot be re-registered with the collection hooks.
This limitation is acceptable for test scenarios where reset() is primarily used to clean up state between tests, but it means:
- After calling
reset(), authentication events (authentication.*_succeeded,authentication.*_failed) will not be emitted - Resource lifecycle events (user.created, organization.created, etc.) will still work
- If you need authentication events after reset, you must create a new emulator instance
const emulator = await createEmulator({ port: 0 });
// First run: authentication events work
await fetch(`${emulator.url}/user_management/authenticate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', email: 'test@example.com', password: 'secret' }),
});
// authentication.password_succeeded webhook is delivered
emulator.reset();
// Second run: authentication events DO NOT work
await fetch(`${emulator.url}/user_management/authenticate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', email: 'test@example.com', password: 'secret' }),
});
// NO authentication.password_succeeded webhook is delivered
// Solution: create a new emulator instance if you need authentication events
await emulator.close();
const newEmulator = await createEmulator({ port: 0 });The emulator is a plain HTTP server, so any language can use it — just point your WorkOS SDK's base URL at the emulator instead of https://api.workos.com.
Start the emulator in the background (or in a separate terminal):
workos-emulate --port 4100 --seed workos-emulate.config.yamlimport workos
workos.api_key = "sk_test_default"
workos.base_url = "http://localhost:4100" # ← emulator
# Use the SDK as normal — requests hit the emulator
user = workos.client.user_management.create_user(email="alice@example.com")
# Add an error hook at runtime to test failure handling
import requests
requests.post("http://localhost:4100/_emulate/hooks", json={
"method": "POST",
"path": "/user_management/users",
"status": 422,
"body": {"message": "Validation failed", "code": "unprocessable_entity"},
})
# Now this call returns a 422 — test your error handling
try:
workos.client.user_management.create_user(email="bob@example.com")
except Exception as e:
print(f"Handled error: {e}")use WorkOS\WorkOS;
$workos = new WorkOS('sk_test_default');
$workos->setApiBaseUrl('http://localhost:4100'); // ← emulator
// Use the SDK as normal
$user = $workos->userManagement->createUser(['email' => 'alice@example.com']);
// Add an error hook at runtime
$ch = curl_init('http://localhost:4100/_emulate/hooks');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'method' => 'POST',
'path' => '/user_management/users',
'status' => 500,
]),
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
// Now user creation returns a 500 — test your error handling
try {
$workos->userManagement->createUser(['email' => 'bob@example.com']);
} catch (\Exception $e) {
echo "Handled error: " . $e->getMessage();
}The same pattern works for any language with a WorkOS SDK (Ruby, Go, Java, etc.) — override the base URL and use the /_emulate/hooks HTTP API to manage error hooks from your test setup.
The emulator implements the full workos.com/docs login story: every resource creation and authentication outcome fires a signed webhook, with event names and payload shapes generated from the WorkOS OpenAPI spec. You can run your app's entire login flow — hosted authorize, callback, token exchange, webhook handling — against the emulator without touching the real API.
Seed it (an empty events list subscribes to everything):
webhookEndpoints:
- endpoint_url: http://localhost:5005/webhooks
secret: whsec_test # optional; generated if omitted
events: []
status: enabled # optional; `enabled` or `disabled`Pin secret when your consumer verifies signatures, so the secret is in its environment before
anything talks to the emulator; a generated one is only discoverable through GET /webhook_endpoints.
Or register at runtime:
curl -X POST http://localhost:4100/webhook_endpoints \
-H "Authorization: Bearer sk_test_default" \
-H "Content-Type: application/json" \
-d '{"endpoint_url":"http://localhost:5005/webhooks","events":["user.created","session.created"]}'The API holds a request to what production holds it to: endpoint_url must be an HTTPS URL
(422 invalid_url), events must name at least one event from the subscribable catalog (422
invalid_request_parameters, with an unknown_event_type item per bad name), a URL another
endpoint already uses is a 409 duplicate_endpoint, and the signing secret is generated — a
secret in the body is ignored, as is enabled. PATCH /webhook_endpoints/:id takes
endpoint_url, events and status (enabled or disabled) under the same rules. One
deliberate, emulator-only relaxation: a plain http:// URL is accepted when its host is a
loopback address (localhost, *.localhost, 127.0.0.0/8, ::1), since a receiver on this
machine is the whole point and rarely has a certificate. Any other http:// receiver, a pinned
secret, and the catch-all empty events list are seed-only: the seed is where the emulator
holds state the real API would never let you create.
Point your SDK's base URL at the emulator and follow the AuthKit quickstart exactly as documented:
- Create a user —
POST /user_management/users→ auser.createdwebhook arrives. - Redirect to AuthKit — send the browser to
GET /user_management/authorize?redirect_uri=...&state=.... By default the emulator immediately redirects back to your callback with acode; with--interactiveit serves a real login page first. - Exchange the code — your callback calls
POST /user_management/authenticatewithgrant_type=authorization_code. You get back the user,access_token, andrefresh_token— andsession.createdplusauthentication.oauth_succeededwebhooks arrive (authentication.password_succeededinstead, when the interactive password page checked the login, or the event of the gate that page cleared last). - Other methods work the same way — password, Magic Auth, email verification, MFA, and SSO logins all emit their spec-named
authentication.*_succeededevents; failed attempts emitauthentication.*_failedwith anerror: { code, message }object.
Codes that WorkOS would deliver by email are delivered to you in the webhook payload instead: magic_auth.created carries the Magic Auth code, password_reset.created carries the reset password_reset_token (and the password_reset_url built around it), and email_verification.created carries the verification code. Your test can drive the whole flow from webhooks alone — see src/e2e.spec.ts for a complete worked example.
Webhooks are signed exactly like production WorkOS: WorkOS-Signature: t=<timestamp>, v1=<hmac> where the HMAC-SHA256 is computed over "{timestamp}.{body}" with the endpoint's secret. The official SDKs' webhooks.constructEvent verifies them unchanged.
Authentication events carry the spec payload { type, status, user_id, email, ip_address, user_agent } (plus error on failures and sso details on SSO events).
| Trigger | Events |
|---|---|
| Login success (per method) | authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_succeeded |
| Login failure (bad/expired credential) | authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_failed |
| Sessions | session.created, session.revoked |
| Users | user.created, user.updated, user.deleted |
| Login-flow resources | magic_auth.created, email_verification.created, password_reset.created, password_reset.succeeded |
| Organizations & domains | organization.*, organization_domain.{created,updated,deleted} (verified only through the store, see Organization domains) |
| Memberships & invitations | organization_membership.*, invitation.{created,accepted,revoked,resent} |
| Connections | connection.activated, connection.deactivated, connection.deleted |
| Directory Sync | dsync.activated, dsync.deleted, dsync.user.*, dsync.group.* |
| Roles & permissions | role.*, organization_role.*, permission.* |
| API keys & feature flags | api_key.{created,updated,revoked}, flag.{created,updated,deleted} |
The full catalog (including names the emulator never emits, like authentication.passkey_* and vault.*) lives in src/workos/generated/events.ts, generated from the @workos/openapi-spec package.
All events are also queryable at GET /events. The events filter is required, as in production — there is no "all events" listing — in any form an SDK sends it (?events=user.created,user.updated, repeated ?events=, or ?events[]=); a name outside the catalog is a 400 invalid_events_request, and so is range_start combined with after.
- Delivery is fire-and-forget with a 5-second timeout and no retries — poll your receiver in tests rather than asserting immediately.
- Resources defined in a seed file record events (visible at
GET /events) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data. - Seeding a directory user into a group emits
dsync.group.user_added. Removing that membership emitsdsync.group.user_removed. There is still no HTTP route to mutate a directory; production connects one in the dashboard.
Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.).
@workos/emulate/core exports the two error classes the emulator itself throws, for hooks that need to
raise a failure rather than describe one: WorkOSApiError(status, message, code) renders the plain
{code, message} envelope, and OauthApiError(status, error, description) the RFC 6749
{error, error_description} one used by /sso/token, /oauth2/token and the OAuth-shaped
authenticate grants (see Authentication failure shapes).
Add errorHooks to your config file:
errorHooks:
- method: POST
path: /user_management/users
status: 422
body:
message: 'Validation failed'
code: 'unprocessable_entity'
errors:
- field: email
code: invalid
message: 'must be a valid email'
- method: GET
path: /user_management/users
status: 500
# Fail the first 3 requests, then let them through
- method: '*'
path: /organizations
status: 503
count: 3| Field | Required | Description |
|---|---|---|
method |
yes | HTTP method to match (GET, POST, etc.) or * for any |
path |
yes | URL path to match — exact (/user_management/users), prefix with wildcard (/user_management/*), or * for all |
status |
yes | HTTP status code to return |
body |
no | Custom JSON response body (message, code, errors). A sensible default is used when omitted. |
count |
no | Number of times the hook fires before it auto-removes. Omit for unlimited. |
Manage hooks at runtime without restarting the emulator. These endpoints require no authentication.
# List all hooks
curl http://localhost:4100/_emulate/hooks
# Add a hook
curl -X POST http://localhost:4100/_emulate/hooks \
-H "Content-Type: application/json" \
-d '{"method":"GET","path":"/user_management/users","status":500}'
# Remove a hook by ID
curl -X DELETE http://localhost:4100/_emulate/hooks/hook_abc123const emulator = await createEmulator({ port: 0 });
// Make user creation return a 422
const hook = emulator.addErrorHook({
method: 'POST',
path: '/user_management/users',
status: 422,
body: { message: 'Email is invalid', code: 'unprocessable_entity' },
});
// Your app code under test handles the error...
// Clean up
emulator.removeErrorHook(hook.id);
// Or list what's active
emulator.listErrorHooks();
// reset() clears all hooks and re-seeds from the original config
emulator.reset();Error hooks can be used for sophisticated testing scenarios:
const emulator = await createEmulator({ port: 0 });
// Make the first 3 requests fail, then succeed
emulator.addErrorHook({
method: 'POST',
path: '/user_management/users',
status: 503,
count: 3, // Auto-remove after 3 uses
});
// Your app's retry logic will handle the failures
for (let i = 0; i < 4; i++) {
const res = await fetch(`${emulator.url}/user_management/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${emulator.apiKey}` },
body: JSON.stringify({ email: 'test@example.com' }),
});
console.log(`Attempt ${i + 1}:`, res.status);
// Attempt 1-3: 503, Attempt 4: 201
}// Simulate validation errors for specific inputs
emulator.addErrorHook({
method: 'POST',
path: '/user_management/users',
status: 422,
body: {
message: 'Validation failed',
code: 'unprocessable_entity',
errors: [
{ field: 'email', code: 'invalid', message: 'must be a valid email' },
{ field: 'password', code: 'too_short', message: 'must be at least 8 characters' },
],
},
});// Simulate rate limiting after 10 requests
let requestCount = 0;
emulator.addErrorHook({
method: '*',
path: '/user_management/*',
status: 429,
body: {
message: 'Rate limit exceeded',
code: 'rate_limit_exceeded',
},
count: 1, // Will be managed manually
});
// In your test, manage the hook manually
const checkRateLimit = async () => {
requestCount++;
if (requestCount > 10) {
// Add the rate limit hook
emulator.addErrorHook({
method: '*',
path: '/user_management/*',
status: 429,
});
}
};By default, the SSO and AuthKit authorize endpoints auto-redirect with an auth code — great for API-level tests, but agent browsers and E2E test frameworks need an actual login page to interact with.
Pass --interactive (CLI) or interactiveAuth: true (programmatic) to enable login pages:
workos-emulate --interactive --seed workos-emulate.config.yamlconst emulator = await createEmulator({
interactiveAuth: true,
seed: {
users: [{ email: 'test@example.com', password: 'secret' }],
connections: [{ name: 'Test SSO', organization: 'Acme', domains: ['example.com'] }],
organizations: [{ name: 'Acme' }],
},
});| Endpoint | Default (auto) | Interactive |
|---|---|---|
GET /sso/authorize |
Immediately redirects to callback with auth code | Serves an HTML login page with an email field |
GET /user_management/authorize |
Immediately redirects to callback with auth code | Serves an HTML login page with an email field, plus a collapsed list of the accounts the emulator holds |
When interactive mode is on:
- Your app redirects to
/sso/authorize?client_id=...&connection=...&redirect_uri=...(or/user_management/authorize?...) - The emulator serves a login page instead of auto-redirecting
- The browser (or agent) fills in the email field and submits the form
- With the password option on, a user who has a password is asked for it next
- Under the same option, the gates the
passwordgrant enforces follow: an unverified mailbox is asked for its emailed code, then an enrolled second factor for its one-time code - If the user belongs to more than one organization, the emulator asks which one, as hosted AuthKit does
- The emulator creates an auth code and redirects back to your app's callback URL
The login_hint parameter pre-fills the email field, so agent browsers can skip typing if desired.
The login page also lists every account the emulator holds, collapsed behind Pick an account (N) and ordered by email, so a test does not have to hard-code an address to sign in as one. Each row submits directly. A user created through the API mid-session appears in the list too. Typing an address that was never seeded still works, which is what sign-up flows rely on.
A user with more than one active membership gets a second page, one row per organization, before any code is minted. This mirrors hosted AuthKit, and it matters because the alternative is the API-level organization_selection_required response, which a browser client receives mid-callback and cannot act on.
Skip the page by passing organization_id to /user_management/authorize; it must be one the user is an active member of, or the request is refused. If the membership behind a code is revoked before the code is redeemed, redemption fails with invalid_grant rather than silently signing the user into a different tenant.
test('multi-organization login', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'alice@example.com');
await page.click('button[type="submit"]');
// Only shown when the user belongs to several organizations
await page.click('text=Acme');
await expect(page).toHaveURL(/dashboard/);
});The login page asks for an email and nothing else, so a suite signs in as anyone with one form fill. Pass --interactive-password (CLI) or interactiveAuth: { password: true } (programmatic) to add the step hosted AuthKit puts next: a user who has a password is asked for it after the email and before any organization question.
workos-emulate --interactive-password --seed workos-emulate.config.yamlconst emulator = await createEmulator({
interactiveAuth: { password: true },
seed: { users: [{ email: 'alice@example.com', password: 'correct-horse', email_verified: true }] },
});- The page shows the email read-only above an
input[name="password"], with a "Use a different account" link back to the email page. A user without a password skips it, so a sign-up or a passwordless seed behaves exactly as before. - A wrong password re-renders the page with an inline error (HTTP 401) and emits
authentication.password_failedwithinvalid_credentials, the same event thepasswordgrant emits. Nothing reaches your callback until the password is right. - Once it is, the code the callback receives remembers how it was earned: the exchange emits
authentication.password_succeededrather thanauthentication.oauth_succeeded, and the response carries"authentication_method": "Password". - The gates the
passwordgrant enforces apply here too, in the order hosted AuthKit shows them. A user whoseemail_verifiedisfalsegets a "Verify your email" page for the code that arrives on theemail_verification.createdwebhook, and a user with an enrolled factor (totp: truein a seed) gets a one-time-code page for a fresh authentication challenge, whose code a test reads from the store as it does to finish themfa-totpgrant. Both pages take aninput[name="code"]. A wrong code re-renders with an inline error (HTTP 401) and emitsauthentication.email_verification_failedorauthentication.mfa_failed, as the matching grant would; three wrong codes lock the code as they would at the grant (one_time_code_too_many_attemptson the event), and the page issues a fresh one, as it does for a code that has expired. The verification page compares the user's latest code, as the grant does, so a code sent since the page sent its own (email_verification/send, another login) retires the page's; the code it accepts is redeemed, not dropped. - When a gate was cleared, the exchange reports what that grant reports:
authentication.email_verification_succeededorauthentication.mfa_succeededin place ofauthentication.password_succeeded, with the session andauthentication_methodstill recordingPassword. Fixtures that should go from the password straight to the callback wantemail_verified: true, as they do for thepasswordgrant. - Every page after the password carries a short-lived token rather than the password. Posting an
organization_id, or a code, without that token lands back on the password page, and posting past an open gate lands back on the gate. - Plain
--interactiveis unchanged.
test('password login', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'alice@example.com');
await page.click('button[type="submit"]');
await page.fill('input[name="password"]', 'correct-horse');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/dashboard/);
});test('SSO login flow', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.click('text=Sign in with SSO');
// Emulator serves the login page
await page.fill('input[name="email"]', 'alice@example.com');
await page.click('button[type="submit"]');
// Redirected back to your app with a valid session
await expect(page).toHaveURL(/dashboard/);
});This replaces the need for WorkOS's Test Identity Provider — no dashboard login required, works in incognito, works with headless browsers.