Payments
BetaAceite cartão, Pix e boleto por mais de um adquirente com uma API só
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.
- 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ó
- 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
- 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
40 endpoints em 8 recursos.
/payments/api/v1/provider-configs/payments/api/v1/intents/payments/api/v1/refunds/payments/api/v1/links/payments/api/v1/customers/payments/api/v1/webhooks/payments/api/v1/analytics/payments/healthResumo 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.
| Atributo | Valor |
|---|---|
| Identificador | payments |
| Categoria | Financeiro |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3027 |
| Path alias | @payments |
| Prefixo HTTP | /payments |
| Schema do banco | payments |
| Status | Beta desde 2026-03 |
| Depende de | PostgreSQL, Redis, gateway de pagamento do cliente |
O problema
negócioO 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 --> EO 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_captureno Stripe,WAITINGno PagSeguro,pendingem outro. O seu código acaba com umswitchgigante 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 custo | Ordem de grandeza | Fonte |
|---|---|---|
| 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 indevido | 33% dos consumidores | Stripe, State of online fraud |
| Taxa que você não renegocia | 0,99% a 4,99% conforme meio e prazo | Tabelas públicas, consulta em 2026-08-16 |
| Engenharia gasta por gateway | Sem estatística pública — semanas por integração | Estimativa, 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.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| Um SDK de gateway espalhado pelo código do produto | Uma interface PaymentProvider — o produto nunca importa o SDK |
| Trocar de adquirente é reescrever a integração | Trocar de adquirente é POST /provider-configs + set-default |
switch de status por gateway em cada serviço | Status canônico único, mapeado uma vez dentro do provider |
| Webhook com assinatura, dedup e log reimplementados a cada integração | Um endpoint por provedor, com verificação, deduplicação e payload cru guardado |
| Relatório de aprovação em planilha, por gateway | GET /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 intent | O que volta em providerData |
|---|---|
PIX | pixQrCode |
BOLETO | boletoUrl, e boletoBarcode na transação |
CREDIT_CARD / DEBIT_CARD | clientSecret, e cardBrand / cardLast4 na transação |
PAYMENT_LINK | checkoutUrl |
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.
Casos de uso reais
negócioCaso 1 — Uma financeira passa a cobrar a parcela por Pix sem trocar de gateway Cenário ilustrativo
Financeira de crédito pessoal com carteira de 40 mil contratos ativos, cobrança mensal via boleto emitido pelo banco liquidante.
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 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
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
Marketplace de serviços com pico forte em datas comerciais, processando cartão de crédito.
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.
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 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
Empresa de software por assinatura, cerca de 6 mil cobranças de cartão por mês.
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.
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"]
| Consulta | Lê de | Serve para |
|---|---|---|
GET /analytics/metrics | Intents, calculado na hora | O número do momento |
POST /analytics/compute | Intents, grava snapshot | Congelar o fechamento do período |
GET /analytics/time-series | Snapshots já gravados | O gráfico histórico |
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
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.
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 orquestrador | Precisa de mais de um adquirente | O Payments entrega hoje |
|---|---|---|
| Recuperar venda recusada retentando em outro | Sim | Fundação sim, retentativa automática não (§15) |
| Não parar quando um provedor cai | Sim | Sim — troca de padrão por API, sem deploy |
| Poder de negociação por migrar volume | Sim | Sim — 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.
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"| ROADMAPVocê 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
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 2025 | Quantidade | Valor | Variação |
|---|---|---|---|
| Total | 71,3 bi de transações | R$ 29,6 tri | — |
| Entre pessoa e empresa | 30,6 bi de transações | R$ 3,5 tri | +38,4% em quantidade |
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.
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"]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.
Mercado e diferenciais
negócioO 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 2025 | Valor | Quantidade | Variação | Fonte |
|---|---|---|---|---|
| Cartões | R$ 4,5 tri | 48,1 bi de transações | +10,1% sobre 2024 | ABECS, 11/02/2026 |
| Cartões, não presencial | R$ 1,1 tri | — | +18,3% | ABECS |
| Pix, total | R$ 29,6 tri | 71,3 bi de transações | — | Banco Central, consulta em 2026-08-16 |
| Pix, pessoa para empresa | R$ 3,5 tri | 30,6 bi de transações | +38,4% em quantidade | Banco 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ério | Catalisa Payments | Stripe | Adyen | Pagar.me | Malga |
|---|---|---|---|---|---|
| O que é | Camada de orquestração | Gateway + adquirente | Adquirente global | Gateway (Stone) | Orquestrador |
| Processa e liquida | Não | Sim | Sim | Sim | Não |
| Mais de um adquirente | Sim, por organização | Não | Não | Não | Sim |
| Retentativa automática entre adquirentes | Não (ver §15) | N/A | N/A | N/A | Sim |
| Modelo de dados | Payment intent | Payment intent | Payment session | Transação/pedido | Payment intent |
| Vem com identidade, billing e pedido | Sim, mesmo catálogo | Não | Não | Não | Não |
| Dado de cartão na nossa infraestrutura | Não, só token | N/A | N/A | N/A | Não, tokeniza |
| Custo | Precificação em definição | Percentual por transação | Negociado por volume | Percentual por transação | Por 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
- 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.
- 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. - O intent já nasce ligado ao resto da operação.
billingInvoiceIdecommerceOrderIdsã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. - 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íses | Adyen — adquirência própria onde dependemos de quem você contratar |
| Regra automática de retentativa entre adquirentes hoje | Malga ou Yuno — nós não entregamos isso ainda (§15) |
| Velocidade máxima de integração, time pequeno, produto global | Stripe direto — e nós falamos com ele quando você mudar de ideia |
| Solução brasileira única com antecipação, split e conta | Pagar.me, Asaas ou Iugu — sem camada extra |
| Não ficar preso a um adquirente, com pagamento dentro de uma operação maior | Catalisa 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.
Modelo de cobrança e ROI
negócioUnidade 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.
| Driver | Por 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 criados | Cada intent é uma chamada ao gateway e um registro com ciclo de vida próprio |
| Número de provedores configurados por organização | Cada 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 custo | Cenário A — integração direta | Cenário B — com o Payments |
|---|---|---|
| Taxa do adquirente | MDR do adquirente | MDR do(s) adquirente(s) — igual |
| Camada de orquestração | Não existe | Camada Payments |
| Engenharia de integração | Por gateway, toda vez | Da primeira integração, uma vez |
| Manutenção | Por gateway | Do contrato canônico, uma vez |
| Custo de migrar de adquirente | Alto — e por isso adiado | ≈ cadastro de provedor |
| Venda perdida sem segunda tentativa | Entra na conta | Fundaçã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"]
endO 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.
| Fornecedor | Crédito à vista | Débito | Pix | Fonte |
|---|---|---|---|---|
| Stripe Brasil | 3,99% + R$ 0,39 | 3,99% + R$ 0,39 | 1,19% | stripe.com/br/pricing |
| PagBank (repasse em 30 dias) | 3,19% | 1,99% | 0% nos 30 primeiros dias | pagbank.com.br |
| PagBank (repasse na hora) | 4,99% | 1,99% | idem | idem |
| Cielo (plano aluguel) | 3,45% | 1,19% | 0,99% após 30 dias | cielo.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 PagBank | Crédito à vista | Diferença |
|---|---|---|
| Em 30 dias | 3,19% | referência |
| Na hora | 4,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 ano | Faixa |
|---|---|
| Pix, tabela | 0,99% a 1,19% |
| Crédito à vista, tabela | 3,19% a 4,99% |
| Diferença anual | R$ 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.
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
| Prefixo | Router | Rotas |
|---|---|---|
/api/v1/provider-configs | providerConfigRouter | 7 |
/api/v1/intents | paymentIntentRouter | 6 |
/api/v1/refunds | refundRouter | 3 |
/api/v1/links | paymentLinkRouter | 5 |
/api/v1/webhooks | webhookRouter | 2 |
/api/v1/analytics | analyticsRouter | 3 |
/api/v1/customers | customerRouter | 13 |
/health | — | versão do serviço |
Os serviços
| Serviço | Responsabilidade |
|---|---|
PaymentProviderConfigService | Credenciais, provedor padrão, fábrica de driver |
PaymentIntentService | Criar, listar, cancelar |
PaymentProcessingService | charge, capture, tentativas |
RefundService | Estorno total e parcial |
PaymentLinkService | Link, sync, desativação |
WebhookHandlerService | Assinatura, deduplicação, efeito |
AnalyticsService | Métricas e snapshots |
SavedPaymentMethodService | Cliente 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ão | Trade-off aceito |
|---|---|
| Modelo copiado do Stripe | Ficamos parecidos com um provedor específico |
| Provider instanciado por requisição | Um objeto novo por chamada |
| Credencial cifrada, não hasheada | A chave mestra vira dependência crítica |
| Corpo cru do webhook preservado | O router precisa ler texto antes do parse |
| Interruptor de verificação de assinatura | O padrão off aceita webhook não verificado |
| Evento publicado depois da escrita | Evento pode se perder sem afetar o pagamento |
| Idempotência falha alto | 409 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.
| Valor | Provider não consegue verificar | Uso previsto |
|---|---|---|
off (padrão) | Aceita e grava aviso no log | Rollout, desenvolvimento |
on | Rejeita com 400 | Produçã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.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
| Provider config | A 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 intent | A intenção de cobrar um valor. É o registro que vive do início ao fim e responde "em que pé está essa cobrança". |
| Transaction | Uma tentativa concreta de cobrança dentro de um intent, numerada por attemptNumber. Um intent pode ter várias. |
| Refund | Um estorno, total ou parcial, sobre um intent já capturado ou concluído. |
| Payment link | Uma URL de cobrança autocontida, com valor, título, métodos permitidos e limite de usos. |
| Webhook event | O evento cru recebido do gateway, guardado com payload completo e marca de processamento. |
| Analytics snapshot | Métricas congeladas de um período, para não recalcular agregação a cada consulta. |
| Capture mode | AUTOMATIC cobra e finaliza numa etapa; MANUAL autoriza agora e captura depois. |
| Checkout type | Onde o cliente digita os dados: TRANSPARENT (na sua tela), EMBEDDED (componente do provedor), HOSTED (página do provedor), PAYMENT_LINK. |
| Canonical | O formato neutro de resposta (CanonicalPaymentIntent e afins). É o que sai do provider e o que o resto do BB entende. |
| Customer | O cliente no provedor (cus_... no Stripe), um por (organização, configuração, externalReference). É a quem os cartões salvos pertencem. |
| Saved method | Cartão salvo no cofre do provedor. Aqui fica só o id (pm_...), bandeira, últimos 4, validade e se é o padrão. |
| Setup session | Página hospedada do provedor (Checkout mode: setup) onde o cliente cadastra o cartão sem pagar nada. |
| Cobrança off_session | Cobranç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 Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
PaymentProviderConfig | payments.payment_provider_configs | Gateway configurado para uma organização | credentials (cifrado), webhookSecret, isDefault, isActive, isLiveMode, único (organizationId, name) |
PaymentIntent | payments.payment_intents | Ciclo de vida de uma cobrança | status, amount, capturedAmount, refundedAmount, externalId, idempotencyKey (único), billingInvoiceId, commerceOrderId |
PaymentCustomer | payments.payment_customers | Cliente no provedor | externalId (cus_...), único (organizationId, configId, externalReference) |
PaymentSavedMethod | payments.payment_saved_methods | Cartão salvo — sem PAN | externalId (pm_...), brand, last4, expMonth, expYear, isDefault, único (customerId, externalId) |
PaymentSetupSession | payments.payment_setup_sessions | Sessão hospedada de cadastro de cartão | externalId (cs_...), status (OPEN/COMPLETED/EXPIRED), url, savedMethodId |
PaymentTransaction | payments.payment_transactions | Cada tentativa de cobrança | attemptNumber, status, cardBrand, cardLast4, pixQrCode, boletoBarcode, failureReason |
PaymentRefund | payments.payment_refunds | Estorno de um intent | amount, status, reason, isDispute |
PaymentLink | payments.payment_links | Link de cobrança | url, shortUrl, status, maxUsages, usageCount, allowedMethods |
PaymentWebhookEvent | payments.payment_webhook_events | Evento cru do gateway | payload, processed, error, único (configId, externalEventId) |
PaymentAnalyticsSnapshot | payments.payment_analytics_snapshots | Métricas congeladas por período | periodType, 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
| Enum | Valores |
|---|---|
PaymentProviderType | STRIPE · PAGSEGURO · MOCK_PAYMENT · LINK |
PaymentIntentStatus | PENDING · PROCESSING · AUTHORIZED · CAPTURED · COMPLETED · FAILED · CANCELLED · EXPIRED |
PaymentMethodType | CREDIT_CARD · DEBIT_CARD · PIX · BOLETO · PAYMENT_LINK · WALLET_APPLE_PAY · WALLET_GOOGLE_PAY |
CheckoutType | TRANSPARENT · EMBEDDED · HOSTED · PAYMENT_LINK |
PaymentCaptureMode | AUTOMATIC · MANUAL |
PaymentTransactionStatus | PENDING · PROCESSING · AUTHORIZED · CAPTURED · FAILED · CANCELLED |
PaymentRefundStatus | PENDING · PROCESSING · COMPLETED · FAILED |
PaymentLinkStatus | ACTIVE · EXPIRED · DEACTIVATED · COMPLETED |
AnalyticsPeriodType | DAILY · 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 noteTerminais: 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 provedor | Intent vira | Observação |
|---|---|---|
succeeded · paid · completed · available | COMPLETED | Grava completedAt, e capturedAmount quando o evento traz valor |
processing · in_analysis | PROCESSING | — |
requires_capture · authorized | AUTHORIZED | — |
failed · declined | FAILED | — |
canceled · cancelled | CANCELLED | — |
| Qualquer outro | Nada muda | Status 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 noteO 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 devolveu | captureMode | Intent vira |
|---|---|---|
CAPTURED | AUTOMATIC | COMPLETED |
CAPTURED | MANUAL | AUTHORIZED |
AUTHORIZED | qualquer | AUTHORIZED |
FAILED ou erro do provedor | qualquer | FAILED, com transação FAILED registrada |
Regras de transição que o código impõe
| Operação | Só a partir de | Erro se fora disso |
|---|---|---|
POST /:id/charge | PENDING | 400 — Cannot charge intent in status: X |
POST /:id/capture | AUTHORIZED | 400 — Cannot capture intent in status: X. Must be AUTHORIZED. |
POST /:id/cancel | PENDING ou AUTHORIZED | 400 — Cannot cancel intent in status: X |
POST /refunds | intent em CAPTURED ou COMPLETED | 400 — Must be CAPTURED or COMPLETED. |
POST /links/:id/deactivate | link ACTIVE | 400 — 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 virada | Efeito |
|---|---|
POST /:id/deactivate sobre link ACTIVE | Status vira DEACTIVATED, no provedor e localmente |
| Webhook de pagamento do link | Incrementa usageCount; atingindo maxUsages, status vira COMPLETED e publica payments.link.completed |
POST /:id/sync | Consulta 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.
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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /payments/api/v1/provider-configs | Cadastra um gateway (testa a conexão antes de salvar) | PAYMENTS_ADMIN |
GET | /payments/api/v1/provider-configs | Lista configurações, paginado | PAYMENTS_READ |
GET | /payments/api/v1/provider-configs/:id | Busca uma configuração | PAYMENTS_READ |
PATCH | /payments/api/v1/provider-configs/:id | Atualiza (revalida conexão se trocar credencial) | PAYMENTS_ADMIN |
DELETE | /payments/api/v1/provider-configs/:id | Exclusão lógica. Responde 204 | PAYMENTS_ADMIN |
POST | /payments/api/v1/provider-configs/:id/set-default | Marca como padrão da organização | PAYMENTS_ADMIN |
POST | /payments/api/v1/provider-configs/:id/test | Testa a conexão com as credenciais salvas | PAYMENTS_ADMIN |
Filtros da listagem: filter[providerType], filter[isActive].
Payment intents — /payments/api/v1/intents
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /payments/api/v1/intents | Cria a intenção de cobrança. Responde 201 | PAYMENTS_WRITE |
GET | /payments/api/v1/intents | Lista intents, paginado | PAYMENTS_READ |
GET | /payments/api/v1/intents/:id | Busca um intent | PAYMENTS_READ |
POST | /payments/api/v1/intents/:id/charge | Executa a cobrança no provedor | PAYMENTS_WRITE |
POST | /payments/api/v1/intents/:id/capture | Captura um valor autorizado | PAYMENTS_WRITE |
POST | /payments/api/v1/intents/:id/cancel | Cancela o intent no provedor e localmente | PAYMENTS_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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /payments/api/v1/refunds | Cria estorno total ou parcial. Responde 201 | PAYMENTS_REFUND |
GET | /payments/api/v1/refunds | Lista estornos, paginado | PAYMENTS_READ |
GET | /payments/api/v1/refunds/:id | Busca um estorno | PAYMENTS_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.
Links de pagamento — /payments/api/v1/links
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /payments/api/v1/links | Cria link de cobrança. Responde 201 | PAYMENTS_WRITE |
GET | /payments/api/v1/links | Lista links, paginado | PAYMENTS_READ |
GET | /payments/api/v1/links/:id | Busca um link | PAYMENTS_READ |
POST | /payments/api/v1/links/:id/sync | Consulta o provedor e atualiza o status local | PAYMENTS_READ |
POST | /payments/api/v1/links/:id/deactivate | Desativa o link no provedor e localmente | PAYMENTS_WRITE |
Filtros da listagem: filter[configId], filter[status].
syncaltera dados mas exige apenasPAYMENTS_READno 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, tratesynccomo exceção conhecida.
Clientes e cartão salvo — /payments/api/v1/customers
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /payments/api/v1/customers | Cria ou devolve o cliente no provedor por externalReference. 201 se criou, 200 se já existia | PAYMENTS_WRITE |
GET | /payments/api/v1/customers | Lista clientes, paginado | PAYMENTS_READ |
GET | /payments/api/v1/customers/:id | Busca um cliente | PAYMENTS_READ |
POST | /payments/api/v1/customers/:id/setup-sessions | Abre a página hospedada para o cliente cadastrar o cartão. Responde 201 com url | PAYMENTS_WRITE |
GET | /payments/api/v1/customers/:id/setup-sessions/:sessionId | Estado da sessão, com o método gravado quando concluída | PAYMENTS_READ |
POST | /payments/api/v1/customers/:id/setup-sessions/:sessionId/sync | Consulta o provedor e, se o cliente concluiu, grava o cartão | PAYMENTS_WRITE |
GET | /payments/api/v1/customers/:id/payment-methods | Cartões salvos, o padrão primeiro | PAYMENTS_READ |
POST | /payments/api/v1/customers/:id/payment-methods/sync | Reconcilia com o provedor (grava o que existe lá, apaga o que sumiu) | PAYMENTS_WRITE |
POST | /payments/api/v1/customers/:id/payment-methods/:methodId/default | Define o cartão padrão | PAYMENTS_WRITE |
DELETE | /payments/api/v1/customers/:id/payment-methods/:methodId | Desanexa no provedor e remove. Responde 204 | PAYMENTS_WRITE |
POST | /payments/api/v1/customers/:id/charges | Cobra o cartão salvo sem o cliente presente. idempotencyKey obrigatória | PAYMENTS_WRITE |
GET | /payments/api/v1/customers/:id/charges/:chargeId | Estado da cobrança | PAYMENTS_READ |
POST | /payments/api/v1/customers/:id/charges/:chargeId/sync | Consulta 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étodo | Rota | Descrição | Autenticação |
|---|---|---|---|
POST | /payments/api/v1/webhooks/:providerType | Recebe evento do gateway | Sem JWT — assinatura do provedor |
GET | /payments/api/v1/webhooks/health | Sonda simples do receptor | Pú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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /payments/api/v1/analytics/metrics | Métricas agregadas do período | PAYMENTS_READ |
GET | /payments/api/v1/analytics/time-series | Série histórica a partir dos snapshots | PAYMENTS_READ |
POST | /payments/api/v1/analytics/compute | Calcula e grava um snapshot. Responde 201 | PAYMENTS_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étodo | Rota | Descrição |
|---|---|---|
GET | /payments/health | Nome 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
{
"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 }
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name | string (1–100) | Sim | Nome interno. Único dentro da organização |
providerType | STRIPE | PAGSEGURO | MOCK_PAYMENT | LINK | Sim | Qual driver usar |
credentials | object<string,string> | Sim | Chaves do gateway. Cifradas na gravação, nunca devolvidas |
isDefault | boolean | Não | Marca como padrão da organização |
isActive | boolean | Não | Padrão true |
isLiveMode | boolean | Não | Padrão false. É rótulo informativo, não altera endpoint |
webhookSecret | string | Não | Segredo usado para verificar a assinatura do webhook |
settings | object | Não | Ajustes do driver, ex.: {"sandbox": true} no PagSeguro |
Chaves esperadas em credentials por driver:
| Driver | Chaves | Observação |
|---|---|---|
STRIPE | secretKey | Chave secreta da API |
PAGSEGURO | token, opcionalmente sandbox: "true" | Token da API v4 |
LINK | baseUrl (opcional) | Gera links próprios, sem gateway externo |
MOCK_PAYMENT | nenhuma | Objeto vazio {} serve |
Resposta 201
{
"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
| Status | Quando |
|---|---|
400 | Corpo reprovado no Zod, ou Failed to connect to provider: ... |
403 | Sem PAYMENTS_ADMIN, ou token sem organizationId |
409 | Já 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
{
"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"
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
amount | number > 0 | Sim | Valor em unidade monetária, não em centavos |
currency | string (3) | Não | Padrão BRL |
configId | uuid | Não | Provedor a usar. Omitido, usa o padrão da organização |
paymentMethod | enum | Não | CREDIT_CARD, DEBIT_CARD, PIX, BOLETO, PAYMENT_LINK, WALLET_APPLE_PAY, WALLET_GOOGLE_PAY |
captureMode | AUTOMATIC | MANUAL | Não | Padrão AUTOMATIC |
checkoutType | enum | Não | Padrão TRANSPARENT |
customerEmail | string (e-mail) | Não | Repassado ao provedor |
customerName | string | Não | Repassado ao provedor |
description | string | Não | Aparece na fatura do cliente em vários gateways |
metadata | object | Não | Livre, guardado como JSONB |
idempotencyKey | string | Não | Único no banco. Repetir devolve 409 |
billingInvoiceId | uuid | Não | Fatura do Billing que originou a cobrança |
commerceOrderId | uuid | Não | Pedido do Commerce que originou a cobrança |
successUrl / cancelUrl | string (URL) | Não | Usados no checkout hospedado |
paymentToken | string | Não | Token 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:
{
"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
| Status | Quando |
|---|---|
400 | Corpo inválido, ou o provedor recusou (Stripe error: ..., PagSeguro error: ...) |
403 | Sem PAYMENTS_WRITE, ou token sem organizationId |
404 | configId não existe na organização, ou não há provedor padrão configurado |
409 | idempotencyKey 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)
{ "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 devolveu | captureMode | Status do intent | Evento publicado |
|---|---|---|---|
CAPTURED | AUTOMATIC | COMPLETED, com capturedAmount = amount | payments.intent.completed |
CAPTURED | MANUAL | AUTHORIZED | payments.intent.authorized |
AUTHORIZED | qualquer | AUTHORIZED | payments.intent.authorized |
FAILED ou erro | qualquer | FAILED | payments.intent.failed |
Erros
| Status | Quando |
|---|---|
400 | Cannot charge intent in status: X — só PENDING cobra |
400 | Provedor recusou. A transação fica gravada como FAILED com o motivo |
404 | Intent não existe nesta organização |
POST /payments/api/v1/refunds
Estorna total ou parcialmente.
Request
{
"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"
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
intentId | uuid | Sim | Intent a estornar. Precisa estar CAPTURED ou COMPLETED |
amount | number > 0 | Não | Omitido, estorna o valor cheio do intent |
reason | string | Não | Motivo, 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
| Status | Quando |
|---|---|
400 | Intent em status não estornável, ou amount acima do saldo estornável |
403 | Sem PAYMENTS_REFUND |
404 | Intent 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:
- Valida se
:providerTypeé conhecido. - Lê
X-Organization-Id(header) ouorganizationId(query). Sem isso,400. - Preserva o corpo cru antes de qualquer parse — é sobre esses bytes que o HMAC é calculado.
- Localiza a configuração ativa daquele tipo de provedor na organização.
- Verifica a assinatura com o
webhookSecretda configuração. - Deduplica por
(configId, externalEventId)— evento repetido responde200sem reprocessar. - Guarda o evento cru em
payment_webhook_events. - Aplica o efeito: primeiro os eventos de Checkout do fluxo de cartão salvo (
checkout.session.completed/expiredde sessãomode: setupgrava 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. - 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
{ "received": true }{ "received": true }Erros
| Status | Quando |
|---|---|
400 | Unknown provider type: X, corpo não é JSON, ou falta a organização |
400 | Invalid webhook signature |
404 | Não há configuração ativa desse provedor nessa organização |
A URL a cadastrar no painel do gateway fica assim:
https://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAOhttps://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAOCartã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
endPOST /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.
{
"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)
{
"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
{ "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:
{
"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
{
"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
| Campo | Tipo | Obrigatório | Nota |
|---|---|---|---|
amount | number | Sim | Em reais (49.90) |
currency | string(3) | Não | BRL |
idempotencyKey | string 8–200 | Sim | Também aceita no header Idempotency-Key. Use uma por ciclo: renovacao-<tenant>-2026-10 |
paymentMethodId | uuid | Não | Sem ele, cobra o cartão padrão |
description | string | Não | Aparece no extrato do Stripe e no link de recuperação |
billingInvoiceId | uuid | Não | Ligação com a fatura do Billing |
metadata | objeto | Não | Guardado no intent |
successUrl, cancelUrl | url | Não | Para 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):
{
"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
}
}
}outcome | status do intent | O que fazer |
|---|---|---|
SUCCEEDED | COMPLETED | Pago. Renove |
PROCESSING | PROCESSING | Aceito; a confirmação chega por webhook (payment_intent.succeeded) ou …/charges/:chargeId/sync |
REQUIRES_ACTION | PENDING | O banco pediu autenticação (3DS). Mande recoveryUrl ao cliente |
DECLINED | FAILED | Recusado (declineCode: insufficient_funds, expired_card…). Mande recoveryUrl — o cliente pode usar outro cartão, que fica salvo |
FAILED | FAILED | Erro 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
| Status | Quando |
|---|---|
400 | Corpo inválido (Zod), cliente sem cartão salvo, provedor sem suporte a cartão salvo |
404 | Cliente, método ou cobrança inexistente ou de outra organização |
409 | A 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 |
503 | Provedor 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.
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
docker-compose up -d postgres redis minio
export PAYMENTS_CREDENTIAL_MASTER_KEY=$(openssl rand -hex 32)
bun run devdocker-compose up -d postgres redis minio
export PAYMENTS_CREDENTIAL_MASTER_KEY=$(openssl rand -hex 32)
bun run dev1. Autenticar
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
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"{ "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
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}'{
"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
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}'{ "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
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}'{ "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
curl -s "$BASE/payments/api/v1/analytics/metrics" \
-H "Authorization: Bearer $TOKEN" | jq .datacurl -s "$BASE/payments/api/v1/analytics/metrics" \
-H "Authorization: Bearer $TOKEN" | jq .dataA resposta traz os campos que o AnalyticsService calcula na hora, sobre a janela padrão de 30 dias:
| Campo | O que é |
|---|---|
totalVolume | Volume transacionado no período |
totalCount | Quantidade de intents no período |
successCount / failedCount | Aprovadas e recusadas |
successRate | successCount / totalCount, em percentual |
avgTicket | totalVolume / successCount |
refundVolume | Volume estornado — confiável |
refundCount | Contagem 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.
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.
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.
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'"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.
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}'{ "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
AUTHORIZEDno 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
CAPTUREDnão há novocapture. 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.
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.
curl -s -X POST $BASE/payments/api/v1/provider-configs/$SECUNDARIO/test \
-H "Authorization: Bearer $TOKEN" | jqcurl -s -X POST $BASE/payments/api/v1/provider-configs/$SECUNDARIO/test \
-H "Authorization: Bearer $TOKEN" | jqEsse endpoint bate no gateway com a credencial salva. Credencial revogada aparece aqui como 503, não no primeiro cliente.
Passo 3 — promover.
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'truetrueArmadilhas.
set-defaultafeta intents novos. Os que já existem continuam amarrados aoconfigIdde 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/testantes. É 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.
https://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAOhttps://SEU-HOST/payments/api/v1/webhooks/stripe?organizationId=SUA-ORGANIZACAOA 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.
# 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'"COMPLETED""COMPLETED"Armadilhas.
- Sem
webhookSecretna configuração do provedor, o Stripe não tem como ser verificado. ComPROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT=onisso 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 responde200e não reprocessa. - Falha ao aplicar o efeito não vira erro HTTP: a resposta é
200com o erro gravado nopayment_webhook_events. Isso evita tempestade de retentativa do gateway, mas significa que200nã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:
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'"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
endArmadilhas.
syncexiste 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
syncdetecta a conclusão, ele incrementausageCounte publicapayments.link.completeduma única vez, na virada deACTIVEparaCOMPLETED. Chamar de novo não republica.
Renovar um plano todo mês com cartão salvo
Passo 1 — cliente e cartão, uma vez.
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.
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.
503não é recusa: repita com a mesma chave. SóDECLINED/FAILEDsão respostas do banco.recoveryUrlsó existe se você mandarsuccessUrlecancelUrlna cobrança.- Com o
MOCK_PAYMENT, o desfecho sai do valor:402.01pede autenticação,402.02recusa,402.03falha,503simula indisponibilidade; qualquer outro valor aprova. A sessão de configuração do mock conclui na primeira consulta com um Visa final 4242.
Gerar um link de cobrança com limite de usos
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}'{ "url": "https://…", "shortUrl": "https://…", "status": "ACTIVE" }{ "url": "https://…", "shortUrl": "https://…", "status": "ACTIVE" }| Campo | Efeito real |
|---|---|
maxUsages | Ao ser atingido por confirmação, o link vira COMPLETED |
allowedMethods | Aceita CREDIT_CARD, DEBIT_CARD, PIX e BOLETO |
expiresAt | Gravado, 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, chamedeactivate.maxUsagessó é contado quando chega confirmação — webhook ousync. Sem uma das duas, o contador não anda.allowedMethodsaceitaCREDIT_CARD,DEBIT_CARD,PIXeBOLETO. 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.
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.
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.
computefaz 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-serieslê snapshots. Semcomputeexecutado antes, a série volta vazia — não é bug.metricscalcula na hora sobre os intents e não depende de snapshot. Usemetricspara o número de agora,time-seriespara a história.
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token com organizationId e as permissões PAYMENTS_*. Sem ele nenhuma rota responde | Sim |
| Billing | O intent carrega billingInvoiceId; os eventos de conclusão permitem dar a fatura por paga | Não |
| Commerce | O intent carrega commerceOrderId, ligando o pagamento ao pedido | Não |
| Webhooks Engine | Repassa os eventos payments.* para os sistemas do cliente | Não |
| BaaS | Complementar, não sobreposto — Payments recebe dinheiro, BaaS movimenta conta | Não |
| Audit Trail | Registra quem cadastrou provedor, quem estornou, quem trocou o padrão | Não |
| Customers | Origem dos dados de pagador que vão em customerEmail e customerName | Não |
Payments não é BaaS, e a diferença importa na venda.
| Payments | BaaS | |
|---|---|---|
| Direção do dinheiro | Entra (você recebe) | Sai e entra em conta própria |
| O que é | Aceitação: checkout, gateway, adquirente | Conta bancária, transferência, chave Pix |
| Contraparte | Adquirente / gateway (Stripe, PagSeguro) | Instituição de pagamento (Celcoin, Fitbank, QI Tech) |
| Objeto central | Payment intent | Conta 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:
| Evento | Quando |
|---|---|
payments.intent.created | Intent criado |
payments.intent.processing | Cobrança em andamento no provedor |
payments.intent.authorized | Autorizado, aguardando captura |
payments.intent.captured | Captura manual concluída |
payments.intent.completed | Pagamento concluído |
payments.intent.failed | Cobrança recusada ou com erro |
payments.intent.cancelled | Intent cancelado |
payments.refund.created | Estorno criado, ainda não concluído |
payments.refund.completed | Estorno concluído |
payments.refund.failed | Estorno falhou |
payments.link.created | Link gerado |
payments.link.completed | Link atingiu o limite de usos ou foi pago |
payments.link.deactivated | Link desativado |
payments.intent.requires_action | Cobrança em cartão salvo pediu autenticação; o cliente conclui pelo link de recuperação |
payments.customer.created | Cliente criado no provedor |
payments.method.saved | Cartão salvo (por sessão de configuração ou sync) |
payments.method.removed | Cartã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.
Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
PAYMENTS_CREDENTIAL_MASTER_KEY | Chave mestra AES-256-GCM das credenciais de gateway. 64 caracteres hexadecimais (32 bytes). Gere com openssl rand -hex 32 | Sim, para usar o módulo | — |
MODULE_PAYMENTS_PORT | Porta no modo standalone | Não | 3027 |
MODULE_PAYMENTS_URL | URL do módulo, usada por outros BBs em standalone | Não | '' |
PAYMENTS_WEBHOOK_BASE_URL | Base pública para montar URLs de webhook | Não | — |
PROVIDER_WEBHOOK_SIGNATURE_ENFORCEMENT | on rejeita webhook não verificável; off aceita e registra aviso. Use on em produção | Não | off |
DATABASE_URL | PostgreSQL | Sim | — |
REDIS_URL | Redis — stream de eventos e rate limit | Sim | — |
DEPLOYMENT_MODE | monolith ou standalone | Não | monolith |
A chave mestra é lida direto de
process.envno 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.envversionado.
Dependências de infraestrutura
| Dependência | Para quê |
|---|---|
| PostgreSQL | Schema payments — dez tabelas (três delas do cartão salvo: payment_customers, payment_saved_methods, payment_setup_sessions) |
| Redis | Publicação dos eventos payments.* e contadores de rate limit |
| Gateway do cliente | Stripe (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
| Evento | Para quê |
|---|---|
payment_intent.succeeded, payment_intent.payment_failed, payment_intent.processing | Status de intents e de cobranças em cartão salvo |
checkout.session.completed, checkout.session.expired, checkout.session.async_payment_succeeded | Cartão salvo pela sessão de configuração e cobrança concluída pelo link de recuperação |
charge.refunded | Estornos |
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
| Driver | Fala com gateway externo | Estado |
|---|---|---|
STRIPE | Sim | Cobertura completa da interface, com verificação de assinatura de webhook implementada, cartão salvo e cobrança off_session |
PAGSEGURO | Sim | Pedido, Pix, boleto, captura e estorno via API v4. Link de pagamento e assinatura de webhook incompletos — ver §15 |
LINK | Não | Gera links próprios sem gateway. Não movimenta dinheiro |
MOCK_PAYMENT | Não | Simulador 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
| Limite | Valor |
|---|---|
| Tamanho do corpo da requisição | 1 MB (bodyLimit) |
| Rate limit global | RATE_LIMIT_GLOBAL_MAX — padrão 10.000 req por janela de 60s |
| Página padrão / tamanho | page[number]=1, page[size]=20 |
| Precisão monetária | Decimal(15,2) |
| Nome da configuração de provedor | 1 a 100 caracteres, único por organização |
| Título do link de pagamento | 1 a 200 caracteres |
| Janela padrão do analytics | Últimos 30 dias |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | — | Corpo reprovado no Zod. O corpo da resposta traz o formato do Zod, não o envelope padrão | Confira campos e tipos contra §9 |
400 | VALIDATION | Transição de status proibida (Cannot charge intent in status: X) | Confira o estado atual na máquina de estados (§8) |
400 | VALIDATION | Refund amount X exceeds maximum refundable amount Y | Consulte refundedAmount do intent |
400 | VALIDATION | Stripe error: ... / PagSeguro error: ... — o gateway recusou | A mensagem do provedor vem junto. Para cobrança, veja failureReason na transação |
400 | VALIDATION | Invalid webhook signature | Confira o webhookSecret da configuração contra o do painel do gateway |
400 | VALIDATION | Payment provider config is not active | Reative a configuração ou informe outro configId |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove pelo IAM |
403 | FORBIDDEN | Falta PAYMENTS_READ, PAYMENTS_WRITE, PAYMENTS_REFUND ou PAYMENTS_ADMIN | Confira as permissões do token |
403 | — | Organization context required | Autentique informando a organização |
404 | NOT_FOUND | Intent, estorno, link ou configuração inexistente ou de outra organização | O 404 é proposital: recurso de outro tenant não existe para você |
404 | NOT_FOUND | Default payment provider config | Nenhum provedor marcado como padrão. Use set-default ou passe configId |
409 | CONFLICT | idempotencyKey repetida, ou nome de configuração já usado | Use outra chave ou outro nome |
503 | SERVICE_UNAVAILABLE | ... connection failed no teste de conexão | Gateway fora do ar ou credencial inválida |
Observabilidade.
GET /payments/healthdevolve nome e versão do serviço. É sonda de liveness, não verifica banco nem gateway.GET /payments/api/v1/webhooks/healthconfirma 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
AppErrorgenérico. O motivo real da recusa está no log e empayment_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.
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"| CMPA 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
PaymentTransactiondeclara:cardBrandecardLast4(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
providerResponseguardado é a resposta do gateway — que também não devolve esses dados.
| Dado | Onde vive |
|---|---|
| PAN, CVV, validade, trilha magnética | Só no dispositivo do portador e no gateway. Nenhuma tabela nossa |
paymentToken | Trafega 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, country | payment_saved_methods. O cartão foi digitado na página hospedada do provedor (Checkout mode: setup); o PAN fica no cofre do provedor |
providerResponse | Resposta 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ão | Concede |
|---|---|
PAYMENTS_READ | Leitura de intents, estornos, links, configurações, analytics, clientes, cartões salvos e cobranças; sync de link |
PAYMENTS_WRITE | Criar 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_REFUND | Criar estorno — separada da escrita de propósito |
PAYMENTS_ADMIN | Gerir 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.
Limitações conhecidas
| Limitação | Impacto | Situação |
|---|---|---|
| Sem retentativa automática entre adquirentes | Vários provedores podem conviver e a troca é por API, mas nada retenta sozinho no segundo quando o primeiro recusa. O roteamento é decisão do chamador | Roadmap — é o que separa o BB de um orquestrador dedicado. Não anuncie failover automático |
| Sem regra de roteamento | Nã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ícito | Roadmap |
| PagSeguro parcialmente implementado | createPaymentLink 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 implementada | Parcial — para link de pagamento real, use Stripe |
| Assinatura de webhook só no Stripe | PAGSEGURO 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, rejeitados | Parcial — ligue on em produção e use webhook só com provedor que verifica |
Sem sync para payment intent comum | POST /: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 webhook | Roadmap |
EXPIRED nunca é atingido | Existe no enum de intent e de link, e expiresAt é gravado, mas nenhuma rotina expira nada. Intent abandonado fica PENDING para sempre | Especificado, não implementado |
| Sem estado de estorno no intent | Não existe REFUNDED nem PARTIALLY_REFUNDED. Um intent totalmente estornado continua COMPLETED; a evidência é refundedAmount | Por 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 disputa | Especificado, não implementado |
| Sem split de pagamento | Não há divisão de valor entre recebedores. Marketplace que precisa de split usa o recurso nativo do adquirente | Não implementado |
| Cartão salvo só no Stripe | Cliente 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 produto | Entregue em 2026-09 para Stripe; PagSeguro no roadmap |
| Sem antecipação de recebível e sem conciliação de liquidação | O BB sabe que a cobrança foi aprovada; não sabe quando o dinheiro cai nem quanto de taxa foi retido | Fora de escopo — o extrato de liquidação é do adquirente |
| Analytics conta estorno de forma imprecisa | refundCount em GET /analytics/metrics conta os intents do período, não os estornos. Para contagem exata, use GET /refunds com filtro de data | Conhecido — o refundVolume é confiável, o refundCount não |
| Múltiplas configurações do mesmo provedor e webhook | Com 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 corretamente | Conhecido — use uma configuração ativa por tipo de provedor por organização |
Idempotência falha alto em POST /intents | Repetir 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óprio | O 400 de schema devolve o formato do Zod, diferente do envelope {error, message, details} do resto do catálogo | Conhecido — trate os dois formatos no cliente |
sync de link exige só PAYMENTS_READ | É uma operação que altera dados sob permissão de leitura | Conhecido — ver §9 |
Perguntas frequentes
| Pergunta | Resposta 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 |
|---|---|---|
STRIPE | Sim | Completa, com verificação de assinatura de webhook |
PAGSEGURO | Sim | Pedido, Pix, boleto, captura e estorno; link e assinatura incompletos (§15) |
LINK | Não | Links internos, sem movimentar dinheiro |
MOCK_PAYMENT | Não | Simulador 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| MANQual 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ção | status do intent | refundedAmount |
|---|---|---|
| Nada estornado | COMPLETED | 0 |
| Estorno parcial | COMPLETED | menor que amount |
| Estornado por inteiro | COMPLETED — não muda | igual a amount |
| Estorno acima do saldo | 400, nada é criado | inalterado |
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