Entrar no painel
Índice

(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:

  1. Qualquer pessoa escreve aquele endereço no browser. Um session_id de uma sessão por pagar leva exatamente ao mesmo sítio.
  2. 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.
  3. 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

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

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

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

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 dizO que a página mostra
complete, com payment.status succeededPago. (A encomenda já foi, ou vai ser, marcada pelo webhook.)
openA confirmar: uma referência por pagar, ou o gateway ainda a responder.
expiredNão foi pago. Oferece uma sessão nova.
A leitura falhouQue não foi possível ler o estado, e nunca "não pago".