# Assinatura HMAC (/docs/webhooks/signing)





O seu endpoint de webhook é uma URL pública: qualquer pessoa na internet pode mandar um `POST` para ela fingindo ser o PEPINO ZAP. A **assinatura HMAC** existe para você ter certeza de duas coisas em cada entrega:

1. **Autenticidade** — a requisição veio mesmo do PEPINO ZAP (e não de um terceiro que descobriu a sua URL).
2. **Integridade** — o corpo recebido não foi alterado no caminho.

Toda entrega de webhook é um `POST` assinado. Junto do corpo, o PEPINO ZAP envia o header `X-Pepino-Signature`, e o seu endpoint **recalcula a mesma assinatura** e compara. Se bater, a entrega é legítima; se não bater, descarte.

```http
X-Pepino-Signature: sha256=<hmac-hex>
```

<Callout type="info">
  Validar a assinatura é **opcional do ponto de vista da entrega** (o PEPINO ZAP envia o `POST` de
  qualquer forma), mas é **fortemente recomendado** em produção. Sem validar, você não tem como
  distinguir uma entrega real de uma forjada por quem descobrir a sua URL. Se preferir começar sem
  validar (ex.: em um protótipo), pule para [Como validar](#como-validar) quando estiver pronto.
</Callout>

## O que é o `X-Pepino-Signature` [#o-que-é-o-x-pepino-signature]

O valor do header tem sempre o formato `sha256=` seguido do HMAC em hexadecimal minúsculo:

```
sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

O trecho após `sha256=` é o resultado de:

```
HMAC-SHA256(secret, corpoBruto)
```

codificado em **hexadecimal** (64 caracteres `0-9a-f`), onde:

* **`secret`** é o valor com prefixo `whsec_` que você recebeu **uma única vez** ao cadastrar o webhook (`POST /v1/webhooks`). É a chave compartilhada entre você e o PEPINO ZAP — só vocês dois a conhecem, e é por isso que a assinatura prova a origem.
* **`corpoBruto`** são os **bytes crus** do body recebido, exatamente como chegaram pela rede — não o JSON re-serializado depois de um parse.

O `sha256=` é apenas um prefixo de versão do esquema, parte fixa do header — ele **faz parte da string** que você compara, não é separado do hash. Nos exemplos abaixo, o valor esperado é montado como `"sha256=" + hex(...)` justamente por isso.

### Headers presentes em toda entrega [#headers-presentes-em-toda-entrega]

Além da assinatura, todo `POST` de webhook traz estes headers, definidos pelo entregador:

| Header                | Exemplo                 | Para que serve                                                                                                                                                                                                                                                                                        |
| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`      | O corpo é sempre JSON.                                                                                                                                                                                                                                                                                |
| `User-Agent`          | `pepino-zap-webhooks/1` | Identifica o entregador. Útil para filtros/logs.                                                                                                                                                                                                                                                      |
| `X-Pepino-Event`      | `message.received`      | Nome do evento. Permite rotear sem fazer parse do corpo. Ver [Eventos](/docs/webhooks/events).                                                                                                                                                                                                        |
| `X-Pepino-Webhook-Id` | `wh_...`                | Identifica **qual webhook cadastrado** originou a entrega. É **constante** por webhook (não é um id por entrega) — útil quando um mesmo endpoint recebe de vários webhooks, para saber qual secret usar. **Não** serve para deduplicar (ver [Entrega e retry](/docs/webhooks/delivery#idempotencia)). |
| `X-Pepino-Signature`  | `sha256=<hmac-hex>`     | A assinatura HMAC-SHA256 do corpo bruto.                                                                                                                                                                                                                                                              |

<Callout type="warn">
  Calcule o HMAC sobre o **corpo bruto** (raw body), **antes** de qualquer parse. Se você fizer
  `JSON.parse` e depois re-serializar o objeto para calcular a assinatura, ela **não vai bater**:
  espaçamento, ordem de chaves e escaping podem mudar na re-serialização, e o HMAC é sensível a
  qualquer byte diferente. Capture os bytes crus do corpo primeiro, valide, e só então faça o parse.
</Callout>

## De onde vem o `secret` [#de-onde-vem-o-secret]

O `secret` é gerado e devolvido **no momento em que você cadastra o webhook**, via API, de forma self-service e escopado ao seu Projeto. O cadastro é feito com `POST /v1/webhooks` (ver [Webhooks](/docs/webhooks)):

```bash
curl -X POST https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "meu-endpoint",
    "url": "https://meusistema.com/webhook/pepinozap",
    "events": []
  }'
```

Resposta `201 Created` — repare no campo `secret`:

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

Características do `secret`:

* Tem sempre o prefixo `whsec_`, seguido de uma sequência aleatória em minúsculas (caracteres `a-z2-7`). Trate-o como uma string opaca: **não** dependa do tamanho exato nem tente derivar nada dele.
* É **exibido uma única vez**, no corpo da resposta da criação. Nas consultas posteriores (`GET /v1/webhooks`) o `secret` **não** é retornado.
* Em modo teste o secret é cadastrado e usado da mesma forma; o que muda é o modo da API key (`pzk_test_`) e o host. Ver [Autenticação](/docs/get-started/authentication).

<Callout type="warn">
  **Guarde o `secret` assim que receber a resposta.** Ele não aparece de novo. Se
  você perdê-lo, **delete o webhook e crie outro** — isso gera um secret novo.
  Não há endpoint para "revelar" ou "rotacionar" o secret de um webhook
  existente: o caminho é deletar e recriar.

  Para remover: `DELETE /v1/webhooks/{id}` (retorna `204 No Content`). Para
  listar os seus webhooks (sem o secret): `GET /v1/webhooks`.
</Callout>

## Como validar [#como-validar]

A validação é simétrica: o PEPINO ZAP calcula `HMAC-SHA256(secret, corpoBruto)` e manda o resultado no header; você faz **exatamente o mesmo cálculo** com o seu secret e o corpo que recebeu, e compara as duas strings. O algoritmo, em qualquer linguagem, é:

1. **Leia o corpo bruto** da requisição (bytes), antes de qualquer parse.
2. Leia o header `X-Pepino-Signature` recebido.
3. Calcule `esperado = "sha256=" + hex(HMAC-SHA256(secret, corpoBruto))`.
4. Compare `esperado` com o header recebido usando **comparação de tempo constante**.
5. Se **não** bater, responda `401` e **descarte** a entrega. Se bater, processe o corpo e responda `2xx`.

<Callout type="warn">
  Use sempre uma função de **comparação de tempo constante** (`timingSafeEqual` no Node,
  `hmac.compare_digest` no Python, `hmac.Equal` no Go) — nunca `==` / `===` direto entre as strings.
  Comparações comuns retornam mais cedo no primeiro caractere diferente, o que vaza, pelo tempo de
  resposta, o quanto do hash estava certo. As funções de tempo constante eliminam esse canal
  lateral.
</Callout>

<Tabs items="['Node.js', 'Python', 'Go']">
  <Tab value="Node.js">
    ```javascript
    import crypto from 'node:crypto';

    // Recalcula o HMAC e compara em tempo constante.
    function verify(rawBody, signatureHeader, secret) {
      const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
      const a = Buffer.from(signatureHeader || '');
      const b = Buffer.from(expected);
      // timingSafeEqual exige buffers do mesmo tamanho — cheque antes.
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }

    // Express: é PRECISO capturar o raw body. express.json() descarta os bytes
    // crus; use express.raw() nesta rota (ou express.json({ verify })).
    app.post('/webhooks/pepino', express.raw({ type: 'application/json' }), (req, res) => {
      if (!verify(req.body, req.get('X-Pepino-Signature'), process.env.PEPINO_WEBHOOK_SECRET)) {
        return res.status(401).end();
      }
      const evt = JSON.parse(req.body.toString('utf8'));
      // processa evt.event / evt.instanceId / evt.data — responda 2xx rápido
      res.status(200).end();
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import hmac, hashlib

    def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
        expected = "sha256=" + hmac.new(
            secret.encode(), raw_body, hashlib.sha256
        ).hexdigest()
        # compare_digest é tempo constante e tolera tamanhos diferentes.
        return hmac.compare_digest(expected, signature_header or "")

    # Flask
    @app.post("/webhooks/pepino")
    def webhook():
        raw = request.get_data()  # bytes crus, antes do parse
        if not verify(raw, request.headers.get("X-Pepino-Signature"), SECRET):
            return "", 401
        evt = request.get_json()
        # processa evt["event"] / evt["instanceId"] / evt["data"]
        return "", 200
    ```
  </Tab>

  <Tab value="Go">
    ```go
    func verify(rawBody []byte, sigHeader, secret string) bool {
        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write(rawBody)
        expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
        return hmac.Equal([]byte(expected), []byte(sigHeader))
    }

    func handler(w http.ResponseWriter, r *http.Request) {
        raw, _ := io.ReadAll(r.Body) // bytes crus
        if !verify(raw, r.Header.Get("X-Pepino-Signature"), secret) {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        // json.Unmarshal(raw, &evt) — processa e responde 2xx
        w.WriteHeader(http.StatusOK)
    }
    ```
  </Tab>
</Tabs>

<Callout type="info">
  **Captura do raw body por framework.** O ponto mais comum de falha é o
  framework consumir o corpo no parser de JSON e te entregar só o objeto. Garanta
  o acesso aos bytes crus: **Express** → `express.raw({ type: 'application/json' })`
  na rota (ou `express.json({ verify })`); **Fastify** → `addContentTypeParser`
  com `parseAs: 'buffer'`; **Flask** → `request.get_data()`; **Django** →
  `request.body`; &#x2A;*Go (net/http)** → `io.ReadAll(r.Body)`. Calcule o HMAC sobre
  esses bytes — não sobre o objeto já parseado.
</Callout>

## Testando a validação por fora [#testando-a-validação-por-fora]

Para conferir que a sua lógica de validação está correta antes de plugar no tráfego real, calcule a assinatura manualmente sobre um corpo de exemplo e confira que o seu endpoint a aceita. Com o secret e o corpo em mãos:

```bash
# secret e corpo de exemplo
SECRET="whsec_mfrggzdfmztwq2lknnwg23tpobyxe43uov3ho6dz"
BODY='{"event":"message.received","instanceId":"inst_123","timestamp":"2026-06-05T12:00:00Z","data":{}}'

# calcula o HMAC-SHA256 do corpo com o secret (hex minúsculo)
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"

# manda pro seu endpoint, exatamente o mesmo corpo, com o header montado
curl -X POST https://meusistema.com/webhook/pepinozap \
  -H "Content-Type: application/json" \
  -H "X-Pepino-Signature: $SIG" \
  --data-raw "$BODY"
```

Se o seu endpoint responder `2xx`, a validação está correta. Troque um caractere do `BODY` (sem recalcular a assinatura) e repita: agora ele deve responder `401`, confirmando que a verificação está realmente ativa.

<Callout type="warn">
  Use `--data-raw` (não `--data`/`-d`) no `curl`: `-d` pode remover quebras de linha e alterar os
  bytes enviados, fazendo a assinatura não bater por um motivo que não é a sua lógica. O corpo usado
  no cálculo do HMAC tem que ser **byte a byte** o mesmo enviado na requisição.
</Callout>

## Boas práticas [#boas-práticas]

* **Responda rápido.** Retorne `2xx` em poucos segundos e processe de forma assíncrona. O timeout de cada entrega é de **15s**; respostas lentas ou fora da faixa `2xx` são tratadas como falha e disparam reentrega com backoff exponencial, em até **6 tentativas**. Ver [Entrega e retry](/docs/webhooks/delivery).
* **Seja idempotente.** Em retries, a mesma entrega pode chegar mais de uma vez. Deduplique por um identificador de **negócio** presente em `data` (como `waMessageId`) — **não** pelo `X-Pepino-Webhook-Id`, que é constante por webhook e o mesmo em todas as entregas. Ver [Entrega e retry](/docs/webhooks/delivery#idempotencia).
* **Guarde o secret fora do código.** Mantenha-o em variável de ambiente ou cofre de segredos, nunca commitado no repositório.
* **Valide antes de fazer parse.** Cheque a assinatura sobre os bytes crus e só então desserialize o JSON. Isso também protege o seu parser de payloads forjados.
* **Um secret por webhook.** Cada webhook cadastrado tem o seu próprio secret. Se você tem múltiplos endpoints, cada um valida com o secret que recebeu na sua criação.

## Erros e diagnóstico [#erros-e-diagnóstico]

A assinatura é validada **no seu lado** — o PEPINO ZAP não retorna erro de assinatura para você; quem decide aceitar ou rejeitar é o seu endpoint. Os sintomas abaixo são os que aparecem ao integrar:

| Sintoma                              | Causa provável                                               | O que fazer                                                                           |
| ------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| A assinatura **nunca** bate          | HMAC calculado sobre o corpo **já parseado/re-serializado**  | Calcule sobre o **raw body** (bytes crus), antes do parse.                            |
| A assinatura **nunca** bate          | Framework alterou os bytes (charset, trim, pretty-print)     | Capture os bytes exatos da requisição; não normalize o corpo.                         |
| A assinatura **nunca** bate          | Você comparou só o hash, esquecendo o prefixo `sha256=`      | Compare a string completa `"sha256=" + hex(...)` com o header inteiro.                |
| A assinatura **nunca** bate          | Secret errado (de outro webhook, ou de teste vs produção)    | Confira que o secret é o **daquele** webhook e do modo correto.                       |
| Funciona às vezes, falha às vezes    | Você está usando o secret de um webhook e recebendo de outro | Cada webhook tem secret próprio; identifique pelo `X-Pepino-Webhook-Id`.              |
| Erro de runtime no `timingSafeEqual` | Buffers de tamanhos diferentes (Node)                        | Cheque `a.length === b.length` antes de comparar (como no exemplo).                   |
| Perdeu o secret                      | O secret só é exibido na criação                             | **Delete o webhook e crie outro** (`DELETE /v1/webhooks/{id}` → `POST /v1/webhooks`). |

Erros que o **endpoint de cadastro** (`POST /v1/webhooks`) pode retornar, antes mesmo de existir um secret para validar — o envelope é `{ "error": { "code", "message" } }`:

* `400` — corpo JSON inválido, `name` ausente, `url` inválida (precisa ser `http`/`https` absoluta e não apontar para rede interna) ou um evento desconhecido em `events` (válidos: `message.received`, `message.status`, `instance.status`, `call.received`, `message.deleted`, `group.participants`, `group.updated`, `contact.updated`, `label.updated`, `label.association`; lista vazia = todos).
* `401` — API key ausente ou inválida (ou key de modo diferente do host: `pzk_test_` em produção, ou vice-versa).
* `404` — (em `DELETE /v1/webhooks/{id}`) webhook não encontrado neste Projeto.

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

* [Webhooks](/docs/webhooks) — cadastrar, listar e remover seus webhooks self-service.
* [Eventos](/docs/webhooks/events) — a estrutura do envelope (`event`, `instanceId`, `timestamp`, `data`) e o payload de cada evento.
* [Entrega e retry](/docs/webhooks/delivery) — timeout, critério de sucesso/falha e política de reenvio.
