Pix2DePix

Webhooks

A cada mudança de estado de uma cobrança sua, mandamos um POST para a URL que você configurou no painel.

O que chega

POST /seu/endpoint HTTP/1.1
content-type: application/json
x-p2d-event-id: 6a9258a6486e6a4f3d30521e
x-p2d-event-type: charge.settled
x-p2d-timestamp: 1787975828
x-p2d-signature: v1=6f3c…64 caracteres hex

{
  "id": "6a9258a6486e6a4f3d30521e",
  "type": "charge.settled",
  "createdAt": "2026-08-29T03:57:07.301Z",
  "environment": "live",
  "data": {
    "charge": { "id": "6a92586d…", "externalId": "pedido-8891", "status": "settled", "…": "…" }
  }
}

data.charge é a cobrança inteira, no mesmo formato de GET /v1/charges/{id}.

Eventos

EventoQuando
charge.awaiting_paymentA cobrança foi criada e o QR está válido.
charge.paid_pending_settlementO Pix foi pago. O DePix ainda não saiu.
charge.settledO DePix foi liquidado. Pode liberar.
charge.expiredO QR venceu sem pagamento.
charge.canceledVocê cancelou.
charge.failedA cobrança morreu sem ser paga.
charge.refundedO Pix pago foi devolvido. Estorne o pedido.
charge.testDisparo manual, do painel ou de POST /v1/webhooks/test.

Valide a assinatura

Não confie no corpo antes de conferir a assinatura. Sua URL é pública: qualquer um pode postar nela um JSON dizendo que um pedido foi pago.

O algoritmo, exatamente:

assinatura = HMAC_SHA256(segredo_do_webhook, "<timestamp>.<corpo bruto>")
header     = "v1=" + assinatura_em_hex_minúsculo

Três detalhes que decidem se vai funcionar:

  1. O corpo bruto, como chegou. Não reserialize o JSON antes de assinar — JSON.stringify(JSON.parse(body)) troca espaços e ordem de chaves, e a assinatura não bate mais. Leia os bytes crus do request.
  2. O ponto entre o timestamp e o corpo faz parte do texto assinado.
  3. Compare em tempo constante (hmac.compare_digest, crypto.timingSafeEqual, hash_equals). == em string vaza o segredo por tempo de resposta.

Recuse também o que tiver mais de 300 segundos de diferença entre x-p2d-timestamp e o seu relógio: é o que impede alguém de regravar uma entrega antiga e reenviá-la. Como o timestamp entra na assinatura, mudá-lo invalida.

Node

import { createHmac, timingSafeEqual } from 'node:crypto';

export function assinaturaValida({ segredo, corpoBruto, cabecalhos }) {
  const ts = Number(cabecalhos['x-p2d-timestamp']);
  if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const esperado =
    'v1=' + createHmac('sha256', segredo).update(`${ts}.${corpoBruto}`).digest('hex');

  const a = Buffer.from(cabecalhos['x-p2d-signature'] ?? '');
  const b = Buffer.from(esperado);
  return a.length === b.length && timingSafeEqual(a, b);
}

Em Express, garanta o corpo bruto:

app.post('/webhooks/pix2depix', express.raw({ type: 'application/json' }), (req, res) => {
  // req.body é um Buffer aqui — é isso que a assinatura cobre.
  if (!assinaturaValida({ segredo: process.env.P2D_WEBHOOK_SECRET, corpoBruto: req.body.toString('utf8'), cabecalhos: req.headers })) {
    return res.sendStatus(401);
  }
  const evento = JSON.parse(req.body.toString('utf8'));
  // …enfileire e responda rápido
  res.sendStatus(200);
});

Python

import hmac, hashlib, time

def assinatura_valida(segredo: str, corpo_bruto: bytes, cabecalhos) -> bool:
    ts = int(cabecalhos.get("x-p2d-timestamp", 0))
    if not ts or abs(time.time() - ts) > 300:
        return False

    assinatura = hmac.new(
        segredo.encode(), f"{ts}.".encode() + corpo_bruto, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(f"v1={assinatura}", cabecalhos.get("x-p2d-signature", ""))

PHP

function assinaturaValida(string $segredo, string $corpoBruto, array $cabecalhos): bool
{
    $ts = (int) ($cabecalhos['x-p2d-timestamp'] ?? 0);
    if ($ts === 0 || abs(time() - $ts) > 300) {
        return false;
    }

    $esperado = 'v1=' . hash_hmac('sha256', $ts . '.' . $corpoBruto, $segredo);
    return hash_equals($esperado, $cabecalhos['x-p2d-signature'] ?? '');
}

// O corpo bruto, sem passar por json_decode antes:
$corpoBruto = file_get_contents('php://input');

Laravel: use $request->getContent(), não $request->all(). E exclua a rota do webhook da verificação CSRF (VerifyCsrfToken::$except).

WooCommerce: registre a rota com register_rest_route e leia o corpo com $request->get_body().

Os exemplos executáveis, com o recebedor completo, estão em exemplos/.

Deduplique pelo id do evento

A entrega é at-least-once: o mesmo evento pode chegar mais de uma vez. Acontece quando o seu servidor processa mas a resposta se perde, e quando você reenvia uma entrega pelo painel.

Guarde os id já processados e ignore repetição:

INSERT INTO eventos_pix2depix (id) VALUES (?) ON CONFLICT DO NOTHING;
-- 0 linhas afetadas = já processamos, pode ignorar

O id do reenvio manual é o mesmo do original, de propósito: para você é a mesma repetição de sempre, e a sua deduplicação já cobre.

Responda rápido

Considere sucesso apenas responder 2xx. Qualquer outra coisa — inclusive 3xx, inclusive um redirecionamento — conta como falha.

Não processe o pedido dentro do request do webhook: valide a assinatura, enfileire e responda 200. Timeout nosso é de 10 segundos.

Retentativa

Falhou ou estourou o tempo, tentamos de novo com espera crescente:

10s → 30s → 2min → 10min → 30min → 1h → 3h → 6h → 12h

Insistimos por até 24 horas. Passado isso, a entrega é marcada como falhada — e se o endpoint não tiver tido nenhum sucesso nesse período, ele é desativado e você recebe um e-mail.

Suas cobranças continuam funcionando normalmente com o webhook desativado; o que para é o aviso. Para reativar, salve a URL de novo no painel.

O log de entregas

No painel, em APIWebhook, cada entrega aparece com o corpo enviado, a resposta recebida e o número de tentativas — e com um botão de reenviar.

É por ali que se resolve "a assinatura não está batendo": compare byte a byte o corpo que mandamos com o que o seu código assinou.

Nunca confie só no webhook

Webhook é o caminho rápido, não a verdade. Antes de liberar produto, confirme com GET /v1/charges/{id} — é uma requisição, e é ela que protege contra evento perdido, fora de ordem ou forjado.