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
| Evento | Quando |
|---|---|
charge.awaiting_payment | A cobrança foi criada e o QR está válido. |
charge.paid_pending_settlement | O Pix foi pago. O DePix ainda não saiu. |
charge.settled | O DePix foi liquidado. Pode liberar. |
charge.expired | O QR venceu sem pagamento. |
charge.canceled | Você cancelou. |
charge.failed | A cobrança morreu sem ser paga. |
charge.refunded | O Pix pago foi devolvido. Estorne o pedido. |
charge.test | Disparo 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:
- 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. - O ponto entre o timestamp e o corpo faz parte do texto assinado.
- 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_routee 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 API → Webhook, 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.