- Início
- Checkout
- Checkout próprio com o SDK
Checkout próprio com o SDK
Monte a sua tela de pagamento com o @quero/paystation-sdk e cobre pela API /v1 no seu servidor, sem cobrar duas vezes nem perder venda.
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.
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-sdkna 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
110200e 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 ooffer_iddo seu catálogo ao lado. - Um webhook cadastrado e validando a assinatura (veja Webhooks).
npm install @quero/paystation-sdkMostrar a oferta
Sua páginaimport { 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:
| Campo | O que mostrar |
|---|---|
payment_methods | Os 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_bumps | O preço sem desconto, o cupom (applied ou invalid) e os order bumps, com os selecionados. |
customer.fields, customer.document_types, customer.custom_fields | O que pedir ao comprador. |
subscription | Só 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<div id="card"></div>
<div id="turnstile"></div>
<button id="pay" disabled>Pagar</button>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.createTokenstokeniza 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 emfailures, 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.mountmostra 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.tsesrc/server/orders.ts) tem a versão completa, com ele e com a espera por uma resposta perdida.
Cobrar no servidor
Seu servidorO 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ó.
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.spacecom a chavesk_test_…ehttps://paystation.quero.spacecom ask_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, responde409 idempotency_key_reuse. usableCardstira o token da Pagar.me depois de 30 segundos. Reenviado vencido, ele deixaria a cobrança emprocessingpara 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
+5511988888888e 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, empayment.expiration_atepayment.expiration_date. Um campo de prazo no corpo (comoexpires_atoudue_date) é recusado com422 unknown_parameter, antes de qualquer cobrança. handleaplica 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 desrc/server/orders.ts, com a decisão emsrc/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.
- 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.
- 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:
402ou422(menos o402 acquirer_disabledde um reenvio),404ou401do primeiro envio,declined, Pix ou boleto vencido pela regra de Acompanhar, ou uma reconciliação que não achou nada. Nunca depois de umpaid, e nunca só porque o limite do item 3 passou. - Resultado desconhecido: repita a mesma chave com a mesma compra. Vale para erro de rede, tempo esgotado,
5xx,429e409 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. 2xx: a cobrança existe. Nunca cobre essa compra de novo. Guarde o id da cobrança na tentativa, confira oamountcom o total esperado (diferente: atendimento, sem entregar) e acompanhe pelostatus, como em Acompanhar.402ou422: nada foi cobrado. Uma nova tentativa usa chave nova e tokeniza de novo. A exceção é o402 acquirer_disablednum reenvio: ele só encerra a tentativa no primeiro envio; num reenvio, siga o item 7.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.- Reconcilie quando passar o limite do item 3, quando não sobrar cartão usável, num
409 idempotency_key_expiredouidempotency_key_unresolved, num401ou402 acquirer_disabledde um reenvio, ou numa resposta que nada acima cobre (a página de erro de um proxy). Veja Reconciliar. - Pare e mostre o erro num
404(offer_not_found) ou num401do 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 servidorO status da cobrança diz o que fazer:
status | O que fazer |
|---|---|
paid | Libere o produto, uma vez por pedido. |
pending | Pix ou boleto em aberto: mostre o QR (payment.qr_code) ou o boleto (payment.boleto_url) e espere. Num cartão, trate como processing. |
processing | A adquirente ainda não respondeu. Mostre "em análise"; passado o seu prazo, mande ao atendimento. |
declined | Recusada. Uma nova tentativa pode começar, com chave nova. |
refund_pending, refunded, chargedback | O 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
2xxou achado na reconciliação, em cada tentativa, nunca pelometadata. 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 qualquermetadata: um comprador pode pagar um Pix barato com oorder_idde um pedido caro. Num webhook, procure o pedido pelowebhookData.transaction.ide confira oferta e valor (paymentData.amount) antes de liberar.offerData.idé o id da oferta no Paystation, ou ooffer_iddo 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, responda5xx: a entrega tenta de novo por uns 10 minutos, e o seu acompanhamento também libera. Use esse campo só para escolher entre5xxe2xx, nunca para liberar. Qualquer outro id (outra forma de venda do mesmo cliente) recebe2xxe é ignorado. - O webhook e a consulta valem para liberar. Ordene as mensagens por
webhookData.createdAt: umtransaction_paidatrasado não desfaz um estorno que veio depois. Um reenvio feito pelo suporte é uma mensagem nova, com outrocreatedAt: na dúvida, confirme comGET /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 depayment.expiration_date(boleto). O anterior continua pagável depois do vencimento; se os dois forem pagos, estorne um: Pix porPOST /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}/refunddevolve a cobrança inteira e responde200com ela emrefund_pending. Use um tempo de espera de pelo menos 60 segundos nessa chamada. Só depois de um200repetir é seguro (responde o mesmo200). Num502 refund_outcome_unknown, num tempo esgotado, numa queda de rede ou num5xxsem o envelope de erro, não repita: o estorno pode ter sido feito, e a confirmação de algumas adquirentes leva horas. Um409 refund_in_progressquer 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 umdeclinedoupendinglido na consulta ou no webhook.
Reconciliar
Seu servidorQuando 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.
curl -g "https://paystation.quero.space/v1/charges?offer=<id da oferta>&metadata[attempt_id]=<chave>" \
-H "Authorization: Bearer sk_live_…"| Resultado | O que fazer |
|---|---|
| Uma cobrança | Confira o amount com o total guardado no pedido e siga essa cobrança. Valor diferente: atendimento. |
| Mais de uma | Siga a que não está processing. Se não houver exatamente uma assim, atendimento. |
| Nenhuma | Nada 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 comsubscription: 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 respondeprocessing, 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 comosubscription_active,subscription_past_due,subscription_canceledesubscription_finished. Cadastre as famíliassubscription_cycleesubscriptionalém decheckout. Aplique os avisos de renovação por ciclo e status (subscriptionCycle.engineTransactionIdeeventName), nunca pela ordem de chegada: umsubscription_cycle_refundedpode chegar antes dosubscription_cycle_paiddo mesmo ciclo. Para o mesmoengineTransactionId,refundedechargedbackvencempaid: umpaidque chega depois não volta a liberar. Os de estado, por assinatura esequence. - 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}/cancelcancela sem multa. Repetir é seguro: uma assinatura já cancelada responde200, e um503pede para tentar de novo. Um409 subscription_not_cancelableou409 cancellation_pendingquer 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}/refundresponde409 subscription_active(ou409 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_paidde 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 oamount. - 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
livesó no servidor de produção, fora do código.