Skip to main content
“Sua exportação está pronta.” “A fatura falhou.” “Alguém mencionou você.” São endereçadas a uma pessoa nomeada, não são marketing, e pertencem ao seu produto e não a uma caixa de entrada.

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.
O template guarda o título e o corpo, com os espaços entre chaves: 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.
Chaves a mais no 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 de product_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:
A linha é marcada como lida de qualquer forma, antes do seu handler rodar: agir sobre uma notificação é o que ler significa. Um clique com modificador — ⌘, Ctrl, Shift — fica com o navegador, então clicar com o botão do meio ainda abre uma aba. É transacional por construção. Não há campo de categoria nesta requisição e não há campo que pudesse ser um: nada que alguém desligue nas preferências suprime uma destas, do mesmo jeito que nada suprime um recibo. Isso é o formato do endpoint, não uma regra que ele aplica. Ela fica na superfície de máquina, então herda as duas promessas de lá por ter sido montada ali: um 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.
O tronco (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:
Um contato sem telefone não é uma requisição malformada, e recusar a chamada inteira por causa disso custaria à pessoa a notificação que ela poderia ter recebido. Então cada canal responde 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 canal whatsapp 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:
Ele é uma identidade do contato, ao lado do e-mail e do seu 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:
O <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á.
O ponto não traz número de propósito: num header, a pergunta de quem passa o olho é “tem alguma coisa?”, e um número dentro de um círculo de 1rem ou é ilegível ou vira “9+”, que responde pior. A contagem continua no nome acessível do botão, onde não custa nada.

Abrir de fora

O painel é comandado de qualquer lugar da página, sem provider no meio:
Fora do React — um atalho de teclado, um link dentro de um estado vazio — os mesmos métodos estão no objeto exportado:
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:
O 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:
O 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 um external_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 passo in_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.