> ## 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.

# IAM Overview

> Roles, partner staff, and the role-assignment matrix that backs every /iam/* endpoint. Clerk-session authenticated.

# 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.

<Warning>
  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](#authentication) below for the full rationale
  and how to call these endpoints from an external app.
</Warning>

## 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.

<Tip>
  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.
</Tip>

| 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

```mermaid theme={null}
flowchart TD
    Platform([Tagada platform])

    Platform --> Internal["<b>Internal staff</b><br/>whitelabelOwnership = null"]
    Platform --> Scoped["<b>Partner-scoped staff</b><br/>whitelabelOwnership = slug"]

    Internal --> Super["<b>superadmin</b><br/>full power, every partner<br/>only role that can grant superadmin"]
    Internal --> Hub["<b>hubadmin</b><br/>day-to-day Tagada ops<br/>manages every partner"]
    Internal --> Legacy["<b>tagadapaymentAdmin</b><br/>legacy — treated like hubadmin"]
    Internal --> AmInt["<b>accountmanager</b> (unscoped)<br/>cross-partner sales / CS"]

    Scoped --> PA["<b>partneradmin</b> (scope X)<br/>owns partner X's roster<br/>can invite / revoke X's staff"]
    Scoped --> AmScoped["<b>accountmanager</b> (scope X)<br/>AM dedicated to one partner"]
    Scoped --> PS["<b>partnerstaff</b> (scope X)<br/>read-only-ish teammate"]
```

Plain-text version (in case mermaid doesn't render in your viewer):

```text theme={null}
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)                                                  |
| Change a merchant's partner attribution (DB + Clerk + Stripe + ClickHouse) | `superadmin` or `founder` only — see [Partner attribution](#partner-attribution-accounts) |

> **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.

<Tip>
  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.
</Tip>

### `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`           | `superadmin`, `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` / `update` | `superadmin`, `hubadmin`, or `tagadapaymentAdmin` (legacy)                                                                                                                                                                 |
| `create`         | `superadmin` only                                                                                                                                                                                                          |
| `archive`        | `superadmin` 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.

| Caller                                         | Scope                              |
| ---------------------------------------------- | ---------------------------------- |
| `superadmin`, `hubadmin`, `tagadapaymentAdmin` | any 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 else                                    | nothing                            |

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](#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'`.

```ts theme={null}
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()`](https://clerk.com/docs/references/javascript/session#get-token)
  and pass it as `Authorization: Bearer <jwt>` from server to
  server.

```bash theme={null}
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:

```json theme={null}
{
  "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](#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` (Processing 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](#inviting-a-teammate) |
| `POST` | `/api/v1/iam/partners/:partnerSlug/staff/revoke`            | Soft revoke — see [Revoke vs delete](#revoke-vs-delete)             |
| `POST` | `/api/v1/iam/partners/:partnerSlug/staff/delete`            | Hard delete — see [Revoke vs delete](#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/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. |

### 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.

| Method | Path                                                                     | Purpose                                                                                                                                                 |
| ------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/api/v1/iam/partners/:partnerSlug/merchants/:accountId/api-keys`        | List 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/create` | Mint 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/revoke`               | Revoke a partner-provisioned key. Only keys this partner minted are revocable (`404` otherwise). An unknown or already-revoked key returns `NOT_FOUND`. |

<Warning>
  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.
</Warning>

### Partner attribution (accounts)

<Warning>
  **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.
</Warning>

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.

| Method | Path                                  | Purpose                                                                                                                                                                                                                                                                   |
| ------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/api/v1/iam/accounts/change-partner` | Canonical multi-surface partner change. **Platform-admin only** (superadmin or founder). See payload + surface contract below.                                                                                                                                            |
| `POST` | `/api/v1/iam/orgs/assign-to-partner`  | **Deprecated** 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**

```jsonc theme={null} theme={null}
{
  // 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.

| 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](#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:

```bash theme={null}
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.

```bash theme={null}
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": "…" }
```

2. **Use** (Bearer API key — the merchant's own integration). The
   `token` from step 1 is now a normal public-API credential:

```bash theme={null}
curl https://api.tagada.io/api/public/v1/orders \
  -H "Authorization: Bearer tgd_live_…"
```

3. **List** what you've provisioned for that merchant (secrets are
   never returned on read):

```bash theme={null}
curl https://api.tagada.io/api/v1/iam/partners/bnp/merchants/acc_01H.../api-keys \
  -H "Authorization: Bearer ${CLERK_SESSION_JWT}"
```

4. **Revoke** when rotating or offboarding:

```bash theme={null}
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:

| 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).
