# Entrega e retry (/docs/webhooks/delivery)





Esta página descreve, ponta a ponta, **como o PEPINO ZAP entrega cada evento** ao
endpoint de webhook que você cadastrou: o método e os headers da requisição, o
critério que define sucesso e falha, o timeout, a política de reenvio (retry com
backoff) e por que o seu endpoint precisa ser **idempotente**. É o que você
precisa para construir um receptor de webhook robusto.

Antes de continuar, confira os pré-requisitos:

* Você cadastrou o webhook via `POST /v1/webhooks` (self-service) e guardou o
  `secret` (prefixo `whsec_`). Ver [Webhooks](/docs/webhooks).
* Para validar a assinatura de cada entrega, ver [Assinatura](/docs/webhooks/signing).
* Para a estrutura do envelope e a lista de eventos, ver [Eventos](/docs/webhooks/events).

## A requisição de entrega [#a-requisição-de-entrega]

Cada evento é entregue como uma **única requisição HTTP**:

* **Método:** `POST`.
* **URL:** a `url` que você cadastrou no webhook.
* **Corpo:** o envelope JSON `{ event, instanceId, timestamp, data }` — os
  **bytes crus** desse corpo são o que a assinatura cobre.

Exemplo do que chega ao seu endpoint (ilustrativo — formatado para leitura; o
corpo real é JSON compacto):

```http
POST /webhook/pepinozap HTTP/1.1
Host: meusistema.com
Content-Type: application/json
User-Agent: pepino-zap-webhooks/1
X-Pepino-Event: message.received
X-Pepino-Webhook-Id: wh_abc123
X-Pepino-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

{"event":"message.received","instanceId":"inst_123","timestamp":"2026-05-30T12:00:00Z","data":{ }}
```

### Headers de toda entrega [#headers-de-toda-entrega]

Estes headers acompanham **todas** as entregas, sem exceção:

| Header                | Valor                                     | Para que serve                                                                                                                                    |
| --------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                        | O corpo é sempre JSON.                                                                                                                            |
| `User-Agent`          | `pepino-zap-webhooks/1`                   | Identifica o agente de entrega. Útil para filtrar/logar tráfego do PEPINO ZAP.                                                                    |
| `X-Pepino-Event`      | nome do evento, ex. `message.received`    | Permite rotear o tratamento **sem precisar dar parse no corpo**. É o mesmo valor do campo `event` do envelope.                                    |
| `X-Pepino-Webhook-Id` | identificador do webhook, ex. `wh_abc123` | Identifica **qual webhook cadastrado** originou a entrega. Ver o gotcha de idempotência abaixo.                                                   |
| `X-Pepino-Signature`  | `sha256=<hmac-hex>`                       | HMAC-SHA256 do corpo bruto, calculado com o seu `secret`. Use para validar autenticidade e integridade. Ver [Assinatura](/docs/webhooks/signing). |

<Callout type="warn">
  O `X-Pepino-Webhook-Id` identifica o **webhook cadastrado** (a configuração), não a entrega
  individual. Ele é **constante** para todas as entregas daquele mesmo webhook — não é um número de
  sequência nem um ID único por POST. Para deduplicar entregas repetidas (ver
  [Idempotência](#idempotencia)), use um identificador de **negócio** presente em `data` (por
  exemplo `waMessageId` em `message.received`), não este header sozinho.
</Callout>

## Critério de sucesso e falha [#critério-de-sucesso-e-falha]

O que decide o destino de cada tentativa é, exclusivamente, **o status HTTP que o
seu endpoint responde** (ou a ausência de resposta):

* **Sucesso:*&#x2A; o endpoint responde com status na faixa &#x2A;*`2xx`** (`200`–`299`). A
  entrega é considerada concluída, o campo `lastSuccessAt` do webhook é
  atualizado e **não há reenvio**.
* **Falha:** qualquer **outra coisa** — status `4xx` ou `5xx`, **timeout**, ou
  **erro de rede** (DNS, conexão recusada, TLS, etc.). A tentativa é marcada como
  falha (registra `lastFailureAt` e `lastFailureReason`) e o evento entra em
  reenvio, conforme a política de [retry](#retry-e-backoff).

<Callout type="warn">
  Redirecionamentos (`3xx`) **são seguidos** automaticamente (até o limite padrão de redirects), e o
  que decide sucesso/falha é o status da resposta **final**: se a cadeia terminar em `2xx`, a
  entrega é considerada concluída. Ainda assim, aponte o webhook diretamente para a **URL final** do
  seu endpoint, sem redirect intermediário (por exemplo, cadastre a URL `https` já de cara) — cada
  salto consome tempo do timeout de 15s e passa pela mesma checagem anti-SSRF, e um redirect que não
  possa ser seguido vira falha.
</Callout>

O corpo da sua resposta é **ignorado** — não precisa retornar nenhum JSON. O que
importa é o código de status. Por isso, a resposta ideal é simplesmente:

```http
HTTP/1.1 200 OK
```

### Responda rápido, processe depois [#responda-rápido-processe-depois]

Valide a assinatura, **responda `2xx` imediatamente** e processe a carga de forma
**assíncrona** (fila interna, job, etc.). Não bloqueie a resposta enquanto grava
no banco, chama outra API ou faz qualquer trabalho lento — se você demorar além
do timeout, a entrega vira falha e o **mesmo evento** será reenviado, mesmo que o
seu processamento já tenha começado.

## Timeout [#timeout]

Cada tentativa de entrega tem **15 segundos** de limite. Se o seu endpoint não
**responder por completo** (headers + status) dentro desse intervalo, a tentativa
é abortada, tratada como falha e entra em reenvio.

<Callout type="info">
  O timeout de 15s vale para a tentativa **inteira**: resolução de DNS, handshake TLS, envio do
  corpo e recebimento da sua resposta. Em endpoints atrás de cold-start (funções serverless que
  "dormem"), a primeira entrega após um período ocioso pode estourar esse limite e cair em retry —
  mantenha o endpoint quente ou garanta que ele responda `2xx` antes de qualquer trabalho pesado.
</Callout>

## Retry e backoff [#retry-e-backoff]

Quando uma tentativa falha, o evento é **reenviado automaticamente** com **backoff
exponencial**, até o limite de **6 tentativas no total** (a entrega inicial + até
5 reenvios). Esgotadas as 6 tentativas, o evento **deixa de ser reenviado** e a
última falha fica registrada no webhook (`lastFailureAt` / `lastFailureReason`).

O intervalo entre as tentativas cresce a cada falha. A tabela abaixo mostra a
janela aproximada (o atraso real recebe um &#x2A;*jitter de ±10%** para evitar picos
sincronizados):

| Após falhar a tentativa | Próxima tentativa | Atraso aproximado                    |
| ----------------------- | ----------------- | ------------------------------------ |
| 1ª (entrega inicial)    | 2ª                | \~1 s                                |
| 2ª                      | 3ª                | \~16 s                               |
| 3ª                      | 4ª                | \~1 min 21 s                         |
| 4ª                      | 5ª                | \~4 min 16 s                         |
| 5ª                      | 6ª                | \~10 min 25 s                        |
| 6ª                      | —                 | sem novo reenvio (evento descartado) |

Na prática, um endpoint indisponível recebe as 6 tentativas ao longo de **cerca
de 16 minutos** antes de o evento ser descartado. Isso cobre quedas curtas
(deploy, reinício, pico de carga) sem perder o evento, mas **não** é uma fila de
retenção de longo prazo: se o seu endpoint ficar fora por mais que essa janela, os
eventos daquele período **não** são reentregues depois.

<Callout type="warn">
  Não há reentrega manual nem "replay" de eventos descartados. Se o seu endpoint ficar indisponível
  além da janela de retry, trate a lacuna pelo seu lado — por exemplo, reconciliando o estado via
  API (`GET /v1/instances/{id}` para conexão, ou consultando suas próprias mensagens). Para
  protótipos e telas ao vivo, o [WebSocket](/docs/webhooks/websocket) é um transporte paralelo, mas
  entrega apenas enquanto o cliente está conectado (sem retry).
</Callout>

### Quando não há retry [#quando-não-há-retry]

Há um caso em que a entrega falha e **não** é reenviada: quando a `url`
cadastrada é **inválida** (não forma uma requisição HTTP válida). Como nenhum
número de tentativas resolveria isso, a entrega é cancelada de imediato e a falha
fica registrada em `lastFailureReason`. Cadastre uma URL `http(s)` absoluta e
válida ao criar o webhook — a criação já valida o formato (ver os
[erros](#erros-ao-cadastrar-o-webhook)).

## Observabilidade: acompanhe a saúde do webhook [#observabilidade-acompanhe-a-saúde-do-webhook]

Cada tentativa atualiza o estado do webhook, que você consulta a qualquer momento
com `GET /v1/webhooks`:

```bash
curl https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`:

```json
{
  "items": [
    {
      "id": "wh_abc123",
      "name": "meu-endpoint",
      "url": "https://meusistema.com/webhook/pepinozap",
      "events": [],
      "enabled": true,
      "lastSuccessAt": "2026-06-05T12:00:00Z",
      "lastFailureAt": "2026-06-05T11:40:00Z",
      "lastFailureReason": "HTTP 500",
      "createdAt": "2026-05-30T12:00:00Z"
    }
  ]
}
```

Campos úteis para diagnóstico:

* `lastSuccessAt` — instante da última entrega bem-sucedida (`2xx`). `null` se
  ainda não houve nenhuma.
* `lastFailureAt` — instante da última tentativa que falhou. `null` se nunca falhou.
* `lastFailureReason` — motivo legível da última falha, por exemplo `HTTP 500`,
  `HTTP 404`, `erro de rede: ...` ou `URL inválida: ...` (truncado em 500
  caracteres). É o primeiro lugar a olhar quando "o webhook parou de chegar".

<Callout type="info">
  `lastSuccessAt` e `lastFailureAt` são **independentes**: cada um reflete o último evento do seu
  tipo. Um webhook saudável que teve uma falha isolada no passado continuará mostrando aquele
  `lastFailureAt` antigo — compare as duas datas para saber o que aconteceu **por último**.
</Callout>

### Log de entregas (tentativa a tentativa) [#log-de-entregas-tentativa-a-tentativa]

Os campos `last*` acima são um **resumo** (a última entrega de cada tipo). Para o
**histórico completo** — útil para depurar "qual evento falhou, com qual status, quantas
vezes" — use `GET /v1/webhooks/{id}/deliveries`:

```bash
curl "https://api.pepinozap.com.br/v1/webhooks/wh_abc123/deliveries?limit=50" \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`:

```json
{
  "items": [
    {
      "id": 10482,
      "event": "message.received",
      "attempt": 1,
      "statusCode": 200,
      "outcome": "success",
      "errorReason": null,
      "durationMs": 142,
      "createdAt": "2026-06-05T12:00:00Z"
    },
    {
      "id": 10480,
      "event": "message.received",
      "attempt": 2,
      "statusCode": 500,
      "outcome": "failed",
      "errorReason": "HTTP 500",
      "durationMs": 87,
      "createdAt": "2026-06-05T11:59:30Z"
    }
  ]
}
```

Cada item é **uma tentativa** (com o retry, um mesmo evento pode aparecer mais de uma vez):

| Campo         | Significado                                                                               |
| ------------- | ----------------------------------------------------------------------------------------- |
| `event`       | Tipo do evento entregue (ex.: `message.received`).                                        |
| `attempt`     | Número da tentativa (1 = primeira; 2+ = reenvio do retry).                                |
| `statusCode`  | Status HTTP que o seu endpoint respondeu; `null` em falha de rede/timeout (sem resposta). |
| `outcome`     | `success` (`2xx`) ou `failed`.                                                            |
| `errorReason` | Motivo legível quando `failed` (ex.: `HTTP 500`, `erro de rede: ...`); `null` no sucesso. |
| `durationMs`  | Quanto a requisição demorou, em milissegundos.                                            |
| `createdAt`   | Quando a tentativa ocorreu (UTC).                                                         |

Mais recente primeiro; paginado por `?limit` (1–200, padrão 50) e `?offset`. O log é
**retido por 30 dias** e **nunca** inclui o secret nem o corpo da resposta do seu endpoint.

<Callout type="info">
  No **painel**, esse mesmo log fica no botão **Entregas** de cada webhook (Integrações →
  Webhooks). E há um **inspetor de eventos ao vivo** (Integrações → Eventos ao vivo) que mostra os
  eventos do projeto em tempo real, conforme acontecem — ótimo para conferir o que está saindo
  enquanto você desenvolve.
</Callout>

## Idempotência [#idempotência]

A política de retry implica que o **mesmo evento pode ser entregue mais de uma
vez**: um reenvio acontece sempre que a tentativa anterior não respondeu `2xx` a
tempo — inclusive quando o seu endpoint **processou** o evento mas demorou a
responder (e estourou o timeout) ou caiu logo depois. Portanto, o seu endpoint
**deve** tratar as entregas de forma **idempotente**.

<Callout type="warn">
  Não assuma que cada entrega corresponde a um evento único. **Deduplique** pelo identificador de
  **negócio** presente em `data` — por exemplo `waMessageId` em `message.received` ou `waMessageIds`
  em `message.status`. O header `X-Pepino-Webhook-Id` **não** serve para isso: ele é o mesmo em
  todas as entregas do webhook (ver [headers](#headers-de-toda-entrega)).
</Callout>

Recomendações práticas:

* **Registre o que já processou.** Mantenha um índice dos identificadores de
  negócio já tratados e ignore entregas repetidas antes de qualquer efeito.
* **Torne a escrita idempotente.** Use `INSERT ... ON CONFLICT DO NOTHING`
  (ou equivalente) com chave única no identificador do evento, para que
  reprocessar o mesmo evento não duplique linhas nem dispare ações repetidas (um
  e-mail enviado duas vezes, por exemplo).
* **Valide a assinatura antes de processar.** Descartar entregas com assinatura
  inválida evita que um terceiro injete eventos forjados. Ver
  [Assinatura](/docs/webhooks/signing).

## Esqueleto de um receptor correto [#esqueleto-de-um-receptor-correto]

Reunindo tudo — validar a assinatura, deduplicar e responder `2xx` rápido:

```javascript
import crypto from 'node:crypto';

const SECRET = process.env.PEPINO_WEBHOOK_SECRET; // "whsec_..."
const processados = new Set(); // troque por um store persistente (Redis/DB)

function assinaturaOk(rawBody, header) {
  const esperado = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(header || '');
  const b = Buffer.from(esperado);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Capture o RAW body (express.raw), não o JSON já parseado.
app.post('/webhook/pepinozap', express.raw({ type: 'application/json' }), (req, res) => {
  // 1) Valide a assinatura sobre os bytes crus.
  if (!assinaturaOk(req.body, req.get('X-Pepino-Signature'))) {
    return res.status(401).end();
  }

  // 2) Responda 2xx imediatamente — processe de forma assíncrona.
  res.status(200).end();

  // 3) Deduplique por identificador de negócio e processe fora do caminho da resposta.
  const evt = JSON.parse(req.body.toString('utf8'));
  const chave = evt.data?.waMessageId ?? `${evt.event}:${evt.timestamp}`;
  if (processados.has(chave)) return; // entrega repetida — ignore
  processados.add(chave);

  enfileirarParaProcessar(evt); // seu job interno
});
```

## Erros ao cadastrar o webhook [#erros-ao-cadastrar-o-webhook]

Os erros abaixo ocorrem no momento de **criar** o webhook (`POST /v1/webhooks`) —
não na entrega. O envelope de erro é `{ "error": { "code", "message" } }`.

* `400` — corpo inválido: `name` ausente, JSON malformado, `url` que não é uma
  URL `http(s)` absoluta (ou aponta para rede interna — bloqueio anti-SSRF), ou
  um valor de `events` que não é um evento suportado.
* `401` — API key ausente ou inválida, ou que não casa com o host
  (`pzk_live_` em `api.pepinozap.com.br`; `pzk_test_` em
  `sandbox.pepinozap.com.br`).
* `403` — projeto inativo ou sem permissão.
* `404` — (em `DELETE /v1/webhooks/{id}`) webhook não encontrado neste Projeto.

<Callout type="info">
  As entregas seguem o **modo** (teste/produção) em que o webhook foi criado: um webhook criado no
  sandbox só recebe eventos de instâncias de teste, e um webhook de produção só recebe eventos de
  produção. Para receber eventos reais, cadastre o webhook com uma key `pzk_live_` no host de
  produção.
</Callout>

## Próximos passos [#próximos-passos]

* [Assinatura](/docs/webhooks/signing) — validar `X-Pepino-Signature` em Node, Python e Go.
* [Eventos](/docs/webhooks/events) — o envelope e o `data` de cada um dos dez eventos.
* [Webhooks](/docs/webhooks) — cadastrar, listar e remover webhooks via API.
* [WebSocket](/docs/webhooks/websocket) — receber os mesmos eventos sem expor um endpoint público.
