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.
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:
- Autenticidade — a requisição veio mesmo do PEPINO ZAP (e não de um terceiro que descobriu a sua URL).
- 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=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08O trecho após sha256= é o resultado de:
HMAC-SHA256(secret, corpoBruto)codificado em hexadecimal (64 caracteres 0-9a-f), onde:
secreté o valor com prefixowhsec_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.corpoBrutosã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:
| 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. |
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). |
X-Pepino-Signature | sha256=<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 (caracteresa-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) osecretnã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, é:
- Leia o corpo bruto da requisição (bytes), antes de qualquer parse.
- Leia o header
X-Pepino-Signaturerecebido. - Calcule
esperado = "sha256=" + hex(HMAC-SHA256(secret, corpoBruto)). - Compare
esperadocom o header recebido usando comparação de tempo constante. - Se não bater, responda
401e descarte a entrega. Se bater, processe o corpo e responda2xx.
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 "", 200func 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: Express → express.raw({ type: 'application/json' })
na rota (ou express.json({ verify })); Fastify → addContentTypeParser
com parseAs: 'buffer'; Flask → request.get_data(); Django →
request.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
2xxem poucos segundos e processe de forma assíncrona. O timeout de cada entrega é de 15s; respostas lentas ou fora da faixa2xxsã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(comowaMessageId) — não peloX-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:
| 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,nameausente,urlinválida (precisa serhttp/httpsabsoluta e não apontar para rede interna) ou um evento desconhecido emevents(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— (emDELETE /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.