PaystationdocsPaystation

Use este caminho quando a tela de pagamento é sua. A página mostra a oferta, os campos de cartão e o desafio anti-robô com o @quero/paystation-sdk e a chave pública. O seu servidor cobra com a chave secreta, guarda cada tentativa de compra e acompanha a cobrança até o fim.

Sua página
tentativa + POST /v1/charges
Seu servidor
Paystation
libera o produto
Seu webhook

O ponto de partida é o checkout de referência, no repositório quero-edu/paystation-client, pasta packages/reference-checkout: uma página e um servidor Node que fazem tudo o que este guia descreve, com testes da sequência inteira. Copie a lógica de decisão (src/server/attempt.ts) e troque o banco SQLite pelo do seu produto.

Antes de começar ​

  • Chaves pública e secreta do seu cliente, emitidas pelo time do Paystation (veja Ambientes e chaves).
  • O pacote @quero/paystation-sdk na versão 0.19.0 ou mais nova. Ele é privado no npm: peça acesso ao time do Paystation e configure o token do npm no build.
  • Os domínios da sua página cadastrados no Turnstile do Paystation, em homologação e em produção. Fora deles o widget falha com o erro 110200 e a página não consegue o token.
  • O id da oferta no Paystation (um uuid). GET /v1/offers, com a chave secreta, lista as ofertas publicadas do cliente com o offer_id do seu catálogo ao lado.
  • Um webhook cadastrado e validando a assinatura (veja Webhooks).
bash
npm install @quero/paystation-sdk

Mostrar a oferta ​

Sua página
ts
import { createPaystation } from '@quero/paystation-sdk'

const paystation = createPaystation({ publishableKey: 'pk_test_…' })

const quote = await paystation.offers.checkout(offerId, { coupon, orderBumpIds })

A chave escolhe o ambiente: pk_test_… fala com a homologação e pk_live_… com a produção. A cotação responde o que a cobrança vai aceitar, em centavos:

CampoO que mostrar
payment_methodsOs métodos habilitados. O cartão traz installments, uma linha por número de parcelas com o total cobrado, e default_installments, o número de parcelas que o checkout abre selecionado; Pix e boleto trazem total.
subtotal, coupon, order_bumpsO preço sem desconto, o cupom (applied ou invalid) e os order bumps, com os selecionados.
customer.fields, customer.document_types, customer.custom_fieldsO que pedir ao comprador.
subscriptionSó em oferta de assinatura: cycles (o número de renovações depois do primeiro pagamento) e amount (o valor de cada renovação, sem cupom, desconto nem juros). null nas outras.

O total da linha que o comprador escolhe é o valor que a cobrança vai ter. O seu servidor refaz a mesma cotação, com o mesmo cupom e os mesmos order bumps, e guarda esse total no pedido: um total vindo do navegador não serve para conferir nada.

O primeiro pagamento não conta como ciclo: depois dele vêm cycles renovações, e o plano tem cycles + 1 pagamentos. Numa oferta com cycles: 11 e amount: 49975, o plano tem 12 pagamentos: o total da linha escolhida agora e 11 renovações de R$ 499,75.

Numa oferta de assinatura, o boleto não aparece, porque a renovação não é cobrada por boleto. A renovação é mensal e usa o método do primeiro pagamento: no cartão ela é cobrada sozinha; no Pix o comprador paga cada renovação numa página do Paystation (veja Assinaturas).

Cartão e Turnstile ​

Sua página
html
<div id="card"></div>
<div id="turnstile"></div>
<button id="pay" disabled>Pagar</button>
ts
const challenge = await paystation.turnstile.mount('#turnstile')
const card = paystation.cards.element().mount('#card')
let sending = false

card.on('change', ({ complete }) => { payButton.disabled = sending || !complete })

payButton.addEventListener('click', async () => {
  if (sending) return
  sending = true
  payButton.disabled = true
  card.lock() // o cartão na tela é o que vai ser cobrado até a tentativa acabar
  let sent = false
  try {
    const turnstileToken = await challenge.getToken()
    const { cards, tokenizedAt } = await card.createTokens({ offerId, installments, turnstileToken })
    sent = true
    const order = await postJson(`/api/orders/${orderId}/pay`, {
      method: 'card',
      installments,
      coupon,
      orderBumpIds,
      cards,
      cardsAgeMs: Date.now() - tokenizedAt,
      turnstileToken,
      customer,
    })
    render(order)
  } catch (error) {
    // Nada saiu (cartão inválido, tokenização, desafio): libere o formulário.
    // Saiu e a resposta se perdeu: pergunte ao servidor, que pode ter cobrado, até ele responder.
    render(sent ? await askUntilAnswered(`/api/orders/${orderId}`) : { canPay: true, error })
  } finally {
    sending = false
  }
})

// O servidor responde o estado do pedido. Só destrave quando ele deixa pagar de novo.
function render(order) {
  showStatus(order)
  if (order.canPay) {
    card.unlock()
    challenge.reset() // o token do desafio já foi usado: a próxima tentativa resolve outro
  }
  payButton.disabled = !order.canPay
}
  • cards.element() desenha os campos na sua página, com máscara pela bandeira e o erro de cada campo. Os campos ficam no DOM da sua página: ela entra no escopo PCI DSS como SAQ A-EP.
  • createTokens tokeniza o cartão em cada adquirente que pode cobrar essa oferta, na ordem em que o Paystation vai tentar. Uma adquirente que falha fica de fora em failures, e a cobrança segue com as outras. Tokenize no clique de pagar: o token da Pagar.me vale 60 segundos e serve a uma cobrança só.
  • Mande a idade dos tokens, não a hora (Date.now() - tokenizedAt): o relógio do navegador pode estar errado em minutos. O servidor guarda a hora no próprio relógio.
  • turnstile.mount mostra o desafio do Cloudflare com a chave do ambiente. Com a chave secreta, a cobrança exige o token quando o Turnstile está ligado no seu cliente, e ele deve estar: é ele que barra o teste de cartões roubados no seu endpoint de pagamento.
  • Pix e boleto não tokenizam. Mande o método, o comprador e o token do Turnstile.
  • Num desafio recusado (403 turnstile_*, item 6), o servidor responde que precisa de um token novo: challenge.reset(), resolva de novo e mande só o token ao servidor, que reenvia a mesma tentativa com ele. Esse caminho não está no exemplo acima; o checkout de referência (src/page/main.ts e src/server/orders.ts) tem a versão completa, com ele e com a espera por uma resposta perdida.

Cobrar no servidor ​

Seu servidor

O servidor cobra com POST /v1/charges e a chave secreta. Cada tentativa de compra tem uma Idempotency-Key: com ela, a mesma tentativa enviada duas vezes cobra uma vez só.

ts
import { randomUUID } from 'node:crypto'
import { usableCards } from '@quero/paystation-sdk'

// POST /api/orders/:id/pay
async function pay(order, input) {
  // A idade dos tokens conta desde a chegada, no relógio do servidor.
  const tokenizedAt = Date.now() - input.cardsAgeMs

  // O total vem da sua cotação, com o mesmo cupom e os mesmos order bumps.
  const quote = await paystationGet(`/v1/offers/${order.offerId}/checkout`, { coupon: input.coupon, order_bump_ids: input.orderBumpIds })
  const expectedAmount = totalFor(quote, input.method, input.installments)

  const key = randomUUID()
  const body = JSON.stringify({
    offer_id: order.offerId,
    coupon: input.coupon || undefined, // vazio é recusado com 422: omita
    order_bump_ids: input.orderBumpIds,
    customer: input.customer,
    payment: input.method === 'card'
      ? { method: 'card', installments: input.installments, cards: input.cards }
      : { method: input.method },
    metadata: { attempt_id: key, order_id: order.id },
  })

  // Uma tentativa viva por pedido, gravada ANTES do primeiro envio, numa transação só:
  // INSERT INTO attempts (...) + UPDATE orders SET live_attempt = $key
  //   WHERE id = $id AND live_attempt IS NULL AND delivered_at IS NULL
  // O token do desafio fica na tentativa: os reenvios em segundo plano precisam dele.
  const claimed = await db.claimAttempt(order.id, { key, body, tokenizedAt, expectedAmount, turnstileToken: input.turnstileToken })
  if (!claimed) return orderView(order.id) // segundo clique, outra aba: a tentativa que já existe

  const attempt = await db.attempts.get(key)
  return handle(await send(attempt, attempt.turnstileToken))
}

// Todo envio, o primeiro também, leva só os cartões ainda usáveis.
async function send(attempt, turnstileToken) {
  const stored = JSON.parse(attempt.body)
  const cards = stored.payment.cards && usableCards(stored.payment.cards, attempt.tokenizedAt)
  if (cards && cards.length === 0) return { outcome: 'reconcile' } // nenhum cartão sobrou: item 7
  // Espalhar mantém cada chave no lugar: o JSON que a chave compara não muda.
  const payload = cards ? JSON.stringify({ ...stored, payment: { ...stored.payment, cards } }) : attempt.body

  await db.attempts.recordSend(attempt.key, Date.now()) // antes de sair: um envio sem resposta pode ter cobrado
  let response
  try {
    response = await fetch(`${process.env.PAYSTATION_API_URL}/v1/charges`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.PAYSTATION_SECRET_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': attempt.key,
        ...(turnstileToken && { 'cf-turnstile-token': turnstileToken }),
      },
      body: payload,
      signal: AbortSignal.timeout(90_000),
    })
  } catch {
    return { outcome: 'unknown', abortedAt: Date.now() } // rede ou tempo esgotado: item 3
  }
  const json = await response.json().catch(() => null)
  if (response.ok) return json ? { outcome: 'created', charge: json } : { outcome: 'unknown' }
  return { outcome: 'error', status: response.status, code: json?.error?.code ?? null } // itens 3 a 8
}
  • PAYSTATION_API_URL é https://paystation-homolog.quero.space com a chave sk_test_… e https://paystation.quero.space com a sk_live_….
  • metadata.attempt_id é a própria chave. É por ele que você acha a cobrança quando a resposta se perde. Acrescente o id do pedido.
  • Guarde o corpo exatamente como foi enviado. A API compara o JSON recebido, ordem das chaves inclusive, com exceção de payment.cards. Um corpo remontado em outra ordem, a partir das colunas do seu banco, responde 409 idempotency_key_reuse.
  • usableCards tira o token da Pagar.me depois de 30 segundos. Reenviado vencido, ele deixaria a cobrança em processing para sempre. Os tokens da Stark e da Adyen valem a tentativa inteira.
  • O comprador vai inteiro na cobrança: nome, e-mail, documento, telefone no formato +5511988888888 e endereço, obrigatório em todos os métodos.
  • Pix e boleto levam só method. O prazo do Pix e o vencimento do boleto vêm da configuração do seu cliente no Paystation e voltam na resposta, em payment.expiration_at e payment.expiration_date. Um campo de prazo no corpo (como expires_at ou due_date) é recusado com 422 unknown_parameter, antes de qualquer cobrança.
  • handle aplica a regra abaixo e guarda a resposta. Os reenvios, a reconciliação e o acompanhamento rodam num processo à parte, que também retoma as tentativas abertas depois de um restart: no checkout de referência, a varredura de src/server/orders.ts, com a decisão em src/server/attempt.ts.

Regra de tentativa ​

Decida pela resposta da criação, nesta ordem. Classifique pelo code do erro, não pelo status: há 409 que não são de idempotência.

  1. Uma tentativa = uma chave (UUID v4), criada e gravada antes do primeiro envio, com o corpo, a hora do primeiro envio e a hora dos tokens. Cada envio é registrado antes de sair, e a resposta quando chega.
  2. Uma tentativa viva por pedido. Duas abas, ou a página e o servidor ao mesmo tempo, criam duas chaves, e as duas podem ser cobradas. O servidor reserva a tentativa no pedido com uma escrita condicional, e um segundo clique recebe a mesma tentativa. A reserva só é liberada quando a tentativa termina sem cobrança: 402 ou 422 (menos o 402 acquirer_disabled de um reenvio), 404 ou 401 do primeiro envio, declined, Pix ou boleto vencido pela regra de Acompanhar, ou uma reconciliação que não achou nada. Nunca depois de um paid, e nunca só porque o limite do item 3 passou.
  3. Resultado desconhecido: repita a mesma chave com a mesma compra. Vale para erro de rede, tempo esgotado, 5xx, 429 e 409 idempotency_key_in_use. Use pausa crescente por pelo menos 3 minutos (a chave fica em uso por até 120 segundos enquanto a primeira chamada pode estar viva) e pare uns 10 minutos depois do primeiro envio. Depois disso a chave morre: ninguém mais a envia, e você segue o item 7.
  4. 2xx: a cobrança existe. Nunca cobre essa compra de novo. Guarde o id da cobrança na tentativa, confira o amount com o total esperado (diferente: atendimento, sem entregar) e acompanhe pelo status, como em Acompanhar.
  5. 402 ou 422: nada foi cobrado. Uma nova tentativa usa chave nova e tokeniza de novo. A exceção é o 402 acquirer_disabled num reenvio: ele só encerra a tentativa no primeiro envio; num reenvio, siga o item 7.
  6. 403 turnstile_*: peça um novo desafio à página e reenvie a mesma chave, poucas vezes. Num cartão, só se ainda sobra cartão usável; senão, siga o item 7. 409 idempotency_key_reuse: reenvie o corpo guardado uma vez; se repetir, siga o item 7. Os dois só dentro do limite do item 3.
  7. Reconcilie quando passar o limite do item 3, quando não sobrar cartão usável, num 409 idempotency_key_expired ou idempotency_key_unresolved, num 401 ou 402 acquirer_disabled de um reenvio, ou numa resposta que nada acima cobre (a página de erro de um proxy). Veja Reconciliar.
  8. Pare e mostre o erro num 404 (offer_not_found) ou num 401 do primeiro envio (chave revogada). Nada foi cobrado; corrija a chave ou a oferta antes de outra tentativa.

Na cobrança de cartão, a API tenta as adquirentes de payment.cards na ordem de prioridade dela e passa para a próxima numa recusa comum ou quando a adquirente recusa o pedido sem cobrar. Ela para na primeira recusa por saldo insuficiente, suspeita de fraude, erro na transação, código irreversível ou excesso de tentativas, e depois de um parcelamento inteligente. O 402 card_declined traz o motivo na mensagem (Payment declined (insufficient_funds).). Quando nenhuma adquirente cobrou porque todas recusaram o pedido, a resposta é 422 payment_rejected. Os dois ficam guardados na chave: outro cartão é sempre outra tentativa, com chave nova.

Acompanhar e entregar ​

Seu servidor

O status da cobrança diz o que fazer:

statusO que fazer
paidLibere o produto, uma vez por pedido.
pendingPix ou boleto em aberto: mostre o QR (payment.qr_code) ou o boleto (payment.boleto_url) e espere. Num cartão, trate como processing.
processingA adquirente ainda não respondeu. Mostre "em análise"; passado o seu prazo, mande ao atendimento.
declinedRecusada. Uma nova tentativa pode começar, com chave nova.
refund_pending, refunded, chargedbackO dinheiro voltou ou está em disputa. Não entregue nem cobre de novo por essa tentativa: atendimento.
  • Ligue o pedido às cobranças pelo id delas, guardado do 2xx ou achado na reconciliação, em cada tentativa, nunca pelo metadata. Um pedido pode ter mais de uma cobrança: um Pix vencido segue pagável depois que outra tentativa começou. Acompanhe toda tentativa não paga por uns 60 dias (o Pix da Stark vale até 59 dias depois do vencimento) e ache o pedido pelo id de qualquer uma delas; se duas forem pagas, estorne uma, registrando o pedido de estorno na tentativa antes de enviá-lo, para a varredura seguinte não pedir de novo. O webhook é o gatilho; a consulta, espaçada: depois do vencimento e das 2 horas, uma por dia basta, porque a consulta de cobranças tem um limite por chave que a tela do Pix também usa. A chave pública está na sua página e cria cobranças com qualquer metadata: um comprador pode pagar um Pix barato com o order_id de um pedido caro. Num webhook, procure o pedido pelo webhookData.transaction.id e confira oferta e valor (paymentData.amount) antes de liberar. offerData.id é o id da oferta no Paystation, ou o offer_id do seu catálogo quando as ofertas do cliente vêm do seu próprio webhook de ofertas.
  • O webhook pode chegar antes da resposta da cobrança, com um id que nenhuma tentativa guardou ainda. Se webhookData.externalMetadata.attempt_id é a chave de uma tentativa sua ainda sem cobrança conhecida, responda 5xx: a entrega tenta de novo por uns 10 minutos, e o seu acompanhamento também libera. Use esse campo só para escolher entre 5xx e 2xx, nunca para liberar. Qualquer outro id (outra forma de venda do mesmo cliente) recebe 2xx e é ignorado.
  • O webhook e a consulta valem para liberar. Ordene as mensagens por webhookData.createdAt: um transaction_paid atrasado não desfaz um estorno que veio depois. Um reenvio feito pelo suporte é uma mensagem nova, com outro createdAt: na dúvida, confirme com GET /v1/charges/{id}, que responde o estado atual a qualquer momento.
  • Pix ou boleto sem pagamento libera uma nova tentativa 2 horas depois de payment.expiration_at (Pix) ou 3 dias úteis depois de payment.expiration_date (boleto). O anterior continua pagável depois do vencimento; se os dois forem pagos, estorne um: Pix por POST /v1/charges/{id}/refund, boleto pelo atendimento, porque a API não estorna boleto. Sem data de vencimento na resposta, mande ao atendimento.
  • Estorno: POST /v1/charges/{id}/refund devolve a cobrança inteira e responde 200 com ela em refund_pending. Use um tempo de espera de pelo menos 60 segundos nessa chamada. Só depois de um 200 repetir é seguro (responde o mesmo 200). Num 502 refund_outcome_unknown, num tempo esgotado, numa queda de rede ou num 5xx sem o envelope de erro, não repita: o estorno pode ter sido feito, e a confirmação de algumas adquirentes leva horas. Um 409 refund_in_progress quer dizer que outro pedido de estorno da mesma cobrança ainda está rodando: também não repita. Nos dois casos, acompanhe a cobrança e o webhook; se nada mudar no dia, fale com o atendimento.
  • Antes de um 2xx, uma nova tentativa só sai das respostas da criação (itens 5 a 8) ou de uma reconciliação que não achou nada, nunca de um declined ou pending lido na consulta ou no webhook.

Reconciliar ​

Seu servidor

Quando a resposta se perdeu de vez, procure a cobrança pela chave. Espere pelo menos 120 segundos depois do limite do item 3 e do último envio. Um envio que você abortou por tempo esgotado conta a partir do abort: a API não abre uma cobrança mais de 60 segundos depois de receber o pedido, mas o pedido pode ter chegado a ela bem depois de sair do seu servidor.

bash
curl -g "https://paystation.quero.space/v1/charges?offer=<id da oferta>&metadata[attempt_id]=<chave>" \
  -H "Authorization: Bearer sk_live_…"
ResultadoO que fazer
Uma cobrançaConfira o amount com o total guardado no pedido e siga essa cobrança. Valor diferente: atendimento.
Mais de umaSiga a que não está processing. Se não houver exatamente uma assim, atendimento.
NenhumaNada foi cobrado. Uma nova tentativa pode começar, com chave nova.

Cobrar na página ​

paystation.charges.create(params, { idempotencyKey, turnstileToken, tokenizedAt }) cobra direto do navegador com a chave pública. Ele coloca a chave em metadata.attempt_id e filtra os cartões sozinho. Mesmo assim, o servidor cria a chave e a entrega à página (item 2): uma chave que só a aba conhece some com ela, e aí ninguém reconcilia. E o servidor confirma antes de entregar, pela busca do item 7 conferindo o amount, ou pelo webhook conferindo oferta e valor: um id de cobrança vindo do navegador não diz de qual oferta ela é.

Assinaturas ​

  • A cobrança de uma oferta de assinatura é o primeiro pagamento. A resposta traz subscription: "sub_<id>", o id da assinatura. Um cartão de oferta comum também pode voltar com subscription: quando a oferta permite parcelamento inteligente e o cartão não tem saldo para o total, a cobrança vira parcelas cobradas mês a mês, como uma assinatura.
  • GET /v1/subscriptions/{id}, com a chave secreta, responde o estado da assinatura, o valor e a data da próxima cobrança (current_period_end). Logo depois do pagamento ele responde processing, enquanto a assinatura é criada: confirme o pagamento pela cobrança, não pela assinatura.
  • As renovações chegam no webhook como subscription_cycle_paid, e as mudanças de estado como subscription_active, subscription_past_due, subscription_canceled e subscription_finished. Cadastre as famílias subscription_cycle e subscription além de checkout. Aplique os avisos de renovação por ciclo e status (subscriptionCycle.engineTransactionId e eventName), nunca pela ordem de chegada: um subscription_cycle_refunded pode chegar antes do subscription_cycle_paid do mesmo ciclo. Para o mesmo engineTransactionId, refunded e chargedback vencem paid: um paid que chega depois não volta a liberar. Os de estado, por assinatura e sequence.
  • No Pix, o comprador paga cada renovação na página https://paystation-client.quero.space/assinatura/<id da compra>. A API não manda esse link ao comprador: combine com o time do Paystation como ele vai recebê-lo.
  • POST /v1/subscriptions/{id}/cancel cancela sem multa. Repetir é seguro: uma assinatura já cancelada responde 200, e um 503 pede para tentar de novo. Um 409 subscription_not_cancelable ou 409 cancellation_pending quer dizer que ela não foi cancelada: logo depois da compra, enquanto ela é criada, tente de novo mais tarde; se persistir, fale com o atendimento. Não ignore o erro.
  • Para estornar a compra que abriu a assinatura, cancele antes: com a assinatura viva, POST /v1/charges/{id}/refund responde 409 subscription_active (ou 409 subscription_processing, enquanto ela é criada). O estorno devolve só o primeiro pagamento, nunca uma renovação.
  • Uma renovação ainda em andamento no cancelamento (um cartão pendente, um Pix já emitido) pode ser paga depois dele: chega subscription_cycle_paid de uma assinatura cancelada. Essa renovação só é estornada pelo atendimento.

Checklist de produção ​

  • O servidor guarda chave, corpo exato, hora dos tokens e cada envio antes de cobrar.
  • Uma tentativa viva por pedido, garantida no banco, não na memória.
  • Um processo que retoma as tentativas abertas (reenvio, reconciliação, acompanhamento) depois de um restart, como a varredura do checkout de referência.
  • Pedido ligado às cobranças pelo id delas, em cada tentativa, com oferta e valor conferidos antes de liberar, e as tentativas não pagas acompanhadas até o Pix não poder mais ser pago.
  • Reenvios só com a mesma chave, só dentro do limite, e só com usableCards.
  • Reconciliação por offer + metadata[attempt_id], conferindo o amount.
  • Produto liberado uma vez por pedido, pelo webhook validado ou pela consulta com a chave secreta.
  • Turnstile ligado no cliente e os domínios da página cadastrados nos dois ambientes.
  • Chaves live só no servidor de produção, fora do código.