Roles, and the vocabulary is yours
owner and member are seeded into every environment, cannot be deleted or
renamed, and are the two strings the JWT’s customer_role claim has always
carried. Beside them, you define your own — a school has a director, a
secretary, a teacher and a student, and none of that fits a boolean.
A role carries permissions from two vocabularies in one list:
That second vocabulary is the point: your backend gates
grades:write offline
with the JWT it already holds, and adding a role never means editing your
gate. A $ string that is not one of the four capabilities is refused on the
write — a role that looks like it grants something and grants nothing is
discovered by whoever it locked out.
The catalogue is per environment (a role is assigned to contacts, and
contacts belong to one) and is administered at
/v1/organization/customer-roles under customers:read and customers:write.
This is a different thing from roles and
permissions, which are your own team’s
inside the panel.
Reading the catalogue from inside a team
Writing roles is the panel’s. Reading them is also the customer’s, because the person who invites somebody has to be offered the role they mean to give:?environment= and there could not be, since the contact’s credential already
says which half of the organization they are in.
It needs $team.manage on the active customer and answers 403 without it.
The list is spent on inviting and reassigning, so whoever may do neither has no
use for it, and it is your internal vocabulary rather than a fact about this
team. There is no member count either: that number spans every team in the
environment.
Send the key back when inviting or changing a role. In the
SDK it is userkit.listCustomerRoles(), and <CustomerProfile /> already
fills both of its role selects from it.
Three invariants come with the roles:
- A customer never loses everybody who can administer it. Demoting,
removing or leaving answers
409 last_ownerwhen the person is the last one whose role holds$team.manage. The count is by capability, not by name: a “director” who administers the team counts, and an owner you deliberately stripped of it does not. ownermay not give up$team.manage. Every other edit to that role is yours to make, including taking$billing.manageoff it. This one answers409 owner_must_manage_the_team, because no membership would move and yet every team in the environment would lose its administrator at once.- You cannot change your own role. That is how a person hands themselves everything.
key is immutable: it is what your code compares and what the JWT
carries, so renaming it would break your own gate with no error raised anywhere
on our side. A rename is a new role plus a reassignment — two calls you can see
yourself making. name is display only and changes freely.
Editing does not overwrite
A role’s permissions are versioned. Every edit writes a new immutable version and the role points at it; the previous set stays where it was.GET /v1/organization/customer-roles/{id}/versions is the history, and
POST …/versions/{id}/restore moves the pointer back to one of them.
Going back writes nothing: the version that was live stays on the shelf, so a
restore is itself reversible. That is what makes a role safe to edit from a
script, an agent, or a screen you are in a hurry on — the set you just replaced
is one call away.
The active customer
A contact may belong to several customers, so every call says which one it is acting inside — in theX-Customer-Id header.
active_customer_id.
A customer id somebody pastes in matches no membership row, and answers
404
— the same 404 as a customer that does not exist. Revoking a membership
takes effect on the next request, not at the next sign-in.There is no shared cursor for one tab to move under the other.
Claims follow what this mint asked for.
setActiveCustomer throws the
cached JWT away along with the choice, so the next mint already names the new
team — and a caller minting by hand has to do the same. See The team in the
JWT.GET /v1/contact/customers:
POST /v1/contact/customers and accepting an invitation with
POST /v1/contact/invitations/accept both answer one of these under customer,
so a client learns what the new seat GRANTS in the same call that made it.
Where the choice lives in your app
The API holds no active customer, so your app decides where the choice survives to. The SDK holds it in memory for as long as the page lives: a reload falls back to the oldest membership, which is correct and is not persistence. Put it in the URL. It survives a reload, it makes the back button mean something, it is a link somebody can send, and — the part that matters — two tabs on two teams stay independent, which is the property the API gave up a storedactive_customer_id to keep. A localStorage cursor hands that property
straight back, and does it while the address bar says otherwise.
href says where each team is and renders the items as real links; navigate
hands a plain click to your router. Without navigate the browser follows the
link, which is a full page load — the honest default for an app with no router
of its own. navigate alone does not type-check: the URL it would navigate to
is the one href builds.
Then read the team out of the route and tell the client, once, where this page
is:
setActiveCustomer writes nothing anywhere — it changes which X-Customer-Id
the next request carries. An id that matches no membership of this contact
answers 404 on the next read rather than granting anything, so a hand-edited
URL is refused by the same rule that refuses a pasted header.
Cmd-clicking a team opens it in another tab and deliberately leaves the current
one where it was: that tab reads its team from the URL it opened, and switching
here would move somebody’s team under them while they are still looking at it.
This is what an anchor with a real
href buys and an onClick cannot./ with no team in the URL. It must not be the answer to
“which team is this page”, or the URL starts lying.
The team screen
<CustomerSwitcher /> chooses the team; <CustomerProfile />
administers the one it chose — who is on it, who has been asked, and the
controls only an owner sees:
useRouter on its second line —
import from @userkit/nextjs: the same two components, and in
proxy mode the whole team surface is forwarded by
the handlers, with the session in an httpOnly cookie on your own origin.
X-Customer-Id rides through with it and still identifies without ever authorizing —
an id matching no membership of the cookie’s contact answers 404 upstream.
onLeft exists because leaving is the one action here whose success empties the
whole page: the screen goes away, and the app has to take the person somewhere.
It is the same section <AccountSettings /> draws when team is on — one
component, mounted with the account card’s frame or with its own. A product with
a team route of its own turns it off there and mounts this here:
<CustomerProfile /> is the same component under the domain’s own
word, exactly as <CustomerSwitcher /> is.
The team in the JWT
POST /v1/contact/token puts the active customer into the
session JWT, so your backend answers “which
team, and may this person administer it” offline:
customer_permissions is the claim holding up the sentence at the top of this
page: the gate is claims.customer_permissions.includes("grades:write"), not a
comparison against the role’s name — which changes the day the tenant adds
another one, and the change would be in your code. The full claim reference is
in Session tokens.
Send X-Customer-Id on the mint and the claims follow it. Naming a customer the
contact does not belong to is refused with 404 rather than silently dropping
the claim — a token missing the field your backend gates on is worse than an
error.
All three claims are absent when the contact belongs to no customer. Treat
absent as “no team”, never as “no permission check needed”.
Switching teams means minting again
A JWT already issued names the team it was minted inside, and nothing reaches it afterwards — the same property that makes revocation need a second document. So switching teams has to throw the cached token away, and that is whatsetActiveCustomer does for you: the client’s cache goes with the choice,
and the next getToken() mints another.
On the server there is no active team for an SDK to hold: getToken({ customerId })
from @userkit/nextjs takes the id your route already read. Without it the
token names the oldest membership, which is a real team and rarely the page’s.
Opening a team
identity_required).
Your own server can also create customers directly, which is the usual shape in
federated mode — there, your product already knows
who works with whom.
From the server, with the secret key
Withuk_sk_… your backend mirrors the account structure and reads it back:
The environment comes off the key and no request field can move it: a live key
asking about a test team gets
404, the same 404 as an id that does not
exist. GET /v1/organization/customers is not the same thing — it is the
panel’s read, it needs a staff session, and it takes the environment as a view
parameter, which is the opposite posture.
The roster leaves out pending invitations: a seat is offered and revoked from
inside the team, and GET /v1/contact/customer/members answers both halves to
somebody who is on it.
Invitations
The answer says nothing about whether that address already has an account with
you. The one conflict it reports —
already_member — is membership of the
caller’s own team, which they can already read off the roster. Every other case
takes the identical path and produces the identical answer.POST /v1/contact/customer/invitations/{id}/resend
rotates the hash and the expiry, so the link in the older message stops working:
an invitation is a credential, and there must never be two live ones for one
seat. Revoke (DELETE …/{id}) closes the seat outright.
Accepting takes two proofs
POST /v1/contact/invitations/accept needs a signed-in contact and the
token.
The token proves the invitation — that this seat was offered to this address.
The session proves the person. Neither is enough alone: a forwarded link would
otherwise seat whoever opened it.
So the invited address must be an identity of the accepting contact, or the
call answers 403 invitation_not_yours. That is the same rule every
address-based flow on this plane already follows: an email is an attribute
until something makes it an identity edge,
and an attribute is something anybody can type.
An address becomes an edge through a magic link, a six-digit code, or your own
server calling POST /v1/contacts with it. Passing an address to /v1/boot
deliberately does not.
Events
Every mutation publishes a fact you can consume through events:
Payloads carry ids, never addresses — the email stays out of every queue and log
the envelope crosses.