Skip to main content

Identity & Access Management

The /iam/* endpoints let you manage users, roles, and partner staff across the Tagada platform from your own application — typically a sales CRM, a partner portal, or any backoffice that needs to provision or audit access without going through the dashboard UI. The same role matrix that gates the dashboard gates these endpoints. There is no “REST is more permissive” path.
IAM is not part of the public, API-key-authenticated surface documented in the rest of this API Reference. It lives on the internal /api/v1/iam/* router and accepts only Clerk session authentication — Bearer API keys are explicitly rejected.See Authentication below for the full rationale and how to call these endpoints from an external app.

Why a separate auth model?

Bearer API keys act on behalf of the account owner with full authority. That’s fine for createStore or refundPayment, but for mutations like “grant superadmin” it would mean a leaked key becomes a one-shot privilege-escalation vector with no human in the loop. To avoid that, all IAM mutations require a real Clerk session so that:
  • The audit log always names a Clerk user (not “the API key for account X”).
  • Role escalation is bounded by what that user is allowed to assign — even if their session is stolen, they can only grant what they themselves can grant.
  • API keys cannot be silently used to flip roles by anyone with read access to a key store.

Concepts

tgdRoles

The canonical list of Tagada platform roles. Stored on Clerk as publicMetadata.tgdRoles (single source of truth) and mirrored locally on public.users.tgd_roles for indexed queries.
Until 2026-05 there was a parallel privateMetadata.tgdRoles fallback that the backend would read if publicMetadata was empty. That fallback was dropped (and the residual rows wiped from Clerk) in favour of a single, auditable source. If you see privateMetadata.tgdRoles referenced in older docs or code, it is no longer authoritative.
RoleScopeTypical use
superadminTagada-wideTagada operations team — can assign any role anywhere
hubadminTagada-wideTagada admin staff (no role assignment authority)
tagadapaymentAdminTagada-wideLegacy payment-ops role; treated like hubadmin
accountmanagerPartner-scoped or Tagada-wideSales / customer success
partneradminPartner-scopedOwner of a partner roster (can invite + revoke their teammates)
partnerstaffPartner-scopedRead-only-ish partner teammate

whitelabelOwnership

The partner slug a user is scoped to, or null for unscoped (Tagada internal) staff. A partneradmin with whitelabelOwnership: "acme" can only manage staff for the acme partner.

Partner

A partner is a Tagada-platform-wide concept (CRM + processor), not exclusive to TagadaPay. A partner can have the CRM only, the processor only, or both. Partner staff is tracked in the memberships table (one row per (user, partner) pair, with a status of pending, active, or revoked).

Roles at a glance

There are six tgdRoles, split across two scopes (Tagada-internal vs partner-scoped). The diagram below shows where each role lives; the two tables below say what each role can do day-to-day.

The role tree

Plain-text version (in case mermaid doesn’t render in your viewer):
Tagada platform

├── Internal staff  (whitelabelOwnership = null)
│   ├── superadmin            full power; only role that can grant superadmin
│   ├── hubadmin              day-to-day Tagada ops; manages every partner
│   ├── tagadapaymentAdmin    legacy; treated like hubadmin
│   └── accountmanager        cross-partner sales / customer success

└── Partner-scoped staff  (whitelabelOwnership = "<slug>")
    ├── partneradmin          owns one partner's roster
    ├── accountmanager        AM dedicated to one partner
    └── partnerstaff          read-only-ish teammate
Noteaccountmanager appears on both branches. The same role string can be either unscoped (Tagada-wide AM seeing every partner) or scoped (AM dedicated to one partner) depending on the user’s whitelabelOwnership value.

One-line role guide

RolePlain English
superadmin”I can do absolutely anything in Tagada, including creating new partners and granting superadmin.”
hubadmin”I run Tagada day-to-day ops. I can manage every partner’s roster but I can’t onboard new partners or hand out superadmin.”
tagadapaymentAdminLegacy operations role from before hubadmin existed. Treated identically to hubadmin.
accountmanager”I’m the human relationship for one or more partners. Read-mostly; I don’t edit roles.”
partneradmin”I run my own partner team — I can invite and revoke teammates, but only inside my partner.”
partnerstaff”I’m a teammate inside one partner. I can use the dashboard but I don’t manage the team.”

Common scenarios

The single source of truth a non-engineer usually needs:
I want to…Who can do it
Sign in to a Tagada dashboardany role
Create a new partnersuperadmin only
Archive (offboard) a partnersuperadmin only
Edit a partner’s branding / commercial terms / pause-or-activate ithubadmin or above
List every partner / view a partner’s profilehubadmin or above
Invite a teammate to my own partnerpartneradmin of that partner — or any hubadmin / superadmin
Revoke a teammate from my own partnerpartneradmin of that partner — or any hubadmin / superadmin
Invite a teammate to another partnerhubadmin or above (partneradmins are scope-locked to their own)
Grant the superadmin role to someonesuperadmin only
Grant hubadmin / accountmanager to someonehubadmin or superadmin
Grant partneradmin / partnerstaff to a teammate of my partnerpartneradmin of that partner — or any hubadmin / superadmin
Move a user from one partner to anothersuperadmin only (the scope-move guard)
Change a merchant’s partner attribution (DB + Clerk + Stripe + ClickHouse)superadmin or founder only — see Partner attribution
Self-promotion is always denied. No role — not even superadmin — can edit its own tgdRoles. You always need a peer to grant or revoke a role on your own account.

Authorization matrix

Every mutation goes through one of two pure policy functions.

canAssignTgdRole

Used by every endpoint that writes to tgdRoles.
CallerMay assign…
superadminany role to any user
partneradmin (scope X)PARTNER_ASSIGNABLE_ROLES only, only to users scoped to X
anyone elsenothing (returns { ok: false, reason })
A user can never escalate themselves — even a superadmin cannot grant themselves a role they don’t already hold via this endpoint.

Assignable role sets

The exact sets the backend will accept on a write — useful when building a role picker that must not show options the server will reject. They are scope-disjoint by design: an internal-only role like hubadmin cannot be granted to a partner-scoped user, and a partner-only role like partneradmin only makes sense with a non-null whitelabelOwnership.
ConstantRolesUsed when target…
INTERNAL_ASSIGNABLE_ROLESaccountmanager, hubadmin, tagadapaymentAdmin, superadminhas whitelabelOwnership IS NULL (Tagada-internal)
PARTNER_ASSIGNABLE_ROLESaccountmanager, partneradminis scoped to a partner
The internal set deliberately excludes partneradmin (requires a scope — route through “set partner scope + set roles” instead) and partnerstaff (legacy role, no longer granted at the REST surface). The partner set deliberately excludes the internal-only roles because a partneradmin cannot mint Tagada staff.
When building an external IAM UI: read the target’s current whitelabelOwnership first, then render the role checkboxes from the appropriate set above. Any role already on the target that is not in the set (e.g. a legacy hubadmin left on a partner-scoped user from a pre-2026 grant) should still be displayed as read-only so the operator can see and remove it, but the picker must not let it be re-added — the backend will refuse the write either way, but UX-side it’s better to fail fast.

canManagePartnerStaff

Used by revoke / delete / resend-invitation.
CallerMay manage staff for…
superadmin, hubadmin, tagadapaymentAdminany partner
partneradmin (scope X)partner X only
anyone elsenothing

canManagePartners

Used by the partner-entity verbs (list / get / create / update / archive).
ActionRequired role
listsuperadmin, hubadmin, tagadapaymentAdmin, accountmanager, or partneradmin (the use-case auto-clamps the result to the actor’s partnerScope when set, so a partneradmin only ever sees their own partner row)
get / updatesuperadmin, hubadmin, or tagadapaymentAdmin (legacy)
createsuperadmin only
archivesuperadmin only

canManageOrgMembership

Used by the merchant-org membership verbs (list / add / remove / change_role) and by the merchant service-API-key verb (manage_api_keys) — the new IAM surface that replaces the Clerk dashboard for managing who is a member of a merchant Clerk org, and that lets a CRM reseller provision the merchant’s public-API tokens.
CallerScope
superadmin, hubadmin, tagadapaymentAdminany merchant org
accountmanager (no scope = internal AM)any merchant org
accountmanager (scoped to X)only orgs whose partnerSlug == X
partneradmin (scope X)only orgs whose partnerSlug == X
anyone elsenothing
Self-remove and self-change_role are always denied (locking yourself out is rarely intentional). Self-add is allowed (support join flow). The membership verbs live on the CRM tRPC surface (api.iamOrgs.{list,membersList,membersAdd,membersRemove,membersSetRole}), not the public REST /iam/* router — they’re only ever called from the IAM panel in the CRM. Promote to REST when an external caller needs them. The manage_api_keys verb has no self-action guard (keys are not scoped to a user, so there is nobody to lock out) — it flows straight through the same actor × org-partner gate as the other verbs. It is exposed on both surfaces: the CRM tRPC (api.partners.{listMerchantApiKeys,createMerchantApiKey,revokeMerchantApiKey}) and the public REST /iam/* router (see Merchant service API keys below), so a white-label partner can manage keys either from the CRM UI or server-to-server. Why create and archive are stricter:
  • create = onboarding a new partner. In practice this happens after out-of-band steps (legal contract, commercial terms, banking setup), so it stays a Tagada-team-only verb to prevent stray rows.
  • archive = the only delete-shaped verb a partner exposes. It has billing implications (open settlement runs, pending payouts), so we gate it behind the most-privileged role.
update callers that pass status="offboarded" are rerouted internally through archive, so the strict policy applies regardless of which verb the adapter used. partneradmin is allowed list (UI affordance — auto-clamped to their own partner) but excluded from the write verbs — partner self-service is the partner-staff lifecycle, not partner-entity CRUD. accountmanager is allowed list for the same reason: the merchant-org table needs partner attribution.

Authentication

The /iam/* endpoints are mounted under /api/v1/iam/* (the internal OpenAPI router) and accept Clerk session authentication only. Bearer API keys, builder sessions, and any other service-account credential are rejected with 401.

From a browser-based dashboard (same Clerk instance)

The session cookie set by Clerk’s standard sign-in flow is enough. Just fetch() the endpoint with credentials: 'include'.
await fetch('/api/v1/iam/permissions/assign-role', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    targetUserId: 'user_2abc...',
    requestedRoles: ['accountmanager'],
    requestedScope: 'acme',
  }),
});

From an external app (the sales CRM use case)

Use Clerk’s standard sign-in flow (OIDC / Clerk-hosted UI / Clerk SDK) on your own domain to sign the operator in against the same Clerk instance that powers Tagada. Then either:
  • Forward the Clerk session cookie when proxying through your backend, or
  • Mint a short-lived Clerk session JWT with getToken() and pass it as Authorization: Bearer <jwt> from server to server.
curl https://api.tagada.io/api/v1/iam/users/find \
  -H "Authorization: Bearer ${CLERK_SESSION_JWT}" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@acme.com"}'
A request without a valid Clerk session — including one that carries a Bearer API key — returns:
{
  "code": "NOT_AUTHORIZED",
  "message": "IAM endpoints require a Clerk user session, not a service account."
}

Why these endpoints aren’t in the right-hand API Reference

The auto-generated reference panes on the right side of every page are built from openapi.json, which is the public-API spec (API-key authenticated). IAM is intentionally absent from that file. The endpoint catalogue below is maintained by hand and lives only in this guide.

Endpoint catalogue

All paths are relative to the base URL of the API deployment you are calling — https://api.tagada.io in production (the value of NEXT_PUBLIC_APP_URL). Note this is not app.tagada.io, which serves the CRM single-page app and will return a 405 / HTML page for these routes.

Permission probes

These never return 403. They always return 200 with a decision object — use them to render-then-mutate (see Patterns below).
MethodPathReturns
POST/api/v1/iam/permissions/assign-role{ ok: boolean, reason?: string }
POST/api/v1/iam/permissions/manage-partner-staff{ ok: boolean, reason?: string }

Partner entities (CRUD)

Lifecycle of partner records themselves (rows in partners). Mounted under a distinct partners-admin prefix so a slug literally named list / create / archive cannot route ambiguously.
MethodPathRequired rolePurpose
POST/api/v1/iam/partners-admin/listhubadmin or aboveAll partners with staffCount, merchantCount (Processing TPAs), and orgCount (Clerk orgs tagged on the partner via accounts.partner_id). Optional status filter.
POST/api/v1/iam/partners-admin/gethubadmin or aboveSingle partner row by id XOR slug, including branding / preferences / commercial terms.
POST/api/v1/iam/partners-admin/list-staffhubadmin or aboveEvery staff member attached to a partner (by id XOR slug). Returns pending invites + active + revoked rows. Reads memberships directly so it doesn’t depend on the Clerk users mirror. Optional status filter to narrow.
POST/api/v1/iam/partners-admin/list-orgshubadmin or aboveEvery Clerk organization tagged on a partner (by id XOR slug). Returns accountId, Clerk org id/name/slug, kind (self_serve | partner_managed), and createdAt. Paginate via limit (1..500, default 100) + offset. Optional kind filter.
POST/api/v1/iam/partners-admin/createsuperadmin onlyNew partner row. Optional adminEmails[] are invited as partneradmin.
POST/api/v1/iam/partners-admin/updatehubadmin or above (status="offboarded" requires superadmin)Patch name / status / branding / preferences / commercial terms. jsonb fields are partial-merged.
POST/api/v1/iam/partners-admin/archivesuperadmin onlySoft delete — sets status="offboarded". Idempotent. There is no hard delete on partner rows.

Partner-staff lifecycle

Scoped to one partner via the :partnerSlug path parameter. Caller must satisfy canManagePartnerStaff for that partner.
MethodPathPurpose
POST/api/v1/iam/partners/:partnerSlug/staff/inviteIdempotent invite — see Inviting a teammate
POST/api/v1/iam/partners/:partnerSlug/staff/revokeSoft revoke — see Revoke vs delete
POST/api/v1/iam/partners/:partnerSlug/staff/deleteHard delete — see Revoke vs delete
POST/api/v1/iam/partners/:partnerSlug/staff/resend-invitationRe-issue a pending Clerk invitation
POST/api/v1/iam/partners/:partnerSlug/staff/set-rolesRewrite tgdRoles of a teammate already on the roster

Internal users (Tagada staff)

Superadmin-only. These act on unscoped users (whitelabelOwnership IS NULL) — Tagada operations team, account managers without a partner scope, etc.
MethodPathPurpose
POST/api/v1/iam/internal-users/set-rolesRewrite a Clerk user’s tgdRoles
POST/api/v1/iam/internal-users/set-partner-scopeSet or detach a Clerk user’s whitelabelOwnership

Organizations

MethodPathPurpose
POST/api/v1/iam/orgs/listFlat list of every Clerk org / merchant with the partner it belongs to pre-joinedpartnerId, partnerSlug, partnerName per row (null when unaffiliated). Use this instead of N+1 partners-admin/get lookups when rendering an “all merchants” CRM table. Filters: q (substring on org name/slug/accountId), partnerId XOR partnerSlug (use partnerId="__none__" for unaffiliated only), kind. Paginate via limit (1..500, default 100) + offset. Hubadmin or above.

Merchant service API keys

Lets a CRM reseller (e.g. BNP) mint / list / revoke an org:admin service API key for each of its merchant accounts. The minted key is the Bearer token the merchant then uses against the public API (/api/public/v1/*) — these endpoints are the IAM way to provision that token without going through the merchant dashboard. Authorization is canManageOrgMembership('manage_api_keys'): a partneradmin scoped to X can only touch merchants whose accounts.partner_id resolves to X (scope clamp), and the account ownership is re-checked on every call. hubadmin / superadmin reach any merchant. Keys are tagged with a partner-provisioning marker, so only keys this partner minted are listed or revocable — the merchant’s own self-served keys are never exposed here.
MethodPathPurpose
POST/api/v1/iam/partners/:partnerSlug/merchants/:accountId/api-keysList the partner-provisioned service keys of one merchant account. Secrets are never returned on read.
POST/api/v1/iam/partners/:partnerSlug/merchants/:accountId/api-keys/createMint a new org:admin (full read+write) service key for the merchant. The secret token is returned once in this response and never again.
POST/api/v1/iam/partners/:partnerSlug/api-keys/:keyId/revokeRevoke a partner-provisioned key. Only keys this partner minted are revocable (404 otherwise). An unknown or already-revoked key returns NOT_FOUND.
Minted keys are org:admin — full read + write on the merchant account (orders, payments, refunds, …). They are not read-only or permission-scoped. Treat the returned token like a production secret: surface it once to the operator, never log it, and revoke + re-mint if it leaks.

Partner attribution (accounts)

Tagada-internal only. These endpoints are listed for transparency and so that the internal sales CRM has a canonical contract to call against. They are superadmin / founder gated and refuse impersonated Clerk sessions — in practice only a human on the Tagada operations team can invoke them. Partners (Suby, BNP, …) do not have credentials that pass these gates and cannot use these routes to retag their own merchants.
Re-attribute a merchant account (or an orphan partner-managed account) to a different partner. Every call is recorded in the partner_change_log audit table and fans out across all of the surfaces that have to stay in sync — there is no longer any way to update one of them without the others.
MethodPathPurpose
POST/api/v1/iam/accounts/change-partnerCanonical multi-surface partner change. Platform-admin only (superadmin or founder). See payload + surface contract below.
POST/api/v1/iam/orgs/assign-to-partnerDeprecated back-compat alias for change-partner. Same auth, same fan-out, same audit row — just the older { clerkOrgId, whitelabel } payload shape. New integrations should target accounts/change-partner so they can pass reason, dryRun and bulkJobId.
accounts/change-partner payload
{
  // Either an org-scoped change (the 97% case) ...
  "scope": { "kind": "org", "clerkOrgId": "org_..." },
  // ... or an orphan partner-managed account (no Clerk org yet):
  // "scope": { "kind": "account", "accountId": "acc_..." },

  // Partner slug to attach, or `null` to detach.
  "toPartner": "bnp",

  // Operator-supplied free text, 5..2000 chars. Stored verbatim on
  // the audit row so a future dispute ("why did BNP lose that
  // commission?") has a human-written explanation attached.
  "reason": "Migration from FMJ — see ticket #4821",

  // Optional. When true, no surface is mutated but a `dry_run=true`
  // row is still written so the preview shows up in the timeline UI.
  "dryRun": false,

  // Optional. Shared across all rows of the same CSV batch so the
  // timeline UI can group them.
  "bulkJobId": "bulk_2026-05-26_bnp-grid"
}
Surfaces fanned out on every successful call
  1. accounts.partner + accounts.partner_id (Postgres) — legacy slug column and canonical FK, written atomically.
  2. hub_requests.business_info.partner (Postgres JSONB) — keeps every in-flight onboarding request consistent with the org’s new partner.
  3. org.publicMetadata.whitelabel (Clerk) — drives the activeOrgWhitelabel session claim and per-partner branding. Also auto-adds every partneradmin of the new partner to the org.
  4. Clerk member users’ publicMetadata.partner tag — so existing members see the new attribution on their next token rotation.
  5. Stripe Connect sub-accounts’ payments_pricing group — resolved from partners.stripe_pricing_group so commissions follow the partner’s grid immediately.
  6. ClickHouse stripe_txns_all.partner — historical backfill so commission reports for past traffic are restated under the new partner.
  7. hub_request_assignments tags — keeps the routing/queue view aligned with the new attribution.
The response is a structured per-surface result blob (the same shape stored on the partner_change_log.surfaces JSONB column) so an operator dialog can show “Stripe OK, ClickHouse deferred, …” without having to query the audit table. orgs/assign-to-partner (deprecated) Kept for the older CRM build that hasn’t migrated to the new payload yet. Internally it just wraps change-partner with a synthetic reason="auto:assignOrgToPartnerAction(legacy REST alias, …)" so the audit trail stays honest. There is no behavioural difference at the surface level — both endpoints write the same audit row and trigger the same 7-surface fan-out.

Read-side queries

Both query the local Clerk mirror (synced via webhook). Eventually consistent — typically <1s lag. For freshness-critical paths (login, immediately after a role re-grant) call the Clerk Backend API directly.
MethodPathReturns
POST/api/v1/iam/users/list{ rows, total, limit, offset }. Filters: q, tgdRole, partnerScope (sentinel "__internal__" selects unscoped staff).
POST/api/v1/iam/users/find{ found, user }. Lookup by email or clerkUserId.

Patterns

”Render-then-mutate” — pre-flight permission checks

Before showing a destructive button, hit POST /api/v1/iam/permissions/assign-role or POST /api/v1/iam/permissions/manage-partner-staff. They always return 200 with { ok: boolean, reason? } so you can disable the button instead of letting the user mash it and discover the 403.

Inviting a teammate

POST /api/v1/iam/partners/:partnerSlug/staff/invite is idempotent on (partnerSlug, email):
  • If the email is already a Clerk user scoped to this partner, the endpoint just appends the requested roles to their existing tgdRoles and returns { status: "role_updated" }.
  • Otherwise it creates a fresh Clerk invitation with the partner’s branded email + redirect URL and returns { status: "invited" }.
The roles[] payload is restricted to PARTNER_ASSIGNABLE_ROLES — see Assignable role sets. The partner-staff set-roles endpoint enforces the same constraint. To grant an internal-only role like hubadmin you must first detach the user from the partner via internal-users/set-partner-scope and then call internal-users/set-roles (both superadmin-only). Cross-partner / hijack guard: if the email belongs to a Clerk user who is already scoped to a different partner, or is an internal Tagada staff member, or owns a merchant org, the invite is rejected even by superadmin callers — superadmin must intervene via internal-users/set-partner-scope first.

Revoke vs delete

  • Revoke (POST .../revoke) is the soft path: strips Clerk roles and scope, flips the audit row in memberships to revoked. The Clerk user account itself is untouched.
  • Delete (POST .../delete) is the hard path: drops the audit row entirely (and revokes any pending invitation in Clerk). Use this when the user was added by mistake and shouldn’t appear in the audit log at all.

Creating a partner

POST /api/v1/iam/partners-admin/create is superadmin-only. Pass adminEmails: ["alice@acme.com", ...] to invite the founding partner admins as partneradmin in the same call. Each invitation is idempotent on (partnerSlug, email). Failures on individual invitations don’t roll back the partner row — they show up as { status: "error", error: "..." } entries in the response’s invited[] and you can re-invite from the Team tab.

Archiving (soft delete) a partner

There is no hard delete on partner rows. Archiving sets status="offboarded" so:
  • Merchants still resolve their partner_id correctly.
  • Billing snapshots and settlement reports keep referencing the row.
  • The slug remains reserved (you cannot create a new partner with the same slug after archive).
Archiving is idempotent. The endpoint returns previousStatus so you can detect “already archived” at the UI layer:
curl https://api.tagada.io/api/v1/iam/partners-admin/archive \
  -H "Authorization: Bearer ${CLERK_SESSION_JWT}" \
  -H "Content-Type: application/json" \
  -d '{"id": "ptr_01H..."}'
Calling update with status="offboarded" is rerouted internally through archive, so the strict (superadmin-only) policy applies either way.

Provisioning a merchant’s API key (end-to-end)

The merchant key lifecycle spans two auth models: you mint the key through IAM (Clerk-session), then the merchant uses it as a Bearer API key against the public API. Concretely:
  1. Mint (Clerk-session, partneradmin of bnp). The secret comes back exactly once — store it immediately.
curl https://api.tagada.io/api/v1/iam/partners/bnp/merchants/acc_01H.../api-keys/create \
  -H "Authorization: Bearer ${CLERK_SESSION_JWT}" \
  -H "Content-Type: application/json"
# → { "object": "service_api_key", "id": "...", "name": "BNP · Acme Store",
#     "orgRole": "org:admin", "token": "tgd_live_…", "createdAt": "…" }
  1. Use (Bearer API key — the merchant’s own integration). The token from step 1 is now a normal public-API credential:
curl https://api.tagada.io/api/public/v1/orders \
  -H "Authorization: Bearer tgd_live_…"
  1. List what you’ve provisioned for that merchant (secrets are never returned on read):
curl https://api.tagada.io/api/v1/iam/partners/bnp/merchants/acc_01H.../api-keys \
  -H "Authorization: Bearer ${CLERK_SESSION_JWT}"
  1. Revoke when rotating or offboarding:
curl https://api.tagada.io/api/v1/iam/partners/bnp/api-keys/key_01H.../revoke \
  -H "Authorization: Bearer ${CLERK_SESSION_JWT}"
The same flow is available without code from the CRM white-label UI under the partner-admin API keys tab — it calls the equivalent api.partners.* tRPC procedures, which delegate to the identical IAM use-cases (same scope clamp, same ownership check, same reveal-once behaviour).

Read-side queries

The users.list and users.find endpoints query the local mirror synced from Clerk via webhook. The mirror is eventually consistent — typically <1s lag. For freshness-critical paths (login, immediately after a role re-grant) call the Clerk Backend API directly. The partnerScope filter on users.list accepts the sentinel "__internal__" to select unscoped staff (whitelabelOwnership IS NULL).

Errors

All /iam/* endpoints return standard HTTP codes:
CodeMeaning
401Missing or invalid Clerk session — or a Bearer API key was sent (rejected by clerkSessionOnlyProcedure)
403Caller is authenticated but the policy denied the action
404Partner / user / invitation not found
409Conflict (e.g. user already on roster with the same role)
422Input validation failed
The body is a JSON object with code (a string from the ZSA error enum) and message (a human-readable description).