@tzam-org/payments
v0.9.0
Published
Pagamentos agnósticos com provedor Stripe, sem dependências: interface PaymentProvider; checkout avulso e assinatura (criar, trocar plano, cancelar, consultar), portal do cliente, cliente por referência externa, criar e arquivar preço; Stripe Connect (con
Readme
@tzam-org/payments
Pagamentos para apps do Tzam, com provedor Stripe por trás de uma interface que não conhece Stripe. Sem dependências
(só fetch e node:crypto), sem framework, ESM e CJS.
O modelo é o do Stripe Connect: existe uma conta Stripe, a da plataforma, e as chaves ficam configuradas nela. Cada organização é uma conta conectada (subconta). A cobrança sai da plataforma e é repassada à conta conectada da organização, descontada a taxa da plataforma (destination charges). As organizações não colam chaves próprias.
O que o pacote faz:
Checkout avulso e em modo assinatura; assinaturas direto na API (criar, trocar plano, cancelar agora ou no fim do período, consultar); portal do cliente; cliente por referência externa (id da org ou do usuário no app).
Connect: criar conta conectada (Express ou Standard), link de onboarding, ler o estado da conta (
chargesEnabled,payoutsEnabled,detailsSubmitted, pendências), link do dashboard Express; repasse com taxa fixa (avulso) ou percentual (assinatura); recusa cobrar para conta que ainda não pode receber (account_not_ready).Webhooks: verificação da assinatura do Stripe (HMAC-SHA256, tolerância de tempo), dois segredos (endpoint da plataforma e endpoint de Connect), evento normalizado (
payment.succeeded,subscription.updated,checkout.completed,account.updated…) e idempotência por interface que o app implementa.Erros normalizados (
PaymentErrorcomcode) que nunca carregam a chave. O pacote não loga nada.Dinheiro sempre inteiro na menor unidade, com moeda explícita. Nunca float.
verifyCredentials()para o botão "testar conexão" da configuração das chaves.@tzam-org/payments/client(subpath, 0.6.0): o lado dos apps das organizações. Chama a API de cobrança do Tzam e confere os eventos que o Tzam envia, sem nada do Stripe. Ver Client da API do Tzam.
Fora do escopo desta versão: reembolsos, disputas, faturas avulsas, múltiplos itens por assinatura, cupons, impostos, métodos de pagamento salvos fora do Checkout, Connect com cobrança direta na conta conectada (ver Decisões).
Instalação
"@tzam-org/payments": "workspace:*"Fora do monorepo: npm i @tzam-org/payments. Node 20+.
Os dois papéis
| Papel | Quem é | O que tem no Stripe | O que faz com o pacote |
|---|---|---|---|
| Plataforma | O produto que opera a cobrança | A conta Stripe, a chave secreta, os dois endpoints de webhook | Cria o provider com a chave, cria as contas conectadas, cobra os clientes finais com transfer, recebe os webhooks, abre o portal |
| Conta conectada | Cada organização cadastrada | Uma conta Express (ou Standard) criada pela plataforma, com onboarding feito pelo próprio dono | Não usa o pacote: passa pelo onboarding (createAccountOnboardingLink), recebe os repasses e, se Express, entra no dashboard por createLoginLink |
Responsabilidade por estornos e disputas
Com destination charges a cobrança é da plataforma: o pagamento acontece na conta da plataforma, o repasse vai para a
conta conectada. Logo reembolsos, chargebacks e disputas caem na plataforma — o Stripe debita a plataforma, e é ela que
reverte o repasse (reverse_transfer) ou absorve. O valor da taxa da plataforma (application_fee_*) também é
reembolsável só pela plataforma. Isto é uma decisão de produto: a plataforma assume o risco e, em troca, fica dona da
experiência de cobrança (recibo, descrição no extrato, portal). Se o produto preferir que a organização carregue esse
risco, o caminho é direct charges na conta conectada (Stripe-Account), que esta versão não cobre — a interface não
impede, o transporte já aceita o header.
Uso
Criar o provider (plataforma)
import { createStripeProvider } from '@tzam-org/payments';
const payments = createStripeProvider({
secretKey: platformSecrets.stripeSecretKey, // sk_live_… / sk_test_… / rk_…
webhookSecret: platformSecrets.stripeWebhookSecret, // whsec_… do endpoint da conta da plataforma
connectWebhookSecret: platformSecrets.stripeConnectWebhookSecret, // whsec_… do endpoint "Connect"
// apiVersion?: '2025-09-30.clover' // sem isto vale a versão padrão da conta
// apiBase?: 'http://127.0.0.1:12111' // stripe-mock nos testes
// httpClient?: fetch // injeção para testes
// timeoutMs?: 30_000
// namespace?: 'staging' // obrigatório na prática se outra instância/app usa a MESMA conta Stripe
});Uma instância por chave. A chave e os segredos vêm cifrados do cofre do app; o pacote não lê variável de ambiente.
externalRef precisa ser único na conta Stripe inteira. A busca de ensureCustomer por metadata.external_ref e a
Idempotency-Key ensure-customer:<externalRef> valem para a conta toda, não para a instância. Se duas instâncias ou dois
apps usam a mesma conta (dev e staging na mesma conta de teste; tzam e payments-demo na mesma plataforma) e os ids podem
coincidir (org_1 nos dois), passe um namespace diferente em cada um: ele é gravado em
metadata.external_ref_namespace (clientes e contas conectadas), entra na busca (… AND
metadata['external_ref_namespace']:'<ns>') e na Idempotency-Key (ensure-customer:<ns>:<externalRef>), e um
customerId de outro namespace não é devolvido. 1 a 32 caracteres de [A-Za-z0-9_.@-]. Sem namespace o comportamento
é o da 0.2.0. Ligar o namespace numa instalação que já tem clientes sem ele faz o ensureCustomer criar clientes novos
para eles — grave external_ref_namespace nos existentes antes.
Com namespace, ele também:
- protege o repasse (0.4.0): cobrança com
transfersó vai para conta conectada deste namespace. Conta de outro namespace, ou sem namespace, é recusada cominvalid_request,param: 'transfer.destination'eaccountId, antes de criar a sessão ou a assinatura (a conta já é lida para ochargesEnabled; não há chamada a mais). Sem namespace na conta também é recusado porque é dinheiro: uma conta sem a chave foi criada fora desta instância (ou antes de ela ter namespace) e nada prova que é daqui. Ao ligar onamespacenuma instalação existente, gravemetadata.external_ref_namespacenas contas conectadas dela (POST /v1/accounts/:id) antes do primeiro repasse.getConnectedAccountnão confere o namespace — devolve conta de qualquer instância; - protege os links de conta (0.5.0):
createAccountOnboardingLinkecreateLoginLinkleem a conta (uma chamada a mais) e recusam outro namespace ou ausência dele (param: 'accountId') — esses links deixam o dono trocar a conta bancária, então é caminho de dinheiro; - protege a troca de plano (0.5.0):
changeSubscriptionrecusa assinatura de outro namespace ou sem ele (param: 'subscriptionId'), na leitura que já fazia; - não confere (documentado):
cancelSubscription(não há leitura antes do cancelamento),getSubscription(devolve qualquer uma; o namespace fica emmetadata) ecreatePortalSession(não lê o cliente). Os ids que chegam a eles têm de vir do banco desta instância; - marca os objetos que geram webhooks (0.4.0): a sessão de checkout, o
payment_intent_data(avulso), osubscription_datae a assinatura decreateSubscriptionlevammetadata.external_ref_namespace— ver "Uma conta Stripe, várias instâncias", em Webhooks.
Como no externalRef, as chaves do pacote contam no limite de 50 do metadata e vencem uma chave igual do app.
Testar a conexão
const check = await payments.verifyCredentials();
// { accountId: 'acct_…', mode: 'test' | 'live', connectEnabled: boolean, country?, defaultCurrency?, businessName? }Chave inválida lança PaymentError com code: 'authentication'; a chave nunca aparece no resultado nem na mensagem.
mode vem do livemode que o Stripe devolve (com o prefixo da chave como reserva). connectEnabled é false quando a
conta não pode listar contas conectadas — Connect ainda não ativado no dashboard. Uma chave restrita (rk_…) sem a
permissão de ler contas conectadas não é confundida com isso: o 403 vira authentication com a mensagem dizendo que falta
a permissão.
Onboarding de uma organização (conta conectada)
// 1. Criar a conta uma vez e guardar o id junto da organização.
const acct = await payments.createConnectedAccount({ country: 'BR', email: org.email, externalRef: org.id });
await orgs.update(org.id, { stripeAccountId: acct.id });
// 2. Mandar o dono da org para o onboarding do Stripe. O link expira em minutos; gere um novo a cada clique.
const link = await payments.createAccountOnboardingLink({
accountId: acct.id,
refreshUrl: `${appUrl}/billing/connect/refresh`, // o Stripe volta aqui se o link expirar
returnUrl: `${appUrl}/billing/connect/return`,
});
redirect(link.url);
// 3. Saber se a conta já pode receber: pela leitura…
const state = await payments.getConnectedAccount(acct.id); // não confere o namespace (ver acima)
state.chargesEnabled; state.payoutsEnabled; state.detailsSubmitted; state.requirements.currentlyDue;
// …ou pelo webhook `account.updated` (abaixo), que é o caminho recomendado.
// 4. Dashboard Express para o dono da org (só Express).
const login = await payments.createLoginLink(acct.id); // com namespace, recusa conta de outro (ver acima)Dados da subconta na criação (0.7.0, todos opcionais):
const acct = await payments.createConnectedAccount({
country: 'BR', externalRef: org.id, email: org.email, idempotencyKey: `${org.id}:connect`,
businessType: 'company', // ou 'individual'; sem o campo, o onboarding pergunta
businessProfile: { name: org.name, url: 'https://org.exemplo', mcc: '8299', productDescription: 'Cursos online',
supportEmail: '[email protected]', supportPhone: '+55 11 99999-9999' },
company: { name: 'Org Ltda' }, // ou individual: { firstName, lastName, email }
defaultCurrency: 'brl',
payoutStatementDescriptor: 'ORG CURSOS', // texto do repasse no extrato (até 22)
// capabilities: ['card_payments', 'transfers'], // padrão no Express; [] = as do Dashboard
});
acct.capabilities; // { card_payments: 'inactive' | 'pending' | 'active', transfers: … }
acct.businessType; // 'company' | 'individual' | ausente- Validação local: país ISO-2 (minúsculas aceitas, enviadas em maiúsculas),
mcccom 4 dígitos,urlhttps, e-mails e telefone com formato, descritor até 22 caracteres ASCII sem< > \ ' ",card_paymentssó comtransfers(o Stripe exige os dois juntos), capabilities explícitas recusadas em conta Standard. - PII fica fora do pacote. Documento (CPF/CNPJ), endereço e dados bancários não existem no tipo: são coletados no
onboarding hospedado do Stripe (
createAccountOnboardingLink), sem passar pela plataforma. O prefill se limita a nomes e e-mail. - Capabilities. Na API,
capabilitiessó é obrigatória para contas sem dashboard (controller.stripe_dashboard.type = none, que inclui Custom); para Express, sem o campo, o Stripe pede as capabilities das configurações do Connect no Dashboard, conforme o país. O pacote pedecard_paymentsetransferspor padrão no Express, que é o que destination charges precisam (transfersrecebe o repasse). Fontes: criar conta, capabilities, contas Express.
País da plataforma e país da subconta. Uma plataforma no Brasil pode criar contas Express (o Brasil está na lista de
países de plataforma do Express) e, por padrão, a conta nasce no país da plataforma. Mas transferências entre países
(cross-border) só existem entre EUA, Canadá, Reino Unido, EEE e Suíça; fora disso, a plataforma e a conta conectada
precisam estar na mesma região, e transferir entre fronteiras não compatíveis retorna erro. Na prática: plataforma no
Brasil → subcontas no Brasil (country: 'BR'), com destination charges. Contas de outros países exigem habilitar o
onboarding por país no Dashboard e um fluxo compatível. Fontes: transferências internacionais,
repasses internacionais, Express fora do país da
plataforma. Conferido na documentação em 2026-10-06.
Por que Express por padrão. Express dá à plataforma o controle do fluxo (onboarding hospedado pelo Stripe, mas a
plataforma decide quando, com que URLs e vê os requirements), o dashboard é reduzido e o Stripe cuida de KYC, impostos e
formulários — o que uma organização pequena não quer administrar. Standard entrega uma conta Stripe completa à
organização, com dashboard completo e relação direta com o Stripe; faz sentido para orgs que já têm conta Stripe ou que
querem autonomia total. type: 'standard' está disponível; capacidades, neste caso, são pedidas pela própria conta.
Onboarding embutido (Connect embedded components)
O cadastro da subconta acontece dentro do app, com o componente account-onboarding do Connect.js, sem
redirecionar para connect.stripe.com (0.7.0). Funciona com as contas Express que createConnectedAccount cria.
1. Servidor: criar a account session (uma rota autenticada do app, chamada pelo fetchClientSecret):
// POST /connect/session — o dono da org autenticado no app
const session = await payments.createAccountSession({
accountId: org.stripeAccountId,
components: {
accountOnboarding: { externalAccountCollection: true }, // coleta a conta bancária no próprio componente
notificationBanner: true, // opcionais:
// accountManagement: true, balances: true, payouts: { editPayoutSchedule: true },
},
});
return Response.json({ clientSecret: session.clientSecret }); // explícito: o campo não é enumerávelComponentes aceitos: accountOnboarding, accountManagement, notificationBanner (features
externalAccountCollection, disableStripeUserAuthentication) e balances, payouts (também instantPayouts,
standardPayouts, editPayoutSchedule); true habilita com as features padrão do Stripe. Nome ou feature fora disso é
recusado antes da rede (o stripe-mock aceitaria qualquer um). Com namespace, a conta é lida e uma de outro namespace
(ou sem) é recusada.
2. Front: Connect.js com a chave publicável e o fetchClientSecret:
<script src="https://connect-js.stripe.com/v1.0/connect.js" async></script>
<div id="onboarding"></div>
<script>
window.StripeConnect = window.StripeConnect || {};
StripeConnect.onLoad = () => {
const instance = StripeConnect.init({
publishableKey: 'pk_test_…', // a da plataforma
fetchClientSecret: async () => { // chamado de novo quando a session expira
const r = await fetch('/connect/session', { method: 'POST' });
const { clientSecret } = await r.json();
return clientSecret;
},
locale: 'pt-BR',
});
const onboarding = instance.create('account-onboarding');
onboarding.setOnExit(() => fetch('/connect/sync', { method: 'POST' }).then(() => location.reload()));
document.getElementById('onboarding').append(onboarding);
};
</script>(Com npm: @stripe/connect-js e loadConnectAndInitialize({ publishableKey, fetchClientSecret }); em React,
@stripe/react-connect-js com <ConnectAccountOnboarding onExit={…} />.)
3. Servidor: sincronizar o status. No onExit e no webhook account.updated (endpoint de Connect), leia a conta e
guarde o estado: const acct = await payments.getConnectedAccount(id) → chargesEnabled, payoutsEnabled,
detailsSubmitted, requirements.currentlyDue, capabilities. Não confie no onExit para dar a conta por pronta: ele
só diz que o usuário saiu do componente.
CSP (da documentação do Stripe, "CSP and HTTP header requirements"):
script-src https://connect-js.stripe.com https://js.stripe.com
frame-src https://connect-js.stripe.com https://js.stripe.com
img-src https://*.stripe.com
style-src 'sha256-0hAheEzaMe6uXIKV4EehS9pu1am1lj/KnnzrOYqckXk='connect-src só precisa de acréscimo se o app passar um CSS de fontes (fonts do Connect.js): a URL dele.
O clientSecret da session é de uso imediato. Ele dá acesso à conta conectada pelo navegador: devolva-o na
resposta da rota e descarte; nunca o guarde (banco, cache, sessão do app) nem o logue. Quando expira, o Connect.js chama
o fetchClientSecret de novo, e a rota cria outra session. No pacote ele é não enumerável e os erros redigem
accs_secret__….
Express, autenticação e país (conferido na documentação em 2026-10-06):
- O onboarding embutido funciona com contas Express: a documentação lista o acesso "Dashboard Express" e cria a conta
com
controller[stripe_dashboard][type]=express(taxas e perdas com a plataforma), que é o equivalente detype: 'express'usado pelo pacote. Não é precisocontrollerpróprio. - Em Express o Stripe coleta os requisitos (
requirement_collection = stripe): o componente pede autenticação do usuário numa janela do Stripe (onboarding, gestão da conta, banner, saldos, repasses).disableStripeUserAuthentication: truesó vale quando a plataforma coleta os requisitos (Custom), e o Stripe recusa nos outros casos. - O formulário é localizado para todos os países compatíveis com o Stripe; não há restrição para plataforma no Brasil, que pode criar contas Express (ver "País da plataforma e país da subconta", acima).
- Fontes: componentes embutidos, account onboarding, onboarding embutido, criar account session.
Contas sem dashboard do Stripe (controller)
Para a org nunca fazer login no Stripe (nem no meio do onboarding embutido), crie a subconta sem dashboard e com a plataforma coletando os requisitos (0.7.0):
const acct = await payments.createConnectedAccount({ country: 'BR', externalRef: org.id, controllerPreset: 'platform_managed' });
// = controller: { stripeDashboard: 'none', fees: 'application', losses: 'application', requirementCollection: 'application' }Com
controller(ou o preset),typenão é enviado — o Stripe marcatypecomo obsoleto — e é recusado se vier. Semcontroller, nada muda (type: 'express'por padrão).Com
stripeDashboard: 'none', a API exigecapabilities: o pacote pedecard_payments+transferspor padrão e recusa[].Combinações. O pacote recusa antes da rede o que a documentação lista em "Unsupported configurations" (o stripe-mock aceita qualquer uma):
| stripeDashboard | fees | losses | requirementCollection | | |---|---|---|---|---| |
none|application|application|application| ✓platform_managed(sem login no Stripe) | |none|applicationouaccount|stripe|stripe| ✓ (componentes pedem autenticação) | |none| qualquer |application|stripe| ✗ não suportado | |express|application|application|stripe| ✓ (equivale aotype: 'express') | |express|account|stripe|stripe| ✓ | |expressoufull| qualquer | qualquer |application| ✗ | |full|account|stripe|stripe| ✓ (equivale ao Standard) | |full|application(ou lossesapplication) | | | ✗ |Atenção:
{ none, application, application, stripe }(sem dashboard, o Stripe coletando requisitos e a plataforma com as perdas) não é suportado pelo Stripe. Sem login no Stripe só comrequirementCollection: 'application', que por sua vez exigelossesefeesapplication.getConnectedAccountdevolvecontroller(type,stripeDashboard,fees,losses,requirementCollection) e otypeda conta (nonepara conta criada só com controller).createLoginLinklê a conta e recusa conta sem o Dashboard Express (noneoufull) com uma mensagem clara — login link só existe para o Dashboard Express.
Saques e conta bancária só pelo app. Numa conta platform_managed, os componentes embutidos fazem as vezes do
dashboard, e disableStripeUserAuthentication: true dispensa o login no Stripe (o pacote lê a conta e recusa a feature
em conta cujo requirementCollection não seja application, que o Stripe também recusaria):
await payments.createAccountSession({
accountId,
components: {
accountOnboarding: { disableStripeUserAuthentication: true, externalAccountCollection: true }, // cadastro + banco
accountManagement: { disableStripeUserAuthentication: true, externalAccountCollection: true }, // dados e conta bancária
notificationBanner: { disableStripeUserAuthentication: true }, // requisitos pendentes
balances: { disableStripeUserAuthentication: true, standardPayouts: true }, // saldo e saque
payouts: { disableStripeUserAuthentication: true, standardPayouts: true, editPayoutSchedule: true },
},
});externalAccountCollection(padrãotrue) deixa a org cadastrar e trocar a conta bancária no próprio componente;standardPayoutseeditPayoutScheduledeixam sacar e mudar o cronograma (saque manual exige o cronograma emmanual);instantPayoutsdepende de elegibilidade.- A documentação recomenda 2FA (ou equivalente) no login do próprio app quando a autenticação do Stripe é dispensada: quem entra no app passa a poder trocar a conta bancária da org.
- Fontes: migrar para controller (valores e combinações não suportadas), criar conta, login link, autenticação nos componentes, payouts, balances, account management. Conferido em 2026-10-06.
Responsabilidade da plataforma em contas sem dashboard
Com platform_managed a plataforma passa a responder pelo que, numa conta com dashboard, o Stripe ou a própria org
resolveriam. Antes de criar contas assim, o Stripe pede que a plataforma reconheça essas responsabilidades no Dashboard
(perfil da plataforma Connect).
- Perdas e saldo negativo (
losses: 'application'). Estornos, reembolsos e disputas podem deixar o saldo da subconta negativo. O Stripe tenta primeiro debitar a conta bancária da subconta; enquanto isso pode reter reserva na conta da plataforma, e se o saldo ficar negativo por 180 dias usa os fundos da plataforma para cobri-lo. Com destination charges (o que o pacote usa), as transações negativas das cobranças já caem no saldo da plataforma. A plataforma pode manter reservas no saldo das subcontas. - Taxas do Stripe (
fees: 'application'). A plataforma paga todas as taxas do Stripe, inclusive as de processamento. A taxa da plataforma (applicationFeeAmount/applicationFeePercent) tem de cobrir isso e a margem. - Disputas. Contestações de cobrança chegam à plataforma: é ela que responde (evidências, prazos) e arca com o valor e a taxa da disputa se perder; o repasse correspondente pode ser revertido da subconta só se a plataforma fizer isso. Ofereça um fluxo para a org enviar evidências, ou responda pela plataforma.
- Coleta de requisitos (
requirementCollection: 'application'). Quando o Stripe pedir dados novos ou atualizados (requirements.currentlyDue,account.updated), é a plataforma que tem de pedir à org — onotificationBannere oaccountOnboardingfazem isso dentro do app. A plataforma também atesta o aceite do contrato de serviços do Stripe (o componente de onboarding coleta esse aceite, ou a plataforma usa o próprio processo). - O que a plataforma passa a oferecer. A org não tem dashboard nem, necessariamente, contato com o Stripe: suporte
de primeiro nível sobre a conta, saldo, saques, conta bancária, extratos e relatórios (componentes
balances,payouts,accountManagement, ou telas próprias), além dos fluxos de reembolso e disputa. - Segurança. Com a autenticação do Stripe dispensada, o login do app é a única barreira para trocar a conta bancária: exija 2FA e trate a troca de conta bancária como operação sensível (notificação à org).
- Fontes: gestão de risco,
boas práticas,
design da integração. A documentação recomenda a API Accounts
v2 para plataformas Connect novas; este pacote usa a v1 (
/v1/accounts), que continua suportada.
Cliente
const customer = await payments.ensureCustomer({
externalRef: user.id, // gravado em metadata.external_ref
customerId: user.stripeCustomerId, // se o app já conhece; devolvido direto quando existe
email: user.email,
name: user.name,
});
if (!customer.existed) await users.update(user.id, { stripeCustomerId: customer.id });Se o customerId informado pertence a outro externalRef (o metadata.external_ref dele é diferente), ele não é
devolvido: o id guardado pelo app está velho ou trocado, e devolvê-lo ligaria esta org à cobrança de outra. O pacote cai
na busca por externalRef, que acha o cliente certo ou cria um — sem exigir intervenção, e sem nunca devolver o cliente
alheio. Recusar com erro protegeria igual, mas travaria o checkout por um ponteiro velho que a busca resolve sozinha.
Cliente sem external_ref (criado fora do pacote) continua sendo devolvido pelo id, como na 0.1.0.
O cliente fica na conta da plataforma (com destination charges o pagador é cliente da plataforma). O app deve persistir
o customerId: a busca por external_ref usa o Search do Stripe, que pode levar até um minuto para indexar um cliente
novo — sem o id guardado, duas chamadas seguidas podem criar dois clientes. A criação usa Idempotency-Key
ensure-customer:<externalRef> como segunda proteção.
Checkout avulso com repasse
const session = await payments.createCheckoutSession({
mode: 'payment',
lineItems: [
{ price: 'price_123', quantity: 2 }, // preço do catálogo
{ amount: { amount: 2500, currency: 'brl' }, name: 'Taxa de inscrição' }, // preço ad hoc
],
successUrl: `${appUrl}/pedidos/ok?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${appUrl}/pedidos/cancelado`,
customerId: customer.id, // ou customerEmail
clientReferenceId: order.id, // volta no evento checkout.completed
metadata: { orgId: org.id, orderId: order.id },
transfer: { destination: org.stripeAccountId, applicationFeeAmount: 300 }, // R$ 3,00 ficam na plataforma
idempotencyKey: `order:${order.id}:checkout`,
});
redirect(session.url);Antes de criar a sessão o pacote lê a conta de destino e recusa com account_not_ready se chargesEnabled for false
(o Stripe aceitaria a sessão e falharia só no pagamento). Em modo payment a taxa é applicationFeeAmount (inteiro, na
moeda da cobrança); applicationFeePercent é recusado. Itens avulsos têm de estar numa moeda só e, quando todos os
itens são avulsos, a taxa fixa não pode passar da soma (amount × quantity); com preço de catálogo no meio a soma não é
conhecida aqui e quem decide é o Stripe.
Assinatura com repasse
O caminho recomendado é o Checkout em modo subscription: o Stripe coleta o meio de pagamento, trata 3DS/SCA, aplica
o trial e cria a assinatura — o app só recebe checkout.completed e subscription.created.
const session = await payments.createCheckoutSession({
mode: 'subscription',
lineItems: [{ price: 'price_pro_mensal' }],
successUrl: `${appUrl}/assinatura/ok`,
cancelUrl: `${appUrl}/assinatura`,
customerId: customer.id,
trialDays: 14,
subscriptionMetadata: { orgId: org.id },
transfer: { destination: org.stripeAccountId, applicationFeePercent: 10 }, // 10% ficam na plataforma
});createSubscription direto na API existe para quando o cliente já tem meio de pagamento salvo (renovação de plano,
venda assistida). Nasce com payment_behavior: default_incomplete: sem meio de pagamento válido o status vem
incomplete em vez de erro, e o app decide o que fazer.
const sub = await payments.createSubscription({ customerId, priceId: 'price_pro_mensal', transfer: { destination, applicationFeePercent: 10 } });
const same = await payments.getSubscription(sub.id);
const upgraded = await payments.changeSubscription({ subscriptionId: sub.id, priceId: 'price_enterprise', prorationBehavior: 'create_prorations' });
// 0.9.0: `applicationFeePercent` (0..100, até 2 casas) vai como `application_fee_percent` na atualização: recalcular a taxa
// na troca de plano. Ausente, a taxa da assinatura não é tocada.
await payments.changeSubscription({ subscriptionId: sub.id, priceId: 'price_enterprise', applicationFeePercent: 2.5 });
// ou um preço avulso recorrente (vai em linha no item; com `name`, o produto é criado e a chave é obrigatória):
const custom = await payments.changeSubscription({ subscriptionId: sub.id, adHocPrice: { amount: { amount: 9990, currency: 'brl' }, interval: 'year', name: 'Plano anual' }, idempotencyKey: `${sub.id}:change:${n}` });
const price = await payments.getPrice('price_enterprise'); // { id, type, amount?, recurring?, productId, active }
// 0.8.0: produto e preço numa chamada (product_data), um produto por preço; a chave é obrigatória.
const plano = await payments.createPrice({ productName: 'Plano Pro', amount: { amount: 4990, currency: 'brl' }, recurring: { interval: 'month' }, idempotencyKey: 'preco:plano-pro' });
await payments.archivePrice(plano.id); // active=false, sem apagar: assinaturas existentes seguem
const ending = await payments.cancelSubscription({ subscriptionId: sub.id, atPeriodEnd: true }); // continua até currentPeriodEnd
const gone = await payments.cancelSubscription({ subscriptionId: sub.id }); // agoraEm assinatura a taxa é applicationFeePercent (0..100, até 2 decimais — 0.29 e 1.15 valem, apesar do ponto flutuante;
o valor vai ao Stripe arredondado a 2 casas); applicationFeeAmount é recusado. A v1 trata
assinaturas de um item: changeSubscription substitui o preço do item existente.
Pagamento embutido (Payment Element)
Para a página de pagamento do próprio app (Stripe.js + Payment Element), em vez do Checkout hospedado (0.7.0):
// Avulso: PaymentIntent na conta da plataforma com repasse (destination charge).
const pi = await payments.createPaymentIntent({
amount: { amount: 4990, currency: 'brl' },
customerId: customer.id, // opcional
transfer: { destination: org.stripeAccountId, applicationFeeAmount: 300 }, // obrigatório
metadata: { tzam_session: session.id },
description: 'Pedido 123', receiptEmail: '[email protected]', // opcionais
idempotencyKey: `${session.id}:payment-intent`, // obrigatória: reabrir a página devolve o mesmo
automaticPaymentMethods: true, // os meios ativos no dashboard
});
render({ clientSecret: pi.clientSecret }); // explícito: ver "o segredo" abaixo
// No retorno (`return_url`), confira pelo servidor, nunca pelo parâmetro da URL:
const now = await payments.getPaymentIntent(pi.id);
// now.status: requires_payment_method | requires_confirmation | requires_action | processing | succeeded | canceled
// now.lastPaymentError?.declineCode → "tentar de novo"
// Assinatura: o mesmo createSubscription, que agora devolve o que confirmar no navegador.
const sub = await payments.createSubscription({ customerId, priceId, trialDays: 7, transfer: { destination, applicationFeePercent: 10 } });
sub.status; // incomplete (pagamento devido) | trialing…
sub.clientSecretKind; // 'payment' → stripe.confirmPayment | 'setup' (teste grátis) → stripe.confirmSetup | ausente: nada a confirmar
render({ clientSecret: sub.clientSecret, kind: sub.clientSecretKind });
sub.setupIntentId; // com teste grátis: guarde para casar o evento setup.succeeded com a sessão
// Vários itens: catálogo e preços ad hoc (com produto existente, ou um novo criado pelo pacote a partir do nome).
await payments.createSubscription({
customerId, idempotencyKey: `${session.id}:subscription`,
items: [
{ priceId: 'price_pro', quantity: 1 },
{ amount: { amount: 990, currency: 'brl' }, recurring: { interval: 'month' }, productId: 'prod_extra' },
{ amount: { amount: 490, currency: 'brl' }, recurring: { interval: 'month' }, name: 'Usuário adicional', quantity: 3 },
],
// saveDefaultPaymentMethod: false // padrão true: o cartão da 1ª fatura vira o padrão e paga as renovações
});
// Conferir o SetupIntent (teste grátis) no retorno, pelo servidor:
const si = await payments.getSetupIntent(sub.setupIntentId!);
// si.status: requires_payment_method | requires_confirmation | requires_action | processing | succeeded | canceled
// si.paymentMethodId, si.customerId, si.lastSetupError?.declineCodecreatePaymentIntentsegue as regras do repasse do checkout: sóapplicationFeeAmount(até o valor),account_not_readyse a conta não pode receber, e, comnamespace, conta de outro namespace (ou sem) recusada emetadata.external_ref_namespaceno PaymentIntent. Valor > 0;descriptionaté 1000 caracteres.createSubscriptionexpandelatest_invoice.confirmation_secretepending_setup_intent(comapiVersionanterior a2025-03-31,latest_invoice.payment_intent, e a leitura aceita os dois formatos). SemapiVersion, vale a versão padrão da conta: se ela for anterior a 2025-03-31, passeapiVersion, porque a expansão do formato atual seria recusada.- O segredo. O
clientSecretpermite confirmar o pagamento. Ele é uma propriedade não enumerável:pi.clientSecretfunciona, masJSON.stringify(pi),console.log(pi)e{ ...pi }não o levam — um log do objeto não vaza o segredo, e por isso ele tem de ser passado à página explicitamente. Nos erros,pi_…_secret_…eseti_…_secret_…são redigidos (o idpi_…fica). Não o grave em banco nem em log; peça-o de novo comgetPaymentIntentse precisar. getPaymentIntentegetSetupIntentnão conferem onamespace(o id vem do banco do app).cancelPaymentIntent(id, { reason?, idempotencyKey? })cancela o PaymentIntent (sessão cancelada ou expirada) e devolve o mesmo formato dogetPaymentIntent;reason:duplicate | fraudulent | requested_by_customer | abandoned. Não confere onamespace.createSubscription({ description })repassa a descrição à assinatura (até 500 caracteres).- Vários itens.
priceId(um preço) ouitems(até 20, na mesma moeda). A Subscriptions API do Stripe exige um produto existente no preço ad hoc: comname, o pacote cria o produto antes (POST /v1/products, com o namespace no metadata e a Idempotency-Keyprod:<sha256("subscription:" + chave), 32 hex>:<i>, que cabe nos 255 do Stripe com qualquer chave). Por isso, item pornameexigeidempotencyKey: sem ela, cada repetição criaria outro produto. Produto criado e assinatura que falha depois deixam o produto órfão no dashboard; para preços recorrentes frequentes, prefiraproductIdou umpriceIddo catálogo.changeSubscriptiontroca um item e recusa assinatura com vários. - Renovação.
payment_settings[save_default_payment_method]=on_subscriptionvai por padrão: o meio de pagamento confirmado na primeira fatura (ou no SetupIntent do teste grátis) vira o padrão da assinatura, e as renovações o usam.saveDefaultPaymentMethod: falsenão envia o campo (o padrão do Stripe é não salvar). - Teste grátis pelo webhook.
setup_intent.succeededesetup_intent.setup_failedchegam comosetup.succeededesetup.failed(setupIntentId,customerId,paymentMethodId,metadata; ofailedcomfailureCode,declineCode,failureMessage), com as mesmas regras deconnectedOrigine namespace. O SetupIntent pendente não herda o metadata da assinatura: case pelosetupIntentIdquecreateSubscriptiondevolveu. - Eventos sem tipo conhecido (
payment_intent.created,setup_intent.created…) chegam comounknownsemclient_secretno objeto cru, em qualquer profundidade (objetos aninhados e listas), numa cópia — o original não muda. O que passar de 10 níveis vira'[depth limit]'. - Nos eventos,
payment.succeeded/payment.failedtrazempaymentIntentIdemetadata;invoice.paideinvoice.payment_failedtrazemsubscriptionIde, quando o payload tem,paymentIntentId(invoice.payment_intentnas versões antigas,invoice.paymentsnas atuais — que o webhook costuma não incluir; mapeie a assinatura pelosubscriptionId).
Portal do cliente
const portal = await payments.createPortalSession({ customerId: customer.id, returnUrl: `${appUrl}/billing` });
redirect(portal.url);O portal é da plataforma (o cliente é da plataforma); a configuração do portal (o que pode trocar, cancelar, ver) é
feita no dashboard da plataforma, e configurationId escolhe uma delas se houver várias.
Webhooks
Configure dois endpoints no Stripe, cada um com seu segredo:
- o da conta (eventos da plataforma:
checkout.session.completed,payment_intent.*,invoice.*,customer.subscription.*) →webhookSecret; - o de Connect ("Listen to events on Connected accounts":
account.updated,payout.*) →connectWebhookSecret.
Podem apontar para a mesma rota do app; parseWebhook tenta a plataforma e depois o Connect, e diz qual verificou
(endpoint). Ou passe { endpoint: 'connect' } para verificar só com um.
Segredos trocados falham alto (0.5.1). O endpoint de Connect do Stripe sempre manda account; um evento sem account
verificado pelo segredo de Connect só acontece com os segredos invertidos (o da plataforma em connectWebhookSecret, ou
só connectWebhookSecret configurado com o segredo da plataforma). Nesse caso parseWebhook lança PaymentError
invalid_request com param: 'connectWebhookSecret' e a mensagem "platform event (no account) verified with the
Connect webhook secret — are webhookSecret and connectWebhookSecret swapped?". Responda 5xx (não 400 nem 200): o
Stripe mostra a falha no dashboard e reentrega depois do conserto. Sem isso, o checkout legítimo seria tratado como
vindo de conta conectada, ignorado com 200, e todos os pagamentos se perderiam em silêncio.
import { handleWebhookOnce, isPaymentError, type ProcessedEventStore } from '@tzam-org/payments';
const store: ProcessedEventStore = { /* ver "Idempotência sob concorrência", abaixo */ };
export async function POST(req: Request) {
const raw = await req.text(); // o corpo CRU — nunca o JSON re-serializado
let event;
try {
event = payments.parseWebhook(raw, req.headers.get('stripe-signature'));
} catch (e) {
if (isPaymentError(e) && e.code === 'webhook_signature') return new Response('bad signature', { status: 400 });
throw e;
}
const result = await handleWebhookOnce(store, event, async (ev) => { // 4º argumento opcional: { foreignNamespace }
switch (ev.kind) {
case 'checkout.completed': /* ev.data.sessionId, customerId, subscriptionId, clientReferenceId, amountTotal, metadata */ break;
case 'payment.succeeded': /* ev.data.paymentIntentId, amount, metadata */ break;
case 'payment.failed': /* ev.data.failureCode, declineCode */ break;
case 'subscription.created':
case 'subscription.updated':
case 'subscription.canceled': /* ev.data é uma Subscription */ break;
case 'invoice.paid':
case 'invoice.payment_failed': /* ev.data.invoiceId, subscriptionId, amountPaid, hostedInvoiceUrl */ break;
case 'account.updated': /* ev.account = conta conectada; ev.data é uma ConnectedAccount */ break;
case 'payout.paid':
case 'payout.failed': /* ev.account; ev.data.payoutId, amount, arrivalDate, failureCode */ break;
case 'unknown': /* ev.type original, ev.data cru; não quebra */ break;
}
});
// Só com store de `claim`: outra entrega tem a reserva e o evento AINDA NÃO foi processado. Responder 200 aqui perderia
// o evento se a outra falhar (ou se ela morreu); 409 faz o Stripe reentregar mais tarde.
if (result.inProgress) return new Response('in progress', { status: 409 });
return new Response('ok'); // handled, duplicate ou ignored (conta conectada / outra instância): 200
}handleWebhookOnce marca o id depois do handler terminar bem; handler que lança não marca, e o Stripe reentrega.
Eventos de contas conectadas trazem ev.account (acct_…).
Idempotência sob concorrência
O Stripe reentrega, e às vezes entrega o mesmo evento duas vezes ao mesmo tempo. Com um store só de has + mark,
não é seguro sob concorrência: as duas entregas passam pelo has antes de qualquer mark, e o handler roda duas
vezes. Para exatamente uma vez, implemente claim (atômico) e release:
has(id)→ já processado:duplicate(responda 200).claim(id)→false: o pacote consultahas(id)de novo. Se o evento foi marcado nesse meio-tempo,duplicate. SenãoinProgress: true(duplicate: false), sem rodar o handler — responda 409 ou 503, para o Stripe reentregar. Nunca 200:inProgresscobre três casos em que o evento ainda não foi processado e se perderia — a reserva órfã (o processo morreu entreclaimemark), a primeira entrega lenta que depois falha e solta a reserva quando o Stripe já não tem mais o que reentregar, e o handler que falhou junto com orelease.- handler lança →
release(id)e o erro sobe (o app responde 5xx e o Stripe reentrega). Se oreleasefalhar, o erro do handler continua sendo o que sobe, e a reserva expira pelo TTL. - handler termina bem →
mark(id). Se omarkfalhar, a reserva não é solta (o handler já rodou); as reentregas recebeminProgressaté a reserva expirar, e aí o handler roda de novo — entrega pelo menos uma vez, em vez de perder o evento.
Store com só um de claim/release é recusado (invalid_request, param store.release ou store.claim).
A reserva precisa expirar: um processo que morre entre o claim e o release não pode prender o evento para sempre.
E o prazo da reserva tem de ser maior que o tempo máximo do handler: nos exemplos abaixo, uma reserva "velha" é
apagada (Prisma) ou expira (Redis) mesmo que o handler dono dela ainda esteja rodando — e então uma reentrega roda o
handler em paralelo. Limite o handler (timeout do próprio app, abaixo do prazo) ou aumente o prazo.
Prisma (Postgres), com uma tabela de chave única:
model ProcessedStripeEvent {
id String @id // o id do evento (evt_…)
claimedAt DateTime @default(now())
processedAt DateTime?
}import { Prisma } from '@prisma/client';
const CLAIM_TTL_MS = 5 * 60_000; // > tempo máximo do handler (ver acima)
const store: ProcessedEventStore = {
has: async (id) => !!(await db.processedStripeEvent.findFirst({ where: { id, processedAt: { not: null } } })),
claim: async (id) => {
// reserva velha (processo morreu) é apagada antes; a criação com chave única é o lock
await db.processedStripeEvent.deleteMany({ where: { id, processedAt: null, claimedAt: { lt: new Date(Date.now() - CLAIM_TTL_MS) } } });
try {
await db.processedStripeEvent.create({ data: { id } });
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return false; // outra entrega reservou
throw e;
}
},
release: async (id) => void (await db.processedStripeEvent.deleteMany({ where: { id, processedAt: null } })),
mark: async (id) => void (await db.processedStripeEvent.update({ where: { id }, data: { processedAt: new Date() } })),
};O mesmo em SQL puro: INSERT INTO processed_stripe_event (id) VALUES ($1) ON CONFLICT (id) DO NOTHING e claim
devolve rowCount === 1.
Redis (SET NX com expiração para a reserva; a marca de processado fica sem expirar, ou com a retenção que o app quiser):
const store: ProcessedEventStore = {
has: async (id) => (await redis.get(`stripe:evt:${id}`)) === 'done',
claim: async (id) => (await redis.set(`stripe:evt:${id}`, 'claimed', 'PX', 5 * 60_000, 'NX')) === 'OK',
release: async (id) => void (await redis.del(`stripe:evt:${id}`)),
mark: async (id) => void (await redis.set(`stripe:evt:${id}`, 'done')),
};Mesmo com claim, vale manter o handler idempotente quando ele escreve fora do banco do store (e-mail, outra API): o
claim garante uma execução por evento, não atomicidade entre o handler e o mark.
Pagamento só vale vindo da plataforma
payment.*, checkout.completed, invoice.* e subscription.* só valem com endpoint === 'platform' e sem
account. Todas as cobranças deste pacote são destination charges: são criadas na conta da plataforma e chegam pelo
endpoint da plataforma, sem account. Um evento desses vindo de conta conectada pode ser forjado pelo dono dela: uma
conta Standard cria uma cobrança ou um Checkout na própria conta, com o metadata e o clientReferenceId que
quiser (o orderId de um pedido seu, o seu external_ref_namespace), e o evento chega pelo endpoint de Connect,
assinado de verdade pelo Stripe. Se o handler o aceitasse, o pedido seria dado como pago sem a plataforma receber nada.
- O pacote marca com
connectedOrigin: truetodo evento comaccount(ou pelo endpointconnect) — inclusive osunknown, como umcharge.succeededforjado (0.5.1) —, excetoaccount.updatedepayout.*, que vêm de conta conectada por natureza. Outros eventos nativos de Connect (capability.updated,account.external_account.*,person.*…) também são marcados e, por padrão, ignorados; quem precisa deles passa{ connectedOrigin: 'handle' }e decide pelokind/typedentro do handler, nunca dando dinheiro por recebido a partir deles. handleWebhookOnceos ignora por padrão:{ handled: false, duplicate: false, ignored: true, reason: 'connected_origin' }, sem handler e sem store — responda 200.{ connectedOrigin: 'handle' }no 4º argumento os entrega, e só faz sentido para quem cobra dentro das contas conectadas (direct charges), o que este pacote não faz. Se o evento também é de outro namespace, a razão éconnected_origin.- Quem não usa
handleWebhookOnceconfereevent.endpoint === 'platform' && !event.account(ou!event.connectedOrigin) antes de dar algo por pago.
Nunca resolva a posse pelo external_ref (nem pelo metadata ou clientReferenceId); use os ids próprios. O
metadata é texto livre de quem criou o objeto. Ao receber checkout.completed, procure a sessão pelo sessionId que o
app guardou ao criá-la (createCheckoutSession devolve o id); em invoice.* e subscription.*, pela assinatura que o
app guardou; em payment.*, pelo paymentIntentId. Id desconhecido: ignore e responda 200.
Uma conta Stripe, várias instâncias
O Stripe manda todos os eventos da conta para todos os endpoints cadastrados: com dev e staging na mesma conta
de teste, ou tzam e payments-demo na mesma plataforma, cada instância recebe os eventos da outra. Com namespace no
provider, cada uma filtra os próprios:
- o evento normalizado traz
namespace, lido demetadata.external_ref_namespacedo objeto (na fatura, também dosubscription_detailsda assinatura), eforeignNamespace: truequando ele é de outro namespace; handleWebhookOnceignora esses eventos por padrão: devolve{ handled: false, duplicate: false, ignored: true, reason: 'namespace' }sem rodar o handler e sem tocar no store. Responda 200: o evento não é desta instância, e qualquer outro status faria o Stripe reentregá-lo aqui por dias.{ foreignNamespace: 'handle' }no 4º argumento entrega o evento ao handler mesmo assim (auditoria, um app que atende vários namespaces);- quem não usa
handleWebhookOnceconfereevent.foreignNamespacee responde 200.
Evento sem namespace legível chega ao handler. Payouts, cobranças de fatura (o PaymentIntent da fatura não leva o
metadata da assinatura) e objetos criados antes do namespace não dizem de quem são; ignorá-los perderia eventos
legítimos. O handler confere a posse pelos próprios ids — a assinatura, a sessão ou a conta conectada está no banco desta
instância? Se não, ignora e responde 200.
Nos testes do app, signWebhookPayload(body, secret, unixSeconds) gera o header Stripe-Signature como o Stripe faria.
Erros
try { await payments.createCheckoutSession(input); }
catch (e) {
if (!isPaymentError(e)) throw e;
switch (e.code) {
case 'invalid_request': // e.param diz o campo (validado antes da rede, ou recusado pelo Stripe)
case 'card_declined': // e.declineCode (insufficient_funds…)
case 'authentication': // chave inválida (401) ou sem permissão (403, chave restrita) — configuração
case 'rate_limit': // esperar e repetir com a mesma idempotencyKey
case 'network': // pode não ter chegado; repetir com a mesma idempotencyKey
case 'account_not_ready': // e.accountId; mandar a org terminar o onboarding
case 'webhook_signature': // responder 400
case 'unknown': // e.status, e.stripeType, e.requestId para suporte
}
}PaymentError tem status, stripeType, stripeCode, declineCode, param, requestId, accountId. A mensagem e
esses detalhes passam por redactSecrets (apaga sk_…, rk_…, pk_…, whsec_…); os erros do transporte apagam também
a chave configurada literal, qualquer que seja o formato dela. Use isPaymentError, não instanceof: ESM e CJS
são classes diferentes.
Validação antes da rede
Tudo abaixo volta como invalid_request com param, sem chamada ao Stripe:
idempotencyKey: 1 a 255 caracteres ASCII imprimíveis, sem CR/LF (vai num header HTTP).metadata/subscriptionMetadata: até 50 chaves (contando oexternal_refque o pacote acrescenta); chave de 1 a 40 caracteres, sem colchetes (no corpo form-encodeda][transfer_data][destinationviraria outro campo); valor string de até 500 caracteres.toleranceSeconds/webhookToleranceSeconds: número finito > 0 (NaN ou Infinity desligariam a proteção contra replay).parseWebhook(…, { endpoint })só aceitaplatformouconnect.
Dinheiro
import { money, assertMoney, type Money } from '@tzam-org/payments';
money(1990, 'BRL'); // { amount: 1990, currency: 'brl' }
assertMoney(input.total, 'total'); // lança invalid_request com param 'total.amount' | 'total.currency'Client da API do Tzam (@tzam-org/payments/client)
Para os apps das organizações: eles não falam com o Stripe. Chamam a API de cobrança do Tzam
({TZAM_URL}/api/v1/payments) com o client_id/client_secret da Application e recebem do Tzam os eventos da própria
cobrança, assinados com o paymentsWebhookSecret (tzwh_…). Contrato: docs/payments-api-contrato.md (v5) no
monorepo. Este subpath não importa nada do lado do servidor do Stripe: só fetch e node:crypto.
Fluxo (v3): o usuário não sai da página do app. O servidor do app cria a sessão de pagamento no Tzam
(createPaymentSession) e recebe clientSecret e publishableKey; o front do app monta o Payment Element do
Stripe com esses dois valores e confirma ali mesmo (o cartão vai do navegador ao Stripe, num iframe, sem passar pelo app
nem pelo Tzam); depois o servidor consulta o Tzam (getPaymentSession) e age pelo paymentStatus dele e pelos
eventos assinados. O client não monta o formulário.
Servidor do app
import { createTzamPaymentsClient, parseTzamWebhook, isTzamPaymentsError } from '@tzam-org/payments/client';
const tzam = createTzamPaymentsClient({
baseUrl: process.env.TZAM_URL!, // https; http só para localhost/127.0.0.1/::1. Aceita também a base com /api/v1/payments
clientId: process.env.TZAM_CLIENT_ID!,
applicationSecret: process.env.TZAM_APPLICATION_SECRET!, // client_secret da Application: NUNCA vai ao navegador
// fetch?: typeof fetch // padrão: o global
// timeoutMs?: number // padrão: 30 000
});
const account = await tzam.getAccount(); // { mode, chargesEnabled, payoutsEnabled, detailsSubmitted }
// v4 (0.8.0): o app registra os PRÓPRIOS preços no Tzam. Só um preço deste app, no modo atual e ativo, serve em
// lineItems[].priceId e na troca de plano; qualquer outro → price_not_allowed (400), sem dizer se existe noutro app.
const plano = await tzam.createPrice(
{ productName: 'Plano Pro', amount: { amount: 4990, currency: 'brl' }, recurring: { interval: 'month' } },
{ idempotencyKey: 'preco:plano-pro' },
); // { priceId, productName, amount, recurring?, active, created } — guarde o priceId (fora do código)
const { data } = await tzam.listPrices(); // só os deste app, no modo atual
await tzam.archivePrice(plano.priceId); // desativa, sem apagar
const session = await tzam.createPaymentSession(
{
mode: 'payment',
lineItems: [{ name: 'Plano Pro', amount: { amount: 4990, currency: 'brl' }, quantity: 1 }],
customer: { customerRef: 'user-42', email: '[email protected]' }, // obrigatório em 'subscription'
clientReferenceId: 'pedido-123',
description: 'Pedido 123',
metadata: { orderId: '123' },
// trialDays: 7, // só em 'subscription'
},
{ idempotencyKey: 'pedido-123:pagamento' },
);
// session: { id: 'tps_…', mode, status, clientSecret, clientSecretKind, publishableKey, amountTotal, subscriptionId?, expiresAt }
// Guarde SÓ session.id no pedido. Entregue { clientSecret, clientSecretKind, publishableKey } ao navegador de quem vai
// pagar e esqueça o segredo: para montar o formulário de novo, peça outra vez (abaixo).
// Depois do retorno do navegador (return_url), consulte o Tzam: ele pergunta ao Stripe pelo servidor.
const s = await tzam.getPaymentSession(session.id);
if (s.paymentStatus === 'succeeded') { /* pedido pago (s.status === 'complete') */ }
await tzam.cancelPaymentSession(session.id, { idempotencyKey: 'cancelar:tps_…' }); // sessão não paga; paga → conflict
await tzam.getSubscription('sub_…');
await tzam.cancelSubscription('sub_…', { idempotencyKey: 'cancelar:sub_…' }); // padrão: fim do período
await tzam.cancelSubscription('sub_…', { atPeriodEnd: false }); // cancelar AGORA exige false explícito
await tzam.changeSubscription('sub_…', { priceId: 'price_…', proration: 'none' });
const { url } = await tzam.createPortalSession({ customerRef: 'user-42', returnUrl: 'https://app.exemplo/conta' });clientSecret é sensível: permite confirmar aquele pagamento. Entregue-o só ao navegador do usuário que vai pagar
(na página de pagamento dele), nunca logue, nunca guarde no banco nem mande a outro usuário.
"Tentar de novo" sem guardar o segredo. Toda vez que a página de pagamento monta o formulário — a primeira vez,
depois de um cartão recusado, depois de o usuário recarregar — o servidor do app pede o segredo ao Tzam com
getPaymentSessionClientSecret(id). O app guarda só o id da sessão:
// Servidor do app, a cada montagem do formulário (rota da página de pagamento do pedido, já autenticada como o dono)
async function dadosDoFormulario(pedido: { paymentSessionId: string }) {
try {
// POST /payment-sessions/{id}/client-secret: só de sessão aberta
const { clientSecret, clientSecretKind, publishableKey } = await tzam.getPaymentSessionClientSecret(pedido.paymentSessionId);
return { clientSecret, clientSecretKind, publishableKey }; // vai na resposta desta página e em nenhum outro lugar
} catch (e) {
if (isTzamPaymentsError(e) && e.code === 'conflict') {
// A sessão não está mais aberta (complete, expired ou canceled): consulte o estado e decida.
const s = await tzam.getPaymentSession(pedido.paymentSessionId);
if (s.paymentStatus === 'succeeded') return { jaPago: true };
return { novaSessao: true }; // expirada/cancelada: crie outra com createPaymentSession
}
throw e;
}
}É um POST (não GET) para não cair em cache nem em log de acesso. Não faça cache da resposta; não a coloque em URL. A publishableKey é
pública. Os erros do client nunca o carregam: qualquer …_secret_… (de PaymentIntent ou SetupIntent) que chegue numa
mensagem é redigido, como o applicationSecret.
applicationSecret ≠ clientSecret. A opção do client é applicationSecret (o client_secret OAuth da
Application), com nome diferente de propósito: no código do app ela fica perto do clientSecret do pagamento, e
trocar um pelo outro mandaria o segredo da Application ao navegador. Passar a opção antiga clientSecret lança
invalid_request (param: 'applicationSecret'), sem alias.
Respostas conferidas. createPaymentSession e getPaymentSessionClientSecret só devolvem o que vai ao front se
clientSecretKind for payment ou setup, clientSecret tiver o formato do kind (^pi_[A-Za-z0-9]+_secret_[A-Za-z0-9]+$
com payment, ^seti_[A-Za-z0-9]+_secret_[A-Za-z0-9]+$ com setup; uma sk_… ou o segredo da Application nesse campo
são recusados) e publishableKey for uma chave publicável (pk_…; uma sk_/rk_ é recusada). Senão lançam network com o status: a resposta não segue o contrato e
o resultado é incerto (a sessão pode ter sido criada; repetir com a mesma Idempotency-Key devolve a mesma). A
mensagem diz o campo, nunca o valor.
Front do app (@stripe/stripe-js)
O pacote não depende do Stripe.js: o front do app o instala e usa direto.
import { loadStripe } from '@stripe/stripe-js';
// clientSecret, clientSecretKind e publishableKey vieram do servidor do app, só para este usuário
const stripe = await loadStripe(publishableKey);
const elements = stripe!.elements({ clientSecret });
elements.create('payment').mount('#pagamento');
async function pagar() {
// URL do próprio app: métodos com redirecionamento e 3DS voltam para cá
const confirmParams = { return_url: `${location.origin}/pedidos/123/retorno` };
// 'payment': cobra agora (PaymentIntent). 'setup': assinatura com período de teste, sem cobrança agora — o
// clientSecret é de um SetupIntent, que só guarda o meio de pagamento; confirmPayment falharia com ele.
const { error } =
clientSecretKind === 'setup'
? await stripe!.confirmSetup({ elements, confirmParams })
: await stripe!.confirmPayment({ elements, confirmParams });
if (error) mostrarErro(error.message); // erro de validação/cartão; nada foi cobrado
}CSP do app: script-src https://js.stripe.com e frame-src https://js.stripe.com https://hooks.stripe.com (mais o
que o app já tiver).
Ao voltar para o return_url, o Stripe acrescenta payment_intent e payment_intent_client_secret (ou
setup_intent e setup_intent_client_secret, com setup) e redirect_status à URL. Não confie no redirect_status (o usuário pode editar a URL): o servidor do app chama
getPaymentSession(id) com o id da sessão que guardou no pedido e decide pelo paymentStatus (succeeded,
processing, requires_action…). O evento payment_session.completed confirma o mesmo pelo webhook.
Idempotência, cancelamento, validação
Mande sempre Idempotency-Key nos POSTs. Todo POST aceita { idempotencyKey } (1 a 255 caracteres ASCII
imprimíveis) e manda o header Idempotency-Key; repetir com a mesma chave não duplica a operação. Um erro network não
diz se o objeto foi criado, e repetir sem a chave cria outra sessão. Derive a chave do que o app já tem (id do
pedido + operação). O client não repete sozinho.
cancelSubscription sem atPeriodEnd manda { "atPeriodEnd": true }: cancela no fim do período, a opção não
destrutiva do contrato. Cancelar na hora exige { atPeriodEnd: false }.
Validação antes da rede. createPaymentSession remonta o corpo só com os campos do contrato (taxa ou
destination passados a mais não saem) e recusa com invalid_request e param: dinheiro que não seja inteiro ≥ 0 na
menor unidade, moeda fora de ISO 4217 de 3 letras, interval fora de subscription (ou em item com priceId), moedas
diferentes em payment, customer ausente em subscription, customerRef fora de 1 a 150 de [A-Za-z0-9_.:@-],
trialDays fora de subscription ou não inteiro ≥ 1, description vazia. successUrl/cancelUrl (v1) não existem
mais e não são enviados. O returnUrl do portal precisa ser http(s) absoluto. O baseUrl precisa ser
https; http só para localhost, 127.0.0.1 e ::1 (o Basic iria em texto claro). changeSubscription exige
{ priceId } ou { name, amount, interval }. Os ids vão no caminho com encodeURIComponent; vazio, ., ..,
espaço e controle são recusados (o URL resolveria ..). A mesma origem do returnUrl com a redirectUri do app é
conferida no Tzam, não aqui.
Erros. Tudo lança TzamPaymentsError { code, status, param? }. code é um dos da tabela do contrato
(unauthorized, forbidden, payments_disabled, not_found, invalid_request, account_not_ready, conflict,
rate_limited, stripe_error, internal_error), ou:
network: sem resposta do contrato — rede, timeout, redirect (o client usaredirect: 'error', para oAuthorizationnunca ir a outra origem) ou resposta fora do contrato (502/504 de proxy com HTML, corpo vazio, 2xx sem objeto JSON, 500 sem{ error }, 503 e outros 5xx fora da tabela; aístatusvem preenchido). A operação pode ter acontecido: repita com a mesmaIdempotency-Key.webhook_signature: só deparseTzamWebhook.amount_below_fee(0.9.0, 400): a cobrança é menor que a taxa da plataforma na conta da organização, e o Tzam a recusa sem chamar o Stripe (nunca limita a taxa em silêncio). A mensagem traz o valor mínimo viável: o app deve mostrar isso ao usuário ou subir o valor do item (preço, quantidade) e tentar de novo com outraIdempotency-Key(o pedido mudou). Repetir o mesmo pedido não adianta. Decida pelocode, nunca pela mensagem: ela serve para exibir, não para extrair o número. Um client 0.8.0 recebe este erro comoinvalid_request(mesmo 400).environment_mismatch(0.9.0): a resposta não combina com o ambiente da credencial, ou apublishableKeynão combina com o ambiente da resposta. Veja Ambientes.webhook_environment_mismatch(0.9.0): evento de outro ambiente que oexpectEnvironmentdo endpoint.
Só uma resposta de erro do contrato — corpo JSON { "error": { … } } — leva um código da tabela: um 502 de proxy com
HTML é network, nunca stripe_error. Dentro dela, um código desconhecido ou que contradiz o status é ignorado e
vale o do status (401, 403, 404, 400, 409, 429, 500, 502): um 500 com { "error": { "code": "conflict" } } é
internal_error. Sem código para o status (503 + conflict, por exemplo), é network. internal_error (500) é falha
do Tzam com resposta do contrato: a operação pode ou não ter acontecido, então repita com a mesma Idempotency-Key. Um 2xx que traz { error } também é erro, nunca resultado. status é undefined quando não
houve resposta HTTP (rede, validação local, webhook). Use isTzamPaymentsError(e) em vez de instanceof (ESM e CJS têm
classes diferentes). O applicationSecret, o token Basic montado com ele e qualquer tzwh_… são redigidos da mensagem,
do toString, do JSON e do stack; o objeto do client não tem nenhum campo de texto.
Ambientes: teste e produção (0.9.0)
Contrato v5: cada Application tem uma credencial por ambiente, tzps_test_… e tzps_live_…, no lugar do
client_secret (que ainda vale só em teste, até o Sunset que o Tzam anuncia). Passe a credencial em
applicationSecret, como antes: o app escolhe o ambiente pela credencial, e a de teste nunca toca produção.
const tzam = createTzamPaymentsClient({
baseUrl: process.env.TZAM_URL!,
clientId: process.env.TZAM_CLIENT_ID!,
applicationSecret: process.env.TZAM_PAYMENTS_CREDENTIAL!, // tzps_test_… ou tzps_live_…
});
tzam.environment; // 'test' | 'live' | undefined (credencial sem 