Menu da documentação
Guias
Webhooks
O Pagaki avisa o seu sistema quando um pagamento muda: criado, pago, expirado, cancelado, falho ou estornado. A referência completa (eventos, exemplo de cada um) está em referência da API.
Cadastrar o endpoint
No painel, em Webhooks:
- a URL precisa ser HTTPS com nome de host (IP literal é recusado, e endereços de rede interna são bloqueados na entrega);
- escolha os eventos, ou
*para todos; - guarde o segredo
whsec_…: ele aparece uma vez só. Cada endpoint tem o seu.
O que chega
POST /webhooks/pagaki HTTP/1.1
Content-Type: application/json
Pagaki-Signature: t=1762191312,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_01jbz3q2w5e8r1t4y7k0m3n6p9",
"type": "payment.paid",
"created_at": "2026-11-03T17:35:12+00:00",
"livemode": false,
"data": { "object": { "id": "pay_01jbz3k8q4m7v2x9c5n6p0r1st", "object": "payment", "status": "paid", "amount": 12550, "amount_paid": 12550 } }
}
| Campo | Para que serve |
|---|---|
id |
evt_…, o mesmo em todas as tentativas: chave de deduplicação |
type |
payment.created, payment.paid, payment.expired, payment.canceled, payment.failed, payment.partially_refunded, payment.refunded |
livemode |
false no sandbox, true em produção. Recuse false no endpoint de produção |
data.object |
O objeto payment completo (no exemplo, resumido), já mascarado, como estava no momento do evento |
Verificar a assinatura
Pagaki-Signature tem o horário da tentativa (t, Unix em segundos) e uma ou mais assinaturas v1:
v1 = hex( HMAC_SHA256( segredo_do_endpoint, "{t}.{corpo_bruto}" ) )
Regras:
- Use o corpo bruto, exatamente como chegou, antes de qualquer parse de JSON. Reformatar o JSON muda a assinatura.
- Recuse se
|agora - t| > 300segundos (replay). - Compare em tempo constante.
- Aceite se qualquer
v1bater: por 24 h depois de você rotacionar o segredo, saem duas (a do segredo novo e a do anterior).
/** Devolve true se o webhook veio do Pagaki. $rawBody = file_get_contents('php://input'). */
function pagakiSignatureIsValid(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$timestamp = ctype_digit($value) ? (int) $value : null;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
import { createHmac, timingSafeEqual } from 'node:crypto';
/** rawBody: Buffer ou string com o corpo exatamente como chegou. */
export function pagakiSignatureIsValid(rawBody, header, secret, tolerance = 300) {
let timestamp = null;
const signatures = [];
for (const part of (header ?? '').split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1') signatures.push(value);
}
if (timestamp === null || signatures.length === 0 || Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
return false;
}
const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'));
return signatures.some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
Para conferir à mão (por exemplo, com um corpo salvo de um teste), em shell:
# body.json = o corpo exatamente como chegou; t = o valor de t= no header
t=1762191312
printf '%s.%s' "$t" "$(cat body.json)" | openssl dgst -sha256 -hmac "$PAGAKI_WEBHOOK_SECRET" -hex
# compare o resultado com o v1= do header
No Express, leia o corpo com
express.raw({ type: 'application/json' })na rota do webhook: oexpress.json()entrega o objeto já interpretado e a assinatura deixa de bater. No Laravel, use$request->getContent().
Responder rápido e processar depois
| Regra do Pagaki | O que isso pede de você |
|---|---|
Sucesso é 2xx em até 10 s (conexão em até 5 s) |
Valide, grave o evento e responda 200. Processe numa fila |
Redirecionamento (3xx) conta como falha |
Cadastre a URL final |
| Falhou: novas tentativas depois de 1 min, 5 min, 15 min, 1 h, 3 h, 6 h e 12 h (8 no total, ~22 h) | Um endpoint fora do ar por algumas horas não perde eventos |
| Esgotou: o evento fica na fila de falhas e pode ser reenviado pelo painel (até 30 dias) | Acompanhe as falhas no painel |
| Pelo menos uma vez, sem ordem garantida | Deduplique e não dependa da ordem |
Idempotência pelo ID do evento
O mesmo evento pode chegar mais de uma vez (reentrega, reenvio manual). Grave o id com uma restrição única e ignore o que já viu:
// $event = json_decode($rawBody, true), depois de validar a assinatura.
// Postgres com PDO.
$stmt = $pdo->prepare('insert into pagaki_events (id, type, received_at) values (?, ?, now()) on conflict (id) do nothing');
$stmt->execute([$event['id'], $event['type']]);
http_response_code(200); // responde já, mesmo se repetido
if ($stmt->rowCount() === 1) {
$queue->push('process-pagaki-event', $event); // processa fora da requisição
}
// Postgres com node-postgres; event = JSON.parse(rawBody), depois de validar a assinatura.
const { rowCount } = await db.query(
'insert into pagaki_events (id, type, received_at) values ($1, $2, now()) on conflict (id) do nothing',
[event.id, event.type],
);
res.sendStatus(200); // responde já, mesmo se repetido
if (rowCount === 1) await queue.add('process-pagaki-event', event);
E, como a ordem não é garantida:
- use o
statusdo objeto como verdade, não otypedo evento: umpayment.paidatrasado pode chegar depois de umpayment.expired, e o certo épaid; - na dúvida, consulte
GET /v1/payments/{id}; - trate
paiddepois deexpired,canceledoufailedcomo pagamento válido: o dinheiro entrou (veja as transições em referência da API).
Testar
- No sandbox, crie um PIX e faça-o mudar de situação (cancele, deixe expirar) para receber eventos reais. Ver ambientes.
- Planejado botão no painel que envia um evento de exemplo assinado para o seu endpoint.