Catalisa.Building Blocks
Catálogo/Financeiro/Payments

Payments

Beta

Aceite cartão, Pix e boleto por mais de um adquirente com uma API só

26
Endpoints
7
Entidades
2
Provedores
Tenant
Escopo
3027
Porta
2026-03
Desde

Você conecta um adquirente hoje e outro no mês que vem sem reescrever o checkout. O Payments fala com todos eles pelo mesmo contrato, e trocar de provedor vira configuração — não projeto.

Para quem é
  • Fintechs e financeiras que cobram tarifa, parcela ou prêmio e hoje dependem de um único adquirente
  • Plataformas B2B e marketplaces que precisam cobrar do cliente final sem virar processadora de pagamento
  • Operações de varejo e serviços que querem Pix e cartão no mesmo fluxo, com conciliação em um lugar só
Substitui
  • Uma integração ponto a ponto por adquirente, mantida à mão dentro do seu produto
  • Assinatura de um orquestrador de pagamentos externo (Malga, Yuno, Spreedly)
  • Painel caseiro de conciliação e taxa de aprovação montado em planilha
O que não é
  • Uma instituição de pagamento — a Catalisa não é adquirente, não liquida e não guarda saldo
  • Conta bancária, transferência ou Pix de saída — isso é o building block BaaS
  • Um antifraude ou motor de decisão de risco transacional
  • Uma página de checkout pronta — o BB devolve o intent e a URL, a interface é sua
O que dá para fazer

40 endpoints em 8 recursos.

Explorar a API →
01

Resumo executivo

O Payments é a camada que recebe dinheiro do seu cliente. Ele conversa com os gateways de pagamento — hoje Stripe e PagSeguro — por trás de um contrato único, guarda o histórico de cada cobrança e devolve para o seu produto uma resposta que não muda quando o gateway muda.

Na prática, isso significa que a decisão "vamos trocar de adquirente porque a taxa ficou melhor" deixa de ser um projeto de dois meses e vira uma chamada de configuração. Você cadastra o provedor novo, marca como padrão, e os pagamentos seguintes saem por ele. Os antigos continuam consultáveis, com o provedor de origem gravado em cada registro.

Está em beta desde março de 2026. O que já roda ponta a ponta é o ciclo de cobrança com Stripe, o link de pagamento, o estorno, a recepção de webhook com verificação de assinatura e — desde setembro de 2026 — o cartão salvo com cobrança sem o cliente presente (off_session), que é a base da renovação automática de plano. O que ainda não está completo está listado, sem rodeio, na seção 15.

AtributoValor
Identificadorpayments
CategoriaFinanceiro
EscopoTenant (exige organizationId no token)
Porta (standalone)3027
Path alias@payments
Prefixo HTTP/payments
Schema do bancopayments
StatusBeta desde 2026-03
Depende dePostgreSQL, Redis, gateway de pagamento do cliente

02

O problema

negócio

O cenário. Uma empresa precisa receber dinheiro pela internet. Cartão de crédito, Pix, às vezes boleto. Ela escolhe um gateway, integra, e por seis meses está tudo bem. Depois começam as perguntas que o gateway não responde: por que essa fatia de transações foi recusada? dá para tentar de novo em outro adquirente? quanto essa taxa está custando por ano? o que acontece se o gateway cair na Black Friday?

flowchart LR
  A["Escolhe um gateway"] --> B["Integra o SDK no produto"]
  B --> C["Seis meses tranquilos"]
  C --> D{"Chegam as perguntas<br/>que o gateway não responde"}
  D --> D1["Por que recusou?"]
  D --> D2["Dá para tentar em outro?"]
  D --> D3["Quanto a taxa custa por ano?"]
  D --> D4["E se ele cair na Black Friday?"]
  D1 --> E["Nenhuma delas tem resposta<br/>dentro do painel do gateway"]
  D2 --> E
  D3 --> E
  D4 --> E

O que trava hoje.

  • A integração é ponto a ponto e não se desfaz. O SDK do gateway espalha tipos, status e nomes de campo pelo código inteiro. Trocar de provedor significa achar todos eles. É por isso que empresas continuam pagando taxa ruim: sair custa mais que ficar.
  • Cada gateway inventou o próprio vocabulário. requires_capture no Stripe, WAITING no PagSeguro, pending em outro. O seu código acaba com um switch gigante que ninguém quer tocar, e cada provedor novo acrescenta um ramo.
  • Um adquirente é um ponto único de falha e um único poder de negociação. Se ele recusa a transação, a venda acabou — não há segunda tentativa. E na hora de renegociar taxa, quem só tem um fornecedor não tem argumento.
  • Webhook é sempre a parte que quebra. Verificar assinatura, tratar evento duplicado, guardar o payload cru para investigar depois: cada integração reimplementa isso, e quase sempre reimplementa errado na primeira vez.
  • Nenhum gateway te mostra o que você precisa ver. O painel dele mostra o que passou por ele. A pergunta que importa — qual provedor está aprovando mais, com que ticket, com quanto de estorno — só existe se você juntar os dados fora.

O custo de não resolver. Ele aparece em três linhas, e duas delas têm ordem de grandeza pública.

Linha do custoOrdem de grandezaFonte
Venda que morre no primeiro "não"US$ 157 bi em risco, US$ 81 bi de perda permanente (EUA, 2023)PYMNTS/Spreedly, 14/01/2025
Cliente que não volta depois do declínio indevido33% dos consumidoresStripe, State of online fraud
Taxa que você não renegocia0,99% a 4,99% conforme meio e prazoTabelas públicas, consulta em 2026-08-16
Engenharia gasta por gatewaySem estatística pública — semanas por integraçãoEstimativa, não medida

Primeira linha: a venda que morre no primeiro "não"

Um levantamento PYMNTS/Spreedly estimou US$ 157 bilhões em vendas de e-commerce nos Estados Unidos em risco por declínios indevidos em 2023, dos quais US$ 81 bilhões viraram perda permanente — e apontou que 82% dos varejistas não conseguem identificar a causa da falha (PYMNTS, 14/01/2025). A Stripe reporta que 33% dos consumidores não voltam depois de um declínio indevido (Stripe, State of online fraud). Com um adquirente só, não existe segunda tentativa.

flowchart LR
  C["Cliente tenta pagar"] --> G["Adquirente único"]
  G -->|aprovado| OK["Venda concluída"]
  G -->|recusado| X["Fim do caminho"]
  X --> P1["Sem segunda tentativa<br/>em outro adquirente"]
  X --> P2["33% não voltam<br/>Stripe"]

Segunda linha: a taxa que você não renegocia

Você não renegocia porque não tem alternativa pronta. Nas tabelas públicas brasileiras, um ponto percentual de MDR é dinheiro visível: o PagBank publica crédito à vista de 3,19% a 4,99% conforme o prazo de repasse, e a Cielo publica 3,45% no crédito à vista do plano de aluguel — enquanto o Pix aparece a 0,99% na Cielo e 1,19% na Stripe (tabelas consultadas em 2026-08-16). Sobre volume anual, essa diferença é a linha inteira de um orçamento.

Terceira linha: a engenharia que não vira produto

Essa não tem estatística e todo diretor de engenharia reconhece: as semanas gastas integrando cada gateway, e as seguintes mantendo, em vez de trabalhar no produto que o cliente compra.


03

Proposta de valor

negócio
AntesDepois
Um SDK de gateway espalhado pelo código do produtoUma interface PaymentProvider — o produto nunca importa o SDK
Trocar de adquirente é reescrever a integraçãoTrocar de adquirente é POST /provider-configs + set-default
switch de status por gateway em cada serviçoStatus canônico único, mapeado uma vez dentro do provider
Webhook com assinatura, dedup e log reimplementados a cada integraçãoUm endpoint por provedor, com verificação, deduplicação e payload cru guardado
Relatório de aprovação em planilha, por gatewayGET /analytics/metrics sobre todos os provedores da organização

O contrato é nosso, não do gateway

Todo provedor implementa a mesma interface — criar intent, cobrar, capturar, estornar, gerar link, ler webhook. O código do seu produto fala com essa interface. O gateway é detalhe de configuração, e detalhe de configuração se troca.

flowchart TD
  APP["Seu produto"] --> API["API do Payments<br/>contrato canônico"]
  API --> IFACE["Interface PaymentProvider"]
  IFACE --> S["StripeProvider"]
  IFACE --> P["PagSeguroProvider"]
  IFACE --> L["LinkProvider"]
  IFACE --> M["MockPaymentProvider"]
  S --> GW["Gateway externo"]
  P --> GW
  IFACE -.->|"gateway novo = 1 classe,<br/>nada acima muda"| N["Provedor futuro"]

Mais de um adquirente na mesma organização

PaymentProviderConfig é por organização e não é único: você pode ter Stripe e PagSeguro ativos ao mesmo tempo, um marcado como padrão, e escolher o outro por transação passando configId. Isso é o que abre a porta para redundância e para negociação com dois fornecedores.

flowchart LR
  ORG["Organização"] --> C1["Config A — STRIPE<br/>isDefault: true"]
  ORG --> C2["Config B — PAGSEGURO<br/>isDefault: false"]
  I1["POST /intents<br/>sem configId"] --> C1
  I2["POST /intents<br/>configId da Config B"] --> C2
  SD["POST /provider-configs/:id/set-default"] -.->|"troca o padrão sem deploy"| C2

Pix, cartão e boleto pelo mesmo fluxo

O método é um campo do intent (PIX, CREDIT_CARD, BOLETO, ...), não uma integração separada. Quando o provedor devolve QR code ou linha digitável, isso volta nos mesmos campos canônicos.

Método no intentO que volta em providerData
PIXpixQrCode
BOLETOboletoUrl, e boletoBarcode na transação
CREDIT_CARD / DEBIT_CARDclientSecret, e cardBrand / cardLast4 na transação
PAYMENT_LINKcheckoutUrl

O pagamento sabe de onde veio

O intent carrega billingInvoiceId e commerceOrderId. Conciliar "esse dinheiro é de qual fatura" deixa de ser um join que alguém faz na mão.

flowchart LR
  B["Billing<br/>fatura"] -->|billingInvoiceId| PI["PaymentIntent"]
  CO["Commerce<br/>pedido"] -->|commerceOrderId| PI
  PI -->|"payments.intent.completed"| BACK["Baixa automática na origem"]

Dado de cartão não entra

A API não tem campo para número de cartão nem CVV. Ela aceita um token gerado pelo provedor. O que fica guardado é bandeira e os quatro últimos dígitos — ver seção 14.


04

Casos de uso reais

negócio

Caso 1 — Uma financeira passa a cobrar a parcela por Pix sem trocar de gateway Cenário ilustrativo

Contexto

Financeira de crédito pessoal com carteira de 40 mil contratos ativos, cobrança mensal via boleto emitido pelo banco liquidante.

A dor

O boleto tem custo por emissão, compensa em D+1 ou pior, e uma fatia relevante dos clientes simplesmente perde o prazo porque só lembra da parcela quando o boleto vence. O time queria oferecer Pix, mas a integração de cobrança estava amarrada ao banco, e abrir uma segunda integração significava duplicar o código de conciliação.

A solução com o BB

A financeira cadastra um PaymentProviderConfig com o gateway que já usa e cria um intent por parcela com paymentMethod: "PIX". O provider devolve pixQrCode no campo canônico, que o app mostra direto na tela da parcela. Quando o pagamento liquida, o gateway chama POST /payments/api/v1/webhooks/{provider}, o BB muda o intent para COMPLETED e publica payments.intent.completed — que o Billing consome para dar a parcela por quitada. O boleto continua existindo como método alternativo no mesmo intent, sem código novo.

sequenceDiagram
  participant App as App da financeira
  participant Pay as Payments
  participant GW as Gateway
  participant Bill as Billing
  App->>Pay: POST /intents — PIX + billingInvoiceId
  Pay->>GW: cria cobrança
  GW-->>Pay: pixQrCode
  Pay-->>App: intent PENDING + pixQrCode
  Note over App: O app mostra o QR na tela da parcela
  GW->>Pay: POST /webhooks/stripe — pago
  Pay->>Pay: intent vira COMPLETED
  Pay-->>Bill: payments.intent.completed
  Bill->>Bill: parcela quitada
O resultado

Um único caminho de conciliação para dois meios de pagamento, e a baixa da parcela deixa de depender de arquivo de retorno.

Caso 2 — Um marketplace mantém dois adquirentes para não parar quando um cai Cenário ilustrativo

Contexto

Marketplace de serviços com pico forte em datas comerciais, processando cartão de crédito.

A dor

Numa indisponibilidade do adquirente único, o checkout inteiro morre. Não é degradação, é zero venda. E como só existia uma integração, não havia botão para virar a chave — a alternativa era subir um deploy no meio do incidente.

A solução com o BB

O marketplace cadastra dois PaymentProviderConfig ativos na mesma organização, um marcado como padrão. O checkout cria o intent sem configId no caminho normal e usa o padrão. Durante um incidente, a operação chama POST /provider-configs/{id}/set-default no provedor secundário, e os intents seguintes saem por ele — sem deploy. Cada intent guarda o configId de origem, então a conciliação depois do incidente sabe exatamente o que passou por onde.

flowchart LR
  CK["Checkout cria intent<br/>sem configId"] --> DEF{"Qual config está<br/>marcada como padrão?"}
  DEF -->|dia normal| A["Config A — adquirente 1"]
  DEF -->|durante o incidente| B["Config B — adquirente 2"]
  OPS["Operação chama<br/>POST /provider-configs/:id/set-default"] -.->|sem deploy| DEF
  A --> REC["Cada intent grava o configId de origem"]
  B --> REC
  REC --> CONC["Conciliação sabe o que passou por onde"]
O resultado

O plano B existe antes do incidente e é acionado por chamada de API. Vale notar o limite: a troca é uma decisão humana ou do seu orquestrador — o BB não retenta automaticamente em outro adquirente (seção 15).

Caso 3 — Uma operação de cobrança recorrente descobre quanto está perdendo em recusa Cenário ilustrativo

Contexto

Empresa de software por assinatura, cerca de 6 mil cobranças de cartão por mês.

A dor

O painel do gateway mostrava transações aprovadas e recusadas, mas não respondia à pergunta que o financeiro fazia: qual o efeito disso no caixa, mês a mês, por método. Todo fechamento consumia um dia de exportação de CSV e tabela dinâmica.

A solução com o BB

GET /payments/api/v1/analytics/metrics devolve volume, contagem, aprovadas, falhas, taxa de sucesso, ticket médio e volume estornado no período, filtrável por provedor. POST /analytics/compute congela o número do período em um snapshot, e GET /analytics/time-series devolve a série para o gráfico. Tudo escopado na organização, sem exportação.

flowchart LR
  PI["payment_intents<br/>do período"] --> M["GET /analytics/metrics<br/>cálculo na hora"]
  PI --> CP["POST /analytics/compute<br/>congela o período"]
  CP --> SN["payment_analytics_snapshots"]
  SN --> TS["GET /analytics/time-series<br/>série do gráfico"]
  M --> HOJE["Número de agora"]
  TS --> HIST["História do fechamento"]
ConsultaLê deServe para
GET /analytics/metricsIntents, calculado na horaO número do momento
POST /analytics/computeIntents, grava snapshotCongelar o fechamento do período
GET /analytics/time-seriesSnapshots já gravadosO gráfico histórico
O resultado

A conversa de fechamento passa a ser sobre a recusa, não sobre montar a planilha que mostra a recusa.

Caso 4 — Por que empresas grandes usam mais de um adquirente Referência de mercado

Contexto

A existência de uma categoria inteira de produto — os orquestradores de pagamento, como Malga, Yuno, Spreedly e Primer — é a evidência mais direta de que operar com um adquirente só é um problema que o mercado paga para resolver. Nenhuma dessas empresas processa pagamento: elas vendem a camada que fala com vários processadores.

A dor do mercado

Os três argumentos que essas empresas usam publicamente são sempre os mesmos: recuperar venda recusada tentando em outro adquirente, não parar quando um provedor fica indisponível, e ter poder de negociação por poder migrar volume. Nenhum deles é possível com integração única.

Argumento do orquestradorPrecisa de mais de um adquirenteO Payments entrega hoje
Recuperar venda recusada retentando em outroSimFundação sim, retentativa automática não (§15)
Não parar quando um provedor caiSimSim — troca de padrão por API, sem deploy
Poder de negociação por migrar volumeSimSim — migrar é configuração, não projeto

A aritmética que a Yuno publica ilustra bem por que a categoria existe: um lojista de US$ 100 milhões por ano com 90% de aprovação deixa US$ 10 milhões na mesa; subir para 93% recupera US$ 3 milhões sem nenhum tráfego adicional (Yuno, 04/08/2025). A Gr4vy afirma ganho de "3%+ na taxa de autorização" ao adotar estratégia multi-PSP (Gr4vy). Trate os dois como número de fornecedor, não como estudo independente — nenhuma das duas publica metodologia, e nesta redação não localizamos estudo primário de analista quantificando o ganho de retentativa em segundo adquirente. O que é verificável é o tamanho do problema que elas atacam: os US$ 157 bilhões de declínio indevido do levantamento PYMNTS/Spreedly citado na seção 2.

Como a Catalisa endereça

O PaymentProviderConfig por organização e a interface PaymentProvider entregam a fundação: vários adquirentes vivos ao mesmo tempo, escolha por transação, mesmo contrato de código. O que ainda não entregamos é o roteamento automático — regra de negócio que decide sozinha para onde mandar e retenta no segundo. Isso está no roadmap e está declarado na seção 15, não vendido como pronto.

flowchart LR
  subgraph PRONTO["Entregue hoje"]
    A1["Vários adquirentes na mesma organização"]
    A2["Escolha por transação via configId"]
    A3["Troca de padrão por API, sem deploy"]
    A4["Contrato de código único"]
  end
  subgraph ROADMAP["Roadmap — §15"]
    B1["Regra de roteamento automática"]
    B2["Retentativa no segundo adquirente"]
  end
  PRONTO -->|"o que falta para virar orquestrador pleno"| ROADMAP
O resultado

Você não precisa contratar um orquestrador separado para ter mais de um adquirente. Precisa contratar um se o que você quer é a regra automática de retentativa hoje.

Caso 5 — O Pix virou o meio de pagamento de varejo, e quem não o oferece paga por isso Referência de mercado

Contexto

Em 2025 o Pix movimentou R$ 29,6 trilhões em 71,3 bilhões de transações no Brasil. O recorte que interessa a quem vende — o Pix entre pessoa e empresa — foram 30,6 bilhões de transações e R$ 3,5 trilhões, crescimento de 38,4% em quantidade sobre o ano anterior. Em julho de 2026 o Pix de varejo passou o Pix entre pessoas em quantidade de transações pela primeira vez (Banco Central, Estatísticas do Pix, consulta em 2026-08-16).

Recorte do Pix em 2025QuantidadeValorVariação
Total71,3 bi de transaçõesR$ 29,6 tri—
Entre pessoa e empresa30,6 bi de transaçõesR$ 3,5 tri+38,4% em quantidade
A dor do mercado

O Pix é bem mais barato para o lojista do que o cartão — nas tabelas públicas, entre 0,99% e 1,19% contra 3,19% a 4,99% no crédito à vista (§6) — e liquida na hora, sem os 30 dias do crédito. Mesmo assim, muita operação continua oferecendo só cartão, porque adicionar Pix significava abrir uma segunda integração, com segunda conciliação e segundo tratamento de confirmação.

Como a Catalisa endereça

Pix não é integração separada aqui: é o valor PIX no campo paymentMethod do mesmo intent. O QR code volta em providerData.pixQrCode, a confirmação chega pelo mesmo webhook, e a métrica sai no mesmo GET /analytics/metrics que o cartão. O código do checkout que já cria intent de cartão passa a criar de Pix mudando um campo.

flowchart LR
  CK["Mesmo código de checkout"] --> PM{"paymentMethod"}
  PM -->|CREDIT_CARD| I1["POST /intents"]
  PM -->|PIX| I2["POST /intents"]
  I1 --> W["Mesmo webhook"]
  I2 --> W
  W --> A["Mesma métrica em /analytics/metrics"]
  W --> C["Mesma conciliação"]
O resultado

A decisão de oferecer o meio mais barato deixa de ter custo de engenharia associado. Vale a ressalva honesta: quem processa Pix é o provedor que você configurar — o Payments não é participante do arranjo Pix e não tem conta no Banco Central.


05

Mercado e diferenciais

negócio

O tamanho da mesa. Os cartões movimentaram R$ 4,5 trilhões no Brasil em 2025, alta de 10,1% sobre 2024, em 48,1 bilhões de transações — cerca de 132 milhões de pagamentos por dia. Compras não presenciais somaram R$ 1,1 trilhão (+18,3%), e a ABECS projeta que 2026 ultrapasse R$ 5 trilhões pela primeira vez (ABECS, Balanço do setor 2025, publicado em 11/02/2026).

Ao lado disso, o Pix parou de ser tendência. Em 2025 foram 71,3 bilhões de transações movimentando R$ 29,6 trilhões; só o Pix entre pessoa e empresa — o que interessa a quem vende — foram 30,6 bilhões de transações e R$ 3,5 trilhões, com alta de 38,4% em quantidade. Em julho de 2026, pela primeira vez, o Pix de varejo (46,4% das transações) passou o Pix entre pessoas (39,5%) (Banco Central, Estatísticas do Pix, consulta em 2026-08-16).

Mercado brasileiro em 2025ValorQuantidadeVariaçãoFonte
CartõesR$ 4,5 tri48,1 bi de transações+10,1% sobre 2024ABECS, 11/02/2026
Cartões, não presencialR$ 1,1 tri—+18,3%ABECS
Pix, totalR$ 29,6 tri71,3 bi de transações—Banco Central, consulta em 2026-08-16
Pix, pessoa para empresaR$ 3,5 tri30,6 bi de transações+38,4% em quantidadeBanco Central

A consequência comercial é direta: o meio de pagamento que mais cresce é também o mais barato para o lojista, e quem não consegue oferecer os dois pelo mesmo fluxo está escolhendo entre conversão e margem sem precisar.

Panorama. O mercado brasileiro de aceitação de pagamento tem três camadas que costumam ser confundidas. Na base estão os adquirentes e subadquirentes, que efetivamente processam e liquidam — Cielo, Rede, Getnet, Stone, PagBank, Mercado Pago. Acima deles, os gateways e plataformas que embrulham essa capacidade numa API decente — Stripe, Pagar.me, Adyen, Asaas, Iugu. E numa terceira camada, mais recente, os orquestradores — Malga, Yuno, Spreedly, Primer — que não processam nada e vendem exatamente a capacidade de falar com vários dos de baixo.

flowchart TD
  L3["Camada 3 — orquestração<br/>Malga · Yuno · Spreedly · Primer<br/>Catalisa Payments"]
  L2["Camada 2 — gateways e plataformas<br/>Stripe · Pagar.me · Adyen · Asaas · Iugu"]
  L1["Camada 1 — adquirentes e subadquirentes<br/>Cielo · Rede · Getnet · Stone · PagBank · Mercado Pago"]
  L3 -->|"fala com vários"| L2
  L2 -->|"processa e liquida"| L1
  L1 --> BAN["Bandeiras e emissores"]

Atenção. Só a camada 1 processa e liquida dinheiro. A Catalisa mora na camada 3: não somos adquirente, não liquidamos e não guardamos saldo.

O Payments da Catalisa mora na terceira camada, com uma diferença de origem: ele não é um produto avulso de orquestração, é o pedaço de pagamento de um catálogo que já tem identidade, cobrança recorrente, pedido e entrega de evento. Quem compra orquestrador puro está resolvendo pagamento. Quem usa o Payments normalmente já está usando o Billing ou o Commerce, e o pagamento é a etapa que faltava.

CritérioCatalisa PaymentsStripeAdyenPagar.meMalga
O que éCamada de orquestraçãoGateway + adquirenteAdquirente globalGateway (Stone)Orquestrador
Processa e liquidaNãoSimSimSimNão
Mais de um adquirenteSim, por organizaçãoNãoNãoNãoSim
Retentativa automática entre adquirentesNão (ver §15)N/AN/AN/ASim
Modelo de dadosPayment intentPayment intentPayment sessionTransação/pedidoPayment intent
Vem com identidade, billing e pedidoSim, mesmo catálogoNãoNãoNãoNão
Dado de cartão na nossa infraestruturaNão, só tokenN/AN/AN/ANão, tokeniza
CustoPrecificação em definiçãoPercentual por transaçãoNegociado por volumePercentual por transaçãoPor transação orquestrada

Sobre meios de pagamento. A tabela não compara cobertura de Pix, boleto e carteira por fornecedor de propósito: essa matriz muda com frequência e por país, e um dado desatualizado numa proposta comercial custa caro. Confirme na documentação pública de cada um na data da sua análise. O que vale para nós é estrutural — o Payments não implementa Pix nem boleto por conta própria, ele expõe o que o provedor configurado oferece. Hoje, na prática: Pix e boleto chegam pelo PagSeguro, e Pix e cartão pelo Stripe.

Nossos diferenciais

  1. Vários adquirentes sem contratar um orquestrador. A configuração de provedor é por organização e permite mais de uma ativa, com escolha por transação. Isso não é difícil de copiar tecnicamente — é difícil de copiar para quem já vendeu integração única, porque significa admitir que o cliente vai embora mais fácil.
  2. O contrato canônico é o produto. CanonicalPaymentIntent, CanonicalTransaction, CanonicalRefund. Cada gateway novo é uma classe que implementa dez métodos; nada acima dela muda. O custo de adicionar o terceiro provedor é o mesmo do segundo, o que não é verdade em integração ponto a ponto.
  3. O intent já nasce ligado ao resto da operação. billingInvoiceId e commerceOrderId são campos indexados do próprio modelo. Um orquestrador externo não tem como ter isso, porque ele não sabe o que é a sua fatura.
  4. Credencial de gateway criptografada e nunca devolvida. As chaves ficam em AES-256-GCM com chave mestra fora do banco, e a serialização de resposta do provider config simplesmente não tem o campo. Não existe endpoint que devolva a chave do seu adquirente.

Quando escolher o concorrente

Se o seu problema é…Escolha
Taxa de aprovação em cross-border, cartão em vários paísesAdyen — adquirência própria onde dependemos de quem você contratar
Regra automática de retentativa entre adquirentes hojeMalga ou Yuno — nós não entregamos isso ainda (§15)
Velocidade máxima de integração, time pequeno, produto globalStripe direto — e nós falamos com ele quando você mudar de ideia
Solução brasileira única com antecipação, split e contaPagar.me, Asaas ou Iugu — sem camada extra
Não ficar preso a um adquirente, com pagamento dentro de uma operação maiorCatalisa Payments

Em prosa, para quem prefere ler. Se você processa cartão em vários países e o problema é taxa de aprovação em cross-border, a Adyen tem adquirência própria em mercados onde nós dependemos de quem você contratar — e isso não se compensa com arquitetura. Se você precisa de regra automática de retentativa entre adquirentes hoje, um orquestrador dedicado como Malga ou Yuno entrega isso agora e nós não (§15). Se o seu produto é global, o time é pequeno e a prioridade é velocidade de integração com a melhor documentação disponível, o Stripe direto continua sendo a escolha mais rápida — e nós, aliás, falamos com ele. E se você quer uma solução brasileira única com antecipação, split e conta tudo no mesmo lugar, Pagar.me, Asaas ou Iugu resolvem sem camada extra. O Payments ganha quando o problema é não ficar preso a um adquirente e quando pagamento é uma peça de uma operação maior que já roda na Catalisa.


06

Modelo de cobrança e ROI

negócio

Unidade de cobrança. Precificação em definição. O Payments não substitui a taxa do adquirente: você continua pagando o MDR do gateway que contratou, direto com ele. O que se discute aqui é o custo da camada de orquestração e do histórico.

O que dispara custo.

DriverPor que ele importa
Volume transacionadoÉ o que dimensiona o valor entregue e o custo de armazenar histórico e webhooks
Número de payment intents criadosCada intent é uma chamada ao gateway e um registro com ciclo de vida próprio
Número de provedores configurados por organizaçãoCada provedor ativo é uma superfície a mais de webhook e de conciliação

O que a conta precisa comparar. Antes de qualquer número, o desenho da comparação é este:

Linha de custoCenário A — integração diretaCenário B — com o Payments
Taxa do adquirenteMDR do adquirenteMDR do(s) adquirente(s) — igual
Camada de orquestraçãoNão existeCamada Payments
Engenharia de integraçãoPor gateway, toda vezDa primeira integração, uma vez
ManutençãoPor gatewayDo contrato canônico, uma vez
Custo de migrar de adquirenteAlto — e por isso adiado≈ cadastro de provedor
Venda perdida sem segunda tentativaEntra na contaFundação para evitar existe (§15)
flowchart LR
  subgraph A["Cenário A — integração direta"]
    A1["MDR do adquirente"] --> A2["+ engenharia da integração"]
    A2 --> A3["+ manutenção por gateway"]
    A3 --> A4["+ custo de migrar — alto, adiado"]
    A4 --> A5["+ venda perdida sem 2ª tentativa"]
  end
  subgraph B["Cenário B — com o Payments"]
    B1["MDR dos adquirentes"] --> B2["+ camada Payments"]
    B2 --> B3["+ engenharia da 1ª integração, uma vez"]
    B3 --> B4["+ custo de migrar ≈ configuração"]
  end

O ganho não está na taxa por transação — essa é do adquirente nos dois cenários. Está em duas coisas mensuráveis dentro da sua operação: o custo de engenharia que não se repete a cada gateway, e a taxa efetiva que você consegue quando pode de fato migrar volume para outro fornecedor.

A referência de mercado que ancora a conta. Estes são preços de tabela, publicados pelos próprios fornecedores e consultados em 2026-08-16. Servem para dimensionar ordem de grandeza, não para fechar proposta: Cielo e PagBank dizem explicitamente que a taxa é negociável, e operação com volume paga menos que a tabela.

FornecedorCrédito à vistaDébitoPixFonte
Stripe Brasil3,99% + R$ 0,393,99% + R$ 0,391,19%stripe.com/br/pricing
PagBank (repasse em 30 dias)3,19%1,99%0% nos 30 primeiros diaspagbank.com.br
PagBank (repasse na hora)4,99%1,99%idemidem
Cielo (plano aluguel)3,45%1,19%0,99% após 30 diascielo.com.br/planos

Duas leituras saltam dessa tabela, e as duas são argumento comercial.

Primeira leitura: receber antes tem preço, e ele é grande

Na curva do PagBank, o mesmo crédito à vista custa 3,19% com repasse em 30 dias e 4,99% recebendo na hora — 1,8 ponto percentual pela antecipação. Isso importa porque o parcelado sem juros respondeu por 42,6% do valor transacionado no crédito em 2025, R$ 1,8 trilhão (ABECS): o descasamento de caixa é estrutural no varejo brasileiro, e ele é pago em MDR.

Prazo de repasse no PagBankCrédito à vistaDiferença
Em 30 dias3,19%referência
Na hora4,99%+1,8 ponto percentual pela antecipação

Atenção. Esse descasamento não é exceção: o parcelado sem juros respondeu por 42,6% do valor transacionado no crédito em 2025, R$ 1,8 trilhão. Quem antecipa paga a antecipação em MDR.

Segunda leitura: Pix e cartão não custam a mesma coisa, nem de longe

Entre 0,99% e 1,19% de tabela no Pix contra 3,19% a 4,99% no crédito à vista, a diferença é de 2 a 4 pontos percentuais — sobre R$ 10 milhões transacionados, algo entre R$ 200 mil e R$ 400 mil por ano. O Payments não reduz nenhuma dessas taxas, elas são do adquirente. O que ele faz é remover o motivo técnico para você não oferecer o meio mais barato ao lado do mais conveniente, no mesmo intent e com a mesma conciliação.

Sobre R$ 10 milhões transacionados por anoFaixa
Pix, tabela0,99% a 1,19%
Crédito à vista, tabela3,19% a 4,99%
Diferença anualR$ 200 mil a R$ 400 mil — estimativa de ordem de grandeza

Como fazer a sua conta

Pegue o volume transacionado do último trimestre e a diferença entre a taxa que você paga e a melhor proposta que recebeu. Multiplique. Compare com o custo de migrar hoje, que na integração direta é um projeto de engenharia e no Payments é cadastro de provedor. Essa subtração é o ROI, e ela é específica da sua operação — por isso não damos um número genérico aqui.

Sobre os números acima. São taxas de tabela na data indicada, e mudam. Nenhuma delas é preço da Catalisa. Não conseguimos consultar publicamente as tabelas de Mercado Pago, Pagar.me, Asaas, Iugu, Getnet e Rede nesta redação — a ausência delas aqui não é julgamento sobre esses fornecedores. Antes de usar qualquer cifra desta seção numa proposta, reconfira na página do fornecedor na data da apresentação.


07

Arquitetura

As camadas

flowchart TD
  HTTP["HTTP"] --> APP["Hono app<br/>basePath /payments + applyCommonMiddleware"]
  APP --> MW["authMiddleware → requirePermission<br/>→ requireOrganization → Zod"]
  MW --> SVC["services/"]
  SVC --> REPO["repositories/ — Prisma<br/>schema payments · 10 modelos"]
  SVC --> PROV["providers/ — interface PaymentProvider"]
  SVC --> REDIS["Redis — stream de eventos<br/>payments.intent.*"]
  PROV --> EXT["Stripe · PagSeguro<br/>drivers externos"]
  PROV --> INT["Link · Mock<br/>drivers internos"]
  EXT -->|HTTPS| GW["Gateway do cliente"]

Proteções aplicadas pelo applyCommonMiddleware, antes de qualquer rota: bodyLimit de 1 MB, CORS, cabeçalhos de segurança e rate limit global.

Os routers montados

PrefixoRouterRotas
/api/v1/provider-configsproviderConfigRouter7
/api/v1/intentspaymentIntentRouter6
/api/v1/refundsrefundRouter3
/api/v1/linkspaymentLinkRouter5
/api/v1/webhookswebhookRouter2
/api/v1/analyticsanalyticsRouter3
/api/v1/customerscustomerRouter13
/health—versão do serviço

Os serviços

ServiçoResponsabilidade
PaymentProviderConfigServiceCredenciais, provedor padrão, fábrica de driver
PaymentIntentServiceCriar, listar, cancelar
PaymentProcessingServicecharge, capture, tentativas
RefundServiceEstorno total e parcial
PaymentLinkServiceLink, sync, desativação
WebhookHandlerServiceAssinatura, deduplicação, efeito
AnalyticsServiceMétricas e snapshots
SavedPaymentMethodServiceCliente no provedor, cartão salvo, cobrança off_session e link de recuperação

O caminho de uma cobrança, de ponta a ponta

sequenceDiagram
  participant CLI as Seu produto
  participant R as Router
  participant S as PaymentProcessingService
  participant DB as PostgreSQL
  participant P as PaymentProvider
  participant GW as Gateway
  participant RD as Redis
  CLI->>R: POST /intents/:id/charge
  R->>R: authMiddleware, requirePermission, requireOrganization, Zod
  R->>S: charge
  S->>DB: intent está PENDING?
  S->>DB: grava PROCESSING
  S->>P: createPaymentProvider com as credenciais da organização
  P->>GW: HTTPS
  GW-->>P: transação canônica
  P-->>S: CanonicalTransaction
  S->>DB: grava status final e a PaymentTransaction
  S->>RD: publica payments.intent.*
  S-->>CLI: intent atualizado

Decisões não óbvias.

DecisãoTrade-off aceito
Modelo copiado do StripeFicamos parecidos com um provedor específico
Provider instanciado por requisiçãoUm objeto novo por chamada
Credencial cifrada, não hasheadaA chave mestra vira dependência crítica
Corpo cru do webhook preservadoO router precisa ler texto antes do parse
Interruptor de verificação de assinaturaO padrão off aceita webhook não verificado
Evento publicado depois da escritaEvento pode se perder sem afetar o pagamento
Idempotência falha alto409 em vez do recurso original

O modelo de dados é o Payment Intent, copiado do Stripe de propósito

PaymentIntent + PaymentTransaction (tentativas) + PaymentRefund, com clientSecret, captureMode automático ou manual e idempotencyKey, é literalmente o desenho do Stripe. Isso é escolha, não coincidência: quando o integrador já sabe o que é um intent, o que é capturar depois de autorizar e por que existe tentativa numerada, o custo de integração cai para perto de zero. Adotar um modelo mental que o mercado já conhece vale mais que inventar um melhor. O preço é que ficamos parecidos com um provedor específico — mitigado pelo fato de o PagSeguroProvider mapear o mundo dele (WAITING, IN_ANALYSIS, PAID) para o mesmo vocabulário canônico.

flowchart LR
  W["WAITING"] --> PEN["PENDING"]
  IA["IN_ANALYSIS"] --> PRO["PROCESSING"]
  PAID["PAID"] --> COMP["COMPLETED"]
  RC["requires_capture — Stripe"] --> AUTH["AUTHORIZED"]
  DEC["declined — Stripe"] --> FAIL["FAILED"]

Provider é objeto criado por requisição, não singleton injetado

createPaymentProvider(tipo, credenciais, settings) monta a instância na hora, a partir da configuração da organização daquela chamada. Não existe cliente de gateway compartilhado entre tenants, então não existe o bug clássico de a credencial de um cliente vazar para a requisição de outro por cache mal escopado. O custo é instanciar um objeto por chamada, o que é irrelevante perto de uma ida ao gateway.

flowchart LR
  REQ["Requisição da organização X"] --> CFG["Lê o PaymentProviderConfig da org X"]
  CFG --> DEC["decryptCredentials"]
  DEC --> FAB["createPaymentProvider"]
  FAB --> INST["Instância descartável, viva só nesta requisição"]
  INST --> GW["Gateway"]

Credenciais criptografadas com AES-256-GCM, não com hash

Diferente de senha, a chave do gateway precisa ser recuperada em texto claro para assinar a chamada. Então é cifra autenticada com PAYMENTS_CREDENTIAL_MASTER_KEY (32 bytes em hex), guardada no formato iv:authTag:ciphertext. O GCM detecta adulteração do registro no banco; um ciphertext editado falha na decifragem em vez de virar credencial estranha.

O corpo cru do webhook é preservado antes de qualquer JSON.parse

O HMAC do Stripe é calculado sobre os bytes exatos recebidos. Reserializar o objeto muda espaçamento e ordem de chave, e a assinatura não bate. Por isso webhook.router.ts lê c.req.text() primeiro e passa rawBody até o provider. É um detalhe pequeno que quebra a integração inteira quando esquecido.

flowchart LR
  IN["Bytes recebidos"] --> RAW["c.req.text — rawBody preservado"]
  RAW --> P["JSON.parse para o efeito de negócio"]
  RAW --> H["HMAC sobre os bytes exatos"]
  H --> V{"Assinatura confere?"}
  V -->|sim| OK["Processa"]
  V -->|não| NO["Invalid webhook signature"]

A verificação de assinatura tem um interruptor de rollout

PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT controla o que acontece quando um provider não consegue verificar. Em off — o padrão — a requisição é aceita e registra aviso; em on, é rejeitada. Isso existe para permitir ligar verificação em produção sem derrubar integração de um dia para o outro. Em produção o valor deve ser on — ver seção 13.

ValorProvider não consegue verificarUso previsto
off (padrão)Aceita e grava aviso no logRollout, desenvolvimento
onRejeita com 400Produção

Evento é publicado depois da escrita no banco, sem bloquear a resposta

A mudança de estado é gravada primeiro; a publicação em Redis vem em seguida. Pagamento confirmado que não conseguiu emitir evento continua confirmado. A recuperação de evento perdido é o PaymentWebhookEvent guardado cru e o próprio estado do intent, consultável a qualquer momento.

Idempotência é do lado de cá, por chave única no banco

PaymentIntent.idempotencyKey é @unique. Repetir a criação com a mesma chave devolve 409 CONFLICT em vez de criar um segundo intent. Note que isso é diferente do comportamento do Stripe, que devolve o recurso original — a escolha aqui foi falhar alto para o chamador perceber a repetição.

Atenção. Trate 409 como "já criei", não como erro. O Stripe devolveria o intent original; nós devolvemos conflito.

Monolito vs. standalone. Em monolito, src/app.ts monta o app do Payments no mesmo processo; em standalone, main.ts sobe na porta MODULE_PAYMENTS_PORT (padrão 3027). O comportamento das rotas é idêntico: as proteções globais são aplicadas pelo próprio app.ts do módulo via applyCommonMiddleware, então não dependem do monolito estar na frente.


08

Conceitos e modelo de dados

Glossário

TermoSignifica
Provider configA configuração de um gateway para uma organização: tipo, credenciais cifradas, segredo de webhook, modo teste ou produção. Uma organização pode ter várias.
Payment intentA intenção de cobrar um valor. É o registro que vive do início ao fim e responde "em que pé está essa cobrança".
TransactionUma tentativa concreta de cobrança dentro de um intent, numerada por attemptNumber. Um intent pode ter várias.
RefundUm estorno, total ou parcial, sobre um intent já capturado ou concluído.
Payment linkUma URL de cobrança autocontida, com valor, título, métodos permitidos e limite de usos.
Webhook eventO evento cru recebido do gateway, guardado com payload completo e marca de processamento.
Analytics snapshotMétricas congeladas de um período, para não recalcular agregação a cada consulta.
Capture modeAUTOMATIC cobra e finaliza numa etapa; MANUAL autoriza agora e captura depois.
Checkout typeOnde o cliente digita os dados: TRANSPARENT (na sua tela), EMBEDDED (componente do provedor), HOSTED (página do provedor), PAYMENT_LINK.
CanonicalO formato neutro de resposta (CanonicalPaymentIntent e afins). É o que sai do provider e o que o resto do BB entende.
CustomerO cliente no provedor (cus_... no Stripe), um por (organização, configuração, externalReference). É a quem os cartões salvos pertencem.
Saved methodCartão salvo no cofre do provedor. Aqui fica só o id (pm_...), bandeira, últimos 4, validade e se é o padrão.
Setup sessionPágina hospedada do provedor (Checkout mode: setup) onde o cliente cadastra o cartão sem pagar nada.
Cobrança off_sessionCobrança de cartão salvo sem o cliente presente. Quando o banco pede autenticação ou recusa, gera um link de recuperação para o cliente concluir.

Modelo de dados — schema payments no PostgreSQL.

erDiagram
  PaymentProviderConfig ||--o{ PaymentIntent : "originou"
  PaymentProviderConfig ||--o{ PaymentLink : "hospeda"
  PaymentProviderConfig ||--o{ PaymentWebhookEvent : "recebe"
  PaymentProviderConfig |o--o{ PaymentAnalyticsSnapshot : "recorta"
  PaymentIntent ||--o{ PaymentTransaction : "tentativas"
  PaymentIntent ||--o{ PaymentRefund : "estornos"
  PaymentProviderConfig ||--o{ PaymentCustomer : "clientes no provedor"
  PaymentCustomer ||--o{ PaymentSavedMethod : "cartões salvos"
  PaymentCustomer ||--o{ PaymentSetupSession : "sessões de cadastro"

Atenção. PaymentRefund não guarda organizationId. O serviço de estorno sobe até o intent para conferir o tenant — ver seção 14.

Modelo PrismaTabelaPropósitoCampos-chave
PaymentProviderConfigpayments.payment_provider_configsGateway configurado para uma organizaçãocredentials (cifrado), webhookSecret, isDefault, isActive, isLiveMode, único (organizationId, name)
PaymentIntentpayments.payment_intentsCiclo de vida de uma cobrançastatus, amount, capturedAmount, refundedAmount, externalId, idempotencyKey (único), billingInvoiceId, commerceOrderId
PaymentCustomerpayments.payment_customersCliente no provedorexternalId (cus_...), único (organizationId, configId, externalReference)
PaymentSavedMethodpayments.payment_saved_methodsCartão salvo — sem PANexternalId (pm_...), brand, last4, expMonth, expYear, isDefault, único (customerId, externalId)
PaymentSetupSessionpayments.payment_setup_sessionsSessão hospedada de cadastro de cartãoexternalId (cs_...), status (OPEN/COMPLETED/EXPIRED), url, savedMethodId
PaymentTransactionpayments.payment_transactionsCada tentativa de cobrançaattemptNumber, status, cardBrand, cardLast4, pixQrCode, boletoBarcode, failureReason
PaymentRefundpayments.payment_refundsEstorno de um intentamount, status, reason, isDispute
PaymentLinkpayments.payment_linksLink de cobrançaurl, shortUrl, status, maxUsages, usageCount, allowedMethods
PaymentWebhookEventpayments.payment_webhook_eventsEvento cru do gatewaypayload, processed, error, único (configId, externalEventId)
PaymentAnalyticsSnapshotpayments.payment_analytics_snapshotsMétricas congeladas por períodoperiodType, totalVolume, successCount, avgTicket, refundVolume

Valores monetários são Decimal(15,2) no banco — nunca ponto flutuante. Na fronteira com provedores que trabalham em centavos, utils/money.ts converte com toCents/fromCents.

Enumerações

EnumValores
PaymentProviderTypeSTRIPE · PAGSEGURO · MOCK_PAYMENT · LINK
PaymentIntentStatusPENDING · PROCESSING · AUTHORIZED · CAPTURED · COMPLETED · FAILED · CANCELLED · EXPIRED
PaymentMethodTypeCREDIT_CARD · DEBIT_CARD · PIX · BOLETO · PAYMENT_LINK · WALLET_APPLE_PAY · WALLET_GOOGLE_PAY
CheckoutTypeTRANSPARENT · EMBEDDED · HOSTED · PAYMENT_LINK
PaymentCaptureModeAUTOMATIC · MANUAL
PaymentTransactionStatusPENDING · PROCESSING · AUTHORIZED · CAPTURED · FAILED · CANCELLED
PaymentRefundStatusPENDING · PROCESSING · COMPLETED · FAILED
PaymentLinkStatusACTIVE · EXPIRED · DEACTIVATED · COMPLETED
AnalyticsPeriodTypeDAILY · WEEKLY · MONTHLY

Máquina de estados do payment intent

Este é o diagrama que um integrador precisa ter na mesa. As transições abaixo são as que existem no código.

stateDiagram-v2
  [*] --> PENDING : POST /intents
  PENDING --> PROCESSING : POST /:id/charge
  PENDING --> CANCELLED : POST /:id/cancel
  PROCESSING --> COMPLETED : provedor devolveu CAPTURED e captureMode AUTOMATIC
  PROCESSING --> AUTHORIZED : provedor devolveu CAPTURED e captureMode MANUAL
  PROCESSING --> AUTHORIZED : provedor devolveu AUTHORIZED
  PROCESSING --> FAILED : provedor devolveu FAILED ou erro
  AUTHORIZED --> CAPTURED : POST /:id/capture
  AUTHORIZED --> CANCELLED : POST /:id/cancel
  COMPLETED --> [*]
  CAPTURED --> [*]
  FAILED --> [*]
  CANCELLED --> [*]
  note right of PROCESSING
    O charge grava PROCESSING antes
    de chamar o provedor.
  end note
  note right of COMPLETED
    COMPLETED grava completedAt
    e capturedAmount igual a amount.
  end note

Terminais: COMPLETED, CAPTURED, FAILED e CANCELLED. Nenhum deles volta para trás por chamada de API.

O que o webhook pode fazer com o status

O webhook é o segundo caminho de mudança de estado, e ele não obedece às mesmas restrições de origem que charge, capture e cancel. A função mapWebhookStatus traduz o status do provedor:

stateDiagram-v2
  state "Status enviado pelo provedor" as PRV
  PRV --> COMPLETED : succeeded, paid, completed, available
  PRV --> PROCESSING : processing, in_analysis
  PRV --> AUTHORIZED : requires_capture, authorized
  PRV --> FAILED : failed, declined
  PRV --> CANCELLED : canceled, cancelled
  PRV --> Ignorado : qualquer outro valor
Status do provedorIntent viraObservação
succeeded · paid · completed · availableCOMPLETEDGrava completedAt, e capturedAmount quando o evento traz valor
processing · in_analysisPROCESSING—
requires_capture · authorizedAUTHORIZED—
failed · declinedFAILED—
canceled · cancelledCANCELLED—
Qualquer outroNada mudaStatus desconhecido é ignorado, sem erro

Atenção. EXPIRED existe no enum mas nenhuma transição do código chega nele hoje — nem por API, nem por webhook, nem por rotina. Intent abandonado fica PENDING para sempre. Ver seção 15.

Estorno não é um estado

POST /refunds sobre um intent CAPTURED ou COMPLETED cria um PaymentRefund e soma em intent.refundedAmount. O status do intent não muda — não existe REFUNDED no enum.

stateDiagram-v2
  [*] --> PENDING : POST /refunds
  PENDING --> PROCESSING : provedor ainda processando
  PENDING --> COMPLETED : provedor confirmou na hora
  PROCESSING --> COMPLETED : webhook succeeded
  PROCESSING --> FAILED : webhook failed
  PENDING --> FAILED : webhook failed
  COMPLETED --> [*]
  FAILED --> [*]
  note right of COMPLETED
    COMPLETED grava completedAt
    e publica payments.refund.completed.
  end note

O status inicial do estorno é o que o provedor devolveu na criação; daí em diante quem move é o webhook, que só grava COMPLETED ou FAILED.

Máquina de estados da transação

Cada tentativa de cobrança é uma PaymentTransaction com vida própria, numerada por attemptNumber. É o status dela que o serviço lê para decidir o status do intent.

stateDiagram-v2
  state "PENDING" as P
  state "PROCESSING" as PR
  state "AUTHORIZED" as A
  state "CAPTURED" as C
  state "FAILED" as F
  state "CANCELLED" as X
  [*] --> P
  P --> PR
  PR --> A
  PR --> C
  PR --> F
  A --> C
  A --> X
  C --> [*]
  F --> [*]
  X --> [*]
Transação devolveucaptureModeIntent vira
CAPTUREDAUTOMATICCOMPLETED
CAPTUREDMANUALAUTHORIZED
AUTHORIZEDqualquerAUTHORIZED
FAILED ou erro do provedorqualquerFAILED, com transação FAILED registrada

Regras de transição que o código impõe

OperaçãoSó a partir deErro se fora disso
POST /:id/chargePENDING400 — Cannot charge intent in status: X
POST /:id/captureAUTHORIZED400 — Cannot capture intent in status: X. Must be AUTHORIZED.
POST /:id/cancelPENDING ou AUTHORIZED400 — Cannot cancel intent in status: X
POST /refundsintent em CAPTURED ou COMPLETED400 — Must be CAPTURED or COMPLETED.
POST /links/:id/deactivatelink ACTIVE400 — Cannot deactivate link in status: X

Estorno também valida o teto: amount não pode passar de intent.amount - intent.refundedAmount. Omitir amount estorna o valor cheio do intent.

Ciclo de vida do payment link

stateDiagram-v2
  [*] --> ACTIVE : POST /links
  ACTIVE --> DEACTIVATED : POST /:id/deactivate
  ACTIVE --> COMPLETED : usageCount atinge maxUsages, via webhook
  ACTIVE --> COMPLETED : POST /:id/sync vê pagamento no provedor
  DEACTIVATED --> [*]
  COMPLETED --> [*]
  note right of DEACTIVATED
    deactivate só aceita link ACTIVE.
    Fora disso: Cannot deactivate link in status X.
  end note
Origem da viradaEfeito
POST /:id/deactivate sobre link ACTIVEStatus vira DEACTIVATED, no provedor e localmente
Webhook de pagamento do linkIncrementa usageCount; atingindo maxUsages, status vira COMPLETED e publica payments.link.completed
POST /:id/syncConsulta o provedor; na virada de ACTIVE para COMPLETED, publica o evento uma única vez

Atenção. EXPIRED existe no enum de link e expiresAt é gravado, mas nenhuma rotina automática expira nada hoje. Para encerrar de fato um link, chame deactivate. Ver seção 15.


09

Referência da API

Prefixo: /payments. Todas as rotas abaixo, exceto as duas de webhook, exigem authMiddleware (JWT), a permissão indicada e requireOrganization — token sem organizationId recebe 403 antes da regra de negócio.

Paginação segue o padrão do catálogo: page[number] e page[size] (padrão 1 e 20). Filtros vão em filter[campo]. O corpo aceita tanto o formato JSON:API ({"data":{"attributes":{...}}}) quanto o objeto plano.

Configurações de provedor — /payments/api/v1/provider-configs

MétodoRotaDescriçãoPermissão
POST/payments/api/v1/provider-configsCadastra um gateway (testa a conexão antes de salvar)PAYMENTS_ADMIN
GET/payments/api/v1/provider-configsLista configurações, paginadoPAYMENTS_READ
GET/payments/api/v1/provider-configs/:idBusca uma configuraçãoPAYMENTS_READ
PATCH/payments/api/v1/provider-configs/:idAtualiza (revalida conexão se trocar credencial)PAYMENTS_ADMIN
DELETE/payments/api/v1/provider-configs/:idExclusão lógica. Responde 204PAYMENTS_ADMIN
POST/payments/api/v1/provider-configs/:id/set-defaultMarca como padrão da organizaçãoPAYMENTS_ADMIN
POST/payments/api/v1/provider-configs/:id/testTesta a conexão com as credenciais salvasPAYMENTS_ADMIN

Filtros da listagem: filter[providerType], filter[isActive].

Payment intents — /payments/api/v1/intents

MétodoRotaDescriçãoPermissão
POST/payments/api/v1/intentsCria a intenção de cobrança. Responde 201PAYMENTS_WRITE
GET/payments/api/v1/intentsLista intents, paginadoPAYMENTS_READ
GET/payments/api/v1/intents/:idBusca um intentPAYMENTS_READ
POST/payments/api/v1/intents/:id/chargeExecuta a cobrança no provedorPAYMENTS_WRITE
POST/payments/api/v1/intents/:id/captureCaptura um valor autorizadoPAYMENTS_WRITE
POST/payments/api/v1/intents/:id/cancelCancela o intent no provedor e localmentePAYMENTS_WRITE

Filtros da listagem: filter[configId], filter[status], filter[paymentMethod], filter[search] (procura em e-mail, nome e descrição), filter[startDate], filter[endDate], filter[minAmount], filter[maxAmount].

Estornos — /payments/api/v1/refunds

MétodoRotaDescriçãoPermissão
POST/payments/api/v1/refundsCria estorno total ou parcial. Responde 201PAYMENTS_REFUND
GET/payments/api/v1/refundsLista estornos, paginadoPAYMENTS_READ
GET/payments/api/v1/refunds/:idBusca um estornoPAYMENTS_READ

Filtros da listagem: filter[status], filter[startDate], filter[endDate].

PAYMENTS_REFUND é uma permissão separada de propósito: quem pode cobrar não devolve dinheiro por padrão.

MétodoRotaDescriçãoPermissão
POST/payments/api/v1/linksCria link de cobrança. Responde 201PAYMENTS_WRITE
GET/payments/api/v1/linksLista links, paginadoPAYMENTS_READ
GET/payments/api/v1/links/:idBusca um linkPAYMENTS_READ
POST/payments/api/v1/links/:id/syncConsulta o provedor e atualiza o status localPAYMENTS_READ
POST/payments/api/v1/links/:id/deactivateDesativa o link no provedor e localmentePAYMENTS_WRITE

Filtros da listagem: filter[configId], filter[status].

sync altera dados mas exige apenas PAYMENTS_READ no código atual. É intencional no sentido de que a origem da verdade é o provedor e a operação é uma leitura ativa, mas se a sua política exige permissão de escrita para qualquer mutação, trate sync como exceção conhecida.

Clientes e cartão salvo — /payments/api/v1/customers

MétodoRotaDescriçãoPermissão
POST/payments/api/v1/customersCria ou devolve o cliente no provedor por externalReference. 201 se criou, 200 se já existiaPAYMENTS_WRITE
GET/payments/api/v1/customersLista clientes, paginadoPAYMENTS_READ
GET/payments/api/v1/customers/:idBusca um clientePAYMENTS_READ
POST/payments/api/v1/customers/:id/setup-sessionsAbre a página hospedada para o cliente cadastrar o cartão. Responde 201 com urlPAYMENTS_WRITE
GET/payments/api/v1/customers/:id/setup-sessions/:sessionIdEstado da sessão, com o método gravado quando concluídaPAYMENTS_READ
POST/payments/api/v1/customers/:id/setup-sessions/:sessionId/syncConsulta o provedor e, se o cliente concluiu, grava o cartãoPAYMENTS_WRITE
GET/payments/api/v1/customers/:id/payment-methodsCartões salvos, o padrão primeiroPAYMENTS_READ
POST/payments/api/v1/customers/:id/payment-methods/syncReconcilia com o provedor (grava o que existe lá, apaga o que sumiu)PAYMENTS_WRITE
POST/payments/api/v1/customers/:id/payment-methods/:methodId/defaultDefine o cartão padrãoPAYMENTS_WRITE
DELETE/payments/api/v1/customers/:id/payment-methods/:methodIdDesanexa no provedor e remove. Responde 204PAYMENTS_WRITE
POST/payments/api/v1/customers/:id/chargesCobra o cartão salvo sem o cliente presente. idempotencyKey obrigatóriaPAYMENTS_WRITE
GET/payments/api/v1/customers/:id/charges/:chargeIdEstado da cobrançaPAYMENTS_READ
POST/payments/api/v1/customers/:id/charges/:chargeId/syncConsulta o provedor (link de recuperação pago, pagamento em processamento)PAYMENTS_WRITE

Filtros da listagem: filter[externalReference], filter[configId]. Provedores com suporte: STRIPE e MOCK_PAYMENT; nos outros as rotas respondem 400 com O provedor X não suporta cartão salvo. Detalhes e exemplos em Cartão salvo e cobrança recorrente.

Webhooks — /payments/api/v1/webhooks

MétodoRotaDescriçãoAutenticação
POST/payments/api/v1/webhooks/:providerTypeRecebe evento do gatewaySem JWT — assinatura do provedor
GET/payments/api/v1/webhooks/healthSonda simples do receptorPública

:providerType aceita stripe, pagseguro, mock_payment ou link (o valor é normalizado para maiúsculas). A organização é identificada pelo header X-Organization-Id ou pelo query param organizationId — configure isso na URL de webhook cadastrada no painel do gateway.

Analytics — /payments/api/v1/analytics

MétodoRotaDescriçãoPermissão
GET/payments/api/v1/analytics/metricsMétricas agregadas do períodoPAYMENTS_READ
GET/payments/api/v1/analytics/time-seriesSérie histórica a partir dos snapshotsPAYMENTS_READ
POST/payments/api/v1/analytics/computeCalcula e grava um snapshot. Responde 201PAYMENTS_ADMIN

Query de metrics e time-series: configId, startDate, endDate, e periodType (só em time-series). Sem datas, a janela padrão é os últimos 30 dias.

Saúde

MétodoRotaDescrição
GET/payments/healthNome e versão do serviço

POST /payments/api/v1/provider-configs

Cadastra um gateway. A conexão é testada antes de gravar — credencial errada falha aqui, não na primeira cobrança de um cliente.

Request

json
{
  "name": "stripe-producao",
  "providerType": "STRIPE",
  "credentials": { "secretKey": "SUBSTITUA_PELA_SUA_CHAVE" },
  "isDefault": true,
  "isActive": true,
  "isLiveMode": true,
  "webhookSecret": "SUBSTITUA_PELO_SEGREDO_DO_WEBHOOK",
  "settings": { "sandbox": false }
}
{
  "name": "stripe-producao",
  "providerType": "STRIPE",
  "credentials": { "secretKey": "SUBSTITUA_PELA_SUA_CHAVE" },
  "isDefault": true,
  "isActive": true,
  "isLiveMode": true,
  "webhookSecret": "SUBSTITUA_PELO_SEGREDO_DO_WEBHOOK",
  "settings": { "sandbox": false }
}
CampoTipoObrigatórioDescrição
namestring (1–100)SimNome interno. Único dentro da organização
providerTypeSTRIPE | PAGSEGURO | MOCK_PAYMENT | LINKSimQual driver usar
credentialsobject<string,string>SimChaves do gateway. Cifradas na gravação, nunca devolvidas
isDefaultbooleanNãoMarca como padrão da organização
isActivebooleanNãoPadrão true
isLiveModebooleanNãoPadrão false. É rótulo informativo, não altera endpoint
webhookSecretstringNãoSegredo usado para verificar a assinatura do webhook
settingsobjectNãoAjustes do driver, ex.: {"sandbox": true} no PagSeguro

Chaves esperadas em credentials por driver:

DriverChavesObservação
STRIPEsecretKeyChave secreta da API
PAGSEGUROtoken, opcionalmente sandbox: "true"Token da API v4
LINKbaseUrl (opcional)Gera links próprios, sem gateway externo
MOCK_PAYMENTnenhumaObjeto vazio {} serve

Resposta 201

json
{
  "data": {
    "type": "payment-provider-config",
    "id": "0f2c9c1e-6d4f-4a3a-9c6c-2f0f2e0a1b33",
    "links": { "self": "/api/v1/payments/provider-configs/0f2c9c1e-..." },
    "attributes": {
      "name": "stripe-producao",
      "providerType": "STRIPE",
      "isDefault": true,
      "isActive": true,
      "isLiveMode": true,
      "settings": { "sandbox": false },
      "createdAt": "2026-08-16T12:00:00.000Z",
      "updatedAt": "2026-08-16T12:00:00.000Z"
    }
  }
}
{
  "data": {
    "type": "payment-provider-config",
    "id": "0f2c9c1e-6d4f-4a3a-9c6c-2f0f2e0a1b33",
    "links": { "self": "/api/v1/payments/provider-configs/0f2c9c1e-..." },
    "attributes": {
      "name": "stripe-producao",
      "providerType": "STRIPE",
      "isDefault": true,
      "isActive": true,
      "isLiveMode": true,
      "settings": { "sandbox": false },
      "createdAt": "2026-08-16T12:00:00.000Z",
      "updatedAt": "2026-08-16T12:00:00.000Z"
    }
  }
}

Repare no que não volta: credentials e webhookSecret. Não existe endpoint que devolva esses valores.

Erros

StatusQuando
400Corpo reprovado no Zod, ou Failed to connect to provider: ...
403Sem PAYMENTS_ADMIN, ou token sem organizationId
409Já existe configuração com esse name na organização

POST /payments/api/v1/intents

Cria a intenção de cobrança e já registra o intent correspondente no gateway.

Request

json
{
  "amount": 149.90,
  "currency": "BRL",
  "paymentMethod": "PIX",
  "captureMode": "AUTOMATIC",
  "checkoutType": "TRANSPARENT",
  "customerEmail": "cliente@exemplo.com.br",
  "customerName": "Maria Souza",
  "description": "Parcela 3/12 — contrato 88213",
  "idempotencyKey": "contrato-88213-parcela-3",
  "billingInvoiceId": "8f6a2b40-1f2c-4c7e-9a01-3b6d2e5f7a10"
}
{
  "amount": 149.90,
  "currency": "BRL",
  "paymentMethod": "PIX",
  "captureMode": "AUTOMATIC",
  "checkoutType": "TRANSPARENT",
  "customerEmail": "cliente@exemplo.com.br",
  "customerName": "Maria Souza",
  "description": "Parcela 3/12 — contrato 88213",
  "idempotencyKey": "contrato-88213-parcela-3",
  "billingInvoiceId": "8f6a2b40-1f2c-4c7e-9a01-3b6d2e5f7a10"
}
CampoTipoObrigatórioDescrição
amountnumber > 0SimValor em unidade monetária, não em centavos
currencystring (3)NãoPadrão BRL
configIduuidNãoProvedor a usar. Omitido, usa o padrão da organização
paymentMethodenumNãoCREDIT_CARD, DEBIT_CARD, PIX, BOLETO, PAYMENT_LINK, WALLET_APPLE_PAY, WALLET_GOOGLE_PAY
captureModeAUTOMATIC | MANUALNãoPadrão AUTOMATIC
checkoutTypeenumNãoPadrão TRANSPARENT
customerEmailstring (e-mail)NãoRepassado ao provedor
customerNamestringNãoRepassado ao provedor
descriptionstringNãoAparece na fatura do cliente em vários gateways
metadataobjectNãoLivre, guardado como JSONB
idempotencyKeystringNãoÚnico no banco. Repetir devolve 409
billingInvoiceIduuidNãoFatura do Billing que originou a cobrança
commerceOrderIduuidNãoPedido do Commerce que originou a cobrança
successUrl / cancelUrlstring (URL)NãoUsados no checkout hospedado
paymentTokenstringNãoToken do meio de pagamento, gerado no cliente pelo SDK do provedor

Resposta 201 — o objeto do intent, com providerData carregando o que o gateway devolveu:

json
{
  "data": {
    "type": "payment-intent",
    "id": "7c3f8a5b-2e1d-4c9a-8f70-11a2b3c4d5e6",
    "attributes": {
      "status": "PENDING",
      "amount": 149.9,
      "currency": "BRL",
      "capturedAmount": 0,
      "refundedAmount": 0,
      "paymentMethod": "PIX",
      "captureMode": "AUTOMATIC",
      "externalId": "pi_3Q...",
      "providerData": {
        "clientSecret": "pi_3Q..._secret_...",
        "checkoutUrl": null,
        "pixQrCode": "00020126580014br.gov.bcb.pix...",
        "boletoUrl": null
      },
      "billingInvoiceId": "8f6a2b40-1f2c-4c7e-9a01-3b6d2e5f7a10",
      "createdAt": "2026-08-16T12:00:00.000Z"
    }
  }
}
{
  "data": {
    "type": "payment-intent",
    "id": "7c3f8a5b-2e1d-4c9a-8f70-11a2b3c4d5e6",
    "attributes": {
      "status": "PENDING",
      "amount": 149.9,
      "currency": "BRL",
      "capturedAmount": 0,
      "refundedAmount": 0,
      "paymentMethod": "PIX",
      "captureMode": "AUTOMATIC",
      "externalId": "pi_3Q...",
      "providerData": {
        "clientSecret": "pi_3Q..._secret_...",
        "checkoutUrl": null,
        "pixQrCode": "00020126580014br.gov.bcb.pix...",
        "boletoUrl": null
      },
      "billingInvoiceId": "8f6a2b40-1f2c-4c7e-9a01-3b6d2e5f7a10",
      "createdAt": "2026-08-16T12:00:00.000Z"
    }
  }
}

Erros

StatusQuando
400Corpo inválido, ou o provedor recusou (Stripe error: ..., PagSeguro error: ...)
403Sem PAYMENTS_WRITE, ou token sem organizationId
404configId não existe na organização, ou não há provedor padrão configurado
409idempotencyKey já usada

POST /payments/api/v1/intents/:id/charge

Executa a cobrança. Só aceita intent em PENDING. Cada chamada cria uma PaymentTransaction com attemptNumber incremental — inclusive quando falha, com failureReason preenchido.

Request (corpo opcional)

json
{ "paymentToken": "pm_1Q...", "paymentMethod": "CREDIT_CARD" }
{ "paymentToken": "pm_1Q...", "paymentMethod": "CREDIT_CARD" }

O paymentToken é gerado no navegador ou no app, pelo SDK do provedor, a partir dos dados do cartão. Ele nunca passa pelo seu servidor nem pelo nosso — ver seção 14.

Resposta 200 — o intent atualizado. O status final depende do captureMode:

Provedor devolveucaptureModeStatus do intentEvento publicado
CAPTUREDAUTOMATICCOMPLETED, com capturedAmount = amountpayments.intent.completed
CAPTUREDMANUALAUTHORIZEDpayments.intent.authorized
AUTHORIZEDqualquerAUTHORIZEDpayments.intent.authorized
FAILED ou erroqualquerFAILEDpayments.intent.failed

Erros

StatusQuando
400Cannot charge intent in status: X — só PENDING cobra
400Provedor recusou. A transação fica gravada como FAILED com o motivo
404Intent não existe nesta organização

POST /payments/api/v1/refunds

Estorna total ou parcialmente.

Request

json
{
  "intentId": "7c3f8a5b-2e1d-4c9a-8f70-11a2b3c4d5e6",
  "amount": 50.00,
  "reason": "Cancelamento parcial solicitado pelo cliente"
}
{
  "intentId": "7c3f8a5b-2e1d-4c9a-8f70-11a2b3c4d5e6",
  "amount": 50.00,
  "reason": "Cancelamento parcial solicitado pelo cliente"
}
CampoTipoObrigatórioDescrição
intentIduuidSimIntent a estornar. Precisa estar CAPTURED ou COMPLETED
amountnumber > 0NãoOmitido, estorna o valor cheio do intent
reasonstringNãoMotivo, guardado e repassado ao provedor quando ele aceita

Resposta 201 — o estorno criado. O refundedAmount do intent é somado no mesmo fluxo. O status do intent não muda — não existe REFUNDED no enum; a evidência do estorno é o refundedAmount e o registro em payment_refunds.

Erros

StatusQuando
400Intent em status não estornável, ou amount acima do saldo estornável
403Sem PAYMENTS_REFUND
404Intent não existe nesta organização

POST /payments/api/v1/webhooks/:providerType

Recebe evento do gateway. Não usa JWT. A confiança vem da assinatura do provedor.

O que acontece, em ordem:

  1. Valida se :providerType é conhecido.
  2. Lê X-Organization-Id (header) ou organizationId (query). Sem isso, 400.
  3. Preserva o corpo cru antes de qualquer parse — é sobre esses bytes que o HMAC é calculado.
  4. Localiza a configuração ativa daquele tipo de provedor na organização.
  5. Verifica a assinatura com o webhookSecret da configuração.
  6. Deduplica por (configId, externalEventId) — evento repetido responde 200 sem reprocessar.
  7. Guarda o evento cru em payment_webhook_events.
  8. Aplica o efeito: primeiro os eventos de Checkout do fluxo de cartão salvo (checkout.session.completed/expired de sessão mode: setup grava o cartão; a de recuperação paga conclui a cobrança) e, se o evento não for desse fluxo, atualiza status do intent, do estorno ou o uso do link.
  9. Marca o evento como processado. Se o passo 8 falhar, grava o erro no evento e ainda responde 200, para o gateway não entrar em retentativa infinita — o evento fica lá para reprocessamento.
sequenceDiagram
  participant GW as Gateway
  participant R as webhook.router
  participant S as WebhookHandlerService
  participant P as PaymentProvider
  participant DB as PostgreSQL
  GW->>R: POST /webhooks/:providerType
  R->>R: 1. providerType conhecido?
  R->>R: 2. lê X-Organization-Id ou organizationId
  R->>R: 3. preserva o corpo cru
  R->>S: handleWebhook com rawBody
  S->>DB: 4. busca config ativa do tipo na organização
  S->>P: 5. verifyWebhookSignature com o webhookSecret
  P-->>S: válida ou não
  S->>DB: 6. dedup por configId e externalEventId
  S->>DB: 7. grava o evento cru
  S->>DB: 8. aplica o efeito no intent, estorno ou link
  S->>DB: 9. marca como processado
  S-->>GW: 200 received true

Atenção. Se o passo 8 falhar, a resposta continua 200 com o erro gravado no evento. 200 não é prova de que o efeito aconteceu — confirme pelo status do intent.

Resposta 200

json
{ "received": true }
{ "received": true }

Erros

StatusQuando
400Unknown provider type: X, corpo não é JSON, ou falta a organização
400Invalid webhook signature
404Não há configuração ativa desse provedor nessa organização

A URL a cadastrar no painel do gateway fica assim:

texto
https://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAO
https://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAO

Cartão salvo e cobrança recorrente

O fluxo tem três momentos: cadastrar o cliente no provedor, o cliente salvar o cartão numa página hospedada pelo provedor e cobrar o cartão salvo sem o cliente presente, quantas vezes for preciso. O número do cartão nunca passa por aqui: o cliente digita na página do Stripe, e o que gravamos é o id do método (pm_...), bandeira, últimos 4 dígitos e validade.

sequenceDiagram
  participant App as Seu produto
  participant Pay as Payments
  participant GW as Stripe
  participant C as Cliente
  App->>Pay: POST /customers {externalReference}
  Pay->>GW: cria customer (idempotente)
  App->>Pay: POST /customers/:id/setup-sessions {successUrl, cancelUrl}
  Pay->>GW: Checkout mode=setup
  Pay-->>App: url
  App->>C: redireciona para url
  C->>GW: digita o cartão
  GW-->>Pay: webhook checkout.session.completed
  Pay->>Pay: grava pm_, bandeira, últimos 4, validade
  Note over App,Pay: todo mês
  App->>Pay: POST /customers/:id/charges {amount, idempotencyKey}
  Pay->>GW: PaymentIntent off_session + confirm
  alt aprovado
    Pay-->>App: outcome SUCCEEDED
  else banco pede autenticação ou recusa
    Pay->>GW: Checkout mode=payment (link de recuperação)
    Pay-->>App: outcome REQUIRES_ACTION ou DECLINED + recoveryUrl
    App->>C: envia recoveryUrl
    C->>GW: autentica ou usa outro cartão
    GW-->>Pay: webhook checkout.session.completed
    Pay->>Pay: cobrança vira COMPLETED
  end
POST /payments/api/v1/customers

Idempotente por (organização, configuração de provedor, externalReference). A referência é sua — o id do tenant, do contrato, do assinante. Chamar de novo devolve o mesmo cliente com 200 e meta.created: false.

json
{
  "data": {
    "type": "payment-customers",
    "attributes": {
      "externalReference": "tenant-3f2a",
      "email": "dono@exemplo.com",
      "name": "Loja Exemplo",
      "configId": "opcional — sem ele usa o provedor padrão"
    }
  }
}
{
  "data": {
    "type": "payment-customers",
    "attributes": {
      "externalReference": "tenant-3f2a",
      "email": "dono@exemplo.com",
      "name": "Loja Exemplo",
      "configId": "opcional — sem ele usa o provedor padrão"
    }
  }
}

Resposta 201 (ou 200 se já existia)

json
{
  "data": {
    "type": "payment-customer",
    "id": "8c1e…",
    "attributes": {
      "configId": "c0a1…",
      "externalReference": "tenant-3f2a",
      "providerCustomerId": "cus_Q…",
      "email": "dono@exemplo.com",
      "name": "Loja Exemplo",
      "metadata": null,
      "createdAt": "2026-09-18T12:00:00.000Z",
      "updatedAt": "2026-09-18T12:00:00.000Z"
    }
  },
  "meta": { "created": true }
}
{
  "data": {
    "type": "payment-customer",
    "id": "8c1e…",
    "attributes": {
      "configId": "c0a1…",
      "externalReference": "tenant-3f2a",
      "providerCustomerId": "cus_Q…",
      "email": "dono@exemplo.com",
      "name": "Loja Exemplo",
      "metadata": null,
      "createdAt": "2026-09-18T12:00:00.000Z",
      "updatedAt": "2026-09-18T12:00:00.000Z"
    }
  },
  "meta": { "created": true }
}
POST /payments/api/v1/customers/:id/setup-sessions
json
{ "successUrl": "https://painel.exemplo.com/plano/cartao?ok=1&session={CHECKOUT_SESSION_ID}",
  "cancelUrl": "https://painel.exemplo.com/plano/cartao?cancelado=1" }
{ "successUrl": "https://painel.exemplo.com/plano/cartao?ok=1&session={CHECKOUT_SESSION_ID}",
  "cancelUrl": "https://painel.exemplo.com/plano/cartao?cancelado=1" }

{CHECKOUT_SESSION_ID} é substituído pelo Stripe. Resposta 201:

json
{
  "data": {
    "type": "payment-setup-session",
    "id": "5d9b…",
    "attributes": {
      "customerId": "8c1e…",
      "url": "https://checkout.stripe.com/c/pay/cs_…",
      "status": "OPEN",
      "paymentMethodId": null,
      "expiresAt": "2026-09-19T12:00:00.000Z",
      "completedAt": null,
      "createdAt": "2026-09-18T12:00:00.000Z"
    }
  }
}
{
  "data": {
    "type": "payment-setup-session",
    "id": "5d9b…",
    "attributes": {
      "customerId": "8c1e…",
      "url": "https://checkout.stripe.com/c/pay/cs_…",
      "status": "OPEN",
      "paymentMethodId": null,
      "expiresAt": "2026-09-19T12:00:00.000Z",
      "completedAt": null,
      "createdAt": "2026-09-18T12:00:00.000Z"
    }
  }
}

Quando o cliente conclui, o webhook checkout.session.completed grava o cartão. Sem webhook (ou para confirmar na volta do successUrl), chame POST …/setup-sessions/:sessionId/sync: a resposta traz status: "COMPLETED" e o cartão em data.included.paymentMethod. sync e webhook são idempotentes entre si — o cartão não duplica. O primeiro cartão do cliente vira o padrão; os seguintes não roubam o padrão.

Método salvo
json
{
  "type": "payment-method",
  "id": "e71f…",
  "attributes": {
    "customerId": "8c1e…",
    "providerPaymentMethodId": "pm_…",
    "type": "card",
    "brand": "visa",
    "last4": "4242",
    "expMonth": 12,
    "expYear": 2030,
    "funding": "credit",
    "country": "BR",
    "isDefault": true,
    "createdAt": "…",
    "updatedAt": "…"
  }
}
{
  "type": "payment-method",
  "id": "e71f…",
  "attributes": {
    "customerId": "8c1e…",
    "providerPaymentMethodId": "pm_…",
    "type": "card",
    "brand": "visa",
    "last4": "4242",
    "expMonth": 12,
    "expYear": 2030,
    "funding": "credit",
    "country": "BR",
    "isDefault": true,
    "createdAt": "…",
    "updatedAt": "…"
  }
}

Remover o cartão padrão promove o mais recente que sobrou — a renovação não fica sem cartão porque o cliente trocou um.

POST /payments/api/v1/customers/:id/charges
CampoTipoObrigatórioNota
amountnumberSimEm reais (49.90)
currencystring(3)NãoBRL
idempotencyKeystring 8–200SimTambém aceita no header Idempotency-Key. Use uma por ciclo: renovacao-<tenant>-2026-10
paymentMethodIduuidNãoSem ele, cobra o cartão padrão
descriptionstringNãoAparece no extrato do Stripe e no link de recuperação
billingInvoiceIduuidNãoLigação com a fatura do Billing
metadataobjetoNãoGuardado no intent
successUrl, cancelUrlurlNãoPara onde o link de recuperação devolve o cliente. Sem eles não há recoveryUrl

Resposta 201 (cobrança nova) ou 200 (mesma idempotencyKey, replayed: true — nada é cobrado de novo):

json
{
  "data": {
    "type": "saved-method-charge",
    "id": "a41c…",
    "attributes": {
      "customerId": "8c1e…",
      "paymentMethodId": "e71f…",
      "amount": 49.9,
      "currency": "BRL",
      "status": "PENDING",
      "outcome": "REQUIRES_ACTION",
      "failureCode": "authentication_required",
      "declineCode": null,
      "failureMessage": "This payment requires authentication.",
      "recoveryUrl": "https://checkout.stripe.com/c/pay/cs_…",
      "recoveryExpiresAt": "2026-09-19T12:00:00.000Z",
      "recoveredAt": null,
      "providerPaymentId": "pi_…",
      "idempotencyKey": "renovacao-tenant-3f2a-2026-10",
      "description": "Plano Biometria — outubro",
      "billingInvoiceId": null,
      "metadata": null,
      "replayed": false,
      "createdAt": "…",
      "updatedAt": "…",
      "completedAt": null
    }
  }
}
{
  "data": {
    "type": "saved-method-charge",
    "id": "a41c…",
    "attributes": {
      "customerId": "8c1e…",
      "paymentMethodId": "e71f…",
      "amount": 49.9,
      "currency": "BRL",
      "status": "PENDING",
      "outcome": "REQUIRES_ACTION",
      "failureCode": "authentication_required",
      "declineCode": null,
      "failureMessage": "This payment requires authentication.",
      "recoveryUrl": "https://checkout.stripe.com/c/pay/cs_…",
      "recoveryExpiresAt": "2026-09-19T12:00:00.000Z",
      "recoveredAt": null,
      "providerPaymentId": "pi_…",
      "idempotencyKey": "renovacao-tenant-3f2a-2026-10",
      "description": "Plano Biometria — outubro",
      "billingInvoiceId": null,
      "metadata": null,
      "replayed": false,
      "createdAt": "…",
      "updatedAt": "…",
      "completedAt": null
    }
  }
}
outcomestatus do intentO que fazer
SUCCEEDEDCOMPLETEDPago. Renove
PROCESSINGPROCESSINGAceito; a confirmação chega por webhook (payment_intent.succeeded) ou …/charges/:chargeId/sync
REQUIRES_ACTIONPENDINGO banco pediu autenticação (3DS). Mande recoveryUrl ao cliente
DECLINEDFAILEDRecusado (declineCode: insufficient_funds, expired_card…). Mande recoveryUrl — o cliente pode usar outro cartão, que fica salvo
FAILEDFAILEDErro que não é recusa (método removido, de outro cliente). Sem recoveryUrl; salve um cartão novo

Quando o cliente paga pelo recoveryUrl, o webhook checkout.session.completed (ou o sync da cobrança) leva a cobrança a COMPLETED, outcome: "SUCCEEDED", recoveredAt preenchido, e o cartão usado passa a ficar salvo. Os eventos publicados são os mesmos dos intents — payments.intent.completed, payments.intent.failed, payments.intent.processing — mais payments.intent.requires_action, com source: "saved_method" ou "recovery", outcome e customerId no payload.

Erros da cobrança

StatusQuando
400Corpo inválido (Zod), cliente sem cartão salvo, provedor sem suporte a cartão salvo
404Cliente, método ou cobrança inexistente ou de outra organização
409A mesma idempotencyKey já foi usada com outro valor, moeda, cliente ou cartão; ou a cobrança com essa chave está em andamento neste instante
503Provedor indisponível (rede, 5xx, 429). Repita com a mesma idempotencyKey: a cobrança é refeita com a mesma chave no Stripe, que devolve o resultado original em vez de cobrar duas vezes

A cobrança é um payment intent comum (checkoutType: TRANSPARENT, paymentMethod: CREDIT_CARD): aparece em GET /intents, entra no analytics e aceita estorno por POST /refunds.


10

Início rápido

Do zero a um pagamento concluído, sem gateway real e sem cartão — usando o driver MOCK_PAYMENT, que existe exatamente para isso.

Os comandos abaixo não foram executados nesta redação. Eles usam o ambiente local (bun run dev, monolito na porta 3000). Em staging, troque a base pela URL do serviço e as credenciais pelas de AMBIENTES.md. Nunca use credencial de produção em script de exemplo.

O caminho tem sete passos, e só o passo 2 muda quando você troca o mock por um gateway de verdade:

flowchart LR
  P0["0. Subir a infra"] --> P1["1. Autenticar no IAM"]
  P1 --> P2["2. Cadastrar o provedor<br/>MOCK_PAYMENT"]
  P2 --> P3["3. Criar o intent<br/>PENDING"]
  P3 --> P4["4. Cobrar<br/>COMPLETED"]
  P4 --> P5["5. Estornar metade<br/>refundedAmount"]
  P5 --> P6["6. Ver no analytics"]

0. Preparar o ambiente

bash
docker-compose up -d postgres redis minio
export PAYMENTS_CREDENTIAL_MASTER_KEY=$(openssl rand -hex 32)
bun run dev
docker-compose up -d postgres redis minio
export PAYMENTS_CREDENTIAL_MASTER_KEY=$(openssl rand -hex 32)
bun run dev

1. Autenticar

bash
BASE=http://localhost:3000

TOKEN=$(curl -s -X POST $BASE/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@test.com",
    "password": "password123",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)
BASE=http://localhost:3000

TOKEN=$(curl -s -X POST $BASE/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@test.com",
    "password": "password123",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

O token precisa trazer organizationId e as permissões PAYMENTS_*. Sem organizationId, toda rota do módulo responde 403 antes de olhar a regra de negócio.

2. Cadastrar o provedor de teste

bash
CONFIG=$(curl -s -X POST $BASE/payments/api/v1/provider-configs \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "sandbox-local",
    "providerType": "MOCK_PAYMENT",
    "credentials": {},
    "isDefault": true
  }')

CONFIG_ID=$(echo "$CONFIG" | jq -r '.data.id')
echo "config: $CONFIG_ID"
CONFIG=$(curl -s -X POST $BASE/payments/api/v1/provider-configs \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "sandbox-local",
    "providerType": "MOCK_PAYMENT",
    "credentials": {},
    "isDefault": true
  }')

CONFIG_ID=$(echo "$CONFIG" | jq -r '.data.id')
echo "config: $CONFIG_ID"
json
{ "data": { "type": "payment-provider-config", "id": "…",
  "attributes": { "name": "sandbox-local", "providerType": "MOCK_PAYMENT",
                  "isDefault": true, "isActive": true, "isLiveMode": false } } }
{ "data": { "type": "payment-provider-config", "id": "…",
  "attributes": { "name": "sandbox-local", "providerType": "MOCK_PAYMENT",
                  "isDefault": true, "isActive": true, "isLiveMode": false } } }

3. Criar o payment intent

bash
INTENT=$(curl -s -X POST $BASE/payments/api/v1/intents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 149.90,
    "currency": "BRL",
    "paymentMethod": "PIX",
    "description": "Primeiro pagamento",
    "idempotencyKey": "primeiro-teste-001"
  }')

INTENT_ID=$(echo "$INTENT" | jq -r '.data.id')
echo "$INTENT" | jq '.data.attributes | {status, amount, externalId, providerData}'
INTENT=$(curl -s -X POST $BASE/payments/api/v1/intents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 149.90,
    "currency": "BRL",
    "paymentMethod": "PIX",
    "description": "Primeiro pagamento",
    "idempotencyKey": "primeiro-teste-001"
  }')

INTENT_ID=$(echo "$INTENT" | jq -r '.data.id')
echo "$INTENT" | jq '.data.attributes | {status, amount, externalId, providerData}'
json
{
  "status": "PENDING",
  "amount": 149.9,
  "externalId": "mock_pi_…",
  "providerData": { "pixQrCode": "00020126580014br.gov.bcb.pix…" }
}
{
  "status": "PENDING",
  "amount": 149.9,
  "externalId": "mock_pi_…",
  "providerData": { "pixQrCode": "00020126580014br.gov.bcb.pix…" }
}

4. Cobrar

bash
curl -s -X POST $BASE/payments/api/v1/intents/$INTENT_ID/charge \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{}' | jq '.data.attributes | {status, capturedAmount}'
curl -s -X POST $BASE/payments/api/v1/intents/$INTENT_ID/charge \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{}' | jq '.data.attributes | {status, capturedAmount}'
json
{ "status": "COMPLETED", "capturedAmount": 149.9 }
{ "status": "COMPLETED", "capturedAmount": 149.9 }

O provedor de teste sempre aprova, e como o captureMode é AUTOMATIC, o intent vai direto de PENDING para COMPLETED.

5. Estornar metade

bash
curl -s -X POST $BASE/payments/api/v1/refunds \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"intentId\":\"$INTENT_ID\",\"amount\":74.95,\"reason\":\"teste\"}" \
  | jq '.data.attributes | {status, amount}'

curl -s $BASE/payments/api/v1/intents/$INTENT_ID \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data.attributes | {status, amount, refundedAmount}'
curl -s -X POST $BASE/payments/api/v1/refunds \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"intentId\":\"$INTENT_ID\",\"amount\":74.95,\"reason\":\"teste\"}" \
  | jq '.data.attributes | {status, amount}'

curl -s $BASE/payments/api/v1/intents/$INTENT_ID \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data.attributes | {status, amount, refundedAmount}'
json
{ "status": "COMPLETED", "amount": 149.9, "refundedAmount": 74.95 }
{ "status": "COMPLETED", "amount": 149.9, "refundedAmount": 74.95 }

O intent continua COMPLETED — o estorno aparece em refundedAmount, como descrito na seção 8.

6. Ver o resultado no analytics

bash
curl -s "$BASE/payments/api/v1/analytics/metrics" \
  -H "Authorization: Bearer $TOKEN" | jq .data
curl -s "$BASE/payments/api/v1/analytics/metrics" \
  -H "Authorization: Bearer $TOKEN" | jq .data

A resposta traz os campos que o AnalyticsService calcula na hora, sobre a janela padrão de 30 dias:

CampoO que é
totalVolumeVolume transacionado no período
totalCountQuantidade de intents no período
successCount / failedCountAprovadas e recusadas
successRatesuccessCount / totalCount, em percentual
avgTickettotalVolume / successCount
refundVolumeVolume estornado — confiável
refundCountContagem de estorno — imprecisa, ver seção 15

Quando for para o gateway de verdade, o único passo que muda é o 2: troque MOCK_PAYMENT por STRIPE com a sua chave. Os passos 3 a 6 são idênticos — que é justamente o ponto do building block.


11

Receitas

Autorizar agora e capturar depois

Útil quando você só cobra ao despachar: reserva o limite do cliente na hora do pedido e captura na expedição.

sequenceDiagram
  participant U as Seu produto
  participant Pay as Payments
  participant GW as Adquirente
  U->>Pay: 1. POST /intents com captureMode MANUAL
  Pay-->>U: intent PENDING
  U->>Pay: 2. POST /:id/charge com paymentToken
  Pay->>GW: autoriza
  GW-->>Pay: AUTHORIZED
  Pay-->>U: intent AUTHORIZED — limite reservado
  Note over U,GW: dias depois, na expedição
  U->>Pay: 3. POST /:id/capture
  Pay->>GW: captura
  GW-->>Pay: CAPTURED
  Pay-->>U: intent CAPTURED com capturedAmount

Passo 1 — criar o intent com captura manual.

bash
INTENT_ID=$(curl -s -X POST $BASE/payments/api/v1/intents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"amount": 320.00, "paymentMethod": "CREDIT_CARD", "captureMode": "MANUAL"}' \
  | jq -r '.data.id')
INTENT_ID=$(curl -s -X POST $BASE/payments/api/v1/intents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"amount": 320.00, "paymentMethod": "CREDIT_CARD", "captureMode": "MANUAL"}' \
  | jq -r '.data.id')

O intent nasce PENDING. Nada foi cobrado ainda.

Passo 2 — cobrar com o token gerado no cliente.

bash
curl -s -X POST $BASE/payments/api/v1/intents/$INTENT_ID/charge \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"paymentToken": "TOKEN_GERADO_NO_FRONTEND"}' | jq '.data.attributes.status'
curl -s -X POST $BASE/payments/api/v1/intents/$INTENT_ID/charge \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"paymentToken": "TOKEN_GERADO_NO_FRONTEND"}' | jq '.data.attributes.status'
json
"AUTHORIZED"
"AUTHORIZED"

O limite do cliente está reservado no adquirente; o dinheiro ainda não saiu.

Passo 3 — dias depois, capturar o valor total ou parcial.

bash
curl -s -X POST $BASE/payments/api/v1/intents/$INTENT_ID/capture \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"amount": 320.00}' | jq '.data.attributes | {status, capturedAmount}'
curl -s -X POST $BASE/payments/api/v1/intents/$INTENT_ID/capture \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"amount": 320.00}' | jq '.data.attributes | {status, capturedAmount}'
json
{ "status": "CAPTURED", "capturedAmount": 320 }
{ "status": "CAPTURED", "capturedAmount": 320 }

Armadilhas.

  • Autorização tem prazo de validade no adquirente, não aqui. Passou do prazo, a captura falha no provedor mesmo com o intent ainda AUTHORIZED no nosso banco. Capture dentro da janela do seu adquirente.
  • Capturar valor menor que o autorizado funciona, mas o restante não é liberado por chamada nossa — o desbloqueio é regra do adquirente.
  • Depois de CAPTURED não há novo capture. Devolver dinheiro é POST /refunds.

Trocar o adquirente padrão sem deploy

Três passos, na ordem: olhar, testar, promover.

flowchart LR
  A["1. Listar configs ativas"] --> B["2. POST /:id/test<br/>no secundário"]
  B -->|credencial ok| C["3. POST /:id/set-default"]
  B -->|falhou| D["Corrija a credencial<br/>antes de promover"]
  C --> E["Intents novos saem pelo secundário"]
  C -.->|"não afeta"| F["Intents já existentes<br/>continuam no configId de origem"]

Passo 1 — ver quem está ativo.

bash
curl -s "$BASE/payments/api/v1/provider-configs?filter[isActive]=true" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {id, name: .attributes.name, default: .attributes.isDefault}'
curl -s "$BASE/payments/api/v1/provider-configs?filter[isActive]=true" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {id, name: .attributes.name, default: .attributes.isDefault}'

A configuração com default: true é a que atende os intents criados sem configId.

Passo 2 — testar o secundário antes de promover.

bash
curl -s -X POST $BASE/payments/api/v1/provider-configs/$SECUNDARIO/test \
  -H "Authorization: Bearer $TOKEN" | jq
curl -s -X POST $BASE/payments/api/v1/provider-configs/$SECUNDARIO/test \
  -H "Authorization: Bearer $TOKEN" | jq

Esse endpoint bate no gateway com a credencial salva. Credencial revogada aparece aqui como 503, não no primeiro cliente.

Passo 3 — promover.

bash
curl -s -X POST $BASE/payments/api/v1/provider-configs/$SECUNDARIO/set-default \
  -H "Authorization: Bearer $TOKEN" | jq '.data.attributes.isDefault'
curl -s -X POST $BASE/payments/api/v1/provider-configs/$SECUNDARIO/set-default \
  -H "Authorization: Bearer $TOKEN" | jq '.data.attributes.isDefault'
json
true
true

Armadilhas.

  • set-default afeta intents novos. Os que já existem continuam amarrados ao configId de origem — e precisam continuar, porque só o provedor que autorizou consegue capturar ou estornar.
  • Um provedor inativo não pode virar padrão: a chamada volta 400.
  • Cadastre o webhook do provedor secundário antes de promover. Sem isso, os pagamentos saem por ele e nenhuma confirmação volta.
  • Teste com POST /:id/test antes. É a diferença entre descobrir a credencial errada agora ou no primeiro cliente.

Receber e reprocessar um webhook

Passo 1 — configurar a URL no painel do gateway.

texto
https://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAO
https://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAO

A organização não vem de token aqui: ela vem do query param organizationId ou do header X-Organization-Id. Sem um dos dois, a resposta é 400.

flowchart TD
  GW["Gateway envia o evento"] --> ORG{"Traz organizationId<br/>no query ou no header?"}
  ORG -->|não| E400["400"]
  ORG -->|sim| CFG{"Existe config ativa<br/>desse provedor na organização?"}
  CFG -->|não| E404["404"]
  CFG -->|sim| SIG{"Assinatura confere?"}
  SIG -->|não| E400B["400 — Invalid webhook signature"]
  SIG -->|sim| DUP{"Já vi esse externalEventId?"}
  DUP -->|sim| OK200["200 — sem reprocessar"]
  DUP -->|não| EF["Grava cru e aplica o efeito"]
  EF -->|efeito falhou| OK200E["200 — erro gravado no evento"]
  EF -->|efeito aplicado| OK200F["200 — processed true"]

Passo 2 — conferir se o evento chegou e o que ele fez.

bash
# O efeito é visível no próprio intent
curl -s $BASE/payments/api/v1/intents/$INTENT_ID \
  -H "Authorization: Bearer $TOKEN" | jq '.data.attributes.status'
# O efeito é visível no próprio intent
curl -s $BASE/payments/api/v1/intents/$INTENT_ID \
  -H "Authorization: Bearer $TOKEN" | jq '.data.attributes.status'
json
"COMPLETED"
"COMPLETED"

Armadilhas.

  • Sem webhookSecret na configuração do provedor, o Stripe não tem como ser verificado. Com PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT=on isso vira rejeição — que é o comportamento correto e o motivo de cadastrar o segredo junto da credencial.
  • O evento é deduplicado por (configId, externalEventId). Reenviar o mesmo evento pelo painel do gateway responde 200 e não reprocessa.
  • Falha ao aplicar o efeito não vira erro HTTP: a resposta é 200 com o erro gravado no payment_webhook_events. Isso evita tempestade de retentativa do gateway, mas significa que 200 não é prova de que o efeito aconteceu. Confirme pelo status do intent.
  • Em ambiente local o gateway não alcança seu localhost. Use túnel, ou use a receita seguinte.

Confirmar pagamento sem webhook

Quando o webhook não é possível — desenvolvimento local, firewall, evento perdido — o link de pagamento tem consulta ativa ao provedor:

bash
curl -s -X POST $BASE/payments/api/v1/links/$LINK_ID/sync \
  -H "Authorization: Bearer $TOKEN" | jq '.data.attributes.status'
curl -s -X POST $BASE/payments/api/v1/links/$LINK_ID/sync \
  -H "Authorization: Bearer $TOKEN" | jq '.data.attributes.status'
json
"COMPLETED"
"COMPLETED"
sequenceDiagram
  participant U as Você
  participant Pay as Payments
  participant GW as Provedor
  U->>Pay: POST /links/:id/sync
  Pay->>GW: consulta o link
  GW-->>Pay: status atual
  alt virou COMPLETED e estava ACTIVE
    Pay->>Pay: grava COMPLETED e incrementa usageCount
    Pay-->>U: payments.link.completed publicado uma única vez
  else nada mudou
    Pay-->>U: status atual, sem evento
  end

Armadilhas.

  • sync existe para link de pagamento e para cobrança em cartão salvo. Para os demais intents não há endpoint equivalente hoje (seção 15); o caminho é o webhook.
  • Quando o sync detecta a conclusão, ele incrementa usageCount e publica payments.link.completed uma única vez, na virada de ACTIVE para COMPLETED. Chamar de novo não republica.

Renovar um plano todo mês com cartão salvo

Passo 1 — cliente e cartão, uma vez.

bash
CUS=$(curl -s -X POST $BASE/payments/api/v1/customers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"externalReference":"tenant-3f2a","email":"dono@exemplo.com"}' | jq -r '.data.id')

curl -s -X POST $BASE/payments/api/v1/customers/$CUS/setup-sessions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"successUrl":"https://painel.exemplo.com/ok","cancelUrl":"https://painel.exemplo.com/cancel"}' \
  | jq -r '.data.attributes.url'
CUS=$(curl -s -X POST $BASE/payments/api/v1/customers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"externalReference":"tenant-3f2a","email":"dono@exemplo.com"}' | jq -r '.data.id')

curl -s -X POST $BASE/payments/api/v1/customers/$CUS/setup-sessions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"successUrl":"https://painel.exemplo.com/ok","cancelUrl":"https://painel.exemplo.com/cancel"}' \
  | jq -r '.data.attributes.url'

Mande o cliente para a URL. Na volta, confira: GET /customers/$CUS/payment-methods deve listar o cartão com isDefault: true.

Passo 2 — todo ciclo, sem o cliente.

bash
curl -s -X POST $BASE/payments/api/v1/customers/$CUS/charges \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: renovacao-tenant-3f2a-2026-10" \
  -d '{"amount":49.90,"description":"Plano — outubro",
       "successUrl":"https://painel.exemplo.com/ok","cancelUrl":"https://painel.exemplo.com/cancel"}' \
  | jq '.data.attributes | {outcome, status, recoveryUrl}'
curl -s -X POST $BASE/payments/api/v1/customers/$CUS/charges \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: renovacao-tenant-3f2a-2026-10" \
  -d '{"amount":49.90,"description":"Plano — outubro",
       "successUrl":"https://painel.exemplo.com/ok","cancelUrl":"https://painel.exemplo.com/cancel"}' \
  | jq '.data.attributes | {outcome, status, recoveryUrl}'

Armadilhas.

  • Uma chave por ciclo, sempre a mesma no mesmo ciclo. O agendador que roda de novo, o retry após timeout e o clique duplo usam a mesma chave e recebem a mesma cobrança. Chave nova é cobrança nova.
  • 503 não é recusa: repita com a mesma chave. Só DECLINED/FAILED são respostas do banco.
  • recoveryUrl só existe se você mandar successUrl e cancelUrl na cobrança.
  • Com o MOCK_PAYMENT, o desfecho sai do valor: 402.01 pede autenticação, 402.02 recusa, 402.03 falha, 503 simula indisponibilidade; qualquer outro valor aprova. A sessão de configuração do mock conclui na primeira consulta com um Visa final 4242.
bash
curl -s -X POST $BASE/payments/api/v1/links \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 89.90,
    "title": "Mensalidade — turma de agosto",
    "description": "Acesso até 30/09",
    "maxUsages": 30,
    "allowedMethods": ["PIX", "CREDIT_CARD"],
    "expiresAt": "2026-09-30T23:59:59.000Z"
  }' | jq '.data.attributes | {url, shortUrl, status}'
curl -s -X POST $BASE/payments/api/v1/links \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "amount": 89.90,
    "title": "Mensalidade — turma de agosto",
    "description": "Acesso até 30/09",
    "maxUsages": 30,
    "allowedMethods": ["PIX", "CREDIT_CARD"],
    "expiresAt": "2026-09-30T23:59:59.000Z"
  }' | jq '.data.attributes | {url, shortUrl, status}'
json
{ "url": "https://…", "shortUrl": "https://…", "status": "ACTIVE" }
{ "url": "https://…", "shortUrl": "https://…", "status": "ACTIVE" }
CampoEfeito real
maxUsagesAo ser atingido por confirmação, o link vira COMPLETED
allowedMethodsAceita CREDIT_CARD, DEBIT_CARD, PIX e BOLETO
expiresAtGravado, mas nenhuma rotina o aplica — use deactivate

Armadilhas.

  • expiresAt é gravado, mas não há rotina que expire o link automaticamente hoje. Para encerrar de fato, chame deactivate.
  • maxUsages só é contado quando chega confirmação — webhook ou sync. Sem uma das duas, o contador não anda.
  • allowedMethods aceita CREDIT_CARD, DEBIT_CARD, PIX e BOLETO. Carteira digital não entra aqui, embora exista no enum de método do intent.

Fechar o mês com número congelado

Passo 1 — congelar o snapshot do mês. Exige PAYMENTS_ADMIN.

bash
curl -s -X POST $BASE/payments/api/v1/analytics/compute \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"periodType":"MONTHLY",
       "startDate":"2026-08-01T00:00:00.000Z",
       "endDate":"2026-08-31T23:59:59.000Z"}' \
  | jq '.data.attributes | {totalVolume, successCount, failedCount, avgTicket}'
curl -s -X POST $BASE/payments/api/v1/analytics/compute \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"periodType":"MONTHLY",
       "startDate":"2026-08-01T00:00:00.000Z",
       "endDate":"2026-08-31T23:59:59.000Z"}' \
  | jq '.data.attributes | {totalVolume, successCount, failedCount, avgTicket}'

O snapshot fica gravado em payment_analytics_snapshots e não muda mais, mesmo que intents antigos mudem de status depois.

Passo 2 — ler a série para o gráfico.

bash
curl -s "$BASE/payments/api/v1/analytics/time-series?periodType=MONTHLY" \
  -H "Authorization: Bearer $TOKEN" | jq '.data | length'
curl -s "$BASE/payments/api/v1/analytics/time-series?periodType=MONTHLY" \
  -H "Authorization: Bearer $TOKEN" | jq '.data | length'

A contagem devolvida é o número de snapshots já computados naquele periodType. Zero significa que ninguém rodou compute ainda.

Armadilhas.

  • compute faz upsert por (organização, provedor, tipo de período, início, fim). Rodar de novo com os mesmos limites atualiza o snapshot, não duplica.
  • time-series lê snapshots. Sem compute executado antes, a série volta vazia — não é bug.
  • metrics calcula na hora sobre os intents e não depende de snapshot. Use metrics para o número de agora, time-series para a história.

12

Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token com organizationId e as permissões PAYMENTS_*. Sem ele nenhuma rota respondeSim
BillingO intent carrega billingInvoiceId; os eventos de conclusão permitem dar a fatura por pagaNão
CommerceO intent carrega commerceOrderId, ligando o pagamento ao pedidoNão
Webhooks EngineRepassa os eventos payments.* para os sistemas do clienteNão
BaaSComplementar, não sobreposto — Payments recebe dinheiro, BaaS movimenta contaNão
Audit TrailRegistra quem cadastrou provedor, quem estornou, quem trocou o padrãoNão
CustomersOrigem dos dados de pagador que vão em customerEmail e customerNameNão

Payments não é BaaS, e a diferença importa na venda.

PaymentsBaaS
Direção do dinheiroEntra (você recebe)Sai e entra em conta própria
O que éAceitação: checkout, gateway, adquirenteConta bancária, transferência, chave Pix
ContraparteAdquirente / gateway (Stripe, PagSeguro)Instituição de pagamento (Celcoin, Fitbank, QI Tech)
Objeto centralPayment intentConta e transação
Pergunta que responde"O cliente pagou?""Quanto tenho e para quem transferi?"

Uma operação completa costuma usar os dois: o Payments recebe do cliente final, o BaaS movimenta o saldo depois. Vender um no lugar do outro gera frustração no primeiro mês.

A cadeia completa de uma cobrança

flowchart LR
  IAM["IAM<br/>emite o token"] -.->|organizationId e permissões| PAY
  BILL["Billing"] -->|"fatura vence — billingInvoiceId"| PAY["Payments"]
  COM["Commerce"] -->|"pedido fechado — commerceOrderId"| PAY
  PAY --> GW["Gateway"] --> ADQ["Adquirente"]
  ADQ -.->|webhook| PAY
  PAY -->|"payments.intent.completed<br/>payments.refund.completed"| WH["Webhooks Engine"]
  WH --> SIS["Sistemas do cliente"]
  PAY --> AUD["Audit Trail"]

Atenção. Em todo o caminho, o organizationId vem do token emitido pelo IAM — nunca do corpo da requisição. A única exceção é o endpoint de webhook, que não tem token e recebe a organização por header ou query.

Este diagrama é o argumento comercial: um orquestrador de pagamento avulso resolve a caixa do meio. A fatura que originou a cobrança, o pedido, a entrega do evento e o rastro de auditoria são peças que ele não tem — e que aqui já estão no mesmo catálogo, falando a mesma linguagem de organização e permissão.

Eventos publicados — todos no stream Redis, consumíveis pelo Webhooks Engine:

EventoQuando
payments.intent.createdIntent criado
payments.intent.processingCobrança em andamento no provedor
payments.intent.authorizedAutorizado, aguardando captura
payments.intent.capturedCaptura manual concluída
payments.intent.completedPagamento concluído
payments.intent.failedCobrança recusada ou com erro
payments.intent.cancelledIntent cancelado
payments.refund.createdEstorno criado, ainda não concluído
payments.refund.completedEstorno concluído
payments.refund.failedEstorno falhou
payments.link.createdLink gerado
payments.link.completedLink atingiu o limite de usos ou foi pago
payments.link.deactivatedLink desativado
payments.intent.requires_actionCobrança em cartão salvo pediu autenticação; o cliente conclui pelo link de recuperação
payments.customer.createdCliente criado no provedor
payments.method.savedCartão salvo (por sessão de configuração ou sync)
payments.method.removedCartão removido

O payload de intent carrega intentId, organizationId, status, amount e, quando existem, billingInvoiceId e commerceOrderId — o suficiente para o consumidor conciliar sem consultar de volta.

Atenção. Quando a mudança de status vem de webhook, o payload traz source: "webhook" e o tipo de evento é reduzido a três: payments.intent.completed quando o novo status é COMPLETED, payments.intent.failed quando é FAILED, e payments.intent.processing para qualquer outro — inclusive quando o intent vira AUTHORIZED ou CANCELLED. Consuma o campo status do payload, não só o tipo do evento.


13

Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
PAYMENTS_CREDENTIAL_MASTER_KEYChave mestra AES-256-GCM das credenciais de gateway. 64 caracteres hexadecimais (32 bytes). Gere com openssl rand -hex 32Sim, para usar o módulo—
MODULE_PAYMENTS_PORTPorta no modo standaloneNão3027
MODULE_PAYMENTS_URLURL do módulo, usada por outros BBs em standaloneNão''
PAYMENTS_WEBHOOK_BASE_URLBase pública para montar URLs de webhookNão—
PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENTon rejeita webhook não verificável; off aceita e registra aviso. Use on em produçãoNãooff
DATABASE_URLPostgreSQLSim—
REDIS_URLRedis — stream de eventos e rate limitSim—
DEPLOYMENT_MODEmonolith ou standaloneNãomonolith

A chave mestra é lida direto de process.env no momento do uso. Trocá-la torna ilegíveis todas as credenciais já gravadas — não há rotação automática. Guarde com SOPS, nunca em .env versionado.

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema payments — dez tabelas (três delas do cartão salvo: payment_customers, payment_saved_methods, payment_setup_sessions)
RedisPublicação dos eventos payments.* e contadores de rate limit
Gateway do clienteStripe (api.stripe.com) ou PagSeguro (api.pagseguro.com, ou sandbox.api.pagseguro.com com settings.sandbox = true)

Saída HTTPS para o gateway precisa estar liberada, e a URL de webhook precisa ser alcançável pela internet.

Eventos do Stripe a assinar no endpoint de webhook

EventoPara quê
payment_intent.succeeded, payment_intent.payment_failed, payment_intent.processingStatus de intents e de cobranças em cartão salvo
checkout.session.completed, checkout.session.expired, checkout.session.async_payment_succeededCartão salvo pela sessão de configuração e cobrança concluída pelo link de recuperação
charge.refundedEstornos

Sem webhook o fluxo de cartão salvo continua funcionando por sync (da sessão, dos métodos e da cobrança) — o webhook só tira a necessidade de consultar.

Drivers disponíveis

DriverFala com gateway externoEstado
STRIPESimCobertura completa da interface, com verificação de assinatura de webhook implementada, cartão salvo e cobrança off_session
PAGSEGUROSimPedido, Pix, boleto, captura e estorno via API v4. Link de pagamento e assinatura de webhook incompletos — ver §15
LINKNãoGera links próprios sem gateway. Não movimenta dinheiro
MOCK_PAYMENTNãoSimulador para desenvolvimento e teste. Aprova por padrão; na cobrança de cartão salvo, valores mágicos simulam autenticação, recusa e indisponibilidade (§11)

Limites e quotas

LimiteValor
Tamanho do corpo da requisição1 MB (bodyLimit)
Rate limit globalRATE_LIMIT_GLOBAL_MAX — padrão 10.000 req por janela de 60s
Página padrão / tamanhopage[number]=1, page[size]=20
Precisão monetáriaDecimal(15,2)
Nome da configuração de provedor1 a 100 caracteres, único por organização
Título do link de pagamento1 a 200 caracteres
Janela padrão do analyticsÚltimos 30 dias

Catálogo de erros

StatusCódigoSignificaO que fazer
400—Corpo reprovado no Zod. O corpo da resposta traz o formato do Zod, não o envelope padrãoConfira campos e tipos contra §9
400VALIDATIONTransição de status proibida (Cannot charge intent in status: X)Confira o estado atual na máquina de estados (§8)
400VALIDATIONRefund amount X exceeds maximum refundable amount YConsulte refundedAmount do intent
400VALIDATIONStripe error: ... / PagSeguro error: ... — o gateway recusouA mensagem do provedor vem junto. Para cobrança, veja failureReason na transação
400VALIDATIONInvalid webhook signatureConfira o webhookSecret da configuração contra o do painel do gateway
400VALIDATIONPayment provider config is not activeReative a configuração ou informe outro configId
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove pelo IAM
403FORBIDDENFalta PAYMENTS_READ, PAYMENTS_WRITE, PAYMENTS_REFUND ou PAYMENTS_ADMINConfira as permissões do token
403—Organization context requiredAutentique informando a organização
404NOT_FOUNDIntent, estorno, link ou configuração inexistente ou de outra organizaçãoO 404 é proposital: recurso de outro tenant não existe para você
404NOT_FOUNDDefault payment provider configNenhum provedor marcado como padrão. Use set-default ou passe configId
409CONFLICTidempotencyKey repetida, ou nome de configuração já usadoUse outra chave ou outro nome
503SERVICE_UNAVAILABLE... connection failed no teste de conexãoGateway fora do ar ou credencial inválida

Observabilidade.

  • GET /payments/health devolve nome e versão do serviço. É sonda de liveness, não verifica banco nem gateway.
  • GET /payments/api/v1/webhooks/health confirma que o receptor de webhook está de pé.
  • POST /provider-configs/:id/test é a sonda que realmente importa em operação: ela bate no gateway com a credencial salva. Vale monitorar periodicamente — credencial revogada aparece aqui antes de aparecer no cliente.
  • Toda falha de provedor é logada com o erro completo antes de virar AppError genérico. O motivo real da recusa está no log e em payment_transactions.failure_reason.
  • Webhook aceito sem verificação (enforcement=off) gera aviso no log com o provedor e o motivo. Esse aviso é o que você monitora durante o rollout da verificação.
  • Todo evento payments.* publicado é contabilizado na métrica de eventos por building block.

14

Segurança e compliance

Isolamento entre tenants

Toda rota autenticada aplica requireOrganization, que devolve 403 quando o token não traz organizationId. O organizationId usado nas consultas vem sempre do token (user.organizationId) e nunca do corpo ou da query. Cada getById recarrega o registro e compara a organização antes de devolver — e quando não bate, a resposta é 404, não 403, para não confirmar a existência do recurso alheio. Estorno tem um passo a mais: como PaymentRefund não guarda organização, o serviço sobe até o intent e verifica a organização lá.

flowchart TD
  T["Token do IAM"] --> RO{"Tem organizationId?"}
  RO -->|não| F403["403 — Organization context required"]
  RO -->|sim| Q["Consulta usa user.organizationId,<br/>nunca o corpo nem a query"]
  Q --> CMP{"O recurso é desta organização?"}
  CMP -->|sim| OK["200"]
  CMP -->|não| F404["404 — não confirma que existe"]
  REF["PaymentRefund não guarda organizationId"] -.->|"sobe até o intent<br/>para conferir o tenant"| CMP

A exceção é o endpoint de webhook, que por natureza não tem token. Nele a organização vem do header ou da query, e a confiança está na assinatura do provedor — motivo pelo qual PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT=on e um webhookSecret cadastrado são requisitos de produção, não opcionais.

Dados de cartão: eles não chegam aqui

Isso merece ser dito com precisão, porque é o ponto que mais pesa numa avaliação técnica.

sequenceDiagram
  participant B as Navegador do portador
  participant SDK as SDK do gateway
  participant GW as Gateway
  participant Seu as Seu servidor
  participant Pay as Payments
  B->>SDK: número, CVV e validade digitados
  SDK->>GW: dados do cartão, direto do dispositivo
  GW-->>SDK: paymentToken opaco
  SDK-->>B: paymentToken
  B->>Seu: só o paymentToken
  Seu->>Pay: POST /:id/charge com paymentToken
  Pay->>GW: cobra usando o token
  GW-->>Pay: transação com cardBrand e cardLast4
  Note over Pay: PAN, CVV e trilha nunca transitam nem repousam aqui
  • Nenhum schema Zod da API tem campo para número de cartão, CVV ou validade. Não há como enviar PAN para este building block, mesmo querendo.
  • O que a API aceita é paymentToken — um identificador opaco gerado no navegador ou no aplicativo pelo SDK do próprio gateway (Stripe.js e equivalentes), a partir dos dados que o cliente digita. O dado do cartão vai do dispositivo do portador direto para o gateway.
  • O que fica no nosso banco é o que PaymentTransaction declara: cardBrand e cardLast4 (VarChar(4)). Bandeira e quatro últimos dígitos, que são dados permitidos para exibição e conciliação.
  • Nenhuma tabela guarda PAN, CVV ou trilha magnética, e o providerResponse guardado é a resposta do gateway — que também não devolve esses dados.
DadoOnde vive
PAN, CVV, validade, trilha magnéticaSó no dispositivo do portador e no gateway. Nenhuma tabela nossa
paymentTokenTrafega pela API, não é persistido como credencial
cardBrand, cardLast4 (VarChar(4))payment_transactions — permitidos para exibição e conciliação
Cartão salvo: id do método no provedor (pm_...), brand, last4, expMonth, expYear, funding, countrypayment_saved_methods. O cartão foi digitado na página hospedada do provedor (Checkout mode: setup); o PAN fica no cofre do provedor
providerResponseResposta do gateway, que também não devolve PAN nem CVV

Sobre PCI-DSS, com honestidade

A Catalisa não declara certificação PCI-DSS para este building block, e este documento não deve ser lido como atestado de conformidade. O que está descrito acima é a arquitetura: com tokenização no provedor, os dados de portador de cartão não transitam nem repousam na nossa infraestrutura, o que é exatamente o desenho que reduz o escopo de PCI de quem integra. O enquadramento formal do seu escopo — qual SAQ se aplica, o que precisa ser evidenciado — é determinado pelo seu adquirente e pelo seu avaliador (QSA), a partir da sua implementação de frontend. Se o seu frontend capturar dados de cartão e enviá-los para qualquer servidor seu, o escopo muda, e essa decisão está fora deste building block.

Credenciais de gateway

flowchart LR
  IN["credentials no POST"] --> ENC["AES-256-GCM<br/>PAYMENTS_CREDENTIAL_MASTER_KEY"]
  ENC --> DBF["Grava iv:authTag:ciphertext"]
  DBF --> USO["Decifra só no momento da chamada ao gateway"]
  DBF -.->|"toResponseData omite o campo"| OUT["Nenhuma resposta devolve a chave"]
  DBF -.->|"registro adulterado"| ERR["Falha na decifragem, não credencial errada"]

Cifradas com AES-256-GCM, chave mestra de 32 bytes vinda de PAYMENTS_CREDENTIAL_MASTER_KEY, formato iv:authTag:ciphertext. A cifra é autenticada: adulteração do registro no banco falha na decifragem em vez de produzir credencial silenciosamente errada. A serialização de resposta (toResponseData) não inclui credentials nem webhookSecret — nenhum endpoint devolve a chave do seu gateway, nem para quem tem PAYMENTS_ADMIN. Perdeu, cadastra de novo.

Assinatura de webhook

O corpo cru é preservado antes de qualquer parse, porque o HMAC é calculado sobre os bytes exatos. Para Stripe, a verificação implementa o esquema oficial (t=<timestamp>,v1=<hmac>, HMAC-SHA256 sobre ${t}.${rawBody}), com comparação em tempo constante. Provedores cuja verificação ainda não está implementada seguem a política de PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT — ver §13 e §15.

Autenticação e permissões

Quatro permissões, com separação de dever embutida:

PermissãoConcede
PAYMENTS_READLeitura de intents, estornos, links, configurações, analytics, clientes, cartões salvos e cobranças; sync de link
PAYMENTS_WRITECriar intent, cobrar, capturar, cancelar; criar e desativar link; criar cliente, abrir sessão de cartão, remover/definir cartão padrão, cobrar cartão salvo e os sync desse fluxo
PAYMENTS_REFUNDCriar estorno — separada da escrita de propósito
PAYMENTS_ADMINGerir credenciais de gateway, definir padrão, testar conexão, calcular snapshot

Quem opera cobrança não devolve dinheiro, e quem opera cobrança não vê nem troca credencial de gateway. Essa divisão é o controle que auditoria financeira procura primeiro.

flowchart TD
  R["PAYMENTS_READ<br/>consultar e sincronizar link"]
  W["PAYMENTS_WRITE<br/>criar, cobrar, capturar, cancelar"]
  RF["PAYMENTS_REFUND<br/>devolver dinheiro"]
  AD["PAYMENTS_ADMIN<br/>credenciais, padrão, snapshot"]
  R -.->|"não concede"| W
  W -.->|"não concede"| RF
  W -.->|"não concede"| AD

Retenção e exclusão

A configuração de provedor usa exclusão lógica (deletedAt) — o histórico de pagamento continua apontando para o provedor que o originou depois da remoção. Intents, transações, estornos e eventos de webhook não têm expurgo automático: são registro financeiro e ficam.

LGPD

O BB guarda customerEmail e customerName — dados pessoais — além do payload cru do webhook, que pode conter dados do pagador enviados pelo gateway. Trate payment_webhook_events como base com dado pessoal no seu inventário. Não há rotina automatizada de eliminação; atender a pedido de exclusão exige processo explícito, ponderado contra a obrigação de retenção de registro financeiro.


15

Limitações conhecidas

LimitaçãoImpactoSituação
Sem retentativa automática entre adquirentesVários provedores podem conviver e a troca é por API, mas nada retenta sozinho no segundo quando o primeiro recusa. O roteamento é decisão do chamadorRoadmap — é o que separa o BB de um orquestrador dedicado. Não anuncie failover automático
Sem regra de roteamentoNão há política do tipo "Pix vai no provedor A, cartão no B" nem roteamento por taxa ou por bandeira. Só existe padrão e configId explícitoRoadmap
PagSeguro parcialmente implementadocreatePaymentLink e getPaymentLink devolvem URL construída localmente, sem chamar a API — não é link real do PagSeguro. getRefund devolve valor fixo sem consultar. Verificação de assinatura de webhook não implementadaParcial — para link de pagamento real, use Stripe
Assinatura de webhook só no StripePAGSEGURO e LINK não verificam assinatura. Com PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT=off (padrão) esses webhooks são aceitos com aviso no log; com on, rejeitadosParcial — ligue on em produção e use webhook só com provedor que verifica
Sem sync para payment intent comumPOST /:id/sync existe para link e para cobrança em cartão salvo (/customers/:id/charges/:chargeId/sync). Para os demais intents, a atualização depende de webhookRoadmap
EXPIRED nunca é atingidoExiste no enum de intent e de link, e expiresAt é gravado, mas nenhuma rotina expira nada. Intent abandonado fica PENDING para sempreEspecificado, não implementado
Sem estado de estorno no intentNão existe REFUNDED nem PARTIALLY_REFUNDED. Um intent totalmente estornado continua COMPLETED; a evidência é refundedAmountPor design — mas quebra a expectativa de quem vem do Stripe. Filtre por refundedAmount, não por status
Sem contestação (chargeback)PaymentRefund.isDispute existe no schema e nenhum caminho de código o preenche. Não há fluxo de disputaEspecificado, não implementado
Sem split de pagamentoNão há divisão de valor entre recebedores. Marketplace que precisa de split usa o recurso nativo do adquirenteNão implementado
Cartão salvo só no StripeCliente no provedor, cartão salvo por página hospedada, cobrança off_session com link de recuperação (§9, Cartão salvo e cobrança recorrente) estão entregues para STRIPE e MOCK_PAYMENT. PAGSEGURO e LINK respondem 400 nessas rotas. O agendamento do ciclo (quando cobrar) é de quem chama — o Billing ou o seu produtoEntregue em 2026-09 para Stripe; PagSeguro no roadmap
Sem antecipação de recebível e sem conciliação de liquidaçãoO BB sabe que a cobrança foi aprovada; não sabe quando o dinheiro cai nem quanto de taxa foi retidoFora de escopo — o extrato de liquidação é do adquirente
Analytics conta estorno de forma imprecisarefundCount em GET /analytics/metrics conta os intents do período, não os estornos. Para contagem exata, use GET /refunds com filtro de dataConhecido — o refundVolume é confiável, o refundCount não
Múltiplas configurações do mesmo provedor e webhookCom duas configurações STRIPE ativas na mesma organização, o webhook é associado à primeira encontrada. Duas contas do mesmo gateway na mesma organização não são atendidas corretamenteConhecido — use uma configuração ativa por tipo de provedor por organização
Idempotência falha alto em POST /intentsRepetir criação de intent com a mesma idempotencyKey devolve 409, e não o intent original como faz o Stripe. A cobrança em cartão salvo (POST /customers/:id/charges) já devolve a original (200, replayed: true)Por design em /intents — trate 409 como "já criei", não como erro
Erro de validação com formato próprioO 400 de schema devolve o formato do Zod, diferente do envelope {error, message, details} do resto do catálogoConhecido — trate os dois formatos no cliente
sync de link exige só PAYMENTS_READÉ uma operação que altera dados sob permissão de leituraConhecido — ver §9

16

Perguntas frequentes

PerguntaResposta curta
Quais gateways funcionam hoje?Stripe completo; PagSeguro parcial
Vocês são PCI-DSS certificados?Não declaramos certificação — dado de cartão não entra
Failover automático entre adquirentes?Não — a virada é sua, por API
Payments é BaaS?Não — um recebe, o outro movimenta
Intent parado em PENDING?Quase sempre o webhook não chegou
Dá para estornar mais de uma vez?Sim, até o teto do valor
Cobrança recorrente?Cartão salvo + POST /customers/:id/charges a cada ciclo; o agendamento é do Billing ou do seu produto
Trocar de gateway quebra o histórico?Não — cada intent guarda o configId de origem
Perdi a chave mestra?Credenciais ficam ilegíveis; cadastre de novo
Um adquirente por meio de pagamento?Sim, via configId — mas a decisão é do seu código

Quais gateways funcionam de verdade hoje?

Dois falam com o mundo externo: Stripe, com cobertura completa da interface e verificação de assinatura de webhook, e PagSeguro, com pedido, Pix, boleto, captura e estorno pela API v4 — mas com link de pagamento e verificação de assinatura incompletos (§15). Os outros dois drivers não são gateways: MOCK_PAYMENT é simulador de desenvolvimento e LINK gera links internos sem movimentar dinheiro. Adicionar um adquirente novo é implementar dez métodos de uma interface; nada acima dela muda.

DriverÉ gateway?Cobertura
STRIPESimCompleta, com verificação de assinatura de webhook
PAGSEGUROSimPedido, Pix, boleto, captura e estorno; link e assinatura incompletos (§15)
LINKNãoLinks internos, sem movimentar dinheiro
MOCK_PAYMENTNãoSimulador de desenvolvimento, sempre aprova

Vocês são PCI-DSS certificados?

Não declaramos certificação para este building block, e desconfie de quem responde essa pergunta rápido demais. O que afirmamos, e está no código: a API não tem campo para número de cartão nem CVV, o que trafega é um token gerado pelo SDK do gateway no dispositivo do cliente, e o banco guarda no máximo bandeira e quatro últimos dígitos. Dado de cartão não entra na nossa infraestrutura. O enquadramento formal do seu escopo de PCI é definido pelo seu adquirente e pelo seu avaliador, e depende de como o seu frontend captura os dados. Detalhes na seção 14.

Se o meu adquirente cair, o pagamento vai automaticamente para o outro?

Não. Isso é a pergunta mais importante desta lista, e a resposta honesta é não. O que existe é a fundação: mais de um provedor ativo na mesma organização, escolha por transação via configId, e troca do padrão por chamada de API sem deploy. A decisão de virar a chave é sua ou do seu orquestrador. Retentativa automática está no roadmap (seção 15). Se você precisa dela hoje, um orquestrador dedicado entrega e nós não.

flowchart TD
  F["Adquirente padrão recusa ou cai"] --> D{"Quem decide o que fazer?"}
  D -->|"orquestrador dedicado"| AUT["Retenta sozinho no segundo"]
  D -->|"Catalisa Payments hoje"| MAN["Você chama set-default<br/>ou passa configId na próxima criação"]
  MAN --> OK["Intents seguintes saem pelo secundário, sem deploy"]
  AUT -.->|roadmap| MAN

Qual a diferença entre Payments e BaaS?

Direção do dinheiro. Payments é aceitação: o cliente final paga você, através de um adquirente. BaaS é conta: você tem saldo, transfere por Pix ou TED, gerencia chaves. Um recebe, o outro movimenta. A maioria das operações completas usa os dois, e usar um esperando o comportamento do outro é a confusão mais cara que a gente vê. A tabela comparativa está na seção 12.

Por que meu payment intent continua PENDING depois que o cliente pagou?

Quase sempre porque o webhook não chegou. Confira, nesta ordem: a URL cadastrada no painel do gateway inclui ?organizationId=... ou o header equivalente; o webhookSecret da configuração bate com o do painel; e o host é alcançável pela internet — em localhost o gateway não chega. Para link de pagamento existe POST /links/:id/sync, que consulta o provedor ativamente. Para intent não há equivalente hoje.

flowchart TD
  P["Intent parado em PENDING"] --> Q1{"A URL do painel traz<br/>organizationId?"}
  Q1 -->|não| F1["Corrija a URL no painel do gateway"]
  Q1 -->|sim| Q2{"O webhookSecret bate<br/>com o do painel?"}
  Q2 -->|não| F2["Atualize o webhookSecret da config"]
  Q2 -->|sim| Q3{"O host é alcançável<br/>pela internet?"}
  Q3 -->|não| F3["Use túnel — localhost o gateway não alcança"]
  Q3 -->|sim| Q4{"É um payment link?"}
  Q4 -->|sim| F4["POST /links/:id/sync"]
  Q4 -->|não| F5["Não há sync para intent hoje — §15"]

Posso estornar mais de uma vez o mesmo pagamento?

Pode, desde que a soma não passe do valor. Cada chamada cria um PaymentRefund e soma em intent.refundedAmount; passar do teto volta 400 com o saldo estornável na mensagem. Repare que o status do intent não muda para nada parecido com "estornado" — quem vem do Stripe estranha. Para saber se um pagamento foi devolvido por inteiro, compare refundedAmount com amount.

Situaçãostatus do intentrefundedAmount
Nada estornadoCOMPLETED0
Estorno parcialCOMPLETEDmenor que amount
Estornado por inteiroCOMPLETED — não mudaigual a amount
Estorno acima do saldo400, nada é criadoinalterado

Como faço cobrança recorrente de assinatura?

Com cartão salvo. O cliente cadastra o cartão uma vez na página hospedada do provedor (POST /customers/:id/setup-sessions) e cada ciclo vira um POST /customers/:id/charges com uma idempotencyKey por ciclo — sem o cliente presente. Se o banco pedir autenticação ou recusar, a resposta traz recoveryUrl para o cliente concluir. Quando cobrar continua sendo de quem chama: o Billing gera a fatura no ciclo e manda billingInvoiceId na cobrança, reagindo a payments.intent.completed; um produto com plano simples pode agendar a chamada ele mesmo. Veja a receita em §11. Para pedido de loja, o equivalente é o Commerce com commerceOrderId. Cada uma das duas peças faz uma coisa, e é isso que permite trocar o adquirente sem mexer no ciclo de faturamento.

Trocar de gateway quebra o histórico?

Não. Cada intent guarda o configId que o originou, e a configuração antiga é excluída logicamente — a linha continua no banco. Intents antigos permanecem consultáveis, e capturar ou estornar um deles continua indo para o provedor de origem, que é o único que consegue fazer isso. O que muda de padrão são apenas os intents novos.

flowchart LR
  A["Config antiga<br/>deletedAt preenchido"] --> IA["Intents antigos<br/>configId da config antiga"]
  IA --> OPA["capture e refund<br/>vão para o provedor de origem"]
  B["Config nova<br/>isDefault true"] --> IB["Intents novos"]
  IB --> OPB["Saem pelo provedor novo"]

O que acontece se eu perder a PAYMENTS_CREDENTIAL_MASTER_KEY?

Todas as credenciais de gateway gravadas ficam ilegíveis, e não há recuperação — é cifra autenticada, não codificação. O caminho é cadastrar as credenciais de novo em cada configuração de provedor. Guarde essa chave com SOPS, junto dos outros segredos de produção, e trate a rotação como migração planejada, não como troca de variável.

Consigo usar dois adquirentes ao mesmo tempo, um para Pix e outro para cartão?

Consegue, mas a decisão é do seu código. Crie o intent de Pix passando o configId de um provedor e o de cartão passando o do outro. O que não existe é regra automática que faça esse roteamento por você (seção 15).


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md