Skip to main content
Um customer é a entidade que paga — um workspace, uma empresa ou, em B2C, o próprio contato. Ele também é um time, e esta página é toda forma de entrar em um. Customers vivem dentro de um ambiente, como todo registro do plano de clientes. Dados de teste e dados de produção compartilham o schema e nunca compartilham linhas — e um assento é uma linha.

Papéis, e o vocabulário é seu

owner e member são semeados em todo ambiente, não podem ser apagados nem renomeados, e são os dois que a claim customer_role do JWT carrega desde sempre. Ao lado deles, você define os seus — uma escola tem diretor, secretaria, professor e aluno, e isso não cabe em um booleano. Um papel carrega permissões de dois vocabulários na mesma lista: Esse segundo vocabulário é o ponto: seu backend valida grades:write offline, com o JWT que já tem em mãos, e criar um papel novo nunca vira uma edição no seu código. Um $ que não seja uma das quatro capabilities é recusado na escrita — um papel que parece conceder algo e não concede é descoberto por quem ele barrou. O catálogo é por ambiente (um papel é atribuído a contatos, e contatos pertencem a um ambiente) e se administra em /v1/organization/customer-roles, com customers:read e customers:write. Isto é diferente de papéis e permissões, que são os do seu time dentro do painel.

Ler o catálogo de dentro do time

Escrever papéis é do painel. Ler também é do customer, porque quem convida alguém precisa poder escolher o papel que pretende dar:
Responde os papéis do ambiente da própria sessão — não há ?environment= e não poderia haver: a credencial do contato já diz em qual metade da organização ele está. Exige $team.manage no customer ativo e responde 403 sem ela. A lista serve para convidar e reatribuir, então quem não pode fazer nem uma coisa nem outra não tem o que fazer com ela — e ela é o seu vocabulário interno, não um fato sobre este time. Também não vem contagem de membros: esse número atravessa todos os times do ambiente. Devolva a key ao convidar ou ao trocar um papel. No SDK é userkit.listCustomerRoles(), e o <CustomerProfile /> já preenche os dois selects de papel a partir dela. Três invariantes vêm com os papéis:
  • Um customer nunca fica sem quem o administre. Rebaixar, remover ou sair responde 409 last_owner quando a pessoa é a última cujo papel tem $team.manage — a conta é pela capability, não pelo nome: um “diretor” que administra o time conta, e um owner de quem você tirou a capability não.
  • owner não pode abrir mão de $team.manage. Qualquer outra edição no papel é sua — inclusive tirar o $billing.manage dele. Essa uma responde 409 owner_must_manage_the_team, porque nenhuma associação mudaria e mesmo assim todo time do ambiente ficaria sem administrador de uma vez.
  • Você não pode trocar o seu próprio papel. É assim que alguém se dá tudo.
A key de um papel é imutável: é o que seu código compara e o que o JWT carrega, então renomeá-la quebraria o seu gate sem erro nenhum do nosso lado. Renomear é criar outro papel e mover as pessoas — duas chamadas que você vê acontecendo. O name é só exibição e esse muda à vontade.

Editar não sobrescreve

As permissões de um papel são versionadas. Toda edição escreve uma versão nova e imutável, e o papel passa a apontar para ela — a versão anterior continua lá. GET /v1/organization/customer-roles/{id}/versions lista o histórico, e POST …/versions/{id}/restore volta o ponteiro para uma delas. Voltar não escreve nada: a versão que estava valendo continua guardada, então restaurar também é reversível. É isso que torna seguro editar um papel de um script, de um agente ou de uma tela com pressa — o conjunto que você acabou de substituir está a uma chamada de distância.

O customer ativo

Um contato pode pertencer a vários customers, então toda chamada diz dentro de qual ela está agindo — no header X-Customer-Id.
Trocar é navegação, não mutação. Nada é gravado quando alguém muda de time: você manda um header diferente. Três coisas seguem daí, e são a razão de não ser um active_customer_id guardado na sessão.
Um id de customer que alguém cole não casa com nenhuma linha de vínculo e responde 404 — o mesmo 404 de um customer que não existe. Revogar um vínculo vale na próxima requisição, não no próximo login.
Não existe um cursor compartilhado para uma aba mover debaixo da outra.
As claims seguem o que esta emissão pediu. setActiveCustomer descarta o JWT em cache junto com a escolha, então a emissão seguinte já nomeia o time novo — e quem emite na mão precisa fazer o mesmo. Veja O time dentro do JWT.
Sem o header, o vínculo mais antigo do contato responde — a mesma queda que uma sessão de staff faz para a organização padrão. Um contato sem time nenhum não é um erro: é todo contato B2C. Leia a lista em GET /v1/contact/customers:
Esse é o formato de uma associação em todo lugar em que ela é respondida: abrir um time com POST /v1/contact/customers e aceitar um convite com POST /v1/contact/invitations/accept respondem uma dessas em customer, então o cliente descobre o que o assento novo CONCEDE na mesma chamada que o criou.

Onde a escolha vive no seu app

A API não guarda customer ativo, então é o seu app que decide onde a escolha sobrevive. O SDK guarda em memória enquanto a página existir: um reload cai de volta no vínculo mais antigo, o que é correto e não é persistência. Coloque na URL. Ela sobrevive a um reload, faz o botão voltar significar alguma coisa, é um link que alguém pode mandar e — a parte que importa — duas abas em dois times continuam independentes, que é justamente a propriedade pela qual a API abriu mão de guardar um active_customer_id. Um cursor em localStorage devolve essa propriedade na hora, e ainda por cima com a barra de endereço dizendo outra coisa.
href diz onde cada time mora e faz os itens virarem links de verdade; navigate entrega um clique comum ao seu router. Sem navigate, o navegador segue o link, o que é um carregamento de página inteira — o padrão honesto para um app que não tem router próprio. navigate sozinho não compila: a URL que ele navegaria é a que href constrói. Depois leia o time da rota e diga ao cliente, uma vez, onde esta página está:
setActiveCustomer não grava nada em lugar nenhum — muda qual X-Customer-Id a próxima requisição carrega. Um id que não casa com nenhum vínculo deste contato responde 404 na próxima leitura em vez de conceder alguma coisa, então uma URL editada na mão é recusada pela mesma regra que recusa um header colado.
Um cmd+clique em um time abre em outra aba e deixa a atual onde estava, de propósito: aquela aba lê o time da URL que ela abriu, e trocar aqui moveria o time de alguém debaixo da pessoa que ainda está olhando para esta. É isso que uma âncora com href de verdade compra e que um onClick não consegue imitar.
Guardar o “último time usado” está ótimo como dica — serve para responder para onde mandar quem chega em / sem time na URL. Só não pode ser a resposta para “de qual time é esta página”, ou a URL começa a mentir.

A tela do time

<CustomerSwitcher /> escolhe o time; <CustomerProfile /> administra o que foi escolhido — quem está dentro, quem foi convidado, e os controles que só o proprietário vê:
Num app Next — que é o que este exemplo já é, com o useRouter na segunda linha — importe do @userkit/nextjs: são os mesmos dois componentes, e em modo proxy toda a superfície do time é encaminhada pelos handlers, com a sessão num cookie httpOnly da sua própria origem. O X-Customer-Id atravessa junto e continua identificando sem nunca autorizar — um id que não casa com nenhuma associação do contato do cookie responde 404 lá em cima. onLeft existe porque sair do time é a única ação aqui cujo sucesso esvazia a página inteira: a tela some e o app precisa levar a pessoa para algum lugar. É a mesma seção que <AccountSettings /> desenha quando team está ligado — um componente só, montado com a moldura da tela de conta ou com a própria. Um produto com rota de time própria desliga lá e monta este aqui:
Ele não desenha nada para um contato que não pertence a time nenhum, que é a cara de um produto B2C, onde o customer é a pessoa e não há time para administrar. <CustomerProfile /> é o mesmo componente com o nome do domínio, do mesmo jeito que <CustomerSwitcher />.

O time dentro do JWT

POST /v1/contact/token coloca o customer ativo no JWT de sessão, para o seu backend responder “qual time, e essa pessoa pode administrá-lo” offline:
customer_permissions é a claim que sustenta a frase lá de cima: o gate é claims.customer_permissions.includes("grades:write"), não uma comparação com o nome do papel — que muda quando o tenant cria mais um, e a mudança seria no seu código. A referência completa das claims está em Tokens de sessão. Mande X-Customer-Id no mint e as claims seguem. Nomear um customer ao qual o contato não pertence é recusado com 404 em vez de simplesmente omitir a claim — um token sem o campo que o seu backend usa como porta é pior que um erro. As três claims ficam ausentes quando o contato não pertence a customer nenhum. Trate ausente como “sem time”, nunca como “sem necessidade de checar”.

Trocar de time é emitir de novo

Um JWT já emitido nomeia o time em que foi emitido, e nada o alcança depois — é a mesma propriedade que faz a revogação precisar de um segundo documento. Então quem troca de time precisa descartar o token em cache, e é o que setActiveCustomer faz por você: o cache do cliente vai junto com a escolha, e a próxima getToken() emite outro.
Emitindo na mão, esqueça isso e você tem 403 por até cinco minutos depois de cada troca de time, só na navegação client-side e só até alguém dar F5 — a forma mais cara que um bug pode ter.
No servidor não há time ativo para o SDK guardar: getToken({ customerId }) do @userkit/nextjs recebe o id que a sua rota já leu. Sem ele o token nomeia o vínculo mais antigo, que é um time de verdade e raramente o da página.

Abrindo um time

O contato que chamou vira o owner, para que a guarda do último owner tenha o que defender antes de qualquer outra pessoa chegar. Um visitante anônimo é recusado (identity_required). O seu próprio servidor também pode criar customers direto, que é o formato usual no modo federado — lá o seu produto já sabe quem trabalha com quem.

Do lado do servidor, com a chave secreta

Com uk_sk_… o seu backend espelha e lê de volta a estrutura de contas: O ambiente sai da chave e nenhum campo da requisição o move: uma chave live perguntando por um time de teste recebe 404, e é o mesmo 404 de um id que não existe. O GET /v1/organization/customers não serve aqui — ele é do painel, exige sessão de staff e recebe o ambiente como parâmetro de visualização, que é a postura oposta. O quadro não traz convites pendentes: um assento oferecido é oferecido e revogado de dentro do time, e GET /v1/contact/customer/members responde as duas metades para quem está nele.

Convites

Só owner. Vale por 7 dias, um convite pendente por endereço por customer, e o e-mail sai em nome da sua organização — quem recebe é cliente do seu produto e pode nunca ter ouvido falar do UserKit.
A resposta não diz nada sobre o endereço já ter conta com você. O único conflito que ela reporta — already_member — é participação no time de quem chamou, que essa pessoa já lê na lista do time. Todo outro caso segue o caminho idêntico e produz a resposta idêntica.
Reenviar gera um token novo. POST /v1/contact/customer/invitations/{id}/resend rotaciona o hash e a validade, então o link da mensagem anterior para de funcionar: um convite é uma credencial, e nunca pode haver duas vivas para o mesmo assento. Revogar (DELETE …/{id}) fecha o assento de vez.

Aceitar exige duas provas

POST /v1/contact/invitations/accept precisa de um contato autenticado e do token. O token prova o convite — que este assento foi oferecido a este endereço. A sessão prova a pessoa. Nenhum dos dois basta sozinho: um link encaminhado sentaria quem o abrisse. Por isso o endereço convidado precisa ser uma identidade do contato que aceita, ou a chamada responde 403 invitation_not_yours. É a mesma regra que todo fluxo por endereço deste plano já segue: um e-mail é um atributo até algo transformá-lo em aresta de identidade, e um atributo é algo que qualquer um digita. Um endereço vira aresta por um magic link, por um código de seis dígitos, ou pelo seu próprio servidor chamando POST /v1/contacts com ele. Passar um endereço para /v1/boot deliberadamente não faz isso.
Um convite pertence a um ambiente. Um contato do outro é recusado com o mesmo invalid_token de um link expirado — usado, expirado, revogado, nunca existiu e ambiente errado são uma resposta só, de propósito.

Eventos

Toda mutação publica um fato que você consome pelos eventos: Payloads carregam ids, nunca endereços — o e-mail fica fora de toda fila e todo log por onde o envelope passa.