Pular para o conteúdo
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:

  1. Use o corpo bruto, exatamente como chegou, antes de qualquer parse de JSON. Reformatar o JSON muda a assinatura.
  2. Recuse se |agora - t| > 300 segundos (replay).
  3. Compare em tempo constante.
  4. Aceite se qualquer v1 bater: 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;
}

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: o express.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
}

E, como a ordem não é garantida:

  • use o status do objeto como verdade, não o type do evento: um payment.paid atrasado pode chegar depois de um payment.expired, e o certo é paid;
  • na dúvida, consulte GET /v1/payments/{id};
  • trate paid depois de expired, canceled ou failed como 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.