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

# Segurança

> Confirme que cada webhook veio da Zapfy, que o corpo não foi alterado e que não é uma requisição antiga sendo reenviada.

A URL do seu endpoint é pública: qualquer pessoa que descubra o endereço consegue
mandar um `POST` para ela. Por isso toda entrega da Zapfy vem **assinada** com um
segredo que só você e a Zapfy conhecem. Verificar a assinatura garante três coisas:

* **Origem** — a requisição foi enviada pela Zapfy.
* **Integridade** — o corpo chegou exatamente como saiu, sem nenhum byte alterado.
* **Frescor** — não é uma requisição antiga, capturada e reenviada (*replay*).

A verificação é opcional, mas recomendada para qualquer endpoint que dispare ação
de negócio (responder cliente, criar pedido, atualizar CRM).

<Info>
  As entregas seguem a especificação aberta [Standard Webhooks](https://www.standardwebhooks.com),
  a mesma usada por vários provedores de webhook. Na prática, você valida com a
  **biblioteca oficial** do padrão, sem escrever código de criptografia.
</Info>

## O secret

Cada webhook tem o seu próprio `secret`, gerado pela Zapfy na criação. Ele volta
**uma única vez**, na resposta do `POST` que cria o webhook:

```json theme={null}
{
  "data": {
    "id": "01J9Z...",
    "url": "https://seu-app.com/webhooks",
    "events": ["MESSAGE.RECEIVED", "MESSAGE.STATUS_UPDATED"],
    "enabled": true,
    "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw...",
    "createdAt": "2026-09-26T01:40:00.000Z"
  }
}
```

<Warning>
  Guarde o `secret` assim que criar o webhook: ele não aparece de novo em nenhuma
  outra resposta da API. Se perder o valor, ou suspeitar que ele vazou, remova o
  webhook e crie outro — o novo vem com um `secret` novo.
</Warning>

Trate o `secret` como uma senha: variável de ambiente ou cofre de segredos, nunca
no código-fonte nem em log. Com ele, qualquer um consegue forjar uma entrega válida.

## Headers da entrega

| Header | Conteúdo |
| - | - |
| `webhook-id` | ID do evento. Igual ao `id` do corpo e estável entre reentregas — use para deduplicar. |
| `webhook-timestamp` | Momento do envio, em segundos Unix (ex.: `1790388490`). |
| `webhook-signature` | Assinatura da entrega: `v1,<base64>`. |

O tipo do evento não tem header próprio: ele está no campo `type` do corpo, que é
assinado.

## Verificando com a biblioteca oficial

Instale a biblioteca do Standard Webhooks e passe a ela o **corpo cru** da
requisição, os headers e o seu `secret`. Ela confere a assinatura, rejeita
requisições com mais de 5 minutos e devolve o evento já parseado.

<CodeGroup>
  ```bash Node.js theme={null}
  npm install standardwebhooks
  ```

  ```bash Python theme={null}
  pip install standardwebhooks
  ```

  ```bash PHP theme={null}
  composer require standard-webhooks/standard-webhooks
  ```
</CodeGroup>

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import express from "express"
  import { Webhook } from "standardwebhooks"

  const wh = new Webhook(process.env.WEBHOOK_SECRET)
  const app = express()

  // express.raw: o corpo chega como Buffer, byte a byte como a Zapfy enviou.
  app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
    let event
    try {
      event = wh.verify(req.body.toString("utf8"), req.headers)
    } catch {
      return res.status(401).end()
    }

    // ... processe o evento (deduplicando por event.id)
    res.status(200).end()
  })
  ```

  ```python Python (Flask) theme={null}
  import os

  from flask import Flask, abort, request
  from standardwebhooks.webhooks import Webhook, WebhookVerificationError

  wh = Webhook(os.environ["WEBHOOK_SECRET"])
  app = Flask(__name__)


  @app.post("/webhooks")
  def webhook():
      try:
          event = wh.verify(request.get_data(), dict(request.headers))
      except WebhookVerificationError:
          abort(401)

      # ... processe o evento (deduplicando por event["id"])
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  require __DIR__ . '/vendor/autoload.php';

  use StandardWebhooks\Webhook;
  use StandardWebhooks\Exception\WebhookVerificationException;

  $wh = new Webhook(getenv('WEBHOOK_SECRET'));

  $headers = [
      'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? null,
      'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null,
      'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null,
  ];

  try {
      $event = $wh->verify(file_get_contents('php://input'), $headers);
  } catch (WebhookVerificationException $e) {
      http_response_code(401);
      exit;
  }

  // ... processe o evento (deduplicando por $event['id'])
  http_response_code(200);
  ```
</CodeGroup>

Há bibliotecas oficiais também para Go, Java, Ruby, Rust, C# e outras linguagens —
veja a lista em [standardwebhooks.com](https://www.standardwebhooks.com).

## Verificando sem biblioteca

Se preferir não adicionar dependência, a verificação cabe em poucas linhas.

A assinatura é um HMAC-SHA256 do ID, do timestamp e do **corpo cru**, separados por
ponto. A chave é o `secret` **sem o prefixo `whsec_`, decodificado de base64** — não
a string em si:

```text theme={null}
chave             = base64_decode( secret sem "whsec_" )
conteúdo assinado = <webhook-id> + "." + <webhook-timestamp> + "." + <corpo cru>
webhook-signature = "v1," + base64( HMAC-SHA256(chave, conteúdo assinado) )
```

1. Leia o **corpo cru** da requisição, antes de qualquer parse de JSON.
2. Leia `webhook-id`, `webhook-timestamp` e `webhook-signature`. Se faltar algum, rejeite.
3. Rejeite se o timestamp estiver a mais de **5 minutos** do seu relógio.
4. Calcule a assinatura esperada como acima.
5. O header pode trazer **mais de uma** assinatura, separadas por espaço. Aceite se
   qualquer uma delas for igual à esperada, usando **comparação de tempo constante**.

```js Node.js theme={null}
import crypto from "node:crypto"

const TOLERANCE_SECONDS = 300

function verifyWebhook({ rawBody, headers, secret }) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const signatures = headers["webhook-signature"]
  if (!id || !timestamp || !signatures) return false

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest()

  return signatures.split(" ").some((versioned) => {
    const [version, signature] = versioned.split(",")
    if (version !== "v1" || !signature) return false
    const received = Buffer.from(signature, "base64")
    return received.length === expected.length && crypto.timingSafeEqual(received, expected)
  })
}
```

Todos os três campos fazem parte do conteúdo assinado, então nenhum pode ser trocado
sem invalidar a assinatura. Não adianta um atacante mudar o `webhook-timestamp` para
"rejuvenescer" uma requisição capturada, nem mudar o `webhook-id` para escapar da sua
deduplicação.

## Proteção contra replay

A assinatura sozinha prova que a requisição saiu da Zapfy, mas não *quando*. Sem a
checagem do timestamp, alguém que capturasse uma entrega válida poderia reenviá-la
dias depois e ela passaria. A tolerância de 5 minutos fecha essa janela — a
biblioteca oficial já faz essa checagem.

* O `webhook-timestamp` é a hora do **envio**, carimbada a cada tentativa. Uma
  reentrega feita depois de uma falha chega com timestamp novo e passa na
  tolerância normalmente.
* Ele **não** é o `timestamp` do corpo, que é a hora do
  [evento](/pt-br/webhooks/events#envelope) e se mantém igual em todas as tentativas.
* Mantenha o relógio do servidor sincronizado (NTP). Um relógio desviado alguns
  minutos passa a rejeitar entregas legítimas.

Para fechar também a janela dos 5 minutos, combine com a **deduplicação pelo ID do
evento** (`webhook-id`, ou o `id` do corpo — são o mesmo valor), que você já precisa
ter porque a entrega é at-least-once. Um replay dentro da janela carrega o ID de um
evento já processado e é descartado; e como o ID é assinado, trocá-lo invalida a
requisição.

## Erros comuns

<AccordionGroup>
  <Accordion title="A assinatura nunca bate">
    Quase sempre é o corpo. Se o framework fizer o parse do JSON e você verificar o
    resultado de `JSON.stringify(req.body)`, os bytes mudam (espaços, ordem de chaves,
    escapes) e o HMAC é outro. Use o corpo **cru**, exatamente como chegou — no
    Express, `express.raw`; no Flask, `request.get_data()`; no PHP, `php://input`.
  </Accordion>

  <Accordion title="Implementei na mão e não bate, mas a biblioteca funciona">
    A chave do HMAC é o `secret` **decodificado de base64**, depois de remover o
    prefixo `whsec_`. Usar a string `whsec_...` direto como chave gera outra
    assinatura. A assinatura também é **base64**, não hex.
  </Accordion>

  <Accordion title="Timestamp sempre fora da tolerância">
    O `webhook-timestamp` está em **segundos**. Comparar com `Date.now()` (milissegundos)
    sem dividir por 1000 rejeita tudo. Confira também o relógio do servidor.
  </Accordion>

  <Accordion title="Por que não comparar com ==">
    A comparação comum para no primeiro caractere diferente, e o tempo de resposta
    vaza quantos caracteres estavam certos. Use `crypto.timingSafeEqual`,
    `hmac.compare_digest` ou `hash_equals` — as bibliotecas oficiais já fazem isso.
  </Accordion>

  <Accordion title="Header não encontrado">
    Nomes de header não diferenciam maiúsculas de minúsculas. No Node eles chegam em
    minúsculo (`req.headers["webhook-id"]`); no PHP, em `$_SERVER` com o prefixo
    `HTTP_` (`HTTP_WEBHOOK_ID`).
  </Accordion>
</AccordionGroup>

## Testando seu endpoint

Para testar a verificação sem esperar um evento real, gere uma requisição assinada
com o seu `secret` no terminal:

```bash theme={null}
SECRET="whsec_..."
URL="https://seu-app.com/webhooks"
BODY='{"type":"MESSAGE.RECEIVED","id":"teste-1","instanceId":"teste","timestamp":"2026-09-26T01:40:00Z","data":{}}'
ID="teste-1"
TS=$(date +%s)
KEY=$(printf '%s' "${SECRET#whsec_}" | openssl base64 -d -A | xxd -p -c 256)
SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary | openssl base64 -A)

curl -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIG" \
  -d "$BODY"
```

Seu endpoint deve responder `2xx`. Altere um caractere do `BODY` depois de gerar a
assinatura, ou use um `TS` de uma hora atrás, e ele deve responder `401`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.