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.
- It accepts
2021-02-05,2024-10-01,2025-04-10,2025-11-10and2026-05-12; any other value answers 400api_version_invalid. - From
2026-05-12,PATCH /v1/users/{user_id}refusespublic_metadata,private_metadataandunsafe_metadata, andPATCH /v1/organizations/{organization_id}refusespublic_metadataandprivate_metadata, each with 422form_param_deprecated: write metadata through the/metadataroutes.
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.
- 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: 401authentication_invalidon the user routes, the/metadataroutes, listing organization memberships and deleting one; 401authorization_header_format_invalidonGET /v1/users/count; 403authentication_invalidonGET /v1/organizationsandGET /v1/organizations/{organization_id}; 403authorization_invalidonPOST /v1/organizations,PATCH /v1/organizations/{organization_id}, adding a membership andGET /v1/users/{user_id}/organization_memberships. DELETE /v1/organizations/{organization_id}andPATCH /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.- 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.
| Resource | Operations |
|---|---|
| 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 |
- Bodies are JSON both ways, and timestamps are Unix milliseconds.
- 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 422form_identifier_exists. A malformed address answers 422form_param_format_invalidonemail_address. GET /v1/usersrefuses anorder_bythat names no sortable field with 422form_param_value_invalid.POST /v1/organizationswithcreated_bygives that user anorg:adminmembership:GET /v1/organizations/{organization_id}/membershipslists it right after the create, andGET /v1/organizations/{organization_id}?include_members_count=truecounts it. Deleting a user removes their memberships, and each organization’s count drops by one.PATCH /v1/organizations/{organization_id}/metadatadeep-mergespublic_metadataandprivate_metadata, removing a key set tonull;PUTreplaces each field it names whole. The user/metadatapair does the same for a user.- Every
image_urlandprofile_image_urlthe twin hands out leads back to the twin, under/preview/, whereGETorHEADanswers 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:
- Set
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_JWT_KEYandNEXT_PUBLIC_CLERK_PROXY_URLfrom/_twin/config. - Point
NEXT_PUBLIC_CLERK_JS_URLandNEXT_PUBLIC_CLERK_UI_URLat the twin’s/clerk.jsand/clerk-ui.js. - Set
CLERK_API_URLto the twin’s origin, and use anysk_value asCLERK_SECRET_KEY. - Set
NEXT_PUBLIC_CLERK_SIGN_IN_URLandNEXT_PUBLIC_CLERK_SIGN_UP_URLto 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.
| Scenario | What it holds |
|---|---|
clerk_acme_tenant | five 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_orgs | one identity across three organizations as admin, billing manager and member, each with its own session context |
clerk_invite_backlog | an organization at its ten-seat cap with two members and eight invitations pending across roles |
clerk_session_sweep | six browser sessions across three users: two long lapsed, one expiring two hours after the pinned clock, three live |
clerk_solo_founder | one 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=notanumberor a numericfirst_nameis accepted where Clerk answers 422 (RYS-1906) - Responses carry no
x-clerk-trace-idheader to match the body’sclerk_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_founderror (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_idPOST /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 morePUT /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:
| Operation | Status | Why |
|---|---|---|
DELETE /v1/organizations/{organization_id}/memberships/{user_id} | 422 | the model describes no input that reaches it |
GET /v1/organizations/{organization_id}/memberships | 422 | nothing the twin serves creates the record it needs |
PATCH /v1/organizations/{organization_id} | 402 | the model describes no input that reaches it |
PATCH /v1/users/{user_id} | 409 | the model describes no input that reaches it |
POST /v1/organizations | 402 | the model describes no input that reaches it |
POST /v1/users | 402 | the 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
codevalues 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
-
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 lookalikeclerk.jsso a@clerk/nextjsapp signs users in against the same state. It runs on Pro and Enterprise. Point@clerk/backendat it with anysk_key (HOWTO.mdshows how); no email is sent and no real instance changes. (RYS-1315, RYS-1234, RYS-2412) -
Users and their metadata, as Clerk keeps them.
POST /v1/usersstores 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 422form_identifier_exists; a malformed one answers 422form_param_format_invalid.GET /v1/usersrefuses anorder_bythat names no sortable field with 422form_param_value_invalid.PATCHandPUT /v1/users/{user_id}/metadatamerge and replace metadata, andDELETE /v1/users/{user_id}removes the user’s organization memberships. Everyimage_urlandprofile_image_urlleads to a PNG the twin serves under/preview/. (RYS-1858, RYS-1899, RYS-1900) -
Organizations, their metadata and their members. An organization created with
created_bylists its creator as anorg:adminmember right after the create, andinclude_members_count=truecounts them.PATCH /v1/organizations/{organization_id}/metadatadeep-mergespublic_metadataandprivate_metadata, removing a key set tonull, andPUTreplaces each field it names whole. Writes and lists answer with the keys Clerk’s answers carry. (RYS-1895, RYS-1899, RYS-1909) -
Clerk-API-Versionis checked on every route and echoed.2021-02-05,2024-10-01,2025-04-10,2025-11-10and2026-05-12(the default) are accepted and echoed inclerk-api-version, and any other value answers 400api_version_invalid. At2026-05-12, metadata sent toPATCH /v1/users/{user_id}orPATCH /v1/organizations/{organization_id}answers 422form_param_deprecated, as Clerk does: write it through the/metadataroutes. (RYS-1914) -
A browser sign-in for
@clerk/nextjs. PointNEXT_PUBLIC_CLERK_JS_URLandNEXT_PUBLIC_CLERK_UI_URLat the twin’s/clerk.jsand/clerk-ui.jsand 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/configgives the publishable key, the public PEM and the proxy URL. (RYS-1234, RYS-1475, RYS-2413, RYS-2414) -
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.*ororganizationMembership.*inGET /_rystic/egress; the organization/metadataroutes record none. Boot withCLERK_TWIN_WEBHOOK_URLand awhsec_CLERK_TWIN_WEBHOOK_SECRETand each is also delivered, itssvix-timestampon 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) -
Five shipped worlds.
--scenario clerk_acme_tenantboots Acme Robotics: five users in one organization, two pending invitations and three live sessions.clerk_holdco_orgsholds one identity across three organizations,clerk_invite_backlogan organization at its ten-seat cap with eight invitations pending,clerk_session_sweepsix sessions from lapsed to live, andclerk_solo_founderone user and her organization;SCENARIOS.mdsays what each is for. Without--scenariothe twin boots an empty instance,POST /_rystic/seedlands users, organizations, memberships, sessions and invitations directly, andPOST /_rystic/resetrewinds the id sequence, so two identical runs from a pinned clock give byte-identical users and organizations. (RYS-1315, RYS-2412) -
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.mdstates 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 nox-clerk-trace-idheader (RYS-1912), an unserved route answers a plain 404, not Clerk’sresource_not_found(RYS-1913), and deleting an organization or changing a membership’s role checks no secret key. (RYS-2412)