Documentation Index
Fetch the complete documentation index at: https://docs.tagada.io/llms.txt
Use this file to discover all available pages before exploring further.
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.
| Role | Scope | Typical use |
|---|
superadmin | Tagada-wide | Tagada operations team — can assign any role anywhere |
hubadmin | Tagada-wide | Tagada admin staff (no role assignment authority) |
tagadapaymentAdmin | Tagada-wide | Legacy payment-ops role; treated like hubadmin |
accountmanager | Partner-scoped or Tagada-wide | Sales / customer success |
partneradmin | Partner-scoped | Owner of a partner roster (can invite + revoke their teammates) |
partnerstaff | Partner-scoped | Read-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
Note — accountmanager 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
| Role | Plain 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.” |
tagadapaymentAdmin | Legacy 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 dashboard | any role |
| Create a new partner | superadmin only |
| Archive (offboard) a partner | superadmin only |
| Edit a partner’s branding / commercial terms / pause-or-activate it | hubadmin or above |
| List every partner / view a partner’s profile | hubadmin or above |
| Invite a teammate to my own partner | partneradmin of that partner — or any hubadmin / superadmin |
| Revoke a teammate from my own partner | partneradmin of that partner — or any hubadmin / superadmin |
| Invite a teammate to another partner | hubadmin or above (partneradmins are scope-locked to their own) |
Grant the superadmin role to someone | superadmin only |
Grant hubadmin / accountmanager to someone | hubadmin or superadmin |
Grant partneradmin / partnerstaff to a teammate of my partner | partneradmin of that partner — or any hubadmin / superadmin |
| Move a user from one partner to another | superadmin only (the scope-move guard) |
| Tag a Clerk organization with a partner brand | superadmin only |
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.
| Caller | May assign… |
|---|
superadmin | any role to any user |
partneradmin (scope X) | PARTNER_ASSIGNABLE_ROLES only, only to users scoped to X |
| anyone else | nothing (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.
| Constant | Roles | Used when target… |
|---|
INTERNAL_ASSIGNABLE_ROLES | accountmanager, hubadmin, tagadapaymentAdmin, superadmin | has whitelabelOwnership IS NULL (Tagada-internal) |
PARTNER_ASSIGNABLE_ROLES | accountmanager, partneradmin | is 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.
| Caller | May manage staff for… |
|---|
superadmin, hubadmin, tagadapaymentAdmin | any partner |
partneradmin (scope X) | partner X only |
| anyone else | nothing |
canManagePartners
Used by the partner-entity verbs (list / get / create / update / archive).
| Action | Required role |
|---|
list / get / update | superadmin, hubadmin, or tagadapaymentAdmin (legacy) |
create | superadmin only |
archive | superadmin only |
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 not in the matrix — partner self-service is the
partner-staff lifecycle, not partner-entity CRUD.
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://app.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 deployment you are
calling (e.g. https://app.tagada.io).
Permission probes
These never return 403. They always return 200 with a decision
object — use them to render-then-mutate (see Patterns
below).
| Method | Path | Returns |
|---|
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.
| Method | Path | Required role | Purpose |
|---|
POST | /api/v1/iam/partners-admin/list | hubadmin or above | All partners with staffCount, merchantCount (Payfac TPAs), and orgCount (Clerk orgs tagged on the partner via accounts.partner_id). Optional status filter. |
POST | /api/v1/iam/partners-admin/get | hubadmin or above | Single partner row by id XOR slug, including branding / preferences / commercial terms. |
POST | /api/v1/iam/partners-admin/list-staff | hubadmin or above | Every 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-orgs | hubadmin or above | Every 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/create | superadmin only | New partner row. Optional adminEmails[] are invited as partneradmin. |
POST | /api/v1/iam/partners-admin/update | hubadmin or above (status="offboarded" requires superadmin) | Patch name / status / branding / preferences / commercial terms. jsonb fields are partial-merged. |
POST | /api/v1/iam/partners-admin/archive | superadmin only | Soft 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.
| Method | Path | Purpose |
|---|
POST | /api/v1/iam/partners/:partnerSlug/staff/invite | Idempotent invite — see Inviting a teammate |
POST | /api/v1/iam/partners/:partnerSlug/staff/revoke | Soft revoke — see Revoke vs delete |
POST | /api/v1/iam/partners/:partnerSlug/staff/delete | Hard delete — see Revoke vs delete |
POST | /api/v1/iam/partners/:partnerSlug/staff/resend-invitation | Re-issue a pending Clerk invitation |
POST | /api/v1/iam/partners/:partnerSlug/staff/set-roles | Rewrite 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.
| Method | Path | Purpose |
|---|
POST | /api/v1/iam/internal-users/set-roles | Rewrite a Clerk user’s tgdRoles |
POST | /api/v1/iam/internal-users/set-partner-scope | Set or detach a Clerk user’s whitelabelOwnership |
Organizations
| Method | Path | Purpose |
|---|
POST | /api/v1/iam/orgs/assign-to-partner | Tag a Clerk organization with a partner brand (whitelabel). Dual-writes Clerk + DB with a compensating revert on DB failure. |
POST | /api/v1/iam/orgs/list | Flat list of every Clerk org / merchant with the partner it belongs to pre-joined — partnerId, 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. |
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.
| Method | Path | Returns |
|---|
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://app.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.
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:
| Code | Meaning |
|---|
| 401 | Missing or invalid Clerk session — or a Bearer API key was sent (rejected by clerkSessionOnlyProcedure) |
| 403 | Caller is authenticated but the policy denied the action |
| 404 | Partner / user / invitation not found |
| 409 | Conflict (e.g. user already on roster with the same role) |
| 422 | Input validation failed |
The body is a JSON object with code (a string from the ZSA error
enum) and message (a human-readable description).