Skip to Content

Simulators

Clerk simulator

clerk-twin is a local, stateful simulator of Clerk’s Backend API — users and their metadata, organizations with their metadata and memberships, and the webhooks they emit — with a browser companion that signs a @clerk/nextjs app in. It never calls Clerk: no email is sent, no provider is contacted and no real instance changes.

Quickstart

You need an API key from Account → API keys  (how).

curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                      # your API key
rystic run clerk-twin -d --port 0       # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version

Expect "product":"clerk-twin". The examples below use localhost:8080; use the port rystic run printed.

On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).

Create a user, then an organization they administer:

curl -s localhost:8080/v1/users -H 'Authorization: Bearer sk_test_placeholder' \
  -H 'Content-Type: application/json' \
  -d '{"email_address":["Ada@Example.com"],"first_name":"Ada","skip_password_requirement":true,"legal_accepted_at":"2026-01-01T00:00:00Z"}'
# -> {...,"email_addresses":[{...,"email_address":"ada@example.com",...}],...,"id":"user_200000000000000000000000001",...,"object":"user",...}

curl -s localhost:8080/v1/organizations -H 'Authorization: Bearer sk_test_placeholder' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Acme","created_by":"user_200000000000000000000000001"}'
# -> {...,"id":"org_1",...,"object":"organization",...}

curl -s localhost:8080/v1/organizations/org_1/memberships -H 'Authorization: Bearer sk_test_placeholder'
# -> {"data":[{...,"role":"org:admin",...}],"total_count":1}

Point the SDK at the twin’s origin; it appends /v1 itself:

// @clerk/backend
const clerk = createClerkClient({ secretKey: "sk_test_placeholder", apiUrl: "http://localhost:8080" });

The API version

The twin answers as Clerk’s Backend API version 2026-05-12 unless a request’s Clerk-API-Version header names another, and every answer carries the version in clerk-api-version.

  1. It accepts 2021-02-05, 2024-10-01, 2025-04-10, 2025-11-10 and 2026-05-12; any other value answers 400 api_version_invalid.
  2. From 2026-05-12, PATCH /v1/users/{user_id} refuses public_metadata, private_metadata and unsafe_metadata, and PATCH /v1/organizations/{organization_id} refuses public_metadata and private_metadata, each with 422 form_param_deprecated: write metadata through the /metadata routes.

Plans

Every plan (plans), Free included, serves the whole Clerk surface, the browser companion and every control route below but /_rystic/network and /_rystic/ws-drop. Without --scenario the twin boots an empty instance, and POST /_rystic/reset wipes it back to empty.

Free runs 5 hours of simulator time a month. Enterprise runs on a license file instead of an API key.

Custom latency and fault injection are Pro and Enterprise: /_rystic/network and /_rystic/ws-drop need the faults feature, and on Free they answer 403 naming it.

Auth

Clerk reads a secret key as Authorization: Bearer sk_…. The twin accepts any bearer value that starts with sk_ and checks nothing else; no Clerk instance or key is involved.

  1. A request with no bearer token, or one whose value does not start with sk_, is refused with the error its route’s spec documents, so the status and code vary by route: 401 authentication_invalid on the user routes, the /metadata routes, listing organization memberships and deleting one; 401 authorization_header_format_invalid on GET /v1/users/count; 403 authentication_invalid on GET /v1/organizations and GET /v1/organizations/{organization_id}; 403 authorization_invalid on POST /v1/organizations, PATCH /v1/organizations/{organization_id}, adding a membership and GET /v1/users/{user_id}/organization_memberships.
  2. DELETE /v1/organizations/{organization_id} and PATCH /v1/organizations/{organization_id}/memberships/{user_id} check no credential, since their spec declares no refusal; Clerk refuses them. None of these refusals is measured against Clerk.
  3. Publishable keys (pk_…) belong to the browser companion below, never to the Backend API.

Coverage

40 routes across 5 resources, counted from the sim’s literal registrations: 20 listed below, and 20 more that answer the same operations at other paths.

ResourceOperations
Organization (8)GET, POST /v1/organizations; GET, PATCH, DELETE /v1/organizations/{organization_id}; GET /v1/organizations/{organization_id}/memberships; PUT, PATCH /v1/organizations/{organization_id}/metadata
Organization Membership (3)POST /v1/organizations/{organization_id}/memberships; PATCH, DELETE /v1/organizations/{organization_id}/memberships/{user_id}
User (9)GET, POST /v1/users; GET /v1/users/count; GET, PATCH, DELETE /v1/users/{user_id}; PUT, PATCH /v1/users/{user_id}/metadata; GET /v1/users/{user_id}/organization_memberships
  1. Bodies are JSON both ways, and timestamps are Unix milliseconds.
  2. Email addresses are stored lowercase and matched in any case: GET /v1/users?email_address= finds a user by any letter case of their address, and a create whose address differs from a taken one only in case answers 422 form_identifier_exists. A malformed address answers 422 form_param_format_invalid on email_address.
  3. GET /v1/users refuses an order_by that names no sortable field with 422 form_param_value_invalid.
  4. POST /v1/organizations with created_by gives that user an org:admin membership: GET /v1/organizations/{organization_id}/memberships lists it right after the create, and GET /v1/organizations/{organization_id}?include_members_count=true counts it. Deleting a user removes their memberships, and each organization’s count drops by one.
  5. PATCH /v1/organizations/{organization_id}/metadata deep-merges public_metadata and private_metadata, removing a key set to null; PUT replaces each field it names whole. The user /metadata pair does the same for a user.
  6. Every image_url and profile_image_url the twin hands out leads back to the twin, under /preview/, where GET or HEAD answers a PNG with no credential.

Not modelled, by decision: ban and unban, lock and unlock, sessions, invitations and session tokens, the Frontend API, and every other Backend API family. An unserved route answers 404 {"message":"No route in twin for …"}, not Clerk’s error envelope.

The browser companion

Beside the Backend API the twin serves lookalike sign-in and sign-up screens for @clerk/nextjs: email sign-in (any email signs in, and an unknown one creates a user after consent), simulated Continue with GitHub and Continue with Google buttons that sign in as github@example.test and google@example.test, organization selection and member management, and RS256 session tokens that carry the current public metadata and organization. It shares its users, organizations and memberships with the Backend API. No provider, OAuth exchange or email is involved.

It needs three things at boot: an RSA private key of your own, your site’s origin, and the twin’s own origin as the browser reaches it (CLERK_TWIN_ISSUER). The twin reads the key from a file on your machine, which the Docker runner does not mount, so the example runs it on the native runner. The issuer’s host needs a dot, because Clerk SDKs refuse the publishable key of a host such as localhost, so the example uses 127.0.0.1:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ./clerk-twin.pem && chmod 600 ./clerk-twin.pem
rystic run clerk-twin --runner native -d --port 8080 --env CLERK_TWIN_PRIVATE_KEY_FILE="$PWD/clerk-twin.pem" \
  --env CLERK_TWIN_SITE_ORIGIN=http://localhost:3000 --env CLERK_TWIN_ISSUER=http://127.0.0.1:8080
curl -s localhost:8080/_twin/config      # the publishable key, the public PEM and the proxy URL

Configure the site from /_twin/config and the twin’s paths:

  1. Set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_JWT_KEY and NEXT_PUBLIC_CLERK_PROXY_URL from /_twin/config.
  2. Point NEXT_PUBLIC_CLERK_JS_URL and NEXT_PUBLIC_CLERK_UI_URL at the twin’s /clerk.js and /clerk-ui.js.
  3. Set CLERK_API_URL to the twin’s origin, and use any sk_ value as CLERK_SECRET_KEY.
  4. Set NEXT_PUBLIC_CLERK_SIGN_IN_URL and NEXT_PUBLIC_CLERK_SIGN_UP_URL to your own pages.

When a session token has expired, or a visit arrives from another origin, the SDK middleware sends the browser to the proxy URL’s /v1/client/handshake. The twin answers there and sends the browser back to your site, with the session renewed if it is still live and signed out if it is not. Without the sign-in and sign-up URLs, the SDK sends a signed-out visitor to Clerk’s hosted Account Portal. Without a key file the companion’s JSON and JWKS routes are off; the Backend API serves either way.

Control plane

Every route under /_rystic/ is listed in routes by plan. The twin has no WebSocket feed.

Scenarios

Five shipped worlds, each on 2026-02-02T00:00:00Z.

ScenarioWhat it holds
clerk_acme_tenantfive users with mixed credential and MFA state in one organization, two admins and three members, two pending invitations and three live browser sessions
clerk_holdco_orgsone identity across three organizations as admin, billing manager and member, each with its own session context
clerk_invite_backlogan organization at its ten-seat cap with two members and eight invitations pending across roles
clerk_session_sweepsix browser sessions across three users: two long lapsed, one expiring two hours after the pinned clock, three live
clerk_solo_founderone user, the organization she created, the membership joining them, her one session and her one invitation

Seeding state

POST /_rystic/seed takes a register map — state_user, state_organization, state_organization_membership (keyed <organization_id>/<user_id>), state_browser_session, state_browser_invitation — and lands it directly; GET /_rystic/registers lists every field. --seed <file> or RYSTIC_SEED=<file> boots one.

Determinism & reset

Ids are a sequence (user_200000000000000000000000001, then …0002; org_1, then org_2). The clock starts at the wall time the twin boots and moves one millisecond per request that stamps a time, never by wall time; every scenario starts at 2026-02-02T00:00:00Z. POST /_rystic/clock pins it, so two identical runs from the same reset and the same pinned clock give byte-identical users and organizations. Sessions expire after 24 hours of twin time, and POST /_rystic/reset ends every session: a token issued before it no longer verifies. A session token’s iat, nbf and exp are stamped on the wall clock instead, with exp at the session’s remaining life, so your site accepts them however long the twin sits idle and wherever its clock is pinned.

Egress

Each user write (its /metadata routes included), each organization create, update and delete, and each membership write records the webhook event Clerk would send: user.created, user.updated, user.deleted, organization.created, organization.updated, organization.deleted, and organizationMembership.created, .updated and .deleted. The organization /metadata routes record none. GET /_rystic/egress returns the events in order. Boot the twin with --env CLERK_TWIN_WEBHOOK_URL=<your receiver> and --env CLERK_TWIN_WEBHOOK_SECRET=whsec_… and each is also delivered, signed with Svix headers whose svix-timestamp is the wall clock at the write, so your receiver’s five-minute window holds however long the twin sat idle; the envelope’s own timestamp is twin time. A failed delivery never undoes the write. The event shapes follow Clerk’s webhook documentation and are not measured against live deliveries.

Limitations

Known issues.

  • Typed parameters are not validated: banned=maybe, created_at_after=notanumber or a numeric first_name is accepted where Clerk answers 422 (RYS-1906)
  • Responses carry no x-clerk-trace-id header to match the body’s clerk_trace_id (RYS-1912)
  • An operation the twin does not serve, such as ban, lock, sessions or invitations, answers a plain 404 instead of Clerk’s resource_not_found error (RYS-1913)

Not served. 232 of the 252 operations in the published spec are not served.

  • Organization Memberships, 4 of 6 served: GET /v1/organization_memberships, PATCH /v1/organizations/{organization_id}/memberships/{user_id}/metadata
  • Organizations, 7 of 12 served: GET /v1/organizations/{organization_id}/billing/credits, POST /v1/organizations/{organization_id}/billing/credits, GET /v1/organizations/{organization_id}/billing/subscription, PUT /v1/organizations/{organization_id}/logo, DELETE /v1/organizations/{organization_id}/logo
  • Users, 9 of 37 served: POST /v1/users/ban, POST /v1/users/unban, DELETE /v1/users/{user_id}/backup_code, POST /v1/users/{user_id}/ban, GET /v1/users/{user_id}/billing/credits, POST /v1/users/{user_id}/billing/credits, GET /v1/users/{user_id}/billing/subscription, GET /v1/users/{user_id}/biometric_credentials, … and 20 more
  • Not served at all: Actor Tokens (2), Admin Portal Link Tokens (2), Agent Tasks (2), Allow-list / Block-list (6), API Keys (8), Beta Features (3), Billing (12), Clients (3), Directories (10), Domains (4), Email & SMS Templates (6), Email Addresses (7), Enterprise Connections (7), Instance Settings (11), Invitations (4), JWKS (1), JWT Templates (5), M2M Tokens (4), Machines (9), Miscellaneous (1), … and 18 more

Required fields the model does not declare. The spec requires these in a success response; the model’s response shape leaves them out, so the twin may not return them:

  • PATCH /v1/organizations/{organization_id}/memberships/{user_id} (200): public_user_data/first_name, public_user_data/has_image, public_user_data/image_url, public_user_data/last_name, public_user_data/profile_image_url, public_user_data/user_id
  • POST /v1/organizations/{organization_id}/memberships (200): organization/admin_delete_enabled, organization/created_at, organization/has_image, organization/id, organization/max_allowed_memberships, organization/name, organization/public_metadata, organization/slug, … and 6 more
  • PUT /v1/users/{user_id}/metadata (200): backup_code_enabled, create_organization_enabled, delete_self_enabled, external_id, last_active_at, last_sign_in_at, legal_accepted_at, lockout_expires_in_seconds, … and 9 more

Not measured. The fidelity suite runs 100 probes, and 19 documented branches have none. 13 need a credential the measuring account does not hold (401 ×6, 403 ×7). The other 6:

OperationStatusWhy
DELETE /v1/organizations/{organization_id}/memberships/{user_id}422the model describes no input that reaches it
GET /v1/organizations/{organization_id}/memberships422nothing the twin serves creates the record it needs
PATCH /v1/organizations/{organization_id}402the model describes no input that reaches it
PATCH /v1/users/{user_id}409the model describes no input that reaches it
POST /v1/organizations402the model describes no input that reaches it
POST /v1/users402the model describes no input that reaches it

1 probe from earlier rounds is kept as unmeasured.

Not modeled. What the model’s operations state they leave out:

  • Credential-refusal outcomes (401/403) are not declared in this operation’s responses and are not observable with the harness’s valid secret key. (DELETE /v1/organizations/{organization_id})
  • Deletion of the organization’s pending invitations (cascade is documented, but no invitation state family or operation is in scope). (DELETE /v1/organizations/{organization_id})
  • The documented side effect of clearing users’ active sessions containing the deleted organization: no session state family exists in the model and no in-scope operation observes it. (DELETE /v1/organizations/{organization_id})
  • The 422 ‘Invalid request parameters’ branch has no documented trigger condition or error code: the evidence names only the response, so no derivable request recipe (violating value, threshold or dedup key) can be declared and the branch carries no executable probe. (DELETE /v1/organizations/{organization_id}/memberships/{user_id})
  • Whether the organization_id path parameter also accepts the instance-unique slug here, as GET /organizations/{organization_id} documents; this operation’s parameter description says only ‘The ID of the organization’. (DELETE /v1/organizations/{organization_id}/memberships/{user_id})
  • Whether the service refuses removal of the last member holding elevated permissions (Organization.missing_member_with_elevated_permissions hints at such a rule) and, if so, at which status — not stated by any chunk in this bundle. (DELETE /v1/organizations/{organization_id}/memberships/{user_id})
  • The 400 ClerkErrors branch has no documented trigger for this operation (no request body, single string path parameter) and no captured wire observation; its condition and error code remain unresolved and no executable recipe is declared. (DELETE /v1/users/{user_id})
  • Whether deleting a user cascades to that user’s organization memberships (state_organization_membership) is not stated in this operation’s evidence. (DELETE /v1/users/{user_id})
  • Error codes carried in ClerkError.code for each rejection are not documented in the slice, so no branch discriminators are declared. (GET /v1/organizations)
  • The exact effect of the +/- prefixes on user_id and organization_id filters, and of order_by on members_count, is described in prose only and is not modeled as separate branches. (GET /v1/organizations)
  • The precise condition that selects 400 (ClerkErrors, ‘Request was not successful’) over 422 for this read is undocumented; the 400 guard carries no derivable request recipe. (GET /v1/organizations)
  • Whether an unknown slug and an unknown id produce distinguishable error codes within the single documented 404 branch. (GET /v1/organizations/{organization_id})
  • Whether an unparseable/non-boolean value for include_members_count or include_missing_member_with_elevated_permissions is rejected (no 400/422 documented for this operation), so no violating query example is declared. (GET /v1/organizations/{organization_id})
  • Whether missing credentials (no Authorization header) answer 403 like an invalid key or some other status; the spec documents only AuthorizationInvalid, so no runtime missing_credential delegation is declared. (GET /v1/organizations/{organization_id})
  • Filter semantics of the array parameters (user_id, email_address, phone_number, username, web3_wallet, role): the +/- include/exclude syntax and the documented 100-value cap have no documented rejection status; values not found are ignored. (GET /v1/organizations/{organization_id}/memberships)
  • Ordering semantics of order_by (+/- prefixes over phone_number, email_address, created_at, first_name, last_name, username) — the spec names the sortable fields but not tie-breaking or the default order. (GET /v1/organizations/{organization_id}/memberships)
  • Whether the organization_id path parameter also accepts the organization slug on this path (documented only for GET /organizations/{organization_id}). (GET /v1/organizations/{organization_id}/memberships)
  • Whether total_count reflects the filtered set or every membership of the organization. (GET /v1/organizations/{organization_id}/memberships)
  • The service behavior when a multi-value filter (email_address, user_id, organization_id, …) exceeds its documented cap of 100 values is not stated: it is modeled under the generic 400 guard with low confidence. (GET /v1/users)
  • The specific ClerkError code values for each 422 cause (cursor-with-offset, cursor-with-non-created_at-ordering, limit out of range) are not documented and no wire capture is present, so the 422 branch carries no x_body_discriminator and the four causes are not split into separate branches. (GET /v1/users)
  • … and 48 more

Fidelity is scored differentially against a live Clerk development instance, per response field: the last full-suite round matched 100 of 100 probes on 2026-10-07 (convergence_log.json 20261007T131900Z), with 0 inconclusive, on a 100-probe suite of the Backend API. The browser companion, session signing and webhook delivery are held by the twin’s own tests, not measured against Clerk. Each release states its own number.

Release notes

Newest first.

v0.0.1 — 2026-10-11

  1. A local Clerk for your app’s users, organizations and sign-in. The first release of the Clerk twin: the 20 Backend API operations on users and their metadata, organizations with their metadata and memberships, at Clerk-API-Version: 2026-05-12, served from a model validated against a live Clerk development instance, and a browser companion that serves a lookalike clerk.js so a @clerk/nextjs app signs users in against the same state. It runs on Pro and Enterprise. Point @clerk/backend at it with any sk_ key (HOWTO.md shows how); no email is sent and no real instance changes. (RYS-1315, RYS-1234, RYS-2412)

  2. Users and their metadata, as Clerk keeps them. POST /v1/users stores each email address in lowercase, GET /v1/users?email_address= finds a user by any letter case of their address, and an address that differs from a taken one only in case answers 422 form_identifier_exists; a malformed one answers 422 form_param_format_invalid. GET /v1/users refuses an order_by that names no sortable field with 422 form_param_value_invalid. PATCH and PUT /v1/users/{user_id}/metadata merge and replace metadata, and DELETE /v1/users/{user_id} removes the user’s organization memberships. Every image_url and profile_image_url leads to a PNG the twin serves under /preview/. (RYS-1858, RYS-1899, RYS-1900)

  3. Organizations, their metadata and their members. An organization created with created_by lists its creator as an org:admin member right after the create, and include_members_count=true counts them. PATCH /v1/organizations/{organization_id}/metadata deep-merges public_metadata and private_metadata, removing a key set to null, and PUT replaces each field it names whole. Writes and lists answer with the keys Clerk’s answers carry. (RYS-1895, RYS-1899, RYS-1909)

  4. Clerk-API-Version is checked on every route and echoed. 2021-02-05, 2024-10-01, 2025-04-10, 2025-11-10 and 2026-05-12 (the default) are accepted and echoed in clerk-api-version, and any other value answers 400 api_version_invalid. At 2026-05-12, metadata sent to PATCH /v1/users/{user_id} or PATCH /v1/organizations/{organization_id} answers 422 form_param_deprecated, as Clerk does: write it through the /metadata routes. (RYS-1914)

  5. A browser sign-in for @clerk/nextjs. Point NEXT_PUBLIC_CLERK_JS_URL and NEXT_PUBLIC_CLERK_UI_URL at the twin’s /clerk.js and /clerk-ui.js and your app gets email sign-in and sign-up with consent, Continue with GitHub and Continue with Google buttons that sign in local test identities, organization creation and selection, and member invitation, role changes and removal; signing in as an invited address joins its organization with no email sent. Sessions are RS256 tokens stamped on the wall clock your site verifies them against, and the SDK middleware’s handshake is answered by the twin, so a sandboxed site never leaves it. Boot the twin with your own RSA key, your site’s origin and its own origin; GET /_twin/config gives the publishable key, the public PEM and the proxy URL. (RYS-1234, RYS-1475, RYS-2413, RYS-2414)

  6. Webhooks Clerk would send, signed with Svix headers. Every user write, organization create, update and delete, and membership write, from the Backend API or the browser, records user.*, organization.* or organizationMembership.* in GET /_rystic/egress; the organization /metadata routes record none. Boot with CLERK_TWIN_WEBHOOK_URL and a whsec_ CLERK_TWIN_WEBHOOK_SECRET and each is also delivered, its svix-timestamp on the wall clock so your receiver’s five-minute window holds however long the twin sat idle. A failed delivery never undoes the write. Event shapes follow Clerk’s documentation and are not measured against live deliveries. (RYS-1317, RYS-2413)

  7. Five shipped worlds. --scenario clerk_acme_tenant boots Acme Robotics: five users in one organization, two pending invitations and three live sessions. clerk_holdco_orgs holds one identity across three organizations, clerk_invite_backlog an organization at its ten-seat cap with eight invitations pending, clerk_session_sweep six sessions from lapsed to live, and clerk_solo_founder one user and her organization; SCENARIOS.md says what each is for. Without --scenario the twin boots an empty instance, POST /_rystic/seed lands users, organizations, memberships, sessions and invitations directly, and POST /_rystic/reset rewinds the id sequence, so two identical runs from a pinned clock give byte-identical users and organizations. (RYS-1315, RYS-2412)

  8. Measured against a live Clerk development instance. The 2026-10-07 full-suite round matched 100 of 100 Backend API probes, with 0 inconclusive. HOWTO.md states what the suite leaves out and the differences this release ships with, each with its ticket: typed query parameters are not validated (RYS-1906), responses carry no x-clerk-trace-id header (RYS-1912), an unserved route answers a plain 404, not Clerk’s resource_not_found (RYS-1913), and deleting an organization or changing a membership’s role checks no secret key. (RYS-2412)

Last updated on