Webhooks
Payload de webhook v2 (moderno)
v2 é o formato que recomendamos para todas as novas integrações. Usa um envelope baseado em eventos — um campo event no topo nomeando o tipo de evento, mais um objeto payload com os campos específicos do evento. Os nomes de campos são snake_case. Pagador e recebedor são sempre objetos aninhados separados, com o ISPB da instituição sempre preenchido.
Quatro vantagens concretas sobre v1:
- Roteamento é um switch em
event— sem mais inferência a partir de combinações detype+status. - ISPB e nome da instituição são campos de primeira classe. Conciliação contra extrato bancário vira comparação de string em vez de heurística.
- Infrações são seu próprio evento (
infraction.updated), não um objeto embutido. Você pode rotear para uma fila separada de handler de compliance. - Rastreio de origem (V2) — todo webhook de cash-in carrega
source_type+ IDs polimórficos (invoice_id,offer_id,product_id,quick_link_id) indicando exatamente de onde a transação veio (API direta, Invoice, Checkout de Offer, QuickLink). Sem precisar fazerGET /transactions/:idpra descobrir a origem.
Estrutura do envelope
Todo webhook v2 tem a mesma forma na raiz:
{
"event": "cashin.paid",
"environment": "LIVE",
"payload": { /* campos especificos do evento */ }
}
event— o tipo do evento (ex:cashin.paid). Use pra rotear no seu handler.environment—"TEST"(sandbox, chavesk_test_*) ou"LIVE"(produção). Use pra separar o processamento — ex: em TEST manda pra um canal de dev, em LIVE pro fluxo real.payload— os campos específicos do evento (em snake_case).
No seu handler, faça roteamento por event:
switch (webhook.event) {
// Cash-in (transações)
case "cashin.paid": return handleCashinPaid(webhook.payload);
case "cashin.refunded": return handleCashinRefunded(webhook.payload);
// Cash-out (saques)
case "cashout.success": return handleCashoutSuccess(webhook.payload);
case "cashout.failed": return handleCashoutFailed(webhook.payload);
case "cashout.returned": return handleCashoutReturned(webhook.payload);
// Disputas (chargeback + bloqueio de compliance dividem este evento)
case "infraction.updated": return handleInfraction(webhook.payload);
// Subscription lifecycle (assinaturas recorrentes)
case "subscription.created": return handleSubscriptionCreated(webhook);
case "subscription.activated": return handleSubscriptionActivated(webhook);
case "subscription.paused": return handleSubscriptionPaused(webhook);
case "subscription.canceled": return handleSubscriptionCanceled(webhook);
case "subscription.past_due": return handleSubscriptionPastDue(webhook);
case "subscription.expired": return handleSubscriptionExpired(webhook);
default:
logger.warn("unknown_webhook_event", { event: webhook.event });
// Retorne 200 mesmo assim — nao nos faca dar retry em algo que voce ainda nao processa
}
✅ Eventos
subscription.*usam o mesmo envelope dos demais eventos V2:{ event, environment, payload: {...} }, com os campos do recurso aninhados empayloade em snake_case (subscription_id,next_billing_at, etc). Veja os exemplos detalhados nas subseções abaixo.
Sempre inclua um branch default. Podemos adicionar novos tipos de evento no futuro, e você não quer que eventos desconhecidos quebrem seu handler e disparem nosso loop de retry.
cashin.paid
Disparado quando uma transação de cash-in é paga (PIX confirmado ou cartão autorizado e capturado).
{
"event": "cashin.paid",
"payload": {
"transaction_id": "17615714245971918718644287",
"external_id": "id-da-transacao-no-seu-sistema",
"amount": 1100,
"end_to_end_id": "E18236120202610271324s05499b347c",
"payer": {
"name": "João Silva",
"document": "99999999999",
"ispb": "19318318",
"institution": "NU PAGAMENTOS"
},
"receiver": {
"name": "Nome da sua empresa",
"document": "12345678000190",
"ispb": "18236120",
"institution": "Pagnovo"
},
"source_type": "API",
"invoice_id": null,
"offer_id": null,
"product_id": null,
"quick_link_id": null
}
}
transaction_id— nosso ID internoexternal_id— sua referência setada na criaçãoamount— centavosend_to_end_id— E2E do Bacen para conciliaçãopayer/receiver— dados institucionais.ispbé o identificador de 8 dígitos do Bacen;institutioné o nome legível.source_type— (V2) origem da transação. Um dos valores:API,INVOICE,CHECKOUT_OFFER,QUICK_LINK. Pode sernullem transações criadas antes do refator V2.invoice_id/offer_id/product_id/quick_link_id— (V2) IDs polimórficos. Apenas os campos relevantes aosource_typevêm preenchidos; o resto vemnull. Veja a seção Rastreio de origem (V2) abaixo pra entender o mapeamento.
cashin.refunded
Mesma forma de envelope e mesmos campos de payload de cashin.paid — o que muda é o que aconteceu. O status da transação mapeia para a tabela de status da v1:
| Evento v2 | Status equivalente em v1 |
|---|---|
cashin.paid | APPROVED |
cashin.refunded | REFUNDED |
infraction.updated | CHARGEBACK ou BLOCKED (veja seção abaixo) |
Em cashin.refunded: o estorno foi concluído com sucesso. Use pra atualizar receita e revogar acesso ao recurso (se aplicável).
Sobre CHARGEBACK e BLOCKED: V2 NÃO tem eventos próprios pra disputa de cartão ou bloqueio de compliance. Ambos os casos disparam o evento infraction.updated — veja a subseção dedicada abaixo. Os campos da disputa vêm no payload da infração, e o transaction_id relacionado também.
Uma observação sobre INCOSISTENT: v2 não tem evento próprio cashin.incosistent. Quando a transação subjacente entra no estado INCOSISTENT (valor pago ≠ valor cobrado), você recebe um evento cashin.paid com um campo status: "INCOSISTENT" embutido no payload. Trate como anomalia: não entregue, não estorne, abra um ticket com transaction_id e x-trace-id.
Sobre REJECTED: v2 NÃO emite nenhum evento quando a transação é rejeitada pelo PSP. Você só vê o status: REJECTED na resposta HTTP do POST /transactions/v2/purchase. Se você usa V1 (postbackUrl per-tx), o POST V1 sai com status: "REJECTED" no payload.
cashout.success / cashout.failed / cashout.returned
Eventos de cash-out usam o mesmo envelope. Recebedor e pagador são invertidos comparados ao cash-in — sua empresa agora é o pagador.
cashout.success (transferência concluída):
{
"event": "cashout.success",
"payload": {
"withdrawal_id": "17615714245971918718644287",
"external_id": "id-do-saque-no-seu-sistema",
"amount": 5000,
"end_to_end_id": "E18236120202610271324s05499b347c",
"receiver": {
"name": "Maria Souza",
"document": "98765432100",
"ispb": "60701190",
"institution": "ITAÚ UNIBANCO"
},
"payer": {
"name": "Nome da sua empresa",
"document": "12345678000190",
"ispb": "18236120",
"institution": "Pagnovo"
}
}
}
cashout.failed (rejeitada pela instituição do recebedor ou pelo SPI):
{
"event": "cashout.failed",
"payload": {
"withdrawal_id": "17615714245971918718644287",
"external_id": "id-do-saque-no-seu-sistema",
"amount": 5000,
"end_to_end_id": "E18236120202610271324s05499b347c",
"error_code": "INVALID_PIX_KEY",
"error_message": "Chave PIX não encontrada no DICT"
}
}
Em cashout.failed, os fundos retornam imediatamente para o seu saldo disponível. Nenhuma ação manual é necessária do seu lado, mas você deve notificar o usuário que o saque dele não passou.
cashout.returned (foi bem-sucedido inicialmente, depois voltou):
Mesma forma de payload de cashout.success, mas a instituição de destino devolveu os fundos — geralmente porque a conta foi encerrada ou sinalizada. Os fundos estão de volta no seu saldo. Investigue antes de tentar de novo.
Nunca faça retry de um saque sem o webhook final. Enquanto o status estiver em WITHDRAW_REQUEST ou WITHDRAW_PROCESSING, aguarde — disparar uma segunda chamada é a forma mais comum de empresas pagarem o mesmo destinatário duas vezes por acidente.
infraction.updated
Dispara sempre que uma infração muda de estado. Em v1 isso era embutido dentro de webhooks de transação; em v2 tem seu próprio evento, pra que você possa rotear pra um handler diferente (tipicamente uma fila de revisão de compliance).
{
"event": "infraction.updated",
"payload": {
"transaction_id": "17615714245971918718644287",
"external_id": "id-da-transacao-no-seu-sistema",
"amount": 1100,
"end_to_end_id": "E18236120202610271324s05499b347c",
"payer": {
"name": "João Silva",
"document": "99999999999",
"ispb": "19318318",
"institution": "NU PAGAMENTOS"
},
"receiver": {
"name": "Nome da sua empresa",
"document": "12345678000190",
"ispb": "18236120",
"institution": "Pagnovo"
},
"source_type": "CHECKOUT_OFFER",
"invoice_id": "inv_a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"offer_id": "off_d4c3b2a1-f6e5-b8a7-d0c9-f2e1b4a3d6c5",
"product_id": "prd_7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b",
"quick_link_id": null,
"infraction": {
"id": "dd0b2c77-8dd6-4eb5-b254-a46417eac46d",
"status": "AWAITING_CUSTOMER_RESPONSE",
"reason_details": "Pagador reportou a transação como não autorizada.",
"analysis_result": null,
"analysis_details": "Aguardando suas evidências.",
"created_at": "2026-04-08T14:30:00.000Z",
"closed_at": null,
"cancelled_at": null,
"response_at": null,
"defended_at": null
}
}
}
A semântica dos status é a mesma da v1 — veja a tabela de status de infração na documentação v1. Os campos passam para snake_case (created_at, reason_details, analysis_result).
Os campos de origem (source_type, invoice_id, offer_id, product_id, quick_link_id) vêm preenchidos igual em cashin.paid — útil pra rotear a infração pra equipe de compliance certa (ex: disputas em Invoice recorrente costumam exigir tratamento diferente de disputas em QuickLink avulso).
Valores finais de analysis_result:
AGREED— concordamos com a parte que abriu a disputa. A transação está sendo revertida.DISAGREED— rejeitamos a disputa. A transação se mantém.null— análise ainda não finalizada; o status estará emUNDER_REVIEWou anterior.
subscription.* (eventos de assinatura recorrente)
✅ Mesmo envelope dos eventos cashin/cashout:
subscription.*usa{ event, environment, payload: {...} }com os campos do recurso aninhados empayloade em snake_case (subscription_id,next_billing_at, etc.). Padronizado com os demais webhooks V2.
Os 6 eventos cobrem todas as transições de status do model Subscription. Subscriptions são criadas via POST /v2/subscriptions (standalone B2B) ou automaticamente quando alguém compra uma Offer recorrente em /o/:userSlug/:offerSlug.
subscription.created — disparado em qualquer subscription nova:
{
"event": "subscription.created",
"environment": "LIVE",
"payload": {
"subscription_id": "sub_8f2c1a9d",
"status": "ACTIVE",
"amount": 9990,
"billing_cycle": "MONTHLY",
"billing_cycle_count": 1,
"next_billing_at": "2026-06-28T00:00:00.000Z",
"customer_id": "cus_...",
"offer_id": "off_..."
}
}
statusno momento da criação (pode serTRIAL,ACTIVE, etc).- Se
status === "ACTIVE"no create, você recebesubscription.activatedtambém logo em seguida.
subscription.activated — disparado quando status vai pra ACTIVE:
Dispara em 2 cenários: criação direto pra ACTIVE, ou retomada após PAUSED.
{
"event": "subscription.activated",
"environment": "LIVE",
"payload": {
"subscription_id": "sub_8f2c1a9d",
"previous_status": null,
"next_billing_at": "2026-06-28T00:00:00.000Z"
}
}
previous_status vem null na criação direto pra ACTIVE, ou "PAUSED" se foi retomada.
subscription.paused — assinatura pausada:
{
"event": "subscription.paused",
"environment": "LIVE",
"payload": {
"subscription_id": "sub_8f2c1a9d",
"previous_status": "ACTIVE",
"paused_at": "2026-05-15T14:30:00.000Z"
}
}
Use pra pausar acesso ao recurso temporariamente. Quando vier subscription.activated depois, restaure.
subscription.canceled — encerrada definitivamente:
{
"event": "subscription.canceled",
"environment": "LIVE",
"payload": {
"subscription_id": "sub_8f2c1a9d",
"previous_status": "ACTIVE",
"canceled_at": "2026-05-15T14:30:00.000Z",
"canceled_reason": "Cliente solicitou via suporte",
"invoices_canceled": 3
}
}
invoices_canceled indica quantas faturas futuras (status PENDING/OVERDUE/DRAFT) foram canceladas automaticamente junto. Use pra revogar acesso na próxima data de billing (não imediatamente — cliente já pagou pelo ciclo atual).
subscription.past_due — cliente atrasou pagamento:
{
"event": "subscription.past_due",
"environment": "LIVE",
"payload": {
"subscription_id": "sub_8f2c1a9d",
"previous_status": "ACTIVE"
}
}
Dispara quando a invoice mais recente da subscription fica OVERDUE. Sistema marca a subscription como PAST_DUE. Use pra iniciar dunning — e-mails de cobrança, suspensão após N dias, etc. Se o cliente pagar a invoice depois, vem um subscription.activated (retomada).
subscription.expired — fim natural do contrato:
{
"event": "subscription.expired",
"environment": "LIVE",
"payload": {
"subscription_id": "sub_8f2c1a9d",
"previous_status": "ACTIVE"
}
}
Dispara quando maxCycles é atingido OU expiresAt passou. Diferente de canceled (que é intervenção manual), expired é o fim previsto do contrato. Não dispara mais nenhum cobrança depois disso.
📌 Idempotência: todos os
subscription.*são one-shot por transição. Marcar PAST_DUE 2x não dispara o evento de novo (lógica emSubscriptionsService.markPastDue). Cancel já cancelada também vira no-op. Usepayload.subscription_id+eventcomo chave única no seu handler.
Rastreio de origem (V2)
Todo webhook de cash-in (cashin.paid, cashin.refunded, infraction.updated) carrega cinco campos extras que indicam de onde a transação veio. Permite roteamento e reconciliação direta no seu lado, sem ter que fazer GET /transactions/:id pra descobrir a origem.
{
"source_type": "QUICK_LINK",
"invoice_id": null,
"offer_id": null,
"product_id": null,
"quick_link_id": "ql_8f2c1a9d-3e4b-4a76-b8e1-9c0d2e3f4a5b"
}
Mapeamento source_type → IDs preenchidos:
source_type | Origem | IDs presentes (não-null) |
|---|---|---|
API | Criada via POST /transactions direto | nenhum |
INVOICE | Cobrança avulsa ou parcela recorrente, paga em /i/:invoiceId | invoice_id (+ offer_id e product_id se a Invoice veio de uma Offer) |
CHECKOUT_OFFER | Compra em /o/:userSlug/:offerSlug (catálogo público) | offer_id, product_id (+ invoice_id quando a Offer é recorrente) |
QUICK_LINK | Pagamento em /p/:userSlug/:linkSlug (link rápido) | quick_link_id |
Por que isso ajuda na prática:
- Reconciliação direta. Se você guarda
offer_id/quick_link_idno seu banco, dá pra fazer JOIN do webhook sem precisar de uma chamada à nossa API. - Roteamento por origem. Tratamento de infração em Invoice recorrente costuma ser diferente do tratamento em QuickLink (B2B vs campanha avulsa). Routeie por
source_typeantes de processar. - Métricas. Permite separar receita por canal (catálogo vs link rápido vs API direta) só com os webhooks.
ℹ️ Transações pré-V2: transações criadas antes do refator V2 podem chegar com
source_type: nulle todos os IDsnull. Trate onullcomo equivalente aAPI— origem desconhecida.
⚠️ Não confie só no
source_typepra decidir. Sempre cheque qual ID veio populado antes de fazer lookup — algumas combinações têm múltiplos IDs (ex:CHECKOUT_OFFERrecorrente trazoffer_id+product_id+invoice_id).
Observações operacionais
- Todos os valores estão em centavos.
1100é R$ 11,00, nunca R$ 1100,00. - Todos os timestamps são ISO 8601 em UTC.
external_idé a sua referência de negócio setada na criação. Use como sua chave de join ao persistir dados de webhook.end_to_end_idé único em todo o ecossistema PIX brasileiro e nunca colide. Use para conciliar com extrato bancário.- Reentregamos entregas que falharam com backoff exponencial por até 24 horas. Faça seu handler idempotente.
- Persista o
x-trace-idde toda entrega de webhook junto com a linha que você criar. É o caminho mais rápido pra investigar qualquer coisa estranha depois. - (V2) Quando precisar saber a origem da transação (Invoice/Offer/QuickLink/API direta), use os campos
source_type+ IDs polimórficos em vez de fazerGET /transactions/:id. Veja Rastreio de origem (V2).