(05) Receber pagamentos
Confirmar o pagamento
O regresso ao teu site não prova pagamento. O webhook prova, e o GET da sessão também.
O regresso não prova o pagamento
Quando o comprador paga, o checkout manda-o para o teu success_url, com ?session_id=cs_... acrescentado. É tentador marcar a encomenda como paga nesse momento. Não o faças, por três razões:
- Qualquer pessoa escreve aquele endereço no browser. Um
session_idde uma sessão por pagar leva exatamente ao mesmo sítio. - O comprador pode nunca lá chegar. Fecha o separador, perde a rede, ou o telemóvel bloqueia depois de confirmar o Multicaixa Express. O pagamento entrou, e a tua loja não soube.
- Uma referência paga-se longe do browser. O comprador sai do checkout com a entidade e a referência, e paga no multibanco horas depois. Não há regresso nenhum.
A regra
A encomenda dá-se por paga em dois sítios, e só nestes: no webhook payment.succeeded, com a assinatura verificada, ou num GET /v1/checkout/sessions/{id} feito pelo teu servidor, com a tua chave, que diga status: complete e payment.status: succeeded.
O webhook: a fonte da verdade
O payment.succeeded chega ao endpoint que configuraste no painel, assinado no cabeçalho Spolly-Signature com o segredo whsec_ desse endpoint. Verifica a assinatura sobre o corpo bruto, deduplica pelo id do evento, marca a encomenda, e responde 200. É o único caminho que apanha os pagamentos que acabam longe do browser.
Em Go
import "github.com/spolly-ao/spolly-payments-go/webhook"
verificador := webhook.Verifier{Secrets: []string{os.Getenv("SPOLLY_WEBHOOK_SECRET")}}
http.HandleFunc("POST /webhooks/spolly", func(w http.ResponseWriter, r *http.Request) {
evento, _, err := verificador.ParseRequest(r) // lê o corpo bruto e verifica Spolly-Signature
if err != nil {
// err é um *webhook.VerificationError; compara-se com errors.Is contra
// ErrNoSecret, ErrNoHeader, ErrMalformedHeader, ErrNoSignature,
// ErrSignatureMismatch, ErrTimestampOutsideTolerance, ErrInvalidPayload
// ou ErrBodyTooLarge (a lista e o que quer dizer cada um estão na página).
http.Error(w, "assinatura inválida", http.StatusBadRequest)
return
}
if jaTratado(evento.ID) { // deduplica pelo id: as retentativas e o reenvio repetem-no
return
}
switch evento.Type {
case webhook.EventPaymentSucceeded:
var pagamento struct {
ID string `json:"id"`
CheckoutSessionID string `json:"checkout_session_id"`
Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(evento.Data.Object, &pagamento); err != nil {
http.Error(w, "objeto ilegível", http.StatusBadRequest)
return
}
marcarPaga(pagamento.CheckoutSessionID, pagamento.ID)
}
marcarTratado(evento.ID)
// 200 implícito
})Em JavaScript
import { constructEvent, WebhookVerificationError } from "@spolly-ao/spolly-payments";
// Com o Express: express.raw() nesta rota, para req.body ser o corpo bruto (um Buffer).
app.post("/webhooks/spolly", express.raw({ type: "application/json" }), (req, res) => {
let evento;
try {
evento = constructEvent(req.body, req.get("Spolly-Signature"), process.env.SPOLLY_WEBHOOK_SECRET);
} catch (erro) {
if (!(erro instanceof WebhookVerificationError)) throw erro;
// erro.code: "no_secret", "no_header", "malformed_header", "no_signature",
// "signature_mismatch", "timestamp_outside_tolerance" ou "invalid_payload".
return res.status(400).send("assinatura inválida");
}
if (jaTratado(evento.id)) return res.sendStatus(200); // as retentativas e o reenvio repetem o id
if (evento.type === "payment.succeeded") {
const pagamento = evento.data.object; // os valores chegam como BigInt (amount.amount)
marcarPaga(pagamento.checkout_session_id, pagamento.id);
}
marcarTratado(evento.id);
res.sendStatus(200);
});Para ligar o evento à encomenda, usa o checkout_session_id (que guardaste ao criar a sessão) ou os teus metadata, que voltam tal como os mandaste. Os pormenores da entrega, das retentativas e da rotação do segredo estão em Webhooks.
A página de regresso: mostra, e não marca
A página do success_url serve para o comprador ver o estado certo enquanto o webhook não chega. Lê a sessão do lado do servidor, com a tua chave (antes de responder, a API pergunta ao gateway pelos pagamentos pendentes), e mostra "pago" ou "a confirmar".
Em Go
http.HandleFunc("GET /obrigado", func(w http.ResponseWriter, r *http.Request) {
sessao, err := cliente.GetCheckoutSession(r.Context(), r.URL.Query().Get("session_id"))
if err != nil {
http.Error(w, "não foi possível ler o pagamento", http.StatusBadGateway) // nunca "não pago"
return
}
if sessao.Paid() { // status complete e payment.status succeeded
mostrar(w, "pago")
} else {
mostrar(w, "a confirmar") // o webhook ainda vai chegar; não marques nada aqui
}
})Em JavaScript
import { SpollyPayments, isPaid } from "@spolly-ao/spolly-payments";
const spolly = new SpollyPayments(process.env.SPOLLY_SECRET_KEY);
app.get("/obrigado", async (req, res) => {
const sessao = await spolly.getCheckoutSession(req.query.session_id); // do lado do servidor
res.render(isPaid(sessao) ? "pago" : "a-confirmar"); // isPaid: complete e payment.status succeeded
});| O que a sessão diz | O que a página mostra |
|---|---|
complete, com payment.status succeeded | Pago. (A encomenda já foi, ou vai ser, marcada pelo webhook.) |
open | A confirmar: uma referência por pagar, ou o gateway ainda a responder. |
expired | Não foi pago. Oferece uma sessão nova. |
| A leitura falhou | Que não foi possível ler o estado, e nunca "não pago". |