Skip to main content
A customer is the entity that pays — a workspace, a company, or in B2C the contact themselves. It is also a team, and this page is every way of getting onto one. Customers live in an environment, like every other customer-plane record. Test data and live data share a schema and never share rows, and a seat is a row.

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:
It answers the roles of the session’s own environment — there is no ?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_owner when 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.
  • owner may not give up $team.manage. Every other edit to that role is yours to make, including taking $billing.manage off it. This one answers 409 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.
A role’s 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 the X-Customer-Id header.
Switching is navigation, not a mutation. Nothing is written when somebody moves between their teams: you send a different header. Three things follow, and they are the reason it is not a stored 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.
Absent the header, the contact’s oldest membership answers — the same fallback a staff session makes to its default organization. A contact with no team at all is not an error: that is every B2C contact. Read the list from GET /v1/contact/customers:
That is the shape of a membership everywhere it is answered: opening a team with 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 stored active_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.
Storing a “last used” team is fine as a hint — it answers where to send somebody who arrives at / 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:
In a Next app — which this example already is, with 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:
It draws nothing for a contact who belongs to no team, which is what a B2C product looks like, where the customer is the person and there is no team to administer. <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 what setActiveCustomer does for you: the client’s cache goes with the choice, and the next getToken() mints another.
Minting by hand, forget this and you have a 403 for up to five minutes after every switch, only on client-side navigation and only until somebody reloads — the most expensive shape a bug can have.
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

The calling contact becomes the owner, so the last-owner guard has something to defend before anybody else arrives. An anonymous visitor is refused (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

With uk_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

Owner only. Valid for 7 days, one pending invite per address per customer, and the email goes out in your organization’s name — the recipient is a customer of your product and may never have heard of UserKit.
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.
Resend mints a new token. 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.
An invitation belongs to one environment. A contact of the other one is refused with the same invalid_token as an expired link — used, expired, revoked, never existed and wrong environment are one answer, on purpose.

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.