WorkOS Docs Homepage
Events
API referenceDashboardSign In
Event typesEvent typesData syncingOverviewOverviewSyncing with events APISyncing with events APISyncing with webhooksSyncing with webhooksData reconciliationData reconciliation
Syncing to external databasesSyncing to external databases
Postgres sync quick startPostgres sync quick startMonitoring and OperationsMonitoring and Operations
ObservabilityStreaming to DatadogStreaming to Datadog
API Reference
API Reference
Integrations
Integrations
Migrate to WorkOS
Migrate to WorkOS
SDKs
SDKs

Sync data to an external database

Mirror your WorkOS data into a database you control and query it with SQL.

On this page

  • Introduction
  • The mirror is a read model
  • How it works
  • The schema
  • The write path
  • What it mirrors
    • Known gaps
    • What is not mirrored
  • Guarantees
    • What is not guaranteed
  • Next steps

Introduction

The WorkOS sync engine mirrors your WorkOS data into a Postgres database you control. Users, organizations, memberships, directory data, and more become ordinary tables that you can query with SQL and join to your own application data:

Members and roles by organization
SELECT o.name AS organization, u.email, m.role_slug
FROM workos.organization_memberships_live m
JOIN workos.users_live u ON u.id = m.user_id AND u._env = m._env
JOIN workos.organizations_live o ON o.id = m.organization_id AND o._env = m._env
ORDER BY o.name, u.email;

The engine is open source at workos/sync-engine and ships as the @workos/sync-engine npm package. We also have an existing implementation for Postgres as @workos/sync-postgres, which includes both a library and a workos-sync-postgres command, and as a container image. To set it up, continue to the quick start.

This page explains what the mirror is, how it stays correct, and what it does and does not guarantee.

The mirror is a read model

The mirror is eventually consistent, typically within seconds of a change in WorkOS. It is not the system of record, and it must not be used as one for authorization decisions or for reading back a change you just made.

Need Read from
An authorization decision The session token
A change you just made The API response you already have
The current state of one object The WorkOS API, or the mirror if seconds of lag is fine
Lists, filters, joins, aggregates The mirror
Anything joined to your own tables The mirror
Dashboards, analytics, reporting The mirror
Agent or LLM access The mirror, through a read-only database role
Sign-in history The mirror, because it exists nowhere else

Writes are one-directional. Send changes to the WorkOS API and let them flow back into the mirror through the event stream. There is no write-back.

How it works

Keeping a correct copy of another system’s data takes three subsystems:

Subsystem What it does Why it exists
Backfill Pages the REST list endpoints and writes everything that currently exists. The events API retains 90 days of history. Everything older exists only through the list endpoints.
Tail Polls the events API from a durable cursor and applies changes. Events arrive in a consistent order, the stream is replayable from any cursor, and your process sets its own pace. No public endpoint is required.
Reconcile Compares the set of ids in the mirror against the API. It is the only mechanism that can find a missed delete. A deleted object is absent from every list endpoint, so re-listing can never reveal that it is gone.

The lifecycle runs in this order:

  1. Capture the current position in the event stream.
  2. Backfill by paging the list endpoints for everything that exists.
  3. Drain by replaying events from the captured position.
  4. Tail by polling continuously as the sole writer, checkpointing after each durable write.

Steps 2 and 3 overlap on purpose. Anything that changes while backfill runs is replayed afterwards, and the write path decides which version wins. Because every write is idempotent, the overlap costs nothing, while trying to make the two phases meet exactly is how gaps get created.

The schema

The engine creates a workos schema with one table per synced object. Every table has the same five columns, and the engine only ever writes one of them.

Column Type Meaning
_env text The environment label from your configuration. Part of every primary key.
_raw jsonb The object exactly as WorkOS returned it. The only column the engine writes.
_updated_at timestamptz When the object changed in WorkOS. Used to reject stale writes.
_synced_at timestamptz When the row was written. Lag is the difference between the two.
_deleted_at timestamptz Tombstone. null means the object is live. Append-only tables omit this column.

Every other column is GENERATED ALWAYS AS ... STORED, projected out of _raw, including the primary key:

workos.users
CREATE TABLE workos.users (
_env text NOT NULL,
_raw jsonb NOT NULL,
_updated_at timestamptz NOT NULL,
_synced_at timestamptz NOT NULL,
_deleted_at timestamptz,
id text GENERATED ALWAYS AS ((_raw->>'id')) STORED,
email text GENERATED ALWAYS AS ((_raw->>'email')) STORED,
external_id text GENERATED ALWAYS AS ((_raw->>'external_id')) STORED,
metadata jsonb GENERATED ALWAYS AS ((_raw->'metadata')) STORED,
PRIMARY KEY (_env, id)
);

This design has three consequences:

A new field in the WorkOS API cannot break your sync. It is queryable as _raw->>'field' the moment it appears, and promoting it to a typed column is a single ALTER TABLE ... ADD COLUMN, computed from rows already present, with no backfill. The extraColumns option does this without editing the engine.

Deleted objects are tombstoned, not removed. _deleted_at is set and the last known payload is kept, because it is the only remaining record of what was deleted. Every mirror table also gets a generated <table>_live view, such as workos.users_live, that is the table minus its tombstoned rows, so the common query needs no _deleted_at filter.

There are no foreign keys. Events routinely arrive with children before parents: a group.user_added can land before the user.created it refers to. A foreign key would turn that ordering artifact into a stuck stream.

The mirror can hold several WorkOS environments at once. Object ids are unique within an environment, which is why _env is part of every primary key and why queries join on _env as well as the id.

The write path

Every write goes through one upsert, and the decisions encoded in it are what make out-of-order and duplicate delivery safe.

Stale writes are rejected. A write whose source timestamp is older than the stored row is skipped. The comparison accepts an equal timestamp, because two events can genuinely carry the same one, and a strict comparison would drop the second permanently.

The timestamp comes from WorkOS, never from the database. _updated_at is the object’s own updated_at, or the event’s created_at when the object has none.

Event writes merge; REST writes replace. Most event payloads are the full API object. Not every one is, though: deprecated fields are left out of event payloads, and a few objects are shaped differently in events than in the API. Replacing the whole row on an event that omits a field would silently null that column, so event writes shallow-merge into _raw, while backfill and refresh writes, which carry the complete API object, replace it.

New objects are complete immediately. A create event carries the full object, so the row is complete as soon as the event is applied. The engine can also re-fetch an object when its event arrives (hydration), which is off by default because it costs a request per event and rarely changes anything.

Idempotency lives in the primary key. A redelivered event is a no-op by construction. There is no event-id table to grow, prune, or get wrong.

Deletes always land, and are never undone. A delete is not subject to the staleness check, and a late updated event after a delete refreshes the payload but does not resurrect the row. WorkOS ids are never reused, so this is safe. Memberships are the one exception, because a member being re-added is a real event.

What it mirrors

By default the engine mirrors every object that emits events:

  • Identity: users, organizations, organization memberships, organization domains, invitations, and sessions
  • Directory Sync and SSO: directories, directory users, directory groups, directory group memberships, and SSO connections
  • Authorization: groups, group memberships, roles, organization roles, permissions, and feature flags
  • Logs: every event, verbatim, in workos.events, and every authentication.* event in workos.auth_attempts

Two further streams have no events and are opt-in: FGA resources and webhook endpoints. They are refreshed only when you run reconcile, and are exactly that fresh. Run workos-sync-postgres streams to list every stream with its group, mode, and whether it is on by default.

Authentication events have no REST endpoint anywhere in the WorkOS API. There is no way to list sign-ins after the fact, so if you do not capture them as they happen, that history is gone after 90 days. Once mirrored, “which accounts had five failed sign-ins in the last hour” is a GROUP BY over workos.auth_attempts.

Credentials never reach the mirror. Invitation acceptance tokens and webhook signing secrets are stripped before any row is written.

Known gaps

Some streams cannot be complete, because of the shape of the upstream API.

Stream Limitation
sessions Only active sessions are listable, and only per user. Ended sessions older than the event window are unrecoverable.
auth_attempts No REST endpoint exists. History begins when you start syncing.
organization_domains No standalone list endpoint; sourced from the organization payload. reconcile cannot check it.
group_memberships, directory_group_memberships Event-only. Memberships that existed before you started syncing are not mirrored, and reconcile cannot check them.
directories No dsync.updated event exists. A directory moving from linked to invalid_credentials emits nothing; only reconcile sees the change.
fga_resources, webhook_endpoints No events. Opt-in, and only as fresh as your last reconcile.

What is not mirrored

Object Why
SSO profiles No list endpoint, no get-by-id, and no events. Only returned during a live sign-in.
Audit log events Not listable, and they emit no events. Use exports or log streams instead.
Radar block and allow lists Write-only endpoints.
Vault values Should not leave Vault. Vault activity events can be archived to workos.events.
Portal links and widget tokens Ephemeral credentials, not state.

Guarantees

Convergence. Every write is idempotent and gated on a source-derived timestamp, so the mirror converges on current WorkOS state regardless of the order events arrive, how many times they are delivered, or when the process restarts.

Durability of what it has seen. The cursor is never advanced before the writes it covers are committed. A crash mid-batch replays that batch rather than skipping it.

No silent loss on unknown input. Every event is archived verbatim before it is routed. An event type the engine does not recognize is stored and reported, never dropped and never thrown.

Detectable drift. reconcile compares the mirror against the API and reports differences in both directions.

What is not guaranteed

Read-your-writes. If you create a user through the API and immediately query the mirror, the row may not be there yet.

Authorization decisions. A mirror row can be stale. Roles and permissions belong in the session token, which is signed and current. Use the mirror to analyze authorization, not to enforce it.

Business truth. The mirror faithfully reflects what WorkOS says. A membership with status = 'active' means active in WorkOS. Whether that person should still have access to your product is your question, answered by your data.

Next steps

  • Quick start: install the engine, create the schema, backfill, and start syncing.
  • Operations: what to monitor, what to alert on, how to deploy, and how to recover.
Postgres sync quick start A step-by-step guide to mirroring your WorkOS data into your own Postgres database
Up next
© WorkOS, Inc.
FeaturesAuthKitSingle Sign-OnDirectory SyncAdmin PortalFine-Grained Authorization
DevelopersDocumentationChangelogAPI Status
ResourcesBlogPodcastPricingSecuritySupport
CompanyAboutCustomersCareersLegalPrivacy
© WorkOS, Inc.