- Início
- Depois do pagamento
- Webhooks
Webhooks
Receba no seu servidor cada mudança de uma transação e de cada renovação de assinatura, confirme que a mensagem veio do Paystation e trate reentregas.
O Paystation avisa o seu servidor quando uma transação muda de status: criada, paga, recusada, estornada. Cada aviso é um POST com JSON para a URL que você cadastrou. É por ele que você libera o produto: o evento na página do checkout serve só à interface.
Cadastrar o destino
Os eventos vêm em três famílias. Cada destino escolhe as famílias que recebe.
| Família | Eventos | Quando |
|---|---|---|
checkout | transaction_<status> | Cada mudança de uma compra feita no checkout ou pela API. |
subscription_cycle | subscription_cycle_<status> | Cada cobrança de renovação de uma assinatura. |
subscription | subscription_<status> | Cada mudança de estado de uma assinatura: ativa, em atraso, cancelada, encerrada. |
Cadastre pela API, com a chave secreta. Até 15 destinos por cliente:
curl -X POST https://paystation.quero.space/v1/webhook_destinations \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.sualoja.com.br/webhooks/paystation", "events": ["checkout", "subscription_cycle", "subscription"] }'- A URL precisa ser
httpscom uma chavelive, sem usuário e senha na URL e apontando para um endereço público. - Repetir o mesmo cadastro devolve o destino que já existe, com
200. - Enquanto o checkout do cliente ainda é entregue nas URLs antigas, configuradas pelo time do Paystation, um cadastro que mexe na família
checkoutrecebe409 legacy_configuration_active. A migração do checkout é feita com o suporte; destinos só desubscription_cycleousubscriptionpodem ser cadastrados direto. - Destinos que o time do Paystation configurou aparecem como
managed_by: "paystation"e só podem ser alterados pelo suporte.
A referência completa está em API /v1. Renovações e mudanças de estado de assinatura só chegam a destinos que assinam subscription_cycle e subscription.
O que chega
Seu webhookPOST /webhooks/paystation HTTP/1.1
Content-Type: application/json
X-Signature-SHA256: 3q2+7wF5mQk1oZ0yJ6l0b8f9Xq2c4P1aT7u0sVnYd3E={
"eventName": "transaction_paid",
"webhookData": {
"transaction": { "id": "6f1c2b1e-7a4d-4c3b-9a55-2f7e0b6d1a11" },
"paymentStatus": "paid",
"createdAt": "2026-10-05T14:22:31.482Z",
"clientToken": "sualoja",
"offerData": { "id": "9f1c2b1e-7a4d-4c3b-9a55-2f7e0b6d1a11", "title": "Formação em Análise de Dados", "metadata": null },
"paymentData": {
"paymentMethod": "card",
"amount": 119640,
"paidAt": "2026-10-05T14:22:29.000Z",
"acquirerName": "pagarme_v5",
"acquirerId": "ch_8Kx2…",
"acquirerCardId": "card_3Pz…",
"paymentGatewayReference": "paystation-6f1c2b1e-…",
"paymentTypeData": {
"installments": 12,
"card": { "brand": "mastercard", "first_digits": "555555", "last_digits": "4444", "holder_name": "MARIA SOUZA" }
}
},
"customerData": {
"name": "Maria Souza",
"email": "maria@exemplo.com",
"document": "52998224725",
"document_type": "cpf",
"phone": "11987654321",
"address": { "street": "Rua das Flores", "number": "100", "city": "São Paulo", "state": "SP", "zip_code": "01000000" },
"custom_fields": null
},
"coupon": { "id": null, "label": null },
"orderBumps": null,
"externalMetadata": { "order_id": "PEDIDO-123" },
"tracking": { "utm_source": "google" }
}
}| Campo | O que é |
|---|---|
eventName | O evento. Veja Eventos. |
webhookData.transaction.id | Id da transação no Paystation: o mesmo id de GET /v1/charges/{id}. |
webhookData.paymentStatus | Status da transação nesta mensagem. |
webhookData.createdAt | Quando a mensagem foi montada. Fica igual em todas as tentativas: use-o para ordenar. |
webhookData.clientToken | O identificador do cliente no Paystation. Não é o segredo da assinatura. |
webhookData.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. |
webhookData.paymentData.amount | Valor em centavos: 119640 é R$ 1.196,40. |
webhookData.paymentData.acquirerCardId | Token do cartão na adquirente, para cobranças futuras do mesmo cartão. Vem null nas renovações. |
webhookData.customerData | O comprador, com endereço e campos personalizados. |
webhookData.externalMetadata | O que a sua integração mandou junto com o pedido: numa cobrança da API, o metadata, com o attempt_id. Pode vir do navegador: não libere um pedido só por ele. |
webhookData.tracking | Parâmetros de rastreamento da venda, como UTMs. |
O exemplo mostra uma compra no cartão. Os campos são os mesmos para Pix e boleto; o que muda é paymentTypeData:
paymentMethod | paymentTypeData |
|---|---|
card | installments, card (brand, first_digits, last_digits, holder_name, validade), strategy (smart num parcelamento inteligente) e, numa recusa, refusal_reason e refusal_message. |
pix | qr_code (o copia e cola) e expiration_at. |
boleto | url (o PDF), barcode, expiration_date e instructions. |
Validar a assinatura
X-Signature-SHA256 é o HMAC-SHA256 do corpo, calculado com o webhook token do cliente e codificado em base64. O cabeçalho só vem quando o cliente tem webhook token cadastrado: peça o token ao time do Paystation e recuse uma mensagem sem o cabeçalho.
- Leia o corpo bruto, exatamente como chegou, antes do parse do JSON.
- Calcule o HMAC-SHA256 desse corpo com o webhook token e codifique em base64.
- Compare com o cabeçalho numa comparação de tempo constante.
- Se não conferir, responda
401e descarte a mensagem.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: o corpo como string, antes do JSON.parse
export function isFromPaystation(rawBody, signature, webhookToken) {
const expected = createHmac('sha256', webhookToken).update(rawBody).digest('base64')
const a = Buffer.from(expected)
const b = Buffer.from(signature ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE_SHA256'] ?? '';
$expected = base64_encode(hash_hmac('sha256', $rawBody, getenv('PAYSTATION_WEBHOOK_TOKEN'), true));
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}import base64, hashlib, hmac
def is_from_paystation(raw_body: bytes, signature: str, webhook_token: str) -> bool:
digest = hmac.new(webhook_token.encode(), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, signature or "")Cole uma mensagem real e o seu token para conferir:
Verificar uma assinatura
Tudo é calculado no seu navegador; nada é enviado.
—Informe o webhook token para calcular a assinatura.
Responder e novas tentativas
Responda com qualquer 2xx em até 30 segundos. Processe depois, numa fila: uma resposta lenta conta como falha.
Quando a entrega falha, o Paystation tenta de novo com espera crescente, até 8 tentativas em cerca de 10 minutos:
- 10s
- +5s25s
- +10s315s
- +20s435s
- +40s51min15
- +1min2062min35
- +2min4075min15
- +5min20810min35
| Resposta do seu servidor | Checkout | Renovação e assinatura |
|---|---|---|
2xx | Entregue. | Entregue. |
5xx, 408, 429, 401, 403, tempo esgotado, conexão recusada | Tenta de novo. | Tenta de novo. |
Outros 4xx, como 400, 404 ou 422 | Não tenta de novo. | Tenta de novo. |
3xx (redirecionamento), num destino seu | Não segue nem tenta de novo. | Não segue; tenta de novo. |
- Destinos cadastrados por você não seguem redirecionamento: cadastre a URL final. Os configurados pelo time do Paystation seguem.
- Com mais de um destino na mesma família, a entrega termina quando qualquer um responde 2xx: um destino que falhou não recebe de novo aquela mensagem.
Entregas repetidas e fora de ordem
A entrega é pelo menos uma vez: a mesma mensagem pode chegar duas vezes, e dois status da mesma transação podem chegar fora de ordem. O suporte também pode reenviar um evento pelo painel, e o reenvio é uma mensagem nova, com outro createdAt. Por isso, não confie numa chave de mensagem: faça o efeito ser idempotente.
- Aplique cada efeito uma vez por transação. Libere o produto se a transação ainda não foi liberada, guardando isso pelo
webhookData.transaction.id, não pela mensagem. - Nunca volte um status. Um
transaction_pendingque chega depois de umtransaction_paiddeve ser ignorado. UsewebhookData.createdAtpara desempatar duas mensagens da mesma transação. - Um status pode voltar de verdade. Uma contestação ganha chega como um segundo
transaction_paiddepois dotransaction_chargedback: trate como "voltou a ser pago", sem liberar o produto de novo.
Eventos
Checkout
eventName é transaction_ mais o status da transação:
| Evento | Quando |
|---|---|
transaction_created | A transação foi registrada. |
transaction_pending | Aguardando pagamento: um Pix ou boleto em aberto. |
transaction_paid | Pagamento confirmado. Libere o produto. |
transaction_overpaid | Pago com valor acima do esperado. |
transaction_unpaid | O pagamento não foi feito a tempo. |
transaction_refused | A adquirente recusou o pagamento. |
transaction_failed | A tentativa de pagamento falhou. |
transaction_waiting_refund | Estorno pedido, em andamento. |
transaction_refunded | Estorno concluído. |
transaction_chargedback | O comprador contestou a compra no banco. |
Renovação de assinatura
Cada cobrança de renovação chega como subscription_cycle_<status>:
| Evento | Quando |
|---|---|
subscription_cycle_paid | A renovação foi paga. |
subscription_cycle_refunded | A renovação foi estornada. A assinatura não é cancelada. |
subscription_cycle_manually_refunded | A renovação foi estornada fora do Paystation. |
subscription_cycle_chargedback | O comprador contestou a renovação no banco. |
Não existe aviso por recusa de cartão na renovação, porque a cobrança é tentada de novo algumas vezes. Uma renovação que não foi paga chega como subscription_past_due, na família subscription.
A mensagem tem o mesmo formato da de checkout, com estas diferenças:
webhookData.transaction.idé sempre o da compra original, em todas as renovações.paymentStatusé igual aeventName, com o prefixosubscription_cycle_.paymentData.paymentTypeData,paymentData.paymentGatewayReference,paymentData.acquirerCardIdeorderBumpsvêmnull: eles descrevem a primeira compra, não a renovação.paymentData.paymentMethodpode virunknownse a cobrança usou um método que o Paystation não mapeia.- Um bloco
subscriptionCycleidentifica a cobrança:
"subscriptionCycle": {
"subscriptionId": 4812,
"cycleIndex": 3,
"engineTransactionId": 99217,
"refundedAt": null
}subscriptionCycle.subscriptionId é um número interno da recorrência, diferente do sub_… da API: a assinatura da API é sub_ mais webhookData.transaction.id.
Aplique cada renovação uma vez por subscriptionCycle.engineTransactionId e status, pelo mesmo motivo. refundedAt é só a data: ela também vem preenchida numa contestação, quando o dinheiro ainda não voltou. Decida pelo eventName.
subscriptionCycle.cycleIndex conta as renovações: o ciclo 1 é o segundo pagamento, porque o primeiro é o da compra. Numa oferta de assinatura, o último ciclo é o subscription.cycles que a oferta tinha na compra.
Estado da assinatura
A família subscription avisa quando o estado da assinatura muda. Só sai aviso numa mudança: o fim do período de teste, por exemplo, não gera nada.
| Evento | Quando |
|---|---|
subscription_active | Ficou ativa: ao ser criada, ao voltar de past_due com o pagamento em dia, ou ao ser reativada pelo suporte. |
subscription_past_due | A renovação atrasou. No cartão, no dia seguinte ao vencimento sem pagamento; no Pix e no boleto, quando a cobrança expira. A assinatura continua sendo cobrada e pode voltar a subscription_active. |
subscription_canceled | Foi cancelada: pela API, pelo suporte ou por uma contestação de renovação. Não cobre estorno nem contestação da compra que abriu a assinatura: esses chegam na família checkout, e a assinatura continua ativa. |
subscription_finished | Terminou o número de ciclos contratado. Num parcelamento inteligente, quer dizer "todas as parcelas pagas". |
canceled e finished não são definitivos: o suporte pode reativar uma assinatura, e isso gera um aviso novo.
O payload é enxuto e não traz dados do comprador:
{
"eventName": "subscription_canceled",
"webhookData": {
"createdAt": "2026-12-01T13:00:05.000Z",
"paymentStatus": "subscription_canceled",
"sequence": 4,
"transaction": { "id": "6f1c2b1e-7a4d-4c3b-9a55-2f7e0b6d1a11" },
"offerData": { "id": "9f1c2b1e-7a4d-4c3b-9a55-2f7e0b6d1a11", "title": "Formação em Análise de Dados", "metadata": null },
"externalMetadata": null,
"tracking": null,
"subscription": {
"id": "sub_6f1c2b1e-7a4d-4c3b-9a55-2f7e0b6d1a11",
"object": "subscription",
"status": "canceled",
"charge": "6f1c2b1e-7a4d-4c3b-9a55-2f7e0b6d1a11",
"payment_method": "card",
"amount": null,
"interval_days": 30,
"current_period_end": null,
"canceled_at": "2026-12-01T13:00:03.000Z"
}
}
}webhookData.subscription é o mesmo objeto que GET /v1/subscriptions/{id} responde. transaction.id é a compra que abriu a assinatura, o mesmo do aviso de checkout.
- Ordene pelo
sequence. Ele cresce a cada mudança daquela assinatura, mas pode pular números. Ignore umsequencemenor ou igual ao último que você aplicou: um reenvio pode chegar depois de um aviso mais novo. - Trate o aviso como estado, não como comando. "Ativa" numa assinatura que já está ativa não pede nada.
- Um aviso que não chega é reenviado por até um dia, depois das 8 tentativas, a cada 15 minutos. Depois disso, consulte
GET /v1/subscriptions/{id}, que sempre responde o estado atual. - As famílias não têm ordem entre si:
subscription_activepode chegar antes dotransaction_paidda compra que abriu a assinatura.
Checklist de produção
- Valide a assinatura antes de qualquer efeito, e recuse a mensagem que chega sem ela.
- Responda 2xx rápido e processe numa fila.
- Torne cada efeito idempotente por transação, como em Entregas repetidas.
- Registre
transaction.id,eventNameecreatedAtde cada entrega, para falar com o suporte. - Teste antes num destino de homologação, com uma chave
test.