Webhooks
É assim que você sabe que algo aconteceu, sem perguntar em laço.
Dois canais, e eles convivem
| Canal | Alcance | Quando usar |
|---|---|---|
| endereço da conta | todas as transações, inscrito numa lista de eventos | canal permanente, para conciliação |
callbackUrl da transação | só os eventos daquela transação | quando você tem um servidor por pedido |
A callbackUrl dispensa cadastro prévio: você a informa na chamada que cria a
cobrança ou o saque.
Quem usa os dois recebe duas entregas por evento, com X-Webhook-Id
diferentes. É a combinação que se escolhe de propósito, e suprimir uma por achar
que é duplicidade quebraria esse desenho. Para deduplicar, use o id da transação
no corpo.
O que chega
{ "type": "charge.paid", "data": { } }
data é o mesmo objeto que a API devolve — Charge em eventos de cobrança,
Payout em eventos de saque. Você não precisa de um parser diferente.
Eventos
| Evento | Quando |
|---|---|
charge.paid | a cobrança foi paga ou a compra foi aprovada |
charge.settled | o dinheiro do cartão ficou disponível |
charge.declined | recusada (o pagador pode tentar de novo) |
charge.expired | o prazo acabou sem pagamento: ninguém tentou pagar |
charge.refunded | devolvida — e também contestada, com data.refund.isChargeback: true |
payout.settled | saque pago |
payout.failed | saque falhou; a retenção foi devolvida |
Cabeçalhos
| Cabeçalho | Conteúdo |
|---|---|
X-Webhook-Signature | t=<epoch>,v1=<hmac-sha256 hex> |
X-Webhook-Id | identificador do evento, estável entre retentativas |
X-Webhook-Event | o mesmo valor de type |
Verificar a assinatura
A assinatura cobre "<timestamp>.<corpo cru>".
O instante entra dentro do que é assinado. Sem ele, quem capturasse uma entrega legítima poderia reenviá-la para sempre, e cada reenvio passaria na sua verificação.
[$t, $v1] = sscanf($request->header('X-Webhook-Signature'), 't=%d,v1=%s');
$esperado = hash_hmac('sha256', $t . '.' . $request->getContent(), $segredo);
if (! hash_equals($esperado, $v1)) {
abort(400);
}
if (abs(time() - $t) > 300) {
abort(400); // entrega antiga demais — provável reenvio
}
:::danger Verifique sobre o corpo cru Antes de qualquer parsing. Reserializar o JSON muda os bytes — a ordem das chaves, um espaço — e a assinatura deixa de conferir.
Em frameworks que fazem parsing automático do corpo, procure o acesso ao
conteúdo bruto: $request->getContent(), request.body cru, req.rawBody.
:::
Qual segredo
| Canal | Segredo |
|---|---|
| endereço da conta | o whsec_… mostrado uma vez, na criação |
callbackUrl | o segredo da credencial que criou a transação |
No segundo caso você já o tem — e revogar a chave derruba os dois sentidos de uma vez.
Responder
Esperamos 2xx. Qualquer outra coisa entra em retentativa.
Responda rápido e processe em segundo plano. O tempo limite é de 10 segundos.
Retentativas
Recuo exponencial: 10s, 30s, 2min, 10min, 1h, 6h. Depois disso, a entrega é marcada como esgotada.
A escala é grande de propósito: a causa mais comum de falha é o seu servidor estar fora, e insistir de dez em dez segundos por seis horas não o traz de volta.
Entrega é ao menos uma vez
Deduplique por X-Webhook-Id. Ele é estável entre retentativas, então o
mesmo evento entregue três vezes traz o mesmo id nas três.
O caminho mais simples: guarde os ids processados e ignore repetidos.
O que você não vai ver
Falha de entrega nunca desfaz o que aconteceu. Se o seu servidor esteve fora por seis horas, a cobrança continua paga e o saldo continua creditado — o que se perdeu foi o aviso, não o fato. Concilie por extrato.