npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 (PaymentError com code) 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 transfer só vai para conta conectada deste namespace. Conta de outro namespace, ou sem namespace, é recusada com invalid_request, param: 'transfer.destination' e accountId, antes de criar a sessão ou a assinatura (a conta já é lida para o chargesEnabled; 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 o namespace numa instalação existente, grave metadata.external_ref_namespace nas contas conectadas dela (POST /v1/accounts/:id) antes do primeiro repasse. getConnectedAccount não confere o namespace — devolve conta de qualquer instância;
  • protege os links de conta (0.5.0): createAccountOnboardingLink e createLoginLink leem 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): changeSubscription recusa 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 em metadata) e createPortalSession (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), o subscription_data e a assinatura de createSubscription levam metadata.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), mcc com 4 dígitos, url https, e-mails e telefone com formato, descritor até 22 caracteres ASCII sem < > \ ' ", card_payments só com transfers (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, capabilities só é 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 pede card_payments e transfers por padrão no Express, que é o que destination charges precisam (transfers recebe 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ável

Componentes 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 de type: 'express' usado pelo pacote. Não é preciso controller pró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: true só 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), type não é enviado — o Stripe marca type como obsoleto — e é recusado se vier. Sem controller, nada muda (type: 'express' por padrão).

  • Com stripeDashboard: 'none', a API exige capabilities: o pacote pede card_payments + transfers por 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 | application ou account | stripe | stripe | ✓ (componentes pedem autenticação) | | none | qualquer | application | stripe | ✗ não suportado | | express | application | application | stripe | ✓ (equivale ao type: 'express') | | express | account | stripe | stripe | ✓ | | express ou full | qualquer | qualquer | application | ✗ | | full | account | stripe | stripe | ✓ (equivale ao Standard) | | full | application (ou losses application) | | | ✗ |

    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ó com requirementCollection: 'application', que por sua vez exige losses e fees application.

  • getConnectedAccount devolve controller (type, stripeDashboard, fees, losses, requirementCollection) e o type da conta (none para conta criada só com controller).

  • createLoginLink lê a conta e recusa conta sem o Dashboard Express (none ou full) 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ão true) deixa a org cadastrar e trocar a conta bancária no próprio componente; standardPayouts e editPayoutSchedule deixam sacar e mudar o cronograma (saque manual exige o cronograma em manual); instantPayouts depende 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 — o notificationBanner e o accountOnboarding fazem 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 });                      // agora

Em 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?.declineCode
  • createPaymentIntent segue as regras do repasse do checkout: só applicationFeeAmount (até o valor), account_not_ready se a conta não pode receber, e, com namespace, conta de outro namespace (ou sem) recusada e metadata.external_ref_namespace no PaymentIntent. Valor > 0; description até 1000 caracteres.
  • createSubscription expande latest_invoice.confirmation_secret e pending_setup_intent (com apiVersion anterior a 2025-03-31, latest_invoice.payment_intent, e a leitura aceita os dois formatos). Sem apiVersion, vale a versão padrão da conta: se ela for anterior a 2025-03-31, passe apiVersion, porque a expansão do formato atual seria recusada.
  • O segredo. O clientSecret permite confirmar o pagamento. Ele é uma propriedade não enumerável: pi.clientSecret funciona, mas JSON.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_… e seti_…_secret_… são redigidos (o id pi_… fica). Não o grave em banco nem em log; peça-o de novo com getPaymentIntent se precisar.
  • getPaymentIntent e getSetupIntent não conferem o namespace (o id vem do banco do app).
  • cancelPaymentIntent(id, { reason?, idempotencyKey? }) cancela o PaymentIntent (sessão cancelada ou expirada) e devolve o mesmo formato do getPaymentIntent; reason: duplicate | fraudulent | requested_by_customer | abandoned. Não confere o namespace.
  • createSubscription({ description }) repassa a descrição à assinatura (até 500 caracteres).
  • Vários itens. priceId (um preço) ou items (até 20, na mesma moeda). A Subscriptions API do Stripe exige um produto existente no preço ad hoc: com name, o pacote cria o produto antes (POST /v1/products, com o namespace no metadata e a Idempotency-Key prod:<sha256("subscription:" + chave), 32 hex>:<i>, que cabe nos 255 do Stripe com qualquer chave). Por isso, item por name exige idempotencyKey: 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, prefira productId ou um priceId do catálogo. changeSubscription troca um item e recusa assinatura com vários.
  • Renovação. payment_settings[save_default_payment_method]=on_subscription vai 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: false não envia o campo (o padrão do Stripe é não salvar).
  • Teste grátis pelo webhook. setup_intent.succeeded e setup_intent.setup_failed chegam como setup.succeeded e setup.failed (setupIntentId, customerId, paymentMethodId, metadata; o failed com failureCode, declineCode, failureMessage), com as mesmas regras de connectedOrigin e namespace. O SetupIntent pendente não herda o metadata da assinatura: case pelo setupIntentId que createSubscription devolveu.
  • Eventos sem tipo conhecido (payment_intent.created, setup_intent.created…) chegam como unknown sem client_secret no 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.failed trazem paymentIntentId e metadata; invoice.paid e invoice.payment_failed trazem subscriptionId e, quando o payload tem, paymentIntentId (invoice.payment_intent nas versões antigas, invoice.payments nas atuais — que o webhook costuma não incluir; mapeie a assinatura pelo subscriptionId).

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:

  1. has(id) → já processado: duplicate (responda 200).
  2. claim(id) → false: o pacote consulta has(id) de novo. Se o evento foi marcado nesse meio-tempo, duplicate. Senão inProgress: true (duplicate: false), sem rodar o handler — responda 409 ou 503, para o Stripe reentregar. Nunca 200: inProgress cobre três casos em que o evento ainda não foi processado e se perderia — a reserva órfã (o processo morreu entre claim e mark), 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 o release.
  3. handler lança → release(id) e o erro sobe (o app responde 5xx e o Stripe reentrega). Se o release falhar, o erro do handler continua sendo o que sobe, e a reserva expira pelo TTL.
  4. handler termina bem → mark(id). Se o mark falhar, a reserva não é solta (o handler já rodou); as reentregas recebem inProgress até 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: true todo evento com account (ou pelo endpoint connect) — inclusive os unknown, como um charge.succeeded forjado (0.5.1) —, exceto account.updated e payout.*, 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 pelo kind/type dentro do handler, nunca dando dinheiro por recebido a partir deles.
  • handleWebhookOnce os 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 handleWebhookOnce confere event.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 de metadata.external_ref_namespace do objeto (na fatura, também do subscription_details da assinatura), e foreignNamespace: true quando ele é de outro namespace;
  • handleWebhookOnce ignora 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 handleWebhookOnce confere event.foreignNamespace e 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 o external_ref que o pacote acrescenta); chave de 1 a 40 caracteres, sem colchetes (no corpo form-encoded a][transfer_data][destination viraria 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ó aceita platform ou connect.

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 usa redirect: 'error', para o Authorization nunca 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í status vem preenchido). A operação pode ter acontecido: repita com a mesma Idempotency-Key.
  • webhook_signature: só de parseTzamWebhook.
  • 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 outra Idempotency-Key (o pedido mudou). Repetir o mesmo pedido não adianta. Decida pelo code, nunca pela mensagem: ela serve para exibir, não para extrair o número. Um client 0.8.0 recebe este erro como invalid_request (mesmo 400).
  • environment_mismatch (0.9.0): a resposta não combina com o ambiente da credencial, ou a publishableKey não combina com o ambiente da resposta. Veja Ambientes.
  • webhook_environment_mismatch (0.9.0): evento de outro ambiente que o expectEnvironment do 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