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