Webhooks
Notificações assíncronas de eventos — a única forma confiável de saber que um PIX/boleto foi pago (não faça polling em produção).
Configurar
Backoffice → Webhooks → Novo endpoint (informe URL + um secret, usado pra assinar
cada entrega) → inscreva-se nos tipos de evento que te interessam. Não há um endpoint público
em /v1 pra isso nesta entrega — é uma operação de configuração, feita uma vez, via
Backoffice/API admin.
Tipos de evento
Só os eventos abaixo são realmente disparados hoje — cada um foi conferido direto no código
(não é uma lista de aspiração). customer.created, merchant.created, payment.authorized,
payment.processing e payment.cancelled existem como tipo reservado no sistema, mas nenhum
lugar do domínio ainda os dispara — não assine, você nunca vai receber nada.
| Evento | Quando dispara |
|---|---|
checkout.started | Cliente abriu o checkout. |
checkout.completed | Checkout terminou (pago ou falhou). |
payment.created | Pagamento submetido (cliente escolheu método e confirmou). |
payment.paid | Aprovado — qualquer método. Dispara junto com pix.paid quando o método é PIX. |
payment.failed | Recusado ou erro. |
payment.refunded | Estornado — pela API (POST /v1/pix/charges/:id/refund, POST /v1/payments/:id/refund) ou pelo Backoffice. |
pix.created | Cobrança PIX emitida. |
pix.paid | PIX pago. |
pix.expired | PIX expirou sem pagamento. |
boleto.created | Boleto emitido. |
boleto.paid | Boleto compensado. |
settlement.created | Repasse agendado. |
settlement.paid | Repasse processado. |
pix.dispute_opened | Disputa MED aberta contra um pagamento seu — ver PIX disputes (MED). |
pix.dispute_resolved | Disputa MED resolvida (aceita ou recusada) por um admin ou pelo provider. |
payout.created | Saque PIX solicitado (inclusive quando fica pendente de aprovação). |
payout.paid | Saque confirmado — na hora (provider síncrono) ou depois, via webhook do provider (provider assíncrono). |
payout.failed | Saque falhou — ou foi devolvido pelo banco do recebedor depois de pago (ver exemplos abaixo). |
Um saque que fica processing (provider assíncrono que ainda não confirmou) não dispara
payout.paid nem payout.failed — só quando o desfecho real chega. Um saque da própria
plataforma (sem tenant dono) nunca dispara nenhum evento — não há pra quem entregar.
Payload
O corpo da entrega é o evento — sem wrapper. Todo evento carrega esse envelope comum:
| Campo | Sempre presente? | Conteúdo |
|---|---|---|
id | sim | Id único deste evento — use pra dedupe (ver "Duplicidade e ordem"). |
type | sim | Ex.: pix.paid. |
tenantId | sim | Tenant dono do evento. |
merchantId | quando aplicável | Ausente em eventos de nível tenant (ex. settlement.*). |
occurredAt | sim | ISO 8601, quando o evento aconteceu no Gateway. |
correlationId | quando disponível | Pra correlacionar com o request original que originou o evento. |
O resto do payload (o que aparece além do envelope) varia por evento — abaixo, o formato real de cada família, direto do código que publica (nunca um formato "ideal" nunca implementado).
Em todos os eventos de cobrança — payment.*, pix.* (created/paid/expired),
boleto.*, payment.refunded e os de MED (pix.dispute_opened/pix.dispute_resolved) — chega
também o externalId: o id do pedido que você mandou ao criar a cobrança (POST /v1/pix/charges,
POST /v1/payments ou POST /v1/boletos; null se não mandou). Use-o pra casar o evento com o
pedido do seu lado sem precisar guardar o nosso id.
payment.* / pix.* / boleto.* confirmados via webhook do provider
Este é o caminho de PIX/boleto: o provider confirma o pagamento via webhook próprio, o Gateway processa e publica. Payload mais completo do sistema:
| Campo | Conteúdo |
|---|---|
checkoutSessionId | Id da sessão de checkout. |
externalId | Id do pedido no seu sistema, como enviado na criação da cobrança (null se não enviado). |
status | Status do pagamento no momento do evento (paid, expired, ...). |
paymentMethod | pix | boleto. |
amountInCents | Valor bruto da cobrança. |
currency | Sempre BRL nesta entrega. |
customerId | Id do cliente, quando o checkout capturou um. |
payerDocument | CPF/CNPJ que o checkout informou na criação da cobrança (pode ser diferente de quem pagou de fato — para isso, use payer). |
payer | Só em pix.paid/payment.paid de PIX: quem pagou de fato, como o banco/PSP informou — nome, CPF/CNPJ, ISPB, banco, agência, conta. Ver tabela abaixo. |
pix | { txid, endToEndId, paidAt } — só em pix.paid/pix.expired. endToEndId/paidAt só existem depois do pagamento confirmado. |
boleto | { ourNumber } — só em boleto.paid. |
{
"id": "8f0e1c2a-...",
"type": "pix.paid",
"tenantId": "961c52b2-...",
"merchantId": "0d303c3b-...",
"occurredAt": "2026-09-04T22:01:06.707Z",
"checkoutSessionId": "cf810b10-...",
"externalId": "pedido-12345",
"status": "paid",
"paymentMethod": "pix",
"amountInCents": 5000,
"currency": "BRL",
"customerId": "cust_abc123",
"payerDocument": "12345678909",
"payer": {
"name": "Maria da Silva",
"document": "12345678909",
"documentType": "cpf",
"ispb": "00360305",
"bankName": "Caixa Econômica Federal",
"branch": "0001",
"account": "12345-6",
"accountType": "CACC"
},
"pix": {
"txid": "1500700188",
"endToEndId": "E58160789202609042129cgTvo93yroq",
"paidAt": "2026-09-04T22:01:05.000Z"
}
}
Objeto payer (quem pagou o PIX)
Os dados vêm do banco/PSP de quem pagou, repassados como o provider informou. Cada campo só aparece quando o provider o envia — nunca é inventado nem completado com o que o checkout pediu. Nome, CPF/CNPJ e ISPB vêm em praticamente todo pagamento; agência e conta dependem do PSP.
| Campo | Conteúdo |
|---|---|
name | Nome do titular da conta pagadora. |
document | CPF ou CNPJ do pagador, só dígitos. |
documentType | cpf | cnpj. |
ispb | ISPB (8 dígitos) da instituição do pagador. |
bankName | Nome da instituição do pagador. |
bankCode | Código COMPE (3 dígitos), quando informado. |
branch | Agência do pagador. |
account | Conta do pagador (com dígito, como o banco informou). |
accountType | Tipo da conta como o banco informou (ex.: CACC corrente, SVGS poupança, TRAN conta de pagamento). |
Os mesmos dados ficam gravados na cobrança: GET /v1/pix/charges/:id (e GET /v1/payments/:id)
devolve metadata.pixPayer e metadata.pixPaidAt depois do pagamento.
Por privacidade, nenhum webhook informa qual provider processou a transação.
payment.paid chega junto com pix.paid/boleto.paid — mesmo envelope, dois eventos pra
cada pagamento aprovado por PIX/boleto: assine só payment.paid se você não se importa com o
método, ou o específico se precisa (ex. endToEndId só existe em pix.paid). pix.expired
pode chegar com um payload mais enxuto (só checkoutSessionId/externalId/status/paymentMethod/
amountInCents/currency, sem customerId/payerDocument/payer/pix) quando a
expiração é detectada por varredura de tempo em vez de um webhook do provider — nunca assuma
que esses campos extras vêm preenchidos num pix.expired.
payment.paid / pix.paid confirmados de forma síncrona (cartão)
Cartão de crédito/débito confirma na hora, sem esperar um webhook do provider — o payload é diferente do formato acima, montado a partir do carrinho, não da sessão:
| Campo | Conteúdo |
|---|---|
checkoutSessionId | Id da sessão de checkout. |
transactionId | Id da transação no provider de cartão. |
valueInCents | Valor pago (mesmo valor de amountInCents no outro formato, nome diferente por vir de outro código). |
currency | Sempre BRL nesta entrega. |
items | [{ id, name, quantity, unitAmountInCents }] — os itens do carrinho, quando informados na criação do checkout. |
paymentMethod | credit_card | debit_card | pix (ver nota abaixo). |
Sim — os dois formatos de payment.paid/pix.paid existem de verdade (dois pontos
diferentes do código publicam o mesmo tipo de evento, um pra confirmação síncrona, outro pra
confirmação via webhook do provider). Se seu handler espera amountInCents, trate
valueInCents como alternativa (ou vice-versa) em vez de assumir que só um formato chega.
payment.created / pix.created / boleto.created
Payload mínimo — só o suficiente pra saber que algo nasceu, sem repetir dados que
GET /v1/payments/:id já devolve:
{ "checkoutSessionId": "cf810b10-...", "externalId": "pedido-12345" }
payment.created pode vir com metadata adicional (ex. method) quando disponível; pix.created/
boleto.created não trazem mais nada além do checkoutSessionId e do externalId.
payment.failed
Mesmo payload mínimo de payment.created ({ checkoutSessionId, externalId }, mais metadata quando
disponível) — para o motivo específico da recusa, consulte GET /v1/payments/:id.
payment.refunded
{
"id": "b7d2e4f1-9a3c-4e5b-8d6f-1a2b3c4d5e6f",
"type": "payment.refunded",
"tenantId": "961c52b2-...",
"merchantId": "0d303c3b-...",
"occurredAt": "2026-10-27T14:10:05.120Z",
"paymentId": "cf810b10-...",
"externalId": "pedido-12345"
}
Só o id do pagamento estornado (e o externalId da cobrança). O estorno é sempre total; quando e quanto ficam em
metadata.refundedAt / metadata.refundedAmountInCents de GET /v1/pix/charges/:id (ou
GET /v1/payments/:id). O status do pagamento continua paid.
checkout.started / checkout.completed
{ "checkoutSessionId": "cf810b10-..." }
checkout.completed dispara tanto pra um checkout que terminou pago quanto pra um que falhou —
não use como sinônimo de "pago" (para isso, assine payment.paid).
settlement.created / settlement.paid
| Campo | Conteúdo |
|---|---|
settlementId | Id do repasse. |
totalAmountInCents | Valor total do repasse. |
{ "settlementId": "8b2f...", "totalAmountInCents": 150000 }
pix.dispute_opened
{
"pixInfractionId": "d4a1...",
"providerInfractionId": "med-9f21...",
"endToEndId": "E58160789202609042129cgTvo93yroq",
"infractionType": "REFUND_REQUEST",
"infractionSituationType": null,
"amountInCents": 5000,
"checkoutSessionId": "cf810b10-...",
"externalId": "pedido-12345"
}
O valor de amountInCents já foi bloqueado preventivamente na sua conta (ver
PIX disputes (MED)) — este evento é seu aviso pra investigar antes de um admin
responder a disputa.
pix.dispute_resolved
{
"pixInfractionId": "d4a1...",
"providerInfractionId": "med-9f21...",
"endToEndId": "E58160789202609042129cgTvo93yroq",
"outcome": "accepted",
"amountInCents": 5000,
"checkoutSessionId": "cf810b10-...",
"externalId": "pedido-12345"
}
Como em pix.dispute_opened, vêm também o checkoutSessionId e o externalId da cobrança contestada.
outcome: "accepted" — a disputa foi aceita, o valor bloqueado saiu de vez pra conta do
provider (nunca volta). "rejected" — a disputa foi recusada, o valor foi liberado de volta pro
seu saldo.
payout.created / payout.paid / payout.failed
O payload é o mesmo formato de GET /v1/payouts/:id (ver Payouts — todos os
campos explicados lá), mais payoutId e o envelope do evento. Atenção: no webhook, id é o id do
evento (o envelope); o id do saque vem em payoutId.
Em payout.paid chegam também o endToEndId (comprovante do Pix no Bacen) e o objeto
recipient — o titular da conta que recebeu, como o banco informou: nome, CPF/CNPJ (document,
só dígitos, e documentType), ISPB, nome do banco e, quando o banco informa, agência, conta e tipo de
conta. Cada campo de recipient só aparece quando o banco do recebedor o envia. failureReason vem
em payout.failed.
Em todos os eventos de saque chega também o externalId — o id do pedido que você mandou
no POST /v1/payouts (null se não mandou). Use-o pra casar o evento com o pedido do seu lado sem
precisar guardar o nosso id.
{
"id": "3f7a2c91-5b0e-4d8a-a1f3-6c2e9b7d4a10",
"type": "payout.paid",
"tenantId": "961c52b2-8fb7-48f4-9717-4d842412ab19",
"merchantId": "0d303c3b-cb6d-417e-8aa7-2a5cc8fde653",
"occurredAt": "2026-10-26T21:02:48.700Z",
"payoutId": "8f1c2a4e-3b7d-4c1a-9e2f-5d6a7b8c9d0e",
"object": "payout",
"externalId": "saque-2026-000123",
"status": "paid",
"amountInCents": 51675,
"feeAmountInCents": 517,
"netAmountInCents": 51158,
"feeMode": "deducted",
"currency": "BRL",
"pixKey": "11222333000181",
"pixKeyType": "cnpj",
"recipientDocument": "11222333000181",
"endToEndId": "E60701190202610262102abcdef123456",
"recipient": {
"name": "Fornecedor Exemplo LTDA",
"document": "11222333000181",
"documentType": "cnpj",
"ispb": "60701190",
"bankName": "Itaú Unibanco"
},
"failureReason": null,
"approvedAt": null,
"createdAt": "2026-10-26T21:02:42.926Z",
"updatedAt": "2026-10-26T21:02:48.610Z",
"processedAt": "2026-10-26T21:02:48.610Z"
}
payout.failed — saque que falhou
O saque não foi efetivado: status: "failed", motivo em failureReason, e o valor já voltou pro
seu saldo. endToEndId e recipient vêm null — o dinheiro não chegou a sair.
{
"id": "5c9e1a7b-2d4f-4b8e-9a1c-3e5f7a9b1d2c",
"type": "payout.failed",
"tenantId": "961c52b2-8fb7-48f4-9717-4d842412ab19",
"merchantId": "0d303c3b-cb6d-417e-8aa7-2a5cc8fde653",
"occurredAt": "2026-10-27T16:05:21.340Z",
"payoutId": "8f1c2a4e-3b7d-4c1a-9e2f-5d6a7b8c9d0e",
"object": "payout",
"externalId": "saque-2026-000123",
"status": "failed",
"amountInCents": 51675,
"feeAmountInCents": 517,
"netAmountInCents": 51158,
"feeMode": "deducted",
"currency": "BRL",
"pixKey": "11222333000181",
"pixKeyType": "cnpj",
"recipientDocument": "11222333000181",
"endToEndId": null,
"recipient": null,
"failureReason": "Pagamento rejeitado pelo PSP do recebedor",
"approvedAt": null,
"createdAt": "2026-10-27T16:05:18.002Z",
"updatedAt": "2026-10-27T16:05:21.310Z",
"processedAt": null
}
payout.failed — saque devolvido depois de pago
Quando o banco do recebedor devolve um saque que já tinha sido pago (ex.: Pix duplicado, conta
encerrada), você recebe primeiro o payout.paid e, depois, um payout.failed com
status: "cancelled". O valor devolvido volta pro seu saldo e failureReason explica o motivo;
endToEndId e recipient continuam os do envio original. Não existe um evento separado de
devolução — é sempre payout.failed, e o status diz qual dos dois casos é.
{
"id": "9a3b5c7d-1e2f-4a6b-8c0d-2e4f6a8b0c1d",
"type": "payout.failed",
"tenantId": "961c52b2-8fb7-48f4-9717-4d842412ab19",
"merchantId": "0d303c3b-cb6d-417e-8aa7-2a5cc8fde653",
"occurredAt": "2026-10-27T18:40:02.915Z",
"payoutId": "8f1c2a4e-3b7d-4c1a-9e2f-5d6a7b8c9d0e",
"object": "payout",
"externalId": "saque-2026-000123",
"status": "cancelled",
"amountInCents": 51675,
"feeAmountInCents": 517,
"netAmountInCents": 51158,
"feeMode": "deducted",
"currency": "BRL",
"pixKey": "11222333000181",
"pixKeyType": "cnpj",
"recipientDocument": "11222333000181",
"endToEndId": "E60701190202610271605abcdef123456",
"recipient": {
"name": "Fornecedor Exemplo LTDA",
"document": "11222333000181",
"documentType": "cnpj",
"ispb": "60701190",
"bankName": "Itaú Unibanco"
},
"failureReason": "Devolvido pelo banco do recebedor — Devolucao pix duplicado (51158 centavos creditados de volta).",
"approvedAt": null,
"createdAt": "2026-10-27T16:05:18.002Z",
"updatedAt": "2026-10-27T18:40:02.880Z",
"processedAt": "2026-10-27T16:05:21.310Z"
}
- Ache o saque do seu lado por
externalId(o seu id do pedido) ou porpayoutId(oidé o do evento) e decida pelostatus—failureReasoné um texto pra pessoas e pode mudar. - Com alguns bancos, uma falha informada passa por uma revisão da nossa equipe antes de ser
confirmada: o saque fica
processinge opayout.failedsó chega depois da confirmação.
Headers de assinatura
Toda entrega chega com 4 headers:
| Header | Conteúdo |
|---|---|
X-Gateway-Signature | HMAC-SHA256 hex de ${timestamp}.${rawBody} |
X-Gateway-Timestamp | Unix timestamp da assinatura |
X-Gateway-Event-Id | Id do evento (mesmo de payload.id) |
X-Gateway-Delivery-Id | Id desta tentativa específica de entrega |
Verificar a assinatura
Sempre use o corpo cru da request (nunca req.body já parseado, que pode re-serializar o
JSON e invalidar a assinatura):
import { verifySignature } from '@gateway/sdk';
app.post('/webhooks/gateway', (req, res) => {
const ok = verifySignature({
payload: req.rawBody,
timestamp: req.headers['x-gateway-timestamp'],
signature: req.headers['x-gateway-signature'],
secret: process.env.GATEWAY_WEBHOOK_SECRET!,
});
if (!ok) return res.status(401).end();
const event = JSON.parse(req.rawBody);
// ...
res.status(200).end();
});
$ok = $webhooks->verifySignature(
file_get_contents('php://input'),
$_SERVER['HTTP_X_GATEWAY_TIMESTAMP'],
$_SERVER['HTTP_X_GATEWAY_SIGNATURE'],
getenv('GATEWAY_WEBHOOK_SECRET'),
);
Sem SDK
O texto assinado é o timestamp, um ponto e o corpo cru: ${timestamp}.${rawBody}. Exemplo:
com X-Gateway-Timestamp: 1730000000 e o corpo {"id":"evt_1"}, o texto é exatamente
1730000000.{"id":"evt_1"}. Calcule o HMAC-SHA256 dele com o secret do endpoint, em hexadecimal,
e compare com X-Gateway-Signature (em tempo constante):
import { createHmac, timingSafeEqual } from 'node:crypto';
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const ok = expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$ok = hash_equals($expected, $signature);
Duplicidade e ordem
O mesmo evento pode chegar mais de uma vez (retry de rede do nosso lado, ou reenvio) —
sempre trate pelo id do evento (idempotente do seu lado: já processou esse id? ignore).
Eventos também não chegam necessariamente na ordem em que aconteceram — nunca assuma que
payment.created chega antes de payment.paid; trate cada evento como um snapshot de
status, não como um passo sequencial.
Retry e dead letter
Uma entrega que falhar (timeout, 4xx/5xx da sua URL) é tentada de novo com backoff
crescente: 1min, 5min, 15min, 1h, 6h, 24h. Depois da última tentativa sem sucesso, a entrega
vai pra dead letter — visível no Backoffice, com replay manual disponível (reprocessa
sob demanda, sem esperar o schedule).
Responda rápido
Devolva 2xx assim que tiver validado a assinatura e enfileirado o processamento
internamente — não faça o processamento pesado (chamadas a outros serviços, etc.) antes de
responder; isso conta como timeout do lado do Gateway e aciona um retry desnecessário.