PEPINO ZAPdocs
Webhooks

Assinatura HMAC

Como validar que a entrega de webhook veio mesmo do PEPINO ZAP, recalculando o HMAC-SHA256 do corpo bruto com o secret whsec_ e comparando com o header X-Pepino-Signature.

Ver como Markdown

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.

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

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 quando estiver pronto.

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

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

HeaderExemploPara que serve
Content-Typeapplication/jsonO corpo é sempre JSON.
User-Agentpepino-zap-webhooks/1Identifica o entregador. Útil para filtros/logs.
X-Pepino-Eventmessage.receivedNome do evento. Permite rotear sem fazer parse do corpo. Ver Eventos.
X-Pepino-Webhook-Idwh_...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).
X-Pepino-Signaturesha256=<hmac-hex>A assinatura HMAC-SHA256 do corpo bruto.

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.

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):

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:

{
  "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.

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.

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.

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.

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();
});
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
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)
}

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: Expressexpress.raw({ type: 'application/json' }) na rota (ou express.json({ verify })); FastifyaddContentTypeParser com parseAs: 'buffer'; Flaskrequest.get_data(); Djangorequest.body; Go (net/http)io.ReadAll(r.Body). Calcule o HMAC sobre esses bytes — não sobre o objeto já parseado.

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:

# 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.

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.

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.
  • 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.
  • 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

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:

SintomaCausa provávelO que fazer
A assinatura nunca bateHMAC calculado sobre o corpo já parseado/re-serializadoCalcule sobre o raw body (bytes crus), antes do parse.
A assinatura nunca bateFramework alterou os bytes (charset, trim, pretty-print)Capture os bytes exatos da requisição; não normalize o corpo.
A assinatura nunca bateVocê comparou só o hash, esquecendo o prefixo sha256=Compare a string completa "sha256=" + hex(...) com o header inteiro.
A assinatura nunca bateSecret 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 vezesVocê está usando o secret de um webhook e recebendo de outroCada webhook tem secret próprio; identifique pelo X-Pepino-Webhook-Id.
Erro de runtime no timingSafeEqualBuffers de tamanhos diferentes (Node)Cheque a.length === b.length antes de comparar (como no exemplo).
Perdeu o secretO secret só é exibido na criaçãoDelete 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

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

On this page