# Receber webhooks

> Requisição, assinatura HMAC, deduplicação, reentrega e reenvio manual.

Fonte: https://metahub.matratecnologia.com/docs/webhooks

## Requisição

O hub faz `POST` na URL cadastrada, com `content-type: application/json` e estes headers:

| Header | Conteúdo |
|---|---|
| `x-hub-event-id` | Id do evento (igual ao `id` do envelope) |
| `x-hub-timestamp` | Unix timestamp em segundos do momento do envio desta tentativa |
| `x-hub-signature` | `sha256=` seguido do HMAC-SHA256 em hex |
| `user-agent` | `MatraMetaHub/1.0` |

Corpo (envelope):

```json
{
  "id": "cm...",
  "type": "whatsapp.message.received",
  "createdAt": "2026-10-22T15:00:00.000Z",
  "workspace": { "id": "...", "externalId": "cliente-123" },
  "connectionId": null,
  "data": {},
  "meta": null
}
```

`connectionId` e `meta` podem ser `null`. O formato de `data` depende do `type`: ver [Eventos de conexão](https://metahub.matratecnologia.com/docs/eventos.md#eventos-de-conexão) e [Eventos do WhatsApp](https://metahub.matratecnologia.com/docs/eventos.md#eventos-do-whatsapp).

## Assinatura

A assinatura cobre o timestamp e o corpo exatamente como chegaram:

```
x-hub-signature = "sha256=" + hex(HMAC_SHA256(secret, `${x-hub-timestamp}.${rawBody}`))
```

Calcule sobre o corpo **cru** (os bytes recebidos), antes de qualquer `JSON.parse`: reserializar muda espaços e ordem de chaves e a assinatura deixa de bater.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

// rawBody: o corpo cru da requisição, como string
export const verifyHubWebhook = (
  secret: string,
  headers: { timestamp: string; signature: string },
  rawBody: string,
  nowSeconds = Math.floor(Date.now() / 1000),
): boolean => {
  const timestamp = Number(headers.timestamp)
  if (!Number.isInteger(timestamp)) return false

  // rejeita replay: timestamp fora de 5 minutos, para mais ou para menos
  if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false

  const expected = `sha256=${createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')}`

  const received = Buffer.from(headers.signature)
  const computed = Buffer.from(expected)

  // timingSafeEqual lança se os tamanhos diferem
  return (
    received.length === computed.length && timingSafeEqual(received, computed)
  )
}
```

Passe `headers['x-hub-timestamp']` e `headers['x-hub-signature']` para a função. Assinatura inválida: responda 401 e não processe.

## O que o seu receptor precisa tratar

- **Responda 2xx rápido e processe depois.** O hub espera no máximo 10 segundos por tentativa; estourou, conta como falha (`timeout`) e a entrega será repetida. Grave o evento numa fila ou tabela e devolva 200 antes de qualquer trabalho pesado.
- **Não use redirect.** O hub não segue redirecionamentos: um 3xx conta como falha. Cadastre a URL final.
- **Deduplique pelo `id` do envelope.** A entrega é "pelo menos uma vez": se o seu 2xx se perder no caminho, o hub reenvia o mesmo evento, e o reenvio manual também repete o `id`. Guarde os ids processados e ignore repetidos.
- **A ordem não é garantida.** Eventos chegam em tentativas independentes: um evento antigo em retry pode chegar depois de um mais novo. Use `createdAt` do envelope para decidir qual estado é mais recente, com uma exceção: num envio, o `accepted` com `meta.overridesAdminResolution: true` vence o `abandoned` mesmo que este tenha `createdAt` maior.
- **Qualquer status 2xx** é sucesso. Qualquer outro status, erro de rede ou timeout agenda nova tentativa. O corpo da sua resposta é descartado sem ser lido.

## Reentrega automática

Falhou, o hub tenta de novo com estes intervalos após a falha anterior:

| Tentativa | Espera antes |
|---|---|
| 2 | 1 min |
| 3 | 5 min |
| 4 | 15 min |
| 5 | 1 h |
| 6 | 3 h |
| 7 | 6 h |
| 8 | 12 h |

São 8 tentativas ao todo (a inicial e 7 retries), cerca de 22 horas. Esgotadas, a entrega fica `failed` e deixa de ser tentada; reenvie por `POST /v1/events/:deliveryId/redeliver` depois de corrigir o receptor. Se o endpoint for desativado depois da criação do evento, a entrega falha de imediato com `endpoint_inactive`, sem tentativa.

Os eventos e as entregas são apagados do hub 30 dias após a criação (exceto os que ainda têm entrega `pending`). Reenvie antes disso.

## URL do endpoint

A equipe Matra valida a URL ao cadastrá-la e o hub confere de novo a cada envio:

- só `https://` (`http://` apenas em desenvolvimento local, nunca em produção);
- sem credenciais na URL (`https://user:senha@...`);
- o endereço resolvido não pode ser privado, link-local, CGNAT, "qualquer endereço", reservado ou multicast (`10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `100.64/10`, `0/8`, `192.0.0/24`, `198.18/15`, `224/4`, `240/4`, `fc00::/7`, `fe80::/10`, `ff00::/8`, `100::/64`, `2001:db8::/32`), nem um prefixo IPv6 que embute IPv4 (`64:ff9b::/96`, `2002::/16`). Loopback só é aceito em desenvolvimento.

O receptor precisa estar acessível pela internet pública.


## Entregas e reenvio


O hub registra uma entrega para cada evento emitido para o seu app, desde que o app tenha um endpoint de webhook ativo. A URL do endpoint e o segredo de assinatura são cadastrados pela equipe Matra (o segredo aparece em claro só na criação ou na rotação).

| Método e rota | O que faz | Respostas |
|---|---|---|
| `GET /v1/events` | Lista as entregas do app. Filtros: `status` (`pending`, `delivered`, `failed`) e `type` | 200 |
| `POST /v1/events/:deliveryId/redeliver` | Reenvia uma entrega | 202, 404 |

Cada item da listagem:

```json
{
  "id": "...",
  "eventId": "...",
  "type": "connection.created",
  "workspaceId": "...",
  "status": "failed",
  "attempts": 8,
  "lastStatusCode": 500,
  "nextAttemptAt": null,
  "deliveredAt": null,
  "createdAt": "2026-10-22T15:00:00.000Z"
}
```

`id` é o da entrega; `eventId` é o do evento (o mesmo `id` do envelope e do header `x-hub-event-id`).

O reenvio zera as tentativas, volta a entrega para `pending` e a enfileira de novo, mesmo se já estava `delivered`. Responde `202 { "id": "...", "status": "pending" }`. Entrega de outro app responde 404.
