> ## 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.

# Notificações no produto

> O seu backend dizendo a um usuário seu alguma coisa dentro do seu produto — transacional por construção, e lida por ele com a própria sessão.

"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.

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/notifications \
  -H "Authorization: Bearer uk_sk_live_…" \
  -H "Idempotency-Key: cartao-recusado-8421-2026-08" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_8421",
    "template": "fatura_vencendo",
    "kind": "action",
    "data": {
      "invoice_id": "inv_5512",
      "valor": "R$ 149,00",
      "vencimento": "05/09"
    }
  }'
```

O template guarda o título e o corpo, com os espaços entre chaves:

| Campo         | O que fica nele                                                                                                                                  |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Chave**     | `fatura_vencendo` — como o seu backend chama esta mensagem. Ela sobrevive ao dia em que alguém renomeia o template, que é o que um nome não faz. |
| **Título**    | `Sua fatura {{.Data.invoice_id}} vence {{.Data.vencimento}}`                                                                                     |
| **Corpo**     | `Olá{{if .Contact.Name}}, {{.Contact.Name}}{{end}}. São {{.Data.valor}}.`                                                                        |
| **Variáveis** | `invoice_id, valor, vencimento`                                                                                                                  |

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.

```json theme={null}
{ "error": { "code": "missing_template_data", "message": "data is missing: vencimento" } }
```

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](/pt-br/guides/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:

| Forma                               | Exemplo                                | O que acontece                                                                                                |
| ----------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Um caminho começando em `/`         | `/faturas/inv_123`                     | Abre no lugar, onde a pessoa já está.                                                                         |
| Um endereço `http`/`https` completo | `https://cobranca.exemplo.com/inv_123` | Mesma origem da página abre no lugar; **outra origem abre em uma aba nova**, com `rel="noopener noreferrer"`. |

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:

```tsx theme={null}
import { useRouter } from "next/navigation";

const router = useRouter();

<NotificationBell onNavigate={(url) => router.push(url)} />;
```

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](/pt-br/guides/messages) 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](/pt-br/api-reference/rate-limits) 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:

```json theme={null}
{
  "external_id": "user_8421",
  "customer_external_id": "time-acme",
  "template": "fatura_vencendo",
  "data": { "invoice_id": "inv_5512", "valor": "R$ 149,00", "vencimento": "05/09" }
}
```

`customer_id` é o nosso id e `customer_external_id` é o seu — o mesmo par que
[`POST /v1/track`](/pt-br/guides/analytics) 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.

```json theme={null}
{ "external_id": "user_8421", "title": "Seu cartão foi recusado", "kind": "action" }
```

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

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/notifications \
  -H "Authorization: Bearer uk_sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_8421",
    "title": "Seu cartão foi recusado",
    "body": "A cobrança de agosto não passou.",
    "url": "https://app.exemplo.dev/faturas/inv_5512",
    "kind": "action",
    "channels": ["in_app", "email", "whatsapp"],
    "email": { "subject": "Atualize seu cartão" },
    "whatsapp": { "text": "Oi! A cobrança de agosto não passou — atualize o cartão em https://app.exemplo.dev/faturas/inv_5512" }
  }'
```

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:

```json theme={null}
{
  "notification": { "id": "…", "kind": "action", "…": "…" },
  "channels": {
    "in_app": { "status": "created", "id": "…" },
    "email": { "status": "queued" },
    "whatsapp": { "status": "skipped", "reason": "the contact has no phone number" }
  }
}
```

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 que falta                    | O que a resposta diz                                | O conserto                             |
| ------------------------------ | --------------------------------------------------- | -------------------------------------- |
| Servidor conectado no ambiente | `this environment has no whatsapp server connected` | Conectar em Configurações → WhatsApp   |
| `phone` no contato             | `the contact has no phone number`                   | Mandar `phone` no `POST /v1/contacts`  |
| O telefone pareado             | a mensagem falha na entrega e fica registrada       | Ler o QR code de novo no seu Evolution |

O telefone chega pelo identify, em formato internacional:

```json theme={null}
{ "external_id": "user_8421", "email": "ana@exemplo.dev", "phone": "5511987654321", "customer": { "external_id": "time-1", "name": "Time" } }
```

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ê

```ts theme={null}
const { notifications, unread } = await userkit.listNotifications();

await userkit.markNotificationRead(id);
await userkit.markAllNotificationsRead();
```

```tsx theme={null}
import { NotificationBell } from "@userkit/react";

<NotificationBell />;
```

```json theme={null}
{
  "notifications": [
    {
      "id": "…",
      "title": "Sua exportação está pronta",
      "body": "A exportação do Q3 terminou e fica disponível pelos próximos sete dias.",
      "url": "/exports/exp_5512",
      "category": "transactional",
      "source": "transactional",
      "kind": "update",
      "customer_id": null,
      "read": false,
      "read_at": null,
      "created_at": "2026-08-01T14:02:00Z"
    }
  ],
  "unread": 3
}
```

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

```ts theme={null}
userkit.setActiveCustomer(idDaAcme);
const { notifications, unread } = await userkit.listNotifications();
```

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](/pt-br/guides/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á.

```tsx theme={null}
<NotificationBell variant="icon" />
```

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:

```tsx theme={null}
import { useNotificationBell } from "@userkit/react";

function MeuSino() {
  const bell = useNotificationBell();
  if (!bell.available) return null;
  return (
    <button onClick={bell.show}>
      Avisos {bell.unread > 0 ? `(${bell.unread})` : ""}
    </button>
  );
}
```

Fora do React — um atalho de teclado, um link dentro de um estado vazio — os
mesmos métodos estão no objeto exportado:

```ts theme={null}
import { notificationBell } from "@userkit/react";

notificationBell.show();
notificationBell.hide();
notificationBell.toggle();
```

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

```tsx theme={null}
<NotificationBell bell={false} />
```

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:

```html theme={null}
<div data-userkit="NotificationBell" data-bell="false"></div>
<button id="avisos" hidden>Avisos <span></span></button>
<script>
  UserKit.notifications.subscribe(({ unread, available }) => {
    const botao = document.getElementById("avisos");
    botao.hidden = !available;
    botao.querySelector("span").textContent = unread > 0 ? unread : "";
  });
  document.getElementById("avisos").onclick = () => UserKit.notifications.show();
</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](/pt-br/customer-auth/session-tokens) 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](/pt-br/guides/campaigns) 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.
