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.

Two roles, and no third

owner and member. That is the whole catalogue. There is no permission catalogue down here and no way to define a role. That is deliberate: roles and permissions exist one plane up because your organization defines its own, and a B2B team inside your product wants the person who administers it and everybody else. Custom roles arrive when somebody asks for them. Two invariants come with the roles, and both are the staff plane’s mirrored:
  • A customer never loses its last owner. Demoting them, removing them or their leaving answers 409 last_owner. Promote somebody else first.
  • You cannot change your own role. That is how a person hands themselves everything.

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 call asked for, and the next mint is at most five minutes away.
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:

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:
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. Both claims are absent when the contact belongs to no customer. Treat absent as “no team”, never as “no permission check needed”.

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.

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 hosted sign-up, a magic link, a six-digit code, a social sign-in the provider vouched for, or your own server calling POST /v1/contacts with it. Passing an address to /v1/boot in federated mode 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.