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

# Webhooks de saída

> Os mesmos eventos de domínio, entregues no seu servidor — assinados, com retry, e honestos sobre chegar duas vezes.

Um endpoint é uma URL, um segredo e uma lista de tipos de evento. Todo fato que o
UserKit publica internamente pode sair por ele, e é por isso que o recurso foi
barato de construir e que nada nos eventos muda de formato na borda.

Webhooks nunca ficam atrás de um plano. São uma primitiva de desenvolvedor, como
a própria API.

## Registrar um endpoint

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/organization/webhooks \
  -H "Authorization: Bearer uk_st_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.exemplo.com/userkit/webhooks",
    "environment": "live",
    "subscribed_events": ["contact.identified", "contact.signed_in"]
  }'
```

Três coisas dessa chamada merecem ser ditas em voz alta.

**Só `https://`.** As entregas carregam fatos sobre os seus próprios clientes e
uma assinatura que prova quem enviou, e nenhum dos dois sobrevive a ser lido no
fio. Um redirect é recusado em vez de seguido — registre o endereço que de fato
responde.

**Um endpoint pertence a um ambiente** e nunca sai dele: um endpoint de teste
ouve tráfego de teste, um de produção ouve tráfego de produção. Fatos sobre a
própria organização (`organization.created`, `member.invited`) não carregam
ambiente e vão para os endpoints **live** — uma organização tem uma existência
real só, e o ambiente de teste é onde o plano de clientes é ensaiado, não onde a
organização está.

**`subscribed_events` vazio significa todos.** Um tipo adicionado ao catálogo
depois passa a chegar sem você mexer em nada, o que costuma ser o que se quer no
primeiro endpoint e raramente é o que se quer no terceiro.

## O payload

```json theme={null}
{
  "id": "evt_10482",
  "type": "contact.signed_in",
  "sequence": 10482,
  "environment": "live",
  "aggregate": { "type": "contact", "id": "0f9a…" },
  "data": { "method": "password", "ip": "203.0.113.7", "user_agent": "…" },
  "occurred_at": "2026-03-04T18:22:11Z"
}
```

`data` carrega **ids, não fotografias** — releia o recurso para saber o estado
atual. Isso mantém nomes e endereços fora de toda fila, log e buffer de retry
entre nós e você, e significa que um handler atrasado lê o que é verdade agora em
vez do que era verdade quando o evento foi escrito.

## A entrega é ao menos uma vez, e fora de ordem

Isso é o contrato, não uma ressalva. A mesma entrega pode chegar duas vezes e a
ordem de chegada não é a ordem dos fatos. Dois campos existem para tornar isso
tratável, e usá-los não é opcional:

* **`id` é por onde você deduplica.** Ele é estável em todo retry *e* em um
  reenvio manual — um reenvio é o mesmo fato chegando de novo, e um handler que
  já processou precisa poder dizer isso. Anote o id antes de agir sobre o evento;
  ignore o que já viu.
* **`sequence` é por onde você ordena.** É um número global monotônico atribuído
  no commit: de dois eventos sobre o mesmo contato, o maior é o fato mais
  recente. Compare-o. Não compare horários de chegada, e não assuma que
  `contact.identified` chega antes de `contact.signed_in`.

Os headers carregam os mesmos dois valores, e mais um: `UserKit-Delivery-Id`
nomeia *este conjunto de tentativas*. Um retry mantém o mesmo, um reenvio ganha
outro. É o que se cita numa conversa de suporte; `UserKit-Event-Id` é o que o seu
código usa para decidir.

## Verificando a assinatura

Toda requisição carrega:

```http theme={null}
UserKit-Signature: t=1772648531,v1=6f3a…
```

`v1` é `HMAC-SHA256(segredo, "<t>.<corpo cru>")`, em hex. Assine os **bytes
crus** que você recebeu — fazer parse e serializar de novo muda os bytes.

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verificar(corpoCru, header, segredo, toleranciaSegundos = 300) {
  const partes = Object.fromEntries(
    header.split(",").map((par) => par.split("=")),
  );
  const idade = Math.abs(Date.now() / 1000 - Number(partes.t));
  if (!(idade < toleranciaSegundos)) return false;

  const esperado = createHmac("sha256", segredo)
    .update(`${partes.t}.${corpoCru}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(esperado), Buffer.from(partes.v1));
}
```

**O timestamp está dentro do que é assinado, e conferi-lo é a sua metade do
acordo.** Assinar só o corpo tornaria uma requisição capturada válida para
sempre: quem gravasse uma entrega poderia reenviá-la a qualquer momento e a
assinatura continuaria batendo. Assinados juntos — e recusados quando velhos —
você tem uma janela de cinco minutos em vez de um buraco aberto.

Compare em tempo constante, e trate `v1` como uma *etiqueta* em vez de uma
suposição: leia os pares que você conhece e ignore o resto, para que um algoritmo
futuro possa ser adicionado ao lado deste em vez de no lugar dele.

## O segredo pode ser exibido de novo

Diferente de uma API key, você pode lê-lo outra vez em
`GET /v1/organization/webhooks/{id}/secret`.

Isso é uma consequência, não uma conveniência. Uma API key só é *comparada*,
então guardar o hash basta; um segredo de webhook precisa **assinar** cada
entrega, então a linha guarda o segredo de qualquer jeito. Uma vez que isso é
verdade, mostrá-lo uma única vez só impediria você de ler o que o banco contém às
claras — ao custo da única recuperação que não é "rotacionar e quebrar todos os
verificadores em produção de uma vez".

A rotação é o controle de verdade, e ela **não tem janela de tolerância**: o novo
segredo vale a partir da próxima entrega. Dois segredos vivos significariam um
verificador que aceita qualquer um dos dois, que é exatamente a propriedade que a
rotação existe para remover. Publique o segredo novo primeiro, rotacione depois.

## Retentativas, e quando paramos

O seu endpoint tem dez segundos para responder. Qualquer `2xx` é sucesso;
confirme primeiro e faça o trabalho depois.

Uma tentativa que falha é retentada **doze vezes**, dobrando a partir de 30
segundos e com teto de seis horas:

```
30s · 1m · 2m · 4m · 8m · 16m · 32m · 1h04 · 2h08 · 4h16 · 6h · 6h
```

São cerca de quatorze horas e meia da primeira tentativa até a última — tempo
suficiente para que um deploy ruim descoberto de manhã não tenha perdido nada, e
curto o bastante para que um endereço permanentemente morto pare de ser chamado
dentro de um dia. Cada espera carrega uma pitada de jitter, que só subtrai: sem
ele, uma rajada de entregas retentaria em sincronia e chegaria num servidor em
recuperação como a mesma rajada que o derrubou.

O retry é uma coluna numa tabela, não um timer num processo — um deploy no meio
do cronograma não muda nada.

### Desligamento automático

**Cinco entregas seguidas esgotando todas as tentativas desligam o endpoint.**
Isso leva pelo menos as quatorze horas que a primeira delas passou retentando,
sobre cinco fatos diferentes, que é o que "sustentado" precisa significar antes
de pararmos de chamar o seu servidor. Um único sucesso zera a sequência.

Um endpoint desligado não recebe nada novo na fila — e **os owners da sua
organização recebem um e-mail**, porque uma integração que fica quieta sem avisar
é descoberta pela ausência. Nada se perde: as entregas estão no histórico, e
reenviar uma é um clique assim que o endereço voltar. Religar zera a sequência,
então a primeira falha depois do conserto não desliga tudo de novo.

## O histórico, o reenvio e o disparo de teste

`GET /v1/organization/webhooks/{id}/deliveries` são as últimas cem entregas, mais
recentes primeiro, com as tentativas gastas, o status que voltou e quanto tempo
levou. Entregas concluídas ficam 30 dias; uma pendente nunca é removida.

`POST …/deliveries/{deliveryId}/replay` envia o corpo guardado de novo, byte a
byte, com um novo id de entrega e **o mesmo id de evento**.

`POST …/test` enfileira uma entrega com o tipo `webhook.test` — deliberadamente
ausente do catálogo, para que um handler que reage a ele esteja reagindo a um
botão que alguém apertou, não a um fato.

Os dois respondem `202`: a linha é durável, a tentativa ainda não aconteceu, e o
resultado aparece no histórico.

## O catálogo publicado

Este é o contrato contra o qual você constrói, e é a mesma lista que o fan-out da
entrega lê — um tipo ausente daqui é um tipo que não vai chegar.
`GET /v1/organization/webhooks/events` devolve o catálogo em tempo de execução.

| Tipo                                   | Agregado       | Quando                                                                                                                             |
| -------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `organization.created`                 | `organization` | Uma organização é criada, por cadastro ou por um usuário logado                                                                    |
| `member.invited`                       | `invitation`   | Um convite é criado                                                                                                                |
| `member.role_changed`                  | `membership`   | O papel de um membro muda                                                                                                          |
| `member.removed`                       | `membership`   | Um membro perde o acesso — removido, ou saiu. O payload diz qual dos dois                                                          |
| `organization.usage_threshold_reached` | `organization` | Os contatos ativos do mês cruzaram 80% ou 100% do limite gratuito                                                                  |
| `contact.identified`                   | `contact`      | Um contato deixa de ser só um visitante. Dispara só na transição — um segundo magic link é um login, não uma segunda identificação |
| `contact.merged`                       | `contact`      | Um contato é absorvido por outro                                                                                                   |
| `contact.signed_in`                    | `contact`      | Uma sessão autenticada foi emitida para um contato identificado, com como (`method`) e de onde (`ip`, `user_agent`)                |

Um fato é publicado internamente e **nunca entregue**:
`webhook.endpoint_disabled`. Ele seria endereçado ao endpoint que acabou de parar
de responder, o que é uma mensagem para ninguém — o e-mail para os owners é o que
carrega esse recado.

## Permissão

Tudo acima exige `webhooks:manage`, concedida a `owner` e `admin` por padrão. Não
há divisão entre leitura e escrita, porque a tela mostra o segredo de assinatura:
ler um endpoint é possuir a credencial dele.
