> ## Documentation Index
> Fetch the complete documentation index at: https://dev.tryno.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Como configurar webhooks

> Receba eventos da sua tribo em tempo quase real, com entrega assinada por HMAC-SHA256 — registro, verificação de assinatura, idempotência e política de reentrega.

Em vez de consultar a API em laço, registre um **endpoint de webhook** e a Tryno
entrega os eventos ao seu servidor. Cada entrega é assinada com HMAC-SHA256,
então você consegue provar que a requisição veio da Tryno e não de outra pessoa.

<Note>
  Webhook é **entrada** de dados no seu sistema. Ele não é um canal de escrita na
  Tryno: a Platform API v1 é somente leitura, então o padrão é sempre
  *evento chega → seu sistema reage → (opcional) você enriquece com `GET /v1/…`*.
  Veja [Receitas de integração](/guides/recipes).
</Note>

## Eventos disponíveis

| Evento           | Disparado quando                        | Campos em `data`                                                          |
| ---------------- | --------------------------------------- | ------------------------------------------------------------------------- |
| `member.joined`  | Alguém entra na tribo                   | `userId`, `handle`, e `via: "invite"` quando a entrada veio de um convite |
| `member.left`    | Um membro sai por conta própria         | `userId`, `handle`                                                        |
| `member.removed` | Um membro é removido pela administração | `userId`, `removedBy`, `handle`                                           |
| `order.paid`     | Um pedido é confirmado como pago        | `orderId`, `buyerId`, `offerId`, `amountCents`, `currency`                |
| `post.created`   | Um post é criado                        | `postId`, `authorId`, `type`, `visibility`                                |
| `win.approved`   | Uma vitória ("win") é aprovada          | `winId`, `userId`, `title`                                                |
| `event.created`  | Um evento é criado                      | `eventId`, `hostId`, `kind`, `title`, `startsAt`, `access`                |

<Note>
  **Os três eventos de membro (`member.joined`/`member.left`/`member.removed`)
  têm um formato GARANTIDO.** `userId` e `handle` (o handle da TRIBO, não do
  membro) estão sempre presentes, em todo caminho de disparo — incluindo
  entrada via convite aceito no cadastro. Todo `data` é montado por um único
  construtor no servidor (`memberEventPayload`), não por código duplicado em
  cada rota, então não existe uma origem do evento que omita `handle`.
</Note>

Além desses, `ping` é entregue quando você dispara um teste. Ele **não** pode ser
assinado na lista `events` do endpoint — é sempre enviado sob demanda, e seu
`data` é `{ "message": "This is a test event from Tryno." }`.

<Note>
  O payload é deliberadamente magro: ele carrega **identificadores**, não o
  registro inteiro. Se você precisa do nome do membro, do corpo do post ou do
  valor formatado, busque em `/api/v1` com a sua chave depois de receber o
  evento. Isso mantém a entrega pequena e impede que dados sensíveis vazem por um
  endpoint mal configurado.
</Note>

## Registrar um endpoint

<Frame caption="Configuração de webhooks na aba API do admin da tribo — URL do endpoint e seleção dos eventos assinados.">
  <img src="https://mintcdn.com/tryno/Js4__YjMMxSypN-k/images/admin-webhooks.png?fit=max&auto=format&n=Js4__YjMMxSypN-k&q=85&s=1f8a82921fbdf55b3a25bf21a0674671" alt="Formulário de registro de webhook na tela de administração da tribo, com o campo de URL e os eventos disponíveis para assinatura" width="2880" height="1800" data-path="images/admin-webhooks.png" />
</Frame>

Autenticado por **sessão**, como dono/administrador da tribo:

```bash theme={"system"}
curl -X POST https://tryno.io/api/communities/sua-tribo/webhooks \
  -H "Content-Type: application/json" \
  --cookie "__session=<sua sessão>" \
  -d '{
        "url": "https://seu-servidor.com/hooks/tryno",
        "events": ["member.joined", "order.paid"],
        "description": "CRM interno"
      }'
```

<ParamField body="url" type="string" required>
  Precisa ser **HTTPS** e apontar para um host público.
</ParamField>

<ParamField body="events" type="string[]" required>
  Lista de eventos. Valores desconhecidos são **descartados em silêncio**; se
  sobrar zero evento válido, a resposta é `400 no_events`. Duplicatas são
  removidas.
</ParamField>

<ParamField body="description" type="string">
  Texto livre de até **140 caracteres**, só para você identificar o endpoint.
</ParamField>

A resposta `201` traz o endpoint criado, incluindo o **segredo de assinatura**
(`whsec_` + 32 bytes aleatórios em base64url). Diferente das chaves de API, o
segredo permanece visível na listagem — você precisa dele para verificar
assinaturas.

<Warning>
  A URL precisa ser HTTPS e resolver para um host público. Loopback
  (`localhost`, `127.x`, `::1`, `0.0.0.0`), redes privadas (`10.x`,
  `192.168.x`, `172.16–31.x`), link-local (`169.254.x`, `fe80::`), ULA
  (`fc00::/7`) e os sufixos `.local` e `.internal` são recusados com
  `invalid_url`. É uma proteção contra SSRF: um endpoint não pode apontar uma
  requisição assinada para dentro de uma infraestrutura.
</Warning>

<Tip>
  **Desenvolvendo local?** Como `localhost` é recusado, use um túnel HTTPS
  público (ngrok, Cloudflare Tunnel, localtunnel) e registre a URL do túnel.
  Depois, dispare o `ping` para validar a verificação de assinatura antes de
  esperar tráfego real.
</Tip>

## Formato da entrega

`POST` no seu endpoint, com `content-type: application/json` e corpo:

```json theme={"system"}
{
  "id": "b6f0…",
  "event": "member.joined",
  "created": 1786029659,
  "data": { "userId": "…", "handle": "sua-tribo" }
}
```

<ResponseField name="id" type="string">
  Id da entrega. **É o mesmo valor do cabeçalho `x-tryno-delivery`** — a chave de
  deduplicação.
</ResponseField>

<ResponseField name="event" type="string">
  Nome do evento, igual ao cabeçalho `x-tryno-event`.
</ResponseField>

<ResponseField name="created" type="integer">
  Unix em **segundos** do momento em que o evento foi enfileirado — não do envio.
  Numa reentrega após 6 horas, `created` continua sendo o instante original.
</ResponseField>

<ResponseField name="data" type="object">
  Os campos do evento (tabela acima). Trate todos como opcionais.
</ResponseField>

E os cabeçalhos:

| Cabeçalho           | Conteúdo                                          |
| ------------------- | ------------------------------------------------- |
| `x-tryno-event`     | Nome do evento, ex.: `member.joined`              |
| `x-tryno-delivery`  | **Id estável da entrega** — use para deduplicação |
| `x-tryno-signature` | `t=<unix em segundos>,v1=<hmac hex minúsculo>`    |
| `user-agent`        | `Tryno-Webhooks/1`                                |
| `content-type`      | `application/json`                                |

<Warning>
  O `t` da assinatura é o **instante do envio**, e muda a cada tentativa. O
  `created` do corpo é o instante do evento e não muda. Sua janela de replay se
  aplica ao `t`, nunca ao `created` — senão toda reentrega legítima seria
  rejeitada.
</Warning>

## Verificar a assinatura

Este é o único passo que você **não pode pular**. Um endpoint que aceita qualquer
`POST` é um endpoint que qualquer pessoa na internet pode usar para injetar
eventos falsos no seu CRM.

A assinatura é `HMAC-SHA256` do segredo do endpoint sobre a string
concatenada `"<t>" + "." + "<corpo bruto>"`, em hexadecimal minúsculo.

<Steps>
  <Step title="Leia o corpo BRUTO, antes de qualquer parse">
    Bytes exatamente como chegaram. Ver o aviso abaixo.
  </Step>

  <Step title="Quebre o cabeçalho x-tryno-signature">
    Separe por vírgula, depois por `=`, e extraia `t` e `v1`. Se faltar
    qualquer um dos dois, rejeite.
  </Step>

  <Step title="Confira a janela de replay">
    Rejeite se `|agora − t|` for maior que **300 segundos**. Sem isso, um
    atacante que capturou uma entrega antiga pode reenviá-la para sempre.
  </Step>

  <Step title="Recalcule e compare em tempo constante">
    `HMAC_SHA256(segredo, t + "." + corpoBruto)` em hex, comparado com `v1`
    usando uma função de comparação em tempo constante — nunca `==`.
  </Step>

  <Step title="Responda 2xx rápido, processe depois">
    Qualquer coisa fora de 2xx, ou acima de 10s, conta como falha e vira
    reentrega.
  </Step>
</Steps>

<Warning>
  **O corpo bruto é obrigatório.** A maioria dos frameworks faz `JSON.parse` do
  corpo antes do seu handler. Se você re-serializar o objeto para calcular o
  HMAC, a assinatura **não vai bater** — a ordem das chaves, os espaços e o
  escape de Unicode mudam os bytes. Assine sobre os bytes originais.
</Warning>

<Accordion title="Como obter o corpo bruto em cada framework">
  * **Express** — `express.raw({ type: "application/json" })` nessa rota
    específica (antes de qualquer `express.json()` global), e use `req.body`
    como `Buffer`.
  * **Fastify** — registre um `addContentTypeParser("application/json", { parseAs: "buffer" }, …)`
    que guarde o buffer.
  * **Next.js (App Router) / Hono / Workers** — `await request.text()`. Chame
    **uma vez** e reaproveite a string; o corpo não pode ser lido duas vezes.
  * **Flask** — `request.get_data()` (o `request.json` já consumiu o parse, mas
    o Flask mantém os bytes).
  * **Django** — `request.body`.
  * **FastAPI** — declare o handler com `request: Request` e use `await request.body()`,
    em vez de um modelo Pydantic no parâmetro.
  * **Laravel** — `$request->getContent()`.
  * **Go (net/http)** — `io.ReadAll(r.Body)` e faça o parse depois, a partir
    desses mesmos bytes.
</Accordion>

<CodeGroup>
  ```js Node.js / TypeScript theme={"system"}
  import crypto from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  /**
   * @param rawBody Buffer ou string com os bytes ORIGINAIS da requisição.
   * @param signatureHeader valor de `x-tryno-signature`.
   * @param secret o `whsec_…` do endpoint.
   */
  export function verifyTrynoWebhook(rawBody, signatureHeader, secret) {
    if (!signatureHeader) return false;

    // "t=1786029659,v1=9f2c…"
    const parts = {};
    for (const kv of signatureHeader.split(",")) {
      const i = kv.indexOf("=");
      if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
    }
    const t = Number(parts.t);
    const received = parts.v1;
    if (!Number.isFinite(t) || !received) return false;

    // Janela de repetição sobre o t da ASSINATURA (não sobre `created`).
    if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.`)
      .update(rawBody) // Buffer ou string — os bytes originais
      .digest("hex");

    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(received, "utf8");
    // timingSafeEqual lança se os tamanhos diferem — cheque antes.
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // --- Express ---------------------------------------------------------------
  import express from "express";
  const app = express();

  app.post(
    "/hooks/tryno",
    express.raw({ type: "application/json" }), // NÃO use express.json() aqui
    (req, res) => {
      const ok = verifyTrynoWebhook(
        req.body, // Buffer com os bytes originais
        req.get("x-tryno-signature"),
        process.env.TRYNO_WEBHOOK_SECRET,
      );
      if (!ok) return res.status(401).send("invalid signature");

      const deliveryId = req.get("x-tryno-delivery");
      const evt = JSON.parse(req.body.toString("utf8"));

      // Responda AGORA; processe fora do caminho da resposta.
      res.status(204).end();
      void handle(deliveryId, evt).catch(console.error);
    },
  );
  ```

  ```python Python theme={"system"}
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 300


  def verify_tryno_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
      """raw_body precisa ser os BYTES originais da requisição."""
      if not signature_header:
          return False

      parts = {}
      for kv in signature_header.split(","):
          if "=" in kv:
              k, v = kv.split("=", 1)
              parts[k.strip()] = v.strip()

      t, received = parts.get("t"), parts.get("v1")
      if not t or not received:
          return False
      try:
          ts = int(t)
      except ValueError:
          return False

      # Janela de repetição sobre o t da assinatura.
      if abs(time.time() - ts) > TOLERANCE_SECONDS:
          return False

      expected = hmac.new(
          secret.encode("utf-8"),
          f"{t}.".encode("utf-8") + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, received)  # tempo constante


  # --- Flask -----------------------------------------------------------------
  import json
  import os

  from flask import Flask, request

  app = Flask(__name__)


  @app.post("/hooks/tryno")
  def tryno_hook():
      raw = request.get_data()  # bytes originais, antes de qualquer parse
      if not verify_tryno_webhook(
          raw,
          request.headers.get("X-Tryno-Signature", ""),
          os.environ["TRYNO_WEBHOOK_SECRET"],
      ):
          return "invalid signature", 401

      delivery_id = request.headers.get("X-Tryno-Delivery", "")
      event = json.loads(raw)
      enqueue(delivery_id, event)  # trabalho pesado fora do handler
      return "", 204
  ```

  ```go Go theme={"system"}
  package tryno

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"math"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"time"
  )

  const toleranceSeconds = 300

  // VerifyTrynoWebhook confere x-tryno-signature sobre os bytes originais.
  func VerifyTrynoWebhook(rawBody []byte, signatureHeader, secret string) bool {
  	if signatureHeader == "" {
  		return false
  	}

  	var tStr, received string
  	for _, kv := range strings.Split(signatureHeader, ",") {
  		k, v, found := strings.Cut(strings.TrimSpace(kv), "=")
  		if !found {
  			continue
  		}
  		switch k {
  		case "t":
  			tStr = v
  		case "v1":
  			received = v
  		}
  	}
  	if tStr == "" || received == "" {
  		return false
  	}

  	ts, err := strconv.ParseInt(tStr, 10, 64)
  	if err != nil {
  		return false
  	}
  	if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
  		return false // fora da janela de repetição
  	}

  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(tStr + "."))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	// hmac.Equal é comparação em tempo constante.
  	return hmac.Equal([]byte(expected), []byte(received))
  }

  func Handler(w http.ResponseWriter, r *http.Request) {
  	raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
  	if err != nil {
  		http.Error(w, "bad request", http.StatusBadRequest)
  		return
  	}
  	if !VerifyTrynoWebhook(raw, r.Header.Get("X-Tryno-Signature"), os.Getenv("TRYNO_WEBHOOK_SECRET")) {
  		http.Error(w, "invalid signature", http.StatusUnauthorized)
  		return
  	}

  	deliveryID := r.Header.Get("X-Tryno-Delivery")
  	go process(deliveryID, raw) // responda rápido, processe depois
  	w.WriteHeader(http.StatusNoContent)
  }
  ```
</CodeGroup>

<Accordion title="Por que comparação em tempo constante, se a assinatura é pública?">
  A assinatura recebida é pública, mas a **esperada** é derivada do seu segredo.
  Um `==` comum retorna assim que encontra o primeiro byte diferente, e essa
  diferença de tempo é mensurável em rede. Com tentativas suficientes, um
  atacante consegue descobrir a assinatura correta byte a byte sem nunca ver o
  segredo. `timingSafeEqual` / `hmac.compare_digest` / `hmac.Equal` sempre
  percorrem o buffer inteiro.
</Accordion>

## Novas tentativas

Sua resposta é considerada bem-sucedida com qualquer status **2xx**. Qualquer
outra coisa — incluindo `3xx`, que **não é seguido** — ou um tempo de resposta
acima de **10 segundos** conta como falha, e a Tryno tenta de novo com espera
crescente:

| Tentativa | Espera desde a falha anterior        |
| --------- | ------------------------------------ |
| 1         | imediata (no primeiro ciclo do cron) |
| 2         | +1 minuto                            |
| 3         | +5 minutos                           |
| 4         | +30 minutos                          |
| 5         | +2 horas                             |
| 6         | +6 horas — **última**                |

Depois da sexta tentativa a entrega é marcada como `failed` e não é mais tentada.
São, no total, **até \~8h40 de janela de recuperação** — folga de sobra para um
deploy ou uma indisponibilidade curta do seu lado.

<Note>
  As entregas são drenadas por um **cron a cada 5 minutos**, em lotes de até
  **50 por ciclo**. Ou seja: a entrega é **quase** em tempo real, não instantânea,
  e a "espera de +1 minuto" na prática arredonda para o próximo ciclo. Se o seu
  caso exige latência de segundos, consulte a API em vez de esperar o evento.
</Note>

<Warning>
  Como o lote é de 50 por ciclo, uma tribo que gere uma rajada muito grande de
  eventos (por exemplo, um lançamento com centenas de compras em minutos) drena a
  fila ao longo de vários ciclos. Isso é vazão, não perda: nada é descartado.
</Warning>

## Idempotência: obrigatória, não opcional

A v1 **não usa trava de linha** na fila de entregas. Dois ciclos de cron
sobrepostos podem, em tese, entregar a mesma linha duas vezes — e reentregas por
timeout também acontecem quando o seu servidor processou o evento mas não
conseguiu responder a tempo.

**Deduplique por `x-tryno-delivery`** (idêntico ao `id` do corpo). É exatamente
para isso que ele existe: o valor é estável entre todas as tentativas da mesma
entrega.

```js Guardando o id da entrega antes de processar theme={"system"}
// A trava é o próprio banco: uma coluna UNIQUE em delivery_id.
// Se a inserção conflita, este evento já foi processado — saia com 2xx.
async function handle(deliveryId, evt) {
  const inserted = await db.query(
    `INSERT INTO tryno_webhook_log (delivery_id, event, received_at)
     VALUES ($1, $2, now())
     ON CONFLICT (delivery_id) DO NOTHING
     RETURNING delivery_id`,
    [deliveryId, evt.event],
  );
  if (inserted.rowCount === 0) return; // duplicata — nada a fazer

  await doTheRealWork(evt);
}
```

<Warning>
  Responder `2xx` a uma duplicata é o comportamento **correto**. Se você responder
  erro para "já processei isso", a Tryno vai continuar reentregando por até seis
  tentativas o evento que você de fato já tratou.
</Warning>

<Tip>
  Se o seu destino final já é idempotente por natureza (um `UPSERT` por
  `userId`, por exemplo), você pode dispensar a tabela de log. O que não dá é
  ter efeitos colaterais não idempotentes — enviar e-mail, cobrar, postar em
  canal — sem nenhuma proteção.
</Tip>

## Testar e depurar

```bash theme={"system"}
# Enfileira uma entrega de teste (evento `ping`)
curl -X POST https://tryno.io/api/communities/sua-tribo/webhooks/<id>/ping \
  --cookie "__session=<sua sessão>"

# Últimas 50 tentativas: status, nº de tentativas, código HTTP e último erro
curl https://tryno.io/api/communities/sua-tribo/webhooks/<id>/deliveries \
  --cookie "__session=<sua sessão>"
```

O log de entregas devolve, por linha: `id`, `event`, `status`
(`pending` / `success` / `failed`), `attempts`, `responseStatus` (o código HTTP
que o **seu** servidor devolveu, ou `null` se a conexão nem chegou a completar),
`lastError`, `nextAttemptAt`, `deliveredAt` e `createdAt`.

<Accordion title="Diagnóstico rápido pelo log">
  * **`responseStatus: 401` repetido** — sua verificação de assinatura está
    rejeitando. Quase sempre é corpo re-serializado (veja o aviso do corpo bruto)
    ou o segredo errado.
  * **`responseStatus: null` com `lastError` de timeout** — seu handler demorou
    mais de 10s. Responda `2xx` primeiro e processe em fila.
  * **`responseStatus: 404` / `405`** — a URL registrada não é a rota que
    aceita `POST`.
  * **`status: pending` sem avançar** — o endpoint está desativado
    (`enabled: false`). Veja a nota abaixo.
  * **`responseStatus: 3xx`** — redirecionamentos não são seguidos. Registre a
    URL final.
</Accordion>

<Note>
  Um endpoint **desativado** (`enabled: false`) não recebe entregas, e as que já
  estavam na fila para ele ficam `pending` até que você o reative — elas não
  expiram sozinhas. Se a intenção é desligar de vez, **apague** o endpoint em vez
  de desativá-lo: a exclusão remove também as entregas pendentes.
</Note>

## Gerenciar

```bash theme={"system"}
# Listar (inclui o segredo de assinatura e a lista de eventos disponíveis)
curl https://tryno.io/api/communities/sua-tribo/webhooks \
  --cookie "__session=<sua sessão>"

# Atualizar url / events / enabled / description
curl -X PATCH https://tryno.io/api/communities/sua-tribo/webhooks/<id> \
  -H "Content-Type: application/json" \
  --cookie "__session=<sua sessão>" \
  -d '{ "enabled": false }'

# Excluir (remove também as entregas)
curl -X DELETE https://tryno.io/api/communities/sua-tribo/webhooks/<id> \
  --cookie "__session=<sua sessão>"
```

O `PATCH` é parcial: envie só os campos que mudam. Um corpo sem nenhum campo
reconhecido responde `400 empty`. Ao trocar `events`, o valor é **substituído**
por inteiro, não somado — envie a lista completa que você quer manter.

<Warning>
  **Não há rotação do segredo de assinatura.** Não existe endpoint para gerar um
  novo `whsec_` num endpoint existente, nem suporte a duas assinaturas
  simultâneas. Se o segredo vazar, o caminho é: registrar um **novo** endpoint
  (com novo segredo), passar a aceitar os dois no seu receptor, e então excluir o
  antigo. Dizemos isso em vez de descrever uma rotação que o produto não tem.
</Warning>

## Boas práticas de recepção

* **Responda em menos de 10s.** Valide a assinatura, grave o evento numa fila e
  responda `204`. Todo o resto é trabalho de segundo plano.
* **Trate `data` como extensível.** Campos novos podem aparecer numa versão
  futura; os campos já documentados na tabela acima, porém, são garantidos —
  não variam por origem do evento (isso vale inclusive para `member.joined`).
* **Não confie na ordem.** Um `order.paid` pode chegar antes de um
  `member.joined` do mesmo usuário — o backoff reordena entregas com falha.
* **Um endpoint por consumidor.** Assim você desativa um destino sem afetar os
  outros, e o log de entregas fica legível.
* **Guarde o `whsec_` como segredo.** Ele é equivalente a uma senha: quem o tem
  consegue forjar eventos para o seu servidor.

<Card title="Próximo passo — receitas de integração →" icon="workflow" href="/guides/recipes" horizontal>
  Monte o fluxo completo: evento chega, você enriquece com `/v1` e empurra para
  planilha, Zapier ou n8n.
</Card>
