O texto mora no Userkit, a chamada manda os valores
Uma notificação não carrega mais o assunto e o conteúdo. Ela nomeia um template — escrito no painel, em Mensagens → Templates — e manda os valores que preenchem os espaços dele. Essa é a razão da mudança: a frase que o seu cliente lê é a coisa mais visível do seu produto, e ela ficava dentro de um deploy. Quem escreve, revisa e traduz essas frases raramente é quem faz deploy.
Repare no que não está no
data: o nome da pessoa. O contato, o time, o
plano e o que ele libera já estão no contexto de todo template — o data é só o
que só o seu backend sabe.
As variáveis são declaradas, e é isso que salva você
{{.Data.invoice_id}} só compila se invoice_id estiver na lista de variáveis
do template. Um erro de digitação é recusado na tela de quem escreveu, e não
renderizado como meia frase em dez mil caixas de entrada.
Do outro lado, a mesma lista é o contrato do envio: falta de um valor é 400
dizendo qual falta.
data são ignoradas, então um payload só serve a três
templates que leem pedaços diferentes dele.
Uma edição vira uma versão
Editar as palavras de um template escreve uma versão nova, e a anterior continua guardada. Voltar éPATCH { "version": 2 } — a mais recente fica na
prateleira, e ir para a frente de novo é a mesma chamada com o número maior.
É o que torna seguro editar esse texto pelo painel, pela API e pelo
MCP: o pior resultado de uma edição infeliz é um restore,
não uma frase perdida. Renomear ou arquivar não escreve versão — só as palavras
escrevem.
O template precisa ser transacional
Este endpoint manda recibo, e recibo é o que ninguém descadastra. Um template deproduct_news aqui seria marketing ignorando um opt-out, então ele é recusado —
e um template transacional, por sua vez, não pode ser passo de campanha. As duas
metades da mesma regra.
Um template por canal, sob a mesma chave
fatura_vencendo é uma linha para in_app, uma para email e uma para
whatsapp, cada uma escrita para o meio em que sai. É isso que faz uma chamada
alcançar três portas — e um canal cuja linha você ainda não escreveu responde
skipped dizendo qual criar, sem custar os canais que já existem.
Nomeie quem recebe com contact_id ou com o seu external_id — um dos dois.
Os dois juntos é 400: uma requisição que nomeia duas pessoas é uma sobre a qual
teríamos que adivinhar, e o palpite aparece semanas depois como uma notificação
que chegou ao usuário errado. De um jeito ou de outro, o ambiente da chave é o
escopo: um external_id de produção perguntado com uma chave de teste responde
404.
Não existe escrita alcançável pelo navegador, e não vai existir. Uma página que
pudesse criar uma notificação é uma página que pode dizer qualquer coisa a
qualquer usuário seu.
Para onde ela leva
url é opcional e é o que transforma um aviso em uma tarefa: a pessoa clica na
linha e cai na fatura que falhou, na exportação que terminou. Deixe de fora para um
comunicado — a maioria das notificações é isso.
Duas formas são aceitas:
Qualquer outra coisa é
400, e as duas que valem nomear são javascript: — um
esquema que executa, num valor que termina num href na sua própria página — e a
forma relativa a protocolo //host/caminho, que se lê como caminho em qualquer
revisão e é um endereço absoluto para o host de outra pessoa. A recusa está na
escrita e no banco embaixo dela, então o que você lê de volta já pode virar link sem
ser sanitizado de novo.
Numa SPA, passe um onNavigate ao <NotificationBell /> para que o seu roteador
trate o caminho em vez de o navegador recarregar a página:
Idempotency-Key repete a primeira resposta em vez de dizer a mesma
coisa duas vezes para alguém, e o
limite por chave conta a chamada e se declara nos
cabeçalhos de toda resposta.
Sobre qual time ela é
Uma pessoa em dois times é uma pessoa com duas contas, e “a fatura da Acme falhou” é uma frase sobre uma delas. Diga qual:customer_id é o nosso id e customer_external_id é o seu — o mesmo par que
POST /v1/track aceita por linha, e a mesma recusa
para os dois juntos (400 ambiguous_customer). Um time que não resolve no
ambiente da chave é 400 unknown_customer, e não uma mensagem guardada sem time:
quem disse qual time quis dizer, e arquivá-la como “da própria pessoa” a mostraria
dentro de todos os times dela para sempre.
A associação não é verificada. A sua chave é a autoridade sobre a qual dos seus
times uma mensagem pertence, exatamente como já é sobre quem a recebe; o que é
verificado é o ambiente, que nunca é do chamador afirmar.
Deixar de fora é uma resposta, não um esquecimento. “Sua senha foi alterada”
é endereçada à pessoa, então pertence a todos os times dela — é isso que null
significa aqui. Um produto sem times nunca preenche o campo, e toda notificação
escrita antes deste campo existir é esse mesmo caso: nada foi atribuído
retroativamente, porque datar uma mensagem antiga pelas associações de hoje a
arquivaria sob uma relação que talvez não existisse na época.
Ação necessária ou atualização
Uma notificação faz uma de duas coisas: pede alguma coisa a alguém, ou conta o que já aconteceu.kind é você dizendo qual das duas.
action é o cartão recusado, o documento a assinar, o limite estourado. update é a
exportação que ficou pronta, o relatório que chegou. O painel agrupa as duas em abas
separadas, e a aba “Ação necessária” é a razão de alguém abrir o sininho.
O padrão é update, e a assimetria é de propósito: uma notificação arquivada
errado como atualização fica numa lista, e uma arquivada errado como ação cobra uma
pessoa que não tem nada a fazer. Um backend escrito antes deste campo existir continua
funcionando e cai na metade que não custa nada quando erra. Um terceiro valor é 400
— quem mandou important acha que o painel está agrupando por ele, e um 201 deixaria
essa crença de pé por meses.
Não é category nem source escritos de outro jeito. Aqueles dois dizem como a linha
foi produzida — qual consentimento a governou e qual maquinário a escreveu; este diz o
que a pessoa que a lê tem que fazer a respeito.
Três canais, uma chamada
channels diz por onde a mensagem sai. Omitir é ["in_app"] — o que este
endpoint fazia antes do campo existir, para que nenhum backend escrito antes
comece a mandar e-mail sozinho.
title, body, url) é o que todo canal diz; os blocos email e
whatsapp são o que um deles precisa e os outros não têm uso. Um e-mail tem
envelope e assunto; o WhatsApp não tem nem um nem outro e é um bloco de texto.
A resposta traz um veredito por canal, não um código de status para os três:
created (a linha do feed, escrita antes da
resposta), queued (entregue à fila — se chegou é o registro de mensagens que
responde) ou skipped com o motivo a consertar. Nada ter saído por porta
nenhuma é 422 com o primeiro motivo no texto: aí um 201 seria mentira
sobre a única coisa que este endpoint faz.
O consentimento é o mesmo nos três: uma mensagem transacional é isenta no feed,
na caixa de entrada e no telefone, porque a isenção é uma propriedade do que a
mensagem é e não de como ela viaja.
WhatsApp: o servidor é seu
O canalwhatsapp fala com um Evolution API que você hospeda, num número
seu. Não há conta de plataforma no meio de propósito: um remetente compartilhado
faria a reputação de um inquilino ser a de todos, e no WhatsApp o custo disso não
é throttle, é banimento do número.
Conecte em Configurações → WhatsApp, por ambiente: endereço, instância e a
chave da API. A chave é guardada criptografada e nunca é devolvida pela tela. O
endereço precisa ser https — uma chave viajando em header por texto puro é uma
chave que qualquer um no caminho lê, e essa falha não tem sintoma.
Três coisas precisam ser verdade para uma mensagem sair por aqui, e cada ausência
é um conserto diferente:
O telefone chega pelo identify, em formato internacional:
external_id —
então a fusão de contatos e a exclusão de dados já sabem dele. Mas ele nunca
resolve um contato: operadoras reciclam números, e quem recebe um número usado
não é quem o tinha. Um número é anexado quando está livre e ignorado quando já
pertence a outra pessoa — uma mensagem que não sai, em vez de uma mensagem que
vai para o estranho errado.
O seu usuário lê
O feed é lido dentro de um time
listNotifications() responde as mensagens do time ativo mais as que foram
endereçadas à própria pessoa. Trocar de time é uma leitura diferente, não um
filtro sobre as mesmas linhas:
<NotificationBell /> acompanha a troca sozinho — o feed recomeça e o badge com
ele. O time viaja no X-Customer-Id, e sem ele responde a associação mais antiga
do contato, exatamente como em todo o resto deste plano; nomear um time do qual a
pessoa não participa é 404, e não uma lista menor, porque uma resposta
silenciosamente curta é pior que um erro. Um contato que não pertence a time
nenhum lê o feed inteiro.
markAllNotificationsRead() tem o mesmo escopo, e isso é o ponto e não um
detalhe: quem limpa o badge numa tela que mostra um time não pode marcar como
lidas as mensagens de outro, porque nada vai voltar a desenhá-las como não lidas.
As sem time são limpas junto — elas estavam na tela que acabou de ser lida.
O customer_id de cada linha é o que a rotula, já que a filtragem aconteceu
antes, no servidor.
source diz qual maquinário produziu a linha e category diz qual consentimento a
governou. Hoje parecem um campo só e não são: um passo de campanha também escreve
aqui, e um aviso de cobrança é uma campanha da qual ninguém se descadastra.
unread é sempre tudo o que está não lido, nunca o tamanho desta página. A
lista é limitada a 50, então um badge derivado dela diria 50 para quem tem 300 — e
?unread=true filtra a lista sem mexer na contagem, que é o que permite a uma única
requisição desenhar o sininho e o painel.
Marcar é idempotente pela própria instrução e não por uma checagem feita antes: ler
duas vezes é ler uma, e o primeiro carimbo permanece. read-all responde 204 e
deliberadamente não devolve uma contagem — “quantas eu acabei de limpar” é um fato
sobre a corrida com o que chegou enquanto a requisição estava em voo, e uma interface
que a renderizasse estaria renderizando essa corrida.
Uma notificação endereçada a outra pessoa responde 404 em vez de ser ignorada. O
contato faz parte da condição da própria escrita, então não há id da lista de outro
que possa casar.
url é null quando a notificação não leva a lugar nenhum — null e não ausente,
para que “não leva a lugar nenhum” e “esta versão da API não diz” continuem sendo
respostas diferentes para um cliente que escolhe entre um link e uma linha de texto.
Abrir o painel não marca nada como lido, e é aí que ele difere do
changelog. Uma nota de versão é um anúncio que todo mundo
recebe, então abrir a lista é honestamente o ato de ler; uma notificação é endereçada
a uma pessoa sobre uma coisa, e limpar dez porque alguém deu uma olhada num menu
perde exatamente o estado que ela veio conferir.
O sino, do jeito que o seu header já é
<NotificationBell /> tem duas aparências. O padrão é o botão com a palavra e a
contagem; variant="icon" é um sino sem borda, com um ponto quando há algo
não lido e nada quando não há.
Abrir de fora
O painel é comandado de qualquer lugar da página, sem provider no meio:available é o que você checa antes de desenhar um botão seu: é false sem
sino montado e false para quem não tem sessão — exatamente quando abrir o
painel abriria um painel vazio.
O seu header já tem um sino
Então esconda o nosso e fique só com o painel:showBell é a resposta inicial; notificationBell.hideBell() e
showBell() passam por cima dele a partir do momento em que são chamados, para
que o próximo render de um componente que ainda passa a prop não desfaça uma
decisão que alguém tomou. resetBell() devolve a decisão para a prop, e é o
único caminho de volta.
Por script:
subscribe é chamado de imediato com o que se sabe agora — a leitura vazia,
antes de o bundle ter chegado — e depois a cada mudança; devolve o cancelamento
de forma síncrona, e é o único método daqui que não é uma promise. Desenhe um
botão seu em cima dele, e não de um relógio: getState() é uma leitura, e
ficar consultando é um timer pela vida da aba para espelhar um store que já
publica cada mudança.
Chamar antes de qualquer coisa montada não perde o comando: o bundle é buscado
e o pedido fica registrado, então o painel abre assim que um sino monta.
UserKit.notifications existe nos dois scripts — userkit.js e widget.js
instalam o mesmo window.UserKit, então trocar um pelo outro não tira método
nenhum da página.
O portão
Esta leitura exige sessão verificada — a versão mais forte da razão que o changelog e a pesquisa dão. Aqueles vazam se alguém está num segmento; esta vaza o que o seu produto disse a uma pessoa nomeada. Uma sessão identificada e não provada é uma afirmação que qualquer um que saiba umexternal_id faz de um navegador, e a
resposta inteira deste endpoint é a correspondência de outra pessoa.
Uma sessão anônima passa pelo portão e lê uma lista vazia, o que é verdade e não
recusa: nada endereça um visitante, porque uma notificação nomeia um contact_id ou
um external_id e um visitante não tem nenhum dos dois.
Em modo proxy as três rotas são encaminhadas
pelo @userkit/nextjs como o resto da superfície do contato, então o mesmo código de
cliente funciona com a sessão num cookie httpOnly na sua própria origem.
Campanhas também caem aqui
Um passoin_app de uma campanha escreve na mesma lista, e
é a única diferença que vale saber: uma linha produzida por campanha é governada pelo
consentimento que o template dela declara, então quem desligou aquela categoria não
recebe. A linha usa o nome da campanha como título, porque um template in-app não
tem assunto.
Sem webhook
notification.created é publicado internamente e deliberadamente não é entregue.
Seria um eco: o fato existe porque o seu backend pediu por ele um instante atrás,
então a entrega devolveria a sua requisição para você sem acrescentar nada — e a
primeira coisa que alguém constrói com isso é um laço.
Push não está aqui. Chega quando houver um SDK móvel para receber, não antes.