> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Customer teams

> A customer is a team: two roles, invitations that go to an inbox, and an active customer that travels in a header.

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.

| Role     | May                                                                                |
| -------- | ---------------------------------------------------------------------------------- |
| `owner`  | Invite, revoke, resend, change roles, remove members — and everything a member may |
| `member` | Read the roster, and leave                                                         |

There is no permission catalogue down here and no way to define a role. That is
deliberate: [roles and permissions](/en/concepts/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.

```bash theme={null}
curl -s $API/v1/contact/customer/members \
  -H "Authorization: Bearer $UK_CONTACT_TOKEN" \
  -H "X-Customer-Id: 8f21…"
```

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

<ResponseField name="The header identifies; the membership authorizes">
  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.
</ResponseField>

<ResponseField name="Two tabs stay correct">
  There is no shared cursor for one tab to move under the other.
</ResponseField>

<ResponseField name="The JWT cannot outlive the choice">
  Claims follow what *this call* asked for, and the next mint is at most five
  minutes away.
</ResponseField>

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`:

```json theme={null}
{
  "customers": [
    { "id": "8f21…", "name": "Contoso", "role": "owner",  "joined_at": "…" },
    { "id": "b7c4…", "name": "Initech", "role": "member", "joined_at": "…" }
  ]
}
```

## The team in the JWT

`POST /v1/contact/token` puts the active customer into the
[session JWT](/en/customer-auth/session-tokens), so your backend answers "which
team, and may this person administer it" **offline**:

```json theme={null}
{
  "sub": "3f9a…",
  "sid": "c1d2…",
  "verified": true,
  "customer_id": "8f21…",
  "customer_role": "owner",
  "exp": 1735689600
}
```

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

```bash theme={null}
curl -s $API/v1/contact/customers \
  -H "Authorization: Bearer $UK_CONTACT_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Contoso" }'
```

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](/en/customer-auth/federated) — there, your product already knows
who works with whom.

## Invitations

```bash theme={null}
curl -s $API/v1/contact/customer/invitations \
  -H "Authorization: Bearer $UK_CONTACT_TOKEN" \
  -H "X-Customer-Id: 8f21…" \
  -H 'Content-Type: application/json' \
  -d '{ "email": "bob@example.com", "role": "member" }'
```

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.

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

**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](/en/concepts/contacts#identity-edges),
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.

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

## Events

Every mutation publishes a fact you can consume through
[events](/en/guides/events):

| Event                          | When                                                        |
| ------------------------------ | ----------------------------------------------------------- |
| `customer.created`             | A team was opened                                           |
| `customer_member.invited`      | A seat was offered                                          |
| `customer_member.joined`       | An invitation was accepted                                  |
| `customer_member.role_changed` | A role changed                                              |
| `customer_member.removed`      | Somebody was removed, or left (`left: true` in the payload) |

Payloads carry ids, never addresses — the email stays out of every queue and log
the envelope crosses.
