Hanzo AI

Clerk

Clerk is the drop-in auth layer for a React app — components, users, orgs, sessions, JWT. Here that is /v1/iam — 159 operations, OIDC end to end, and the org is the owner half of every address rather than a claim your frontend switches into.

Clerk authenticates people, groups them into organizations, and gives you React components that render the whole flow. /v1/iam (159 operations) does the identity half at the addresses OIDC names them, and serves the login screen's contents rather than shipping you a component to hold them.

The structural difference to know before you port anything: Clerk's organization is a claim the browser switches into and your backend reads back off the request. setActive({ organization }) changes it client-side, the session token carries org_id, and every org-scoped Backend API call takes that id in the path. Here the org is the owner half of the address, and on most listing routes it is not a parameter at all — it comes from the credential you authenticated with, so there is no id to pass, keep in step, or get wrong.

Start here

Mint a key, find the application people sign in to, then read the login screen it should draw.

# 1. mint a key — sk- belongs on a server, pk- is safe in a browser
curl -sS -X POST https://api.hanzo.ai/v1/account/keys \
  -H "Authorization: Bearer $HANZO_SESSION" \
  -H 'Content-Type: application/json' \
  -d '{"type":"secret"}'

# 2. list the applications in your org — each carries its own OAuth client id
curl -sS "https://hanzo.id/v1/iam/applications?owner=acme" \
  -H "Authorization: Bearer $HANZO_API_KEY"

# 3. read what a login screen for one of them should render
curl -sS "https://hanzo.id/v1/iam/get-app-login?clientId=$CLIENT_ID&responseType=code" \
  -H "Authorization: Bearer $HANZO_API_KEY"

Step 3 is the <SignIn /> replacement: one application's branding plus every sign-in method it has switched on, client secrets masked, served before anyone has signed in. The only identifiers in play are acme and an OAuth client id — no org_2xxx anywhere, and switching a provider on changes what step 3 returns without a frontend release.

Core capabilities

CapabilityWhat it doesOperations
/v1/iamUsers, orgs, applications, sessions, OIDC, MFA, SCIM — Clerk's Backend API, plus the contents its components used to hold159
/v1/webhookSubscribe by subject pattern (commerce.order.>), delivered with retries; per-endpoint secret rotation, delivery log and test send8
/v1/accountThe key you minted in step 1, the avatar, and the orgs one identity may act in10

Nouns

The tenant and the people in it

ClerkHanzo
Instance (development, production)Nothing to port. The org is the tenant
ApplicationApplication — /v1/iam/applications, addressed {owner}/{name}
User, user_2xxx/v1/iam/users, addressed {owner}/{name}
Organization, org_2xxx/v1/iam/organizations, addressed {owner}/{name}
Organization membership and its rolePOST /v1/iam/memberships, POST /v1/iam/delete-membership
Organization invitationPOST /v1/iam/invitations — org taken from your key
org:admin, custom permissions/v1/iam/roles, /v1/iam/permissions
Impersonation, actor tokensPOST /v1/iam/assume, then POST /v1/iam/release
Audit logs (Enterprise)GET /v1/iam/audit-logs — and POST to write your own events into the same trail

Signing in

ClerkHanzo
<SignIn />, <SignUp />GET /v1/iam/get-app-login — branding and every enabled method, from the server
Which factors are switched onGET /v1/iam/auth/methods
signIn.create, signUp.createPOST /v1/iam/login, POST /v1/iam/signup, then POST /v1/iam/signin
Hosted account portalGET /v1/iam/oauth/authorize, hosted at hanzo.id
Session/v1/iam/sessions, one per {owner}/{name}/{application}
Session token (JWT)POST /v1/iam/oauth/token
Networkless verificationGET /v1/iam/.well-known/jwks
Is this token still goodPOST /v1/iam/oauth/introspect — verification and revocation in one answer
signOut()POST /v1/iam/oauth/revoke, or /v1/iam/oauth/logout for the browser
Email code, SMS codePOST /v1/iam/send-verification-code
PasskeysGET /v1/iam/webauthn/signin/begin, /v1/iam/webauthn-credentials
TOTP, backup codesPOST /v1/iam/mfa/setup/initiate, then /v1/iam/mfa/setup/enable
Web3 wallet sign-inGET /v1/iam/web3/nonce, POST /v1/iam/web3/verify
Sign-in on a TV or a CLIPOST /v1/iam/oauth/device

Around the edges

ClerkHanzo
<UserProfile />GET and PUT /v1/iam/account, POST /v1/account/avatar
<OrganizationSwitcher />GET /v1/iam/memberships?user=acme/dana — the orgs one identity may act in
External accounts (social connections)/v1/iam/linked-accounts, POST /v1/iam/link, POST /v1/iam/unlink
SAML connections, enterprise SSO/v1/iam/providers — configured once, switched on per application
SCIM directory sync (Enterprise)/v1/iam/scim/v2/Users — not a tier
Machines and M2M API keysPOST /v1/iam/keys, /v1/iam/service-accounts
Publishable key, pk_live_…GET /v1/iam/keys/org — resolves to an org and never to a person
Webhooks (user.created, via Svix)/v1/webhook (8 operations)
Bulk import for migrationPOST /v1/iam/admin/users/upsert — idempotent, one person per call
Signing key rotation/v1/iam/certs — stage the next kid before it signs

The call

Clerk, inviting somebody into an organization:

curl -sS -X POST \
  "https://api.clerk.com/v1/organizations/org_2b9KpQ/invitations" \
  -H "Authorization: Bearer $CLERK_SECRET_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "email_address": "[email protected]",
    "inviter_user_id": "user_2c7QwZ",
    "role": "org:member"
  }'

Hanzo:

curl -sS -X POST https://hanzo.id/v1/iam/invitations \
  -H "Authorization: Bearer $HANZO_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "dana",
    "email": "[email protected]",
    "application": "acme-web",
    "signupGroup": "member",
    "quota": 1
  }'

Two opaque identifiers left the request. The organization is not in the path because the listing and the write both read it off the validated credential — owner is an optional query parameter on /v1/iam/invitations, /v1/iam/roles, /v1/iam/certs and /v1/iam/audit-logs, and leaving it out is the normal case. The inviter is gone for the same reason: the credential already names who is asking, so a second field naming them again could only ever disagree with it.

Support access is the sharper case. Clerk mints an actor token and then walks a browser through a ticket to start a session:

curl -sS -X POST https://api.clerk.com/v1/actor_tokens \
  -H "Authorization: Bearer $CLERK_SECRET_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"user_id":"user_2c7QwZ","actor":{"sub":"user_2aAdRn"}}'
# then send a browser to the returned ticket URL to open the session

Hanzo:

curl -sS -X POST https://hanzo.id/v1/iam/assume \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"org":"acme"}'

curl -sS -X POST https://hanzo.id/v1/iam/release \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"org":"acme"}'

One call, no browser, and what comes back is the operator's own token re-scoped to the tenant — it still names the operator and records the org it was scoped to, so every write made with it is attributed to the person who made it rather than to the customer. release hands back the credential they held before, so stepping out is a call rather than waiting for something to expire. Only a platform operator may step in, and the attempt is recorded whether or not it succeeds.

Token checks work the same way in both places until revocation. Clerk verifies the session JWT locally against a JWKS and bounds a revoked session by its 60-second lifetime — you wait for it. The same offline verification is here at GET /v1/iam/.well-known/jwks, and when you need the answer now:

curl -sS -X POST https://hanzo.id/v1/iam/oauth/introspect \
  -H "Authorization: Bearer $HANZO_API_KEY" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "token=$ACCESS_TOKEN"

A token is active only if it verifies and has not been revoked, so a revoked token reads dead on the next call rather than at the end of its window. That is also why refresh is rotating: a refresh returns a new refresh token and retires the one you sent, and presenting a retired one revokes the whole chain on the assumption it was copied.

What does not carry

The rendered components do not port. <SignIn />, <UserButton /> and clerkMiddleware() are Clerk's product — nothing here draws a screen for you. The React around them does port: @hanzo/iam/react publishes IamProvider in place of <ClerkProvider>, plus useIam and useOrganizations, so it is an OIDC client with React bindings rather than a component library. What replaces the screens is the other half of the same problem: GET /v1/iam/get-app-login returns one application's branding and every sign-in method it has switched on, with client secrets masked, so a screen you draw yourself grows a new provider button the moment somebody enables one. The hosted alternative is hanzo.id.

No JWT templates. Clerk mints a differently-shaped token per integration — Supabase, Firebase, Hasura — from a template you edit in the dashboard. Tokens here carry the standard OIDC claims plus owner, signed by a certificate you register at /v1/iam/certs. A third party that needs a bespoke claim shape gets it from a service of yours that has already verified the real token.

The three metadata buckets collapse to one. publicMetadata, privateMetadata and unsafeMetadata differ by who may read and who may write them. The user record here has properties, tag and externalId, and they are read through the same admin-scoped user routes as everything else — there is no bag the browser reads directly and no bag the user writes. Settings a person owns live at /v1/iam/preferences, and their privacy answers at /v1/iam/consent; both are writable only by that person. Application state about a user belongs in your store, keyed on {owner}/{name}.

user_2xxx and org_2xxx do not survive the move. The address here is {owner}/{name} — a username inside an org — and there is no route that accepts a Clerk id. Carry the old id in externalId on the user record and rewrite your foreign keys once, rather than maintaining a lookup table forever.

Password digests carry only if they are bcrypt or argon2id. The user record holds passwordHash, passwordSalt and passwordType, and the scheme field is what lets a legacy argon2id row still verify and be re-hashed to bcrypt on the next sign-in. Clerk's other importable hashers have no verifier here. Export the users, and for any other scheme move those people onto a provider or a password reset.

Bot protection is not a switch on the sign-up form. Clerk fronts sign-up with Smart CAPTCHA. /v1/iam models no CAPTCHA provider and POST /v1/iam/send-verification-code accepts captchaType and ignores it. Rate limiting and origin rules are /v1/gateway/config, and the abuse score is /v1/risk/score — separate capabilities with their own vocabulary, so this is a thing to configure rather than a checkbox you tick back on.

How is this guide?