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

# Cadastrando e verificando webhooks

> Como cadastrar um listener no Console, verificar a assinatura HMAC e lidar com retries.

Esta é a página de referência única para integrar webhooks da Cativa: cadastro do listener, lista de eventos, verificação de assinatura, retries e idempotência.

## Como cadastrar

No painel Cativa, abra **Console > Webhooks** e clique em **Adicionar listener**. O fluxo pede 3 informações:

1. **URL** — o endpoint público do seu app que vai receber o `POST` (ex: `https://meuapp.com/webhooks/cativa`).
2. **Eventos** — quais nomes de evento você quer escutar (snake\_case — ex: `user_received_badge`, `paywall_payment_completed`).
3. **Secret** — gerado automaticamente. Após criar o listener, abra-o e clique em **Reveal secret** para copiar o valor (formato `whsec_` + 64 caracteres hex). **O secret só pode ser visualizado pelo admin do tenant** — guarde com segurança no seu cofre de credenciais.

<Note>
  Cada listener tem **seu próprio secret**. Se você cadastrar dois listeners (ex: um para staging e outro para produção), cada um assina os disparos com a chave dele.
</Note>

Depois disso, todo evento que bater nas regras do listener é entregue via `POST` com `Content-Type: application/json`, `X-Cativa-Signature`, `X-Cativa-Execution-Id` e `X-Cativa-Automation-Id`.

## Eventos disponíveis

| Evento                                                                            | Descrição                                                    |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| [`user_created`](/pt-BR/webhooks/events/user-created)                             | Novo usuário cadastrado no tenant.                           |
| [`user_joined_group`](/pt-BR/webhooks/events/user-joined-group)                   | Usuário entrou num grupo (manual, via badge ou via convite). |
| [`user_received_badge`](/pt-BR/webhooks/events/user-received-badge)               | Badge atribuído a um usuário.                                |
| [`post_created`](/pt-BR/webhooks/events/post-created)                             | Post publicado num grupo.                                    |
| [`paywall_payment_completed`](/pt-BR/webhooks/events/paywall-payment-completed)   | Pagamento do paywall confirmado.                             |
| `comment_created` <Tooltip tip="página em breve">em breve</Tooltip>               | Comentário criado num post.                                  |
| `course_completed` <Tooltip tip="página em breve">em breve</Tooltip>              | Usuário completou um curso inteiro.                          |
| `lesson_completed` <Tooltip tip="página em breve">em breve</Tooltip>              | Usuário completou uma aula.                                  |
| `user_received_private_message` <Tooltip tip="página em breve">em breve</Tooltip> | Mensagem privada recebida.                                   |

Use **exatamente esses nomes em snake\_case** ao cadastrar o listener — é como o Console faz o match.

## Verificando a assinatura HMAC

Cada disparo chega com o header:

```
X-Cativa-Signature: t=1715177521,v1=8c1d4e2a3b5f4d8a9c6e7f0b1a2d3e4f8a9c6e7f0b1a2d3e4f8a9c6e7f0b1a2d
```

* `t` — timestamp Unix (em segundos) do momento do disparo.
* `v1` — HMAC-SHA256 (hex) sobre a string `"<t>.<rawBody>"`, usando o `secret` do listener como chave.

Para validar, faça três passos:

1. **Parse** do header em `t` e `v1`.
2. **Anti-replay**: rejeite o request se `|now - t| > 300` segundos (5 minutos é o padrão de mercado). Isso evita ataque de replay com requests antigos capturados.
3. **Recomputar** o HMAC e comparar em **tempo constante**.

<Warning>
  Sempre compute o HMAC sobre o **body bruto** (a string exata recebida no request), nunca sobre o JSON re-serializado. Re-serializar muda espaços e ordem de chaves, invalidando a assinatura.
</Warning>

### Node.js (Express)

```js theme={null}
import express from 'express';
import crypto from 'crypto';

const app = express();
const SECRET = process.env.CATIVA_WEBHOOK_SECRET; // whsec_...

app.post(
  '/webhooks/cativa',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('X-Cativa-Signature') ?? '';
    const parts = Object.fromEntries(
      header.split(',').map(p => p.split('='))
    );
    const ts = Number(parts.t);
    const sig = parts.v1;

    if (!ts || !sig) return res.status(400).send('bad signature header');
    if (Math.abs(Date.now() / 1000 - ts) > 300) {
      return res.status(400).send('stale timestamp');
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${ts}.${req.body.toString('utf8')}`)
      .digest('hex');

    const ok = crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(sig, 'hex')
    );
    if (!ok) return res.status(401).send('invalid signature');

    const event = JSON.parse(req.body.toString('utf8'));
    // ... processa o evento
    res.status(200).send('ok');
  }
);
```

### Python (Flask)

```python theme={null}
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['CATIVA_WEBHOOK_SECRET'].encode()  # whsec_...

@app.post('/webhooks/cativa')
def receive():
    header = request.headers.get('X-Cativa-Signature', '')
    parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
    try:
        ts = int(parts['t'])
        sig = parts['v1']
    except (KeyError, ValueError):
        abort(400, 'bad signature header')

    if abs(time.time() - ts) > 300:
        abort(400, 'stale timestamp')

    raw_body = request.get_data()  # bytes brutos, não usar request.json
    expected = hmac.new(
        SECRET,
        f"{ts}.".encode() + raw_body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(401, 'invalid signature')

    payload = request.get_json()
    # ... processa o evento
    return ('ok', 200)
```

### Go

```go theme={null}
package main

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

var secret = []byte(os.Getenv("CATIVA_WEBHOOK_SECRET")) // whsec_...

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read body", http.StatusBadRequest)
        return
    }

    var ts int64
    var sig string
    for _, part := range strings.Split(r.Header.Get("X-Cativa-Signature"), ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            ts, _ = strconv.ParseInt(kv[1], 10, 64)
        case "v1":
            sig = kv[1]
        }
    }
    if ts == 0 || sig == "" {
        http.Error(w, "bad signature header", http.StatusBadRequest)
        return
    }

    if abs(time.Now().Unix()-ts) > 300 {
        http.Error(w, "stale timestamp", http.StatusBadRequest)
        return
    }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(sig)) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    // ... processa o evento usando body
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

func abs(x int64) int64 {
    if x < 0 {
        return -x
    }
    return x
}
```

### C# (ASP.NET Core)

```csharp theme={null}
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

app.MapPost("/webhooks/cativa", async (HttpContext ctx) =>
{
    var secret = Encoding.UTF8.GetBytes(
        Environment.GetEnvironmentVariable("CATIVA_WEBHOOK_SECRET")!); // whsec_...

    using var ms = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(ms);
    var rawBody = ms.ToArray();

    var header = ctx.Request.Headers["X-Cativa-Signature"].ToString();
    long ts = 0;
    string? sig = null;
    foreach (var part in header.Split(','))
    {
        var kv = part.Split('=', 2);
        if (kv.Length != 2) continue;
        if (kv[0] == "t") long.TryParse(kv[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out ts);
        else if (kv[0] == "v1") sig = kv[1];
    }

    if (ts == 0 || sig is null)
        return Results.BadRequest("bad signature header");

    var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(nowUnix - ts) > 300)
        return Results.BadRequest("stale timestamp");

    using var hmac = new HMACSHA256(secret);
    var data = Encoding.UTF8.GetBytes($"{ts}.").Concat(rawBody).ToArray();
    var expected = Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant();

    var expectedBytes = Encoding.UTF8.GetBytes(expected);
    var sigBytes = Encoding.UTF8.GetBytes(sig);
    if (expectedBytes.Length != sigBytes.Length ||
        !CryptographicOperations.FixedTimeEquals(expectedBytes, sigBytes))
        return Results.Unauthorized();

    // ... processa o evento usando rawBody
    return Results.Ok("ok");
});
```

## Retries e falhas permanentes

Se seu endpoint não responder com `2xx`, a Cativa re-tenta de acordo com a curva:

```
30s  →  5min  →  30min  →  2h  →  6h  →  24h
```

São **6 retries** após a tentativa inicial — **7 entregas** no total, cobrindo cerca de **33 horas**. Cada retry recebe o mesmo `X-Cativa-Execution-Id` (estável entre retries do mesmo evento) — use-o como chave de idempotência.

A Cativa respeita o header `Retry-After` que você retornar (limitado ao máximo da próxima janela do backoff).

### Tabela de comportamento por status

| Código                          | Comportamento                    |
| ------------------------------- | -------------------------------- |
| `2xx`                           | Sucesso — sem retry              |
| `408 Request Timeout`           | Retry                            |
| `429 Too Many Requests`         | Retry (respeita `Retry-After`)   |
| `5xx` (500, 502, 503, 504, ...) | Retry                            |
| Erros de rede / timeouts TCP    | Retry                            |
| `400 Bad Request`               | **Falha permanente** — sem retry |
| `401 Unauthorized`              | **Falha permanente** — sem retry |
| `403 Forbidden`                 | **Falha permanente** — sem retry |
| `404 Not Found`                 | **Falha permanente** — sem retry |
| `410 Gone`                      | **Falha permanente** — sem retry |

A lógica é: status na faixa `4xx` (exceto `408` e `429`) significa "o request está errado e re-tentar não vai ajudar" — tipicamente bug do cliente, URL desativada ou auth incorreta. Status `5xx`, `408`, `429` e erros de transporte significam "tente de novo mais tarde".

## Quando todas as tentativas falham

Se a 7ª tentativa também falhar, o disparo é registrado internamente como falho. A v1 ainda **não** tem painel no Console para inspecionar entregas falhas — monitore o uptime do seu endpoint pelo seu próprio lado e, se desconfiar de eventos perdidos, abra ticket em [dev@cativa.digital](mailto:dev@cativa.digital) que o time investiga internamente.

## Idempotência

A entrega é at-least-once. Mesmo evento pode chegar mais de uma vez (retries após timeout, perda de conexão na hora de responder, etc.) — você precisa **detectar duplicatas** no seu lado.

A chave canônica é o header `X-Cativa-Execution-Id`: ele é gerado uma vez por evento e **se mantém igual em todos os retries**. Salve esse ID na mesma transação da lógica de negócio:

```js theme={null}
async function processEvent(executionId, payload) {
  const alreadyProcessed = await db.events.exists(executionId);
  if (alreadyProcessed) return; // já processado — retorna sucesso

  await db.transaction(async (tx) => {
    await applyBusinessLogic(tx, payload);
    await tx.events.insert({ id: executionId, processedAt: new Date() });
  });
}
```

Isso garante que ou tudo aconteceu, ou nada aconteceu — sem chance de processar duas vezes.

## Eventos relacionados

<CardGroup cols={2}>
  <Card title="Webhooks (visão geral)" icon="webhook" href="/pt-BR/concepts/webhooks">
    Por que webhooks, garantias de entrega e o formato dos payloads.
  </Card>

  <Card title="user_received_badge" icon="shield-check" href="/pt-BR/webhooks/events/user-received-badge">
    Página de referência completa de um evento — payload, headers e receiver de exemplo.
  </Card>
</CardGroup>
