PagnovoPagnovodocs

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:

  1. Roteamento é um switch em event — sem mais inferência a partir de combinações de type + status.
  2. 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.
  3. 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.
  4. 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 fazer GET /transactions/:id pra descobrir a origem.

Estrutura do envelope

Todo webhook v2 tem a mesma forma na raiz:

JSON
{
  "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, chave sk_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:

TypeScript
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 em payload e 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).

JSON
{
  "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 interno
  • external_id — sua referência setada na criação
  • amount — centavos
  • end_to_end_id — E2E do Bacen para conciliação
  • payer / 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 ser null em transações criadas antes do refator V2.
  • invoice_id / offer_id / product_id / quick_link_id(V2) IDs polimórficos. Apenas os campos relevantes ao source_type vêm preenchidos; o resto vem null. 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 v2Status equivalente em v1
cashin.paidAPPROVED
cashin.refundedREFUNDED
infraction.updatedCHARGEBACK 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):

JSON
{
  "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):

JSON
{
  "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).

JSON
{
  "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á em UNDER_REVIEW ou anterior.

subscription.* (eventos de assinatura recorrente)

Mesmo envelope dos eventos cashin/cashout: subscription.* usa { event, environment, payload: {...} } com os campos do recurso aninhados em payload e 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:

JSON
{
  "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_..."
  }
}
  • status no momento da criação (pode ser TRIAL, ACTIVE, etc).
  • Se status === "ACTIVE" no create, você recebe subscription.activated també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.

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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 em SubscriptionsService.markPastDue). Cancel já cancelada também vira no-op. Use payload.subscription_id + event como 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.

JSON
{
  "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_typeOrigemIDs presentes (não-null)
APICriada via POST /transactions diretonenhum
INVOICECobrança avulsa ou parcela recorrente, paga em /i/:invoiceIdinvoice_id (+ offer_id e product_id se a Invoice veio de uma Offer)
CHECKOUT_OFFERCompra em /o/:userSlug/:offerSlug (catálogo público)offer_id, product_id (+ invoice_id quando a Offer é recorrente)
QUICK_LINKPagamento 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_id no 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_type antes 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: null e todos os IDs null. Trate o null como equivalente a API — origem desconhecida.

⚠️ Não confie só no source_type pra decidir. Sempre cheque qual ID veio populado antes de fazer lookup — algumas combinações têm múltiplos IDs (ex: CHECKOUT_OFFER recorrente traz offer_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-id de 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 fazer GET /transactions/:id. Veja Rastreio de origem (V2).