PagnovoPagnovodocs

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:

  1. Pegue o payload a ser enviado.
  2. Remova o campo chamado signature se ele existir (caso contrário estaríamos assinando a própria assinatura).
  3. Ordene as chaves do objeto recursivamente em ordem alfabética. Arrays preservam a ordem; apenas chaves de objeto são ordenadas.
  4. Serialize o resultado com JSON.stringify — esse é agora o seu JSON canônico.
  5. 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):

JSON
{
  "status": "APPROVED",
  "amount": 5000,
  "transactionId": "trx_123",
  "end2End": "E12345678..."
}

Depois de sortObjectKeys (alfabético):

JSON
{
  "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:

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

TypeScript
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

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_id ou withdrawal_id que 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-id do 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.