Webhooks
Verificação de assinatura de webhook
Todo webhook que a Pagnovo envia para o seu endpoint inclui um header x-signature — uma assinatura HMAC-SHA256 do payload, calculada com a sua secret key. Você deve verificar essa assinatura antes de processar o webhook.
Um endpoint de webhook sem verificação de assinatura é uma porta aberta. URLs de webhook vazam por ferramentas de monitoramento, agregadores de log, commits acidentais, DevTools do navegador, screenshots — a suposição de que a URL é secreta está errada. Com verificação de assinatura, mesmo um atacante que conhece a URL não consegue forjar uma requisição que você aceitaria.
Esse é o mesmo padrão usado pela Stripe, GitHub, Shopify e a maioria das APIs modernas. A matemática é bem compreendida e validada em produção. Os erros — quando acontecem — são quase sempre nos detalhes de implementação, não no algoritmo.
Como a assinatura é calculada
O algoritmo exato que usamos:
- Pegue o payload a ser enviado.
- Remova o campo chamado
signaturese ele existir (caso contrário estaríamos assinando a própria assinatura). - Ordene as chaves do objeto recursivamente em ordem alfabética. Arrays preservam a ordem; apenas chaves de objeto são ordenadas.
- Serialize o resultado com
JSON.stringify— esse é agora o seu JSON canônico. - Calcule
HMAC-SHA256(jsonCanonico, secretKey)e codifique o resultado em hex.
Essa string hex é o valor do header x-signature.
Por que ordenação alfabética?
JSON.stringify do JavaScript preserva a ordem de inserção, mas dois objetos semanticamente idênticos podem ter ordens de inserção diferentes. Se você assinar com uma ordem e verificar com outra, as assinaturas nunca batem. Ordenação alfabética é uma forma canônica determinística — não importa qual linguagem criou o objeto, o JSON canônico é o mesmo.
Exemplo concreto:
Payload original (na ordem que nosso sistema produz):
{
"status": "APPROVED",
"amount": 5000,
"transactionId": "trx_123",
"end2End": "E12345678..."
}
Depois de sortObjectKeys (alfabético):
{
"amount": 5000,
"end2End": "E12345678...",
"status": "APPROVED",
"transactionId": "trx_123"
}
O HMAC é calculado sobre a segunda string JSON, não a primeira.
Implementação em TypeScript
Uma função sortObjectKeys reutilizável mais um helper de verificação:
import * as crypto from "crypto";
function sortObjectKeys(obj: unknown): unknown {
if (obj === null || typeof obj !== "object" || obj instanceof Date) return obj;
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj as object).sort()) {
out[key] = sortObjectKeys((obj as Record<string, unknown>)[key]);
}
return out;
}
export function verifyWebhookSignature(
payload: Record<string, unknown>,
receivedSignature: string,
secret: string,
): boolean {
// Remove o campo signature embutido se existir
const { signature: _ignored, ...rest } = payload;
const canonical = JSON.stringify(sortObjectKeys(rest));
const expected = crypto
.createHmac("sha256", secret)
.update(canonical)
.digest("hex");
// Checa o tamanho antes do timingSafeEqual — tamanhos diferentes lancam erro
if (expected.length !== receivedSignature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(receivedSignature, "hex"),
);
}
Controller NestJS completo:
import { Controller, Post, Req, Headers, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { verifyWebhookSignature } from "./verify-signature";
@Controller("webhooks")
export class WebhookController {
@Post(":provider")
async handle(
@Req() req: Request,
@Headers("x-signature") signature: string,
@Headers("x-trace-id") traceId: string,
) {
const secret = process.env.PAGNOVO_WEBHOOK_SECRET!;
// Captura o trace ID antes de qualquer coisa
this.logger.info("webhook_received", { trace_id: traceId, type: req.body?.event });
if (!signature) throw new UnauthorizedException("Missing signature");
if (!verifyWebhookSignature(req.body, signature, secret)) {
this.logger.warn("invalid_signature", { trace_id: traceId });
throw new UnauthorizedException("Invalid signature");
}
await this.processWebhook(req.body);
return { ok: true };
}
}
Implementação em Python
import hmac, hashlib, json
from typing import Any
def sort_object_keys(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: sort_object_keys(obj[k]) for k in sorted(obj.keys())}
if isinstance(obj, list):
return [sort_object_keys(item) for item in obj]
return obj
def verify_webhook_signature(payload: dict, received_signature: str, secret: str) -> bool:
payload = {k: v for k, v in payload.items() if k != "signature"}
canonical = json.dumps(sort_object_keys(payload), separators=(",", ":"))
expected = hmac.new(
secret.encode(),
canonical.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_signature)
Observação: o json.dumps do Python adiciona espaços depois de : e , por padrão. Use separators=(",", ":") para casar com o JSON compacto que nosso servidor produz. hmac.compare_digest é o equivalente timing-safe do crypto.timingSafeEqual.
Erros comuns
1. Verificar depois de re-serializar.
Sintoma: a assinatura bate às vezes, falha aparentemente do nada — especialmente quando o payload tem números ou unicode.
Causa: a maioria dos frameworks web faz parse do JSON em um objeto nativo antes do seu handler rodar. Se você re-serializa esse objeto para calcular a string canônica, pode perder precisão (inteiros grandes), reordenar campos ou introduzir whitespace extra.
Correção: ou trabalhe com o corpo raw da requisição (Express: bodyParser.raw; NestJS: registre o middleware de raw body) ou use uma canonicalização determinística como sortObjectKeys seguida de JSON.stringify sem espaços.
2. Usar === para comparar assinaturas.
Sintoma: funciona em dev, mas é teoricamente vulnerável a timing attacks.
Causa: comparação de string normal faz short-circuit no primeiro caractere que difere, vazando informação sobre quantos bytes batem.
Correção: sempre use crypto.timingSafeEqual (Node), hmac.compare_digest (Python) ou o equivalente da sua linguagem.
3. Secret key errada.
Sintoma: 100% das assinaturas inválidas, imediatamente, do primeiro dia.
Causa: o segredo de assinatura de webhook não é o mesmo que sua API key. São valores separados, exibidos em partes diferentes do dashboard.
Correção: no dashboard, vá em Integrações > Webhooks e copie o Webhook Secret. Não reaproveite a chave de autenticação da API.
4. Esquecer de ordenar as chaves.
Sintoma: a assinatura nunca bate mesmo com todo o resto parecendo certo.
Causa: JSON.stringify preserva ordem de inserção. Qualquer reordenação no caminho entre nós e você quebra a assinatura.
Correção: chame sortObjectKeys (ou o equivalente em Python) antes de serializar. Sempre.
Boas práticas operacionais
- Processe webhooks idempotentemente. Nós reentregamos entregas que falharam. Seu handler deve ser seguro de chamar duas vezes com o mesmo payload — tipicamente registrando o
transaction_idouwithdrawal_idque você já processou. - Retorne 200 só depois da assinatura passar na verificação. Um 401/403 em falha de assinatura nos diz para não ficar reentregando à toa.
- Processe de forma assíncrona. Coloque o payload validado em uma fila e retorne 200 imediatamente. Não faça a Pagnovo esperar enquanto você conversa com seu banco de dados, seu CRM e seu provedor de e-mail — é assim que tempos de entrega explodem e webhooks começam a dar timeout.
- Registre o trace ID. O header
x-trace-iddo webhook é o mesmo que temos nos nossos logs. Persista. Quando algo parecer estranho, esse ID é seu caminho mais rápido pra resposta. - Faça rate-limit das tentativas com assinatura inválida. Se alguém está martelando seu endpoint de webhook com assinaturas ruins, não somos nós — é um atacante sondando o endpoint. Faça throttle ou bloqueie.