Entrar no painel
Índice

(01) Começar

Início rápido

Da conta ao primeiro pagamento de teste confirmado por webhook, com o SDK em Go ou em JavaScript.

No fim deste guia tens uma loja mínima, a correr no teu computador, que cria uma sessão de checkout, manda o comprador pagar, e marca a encomenda como paga quando chega o webhook payment.succeeded, com a assinatura verificada. Tudo em modo teste, sem dinheiro. Escolhe uma língua (Go ou JavaScript): os passos são os mesmos.

Precisas de:

  • uma conta Spolly (cria-se no primeiro passo);
  • Go 1.26 ou mais recente, ou Node.js 22 ou mais recente;
  • curl, e um túnel que dê um endereço público ao teu computador, para receber o webhook (o cloudflared ou o ngrok, por exemplo). A API de produção recusa endereços da rede interna (localhost, 127.0.0.1, 10.x, 192.168.x).

Passo 01

Cria a conta e copia a chave de teste

  1. Abre o painel, em https://payments.spolly.ao/entrar, e entra com a tua conta Spolly. Se ainda não tens, escolhe Usar outra conta (Use another account) e depois Criar conta (Create account); abre a ligação do email de confirmação, volta ao painel e entra. Na primeira vez a conta Spolly pergunta se autorizas o Spolly Payments a ver o teu nome e email: Autorizar (Allow). A conta Spolly aparece em português ou em inglês, conforme o browser.
  2. Carrega em Registar a empresa: o nome comercial, o nome legal, o NIF, a atividade, a morada e um email de contacto. Para testar não é preciso entregar documentos.
  3. No fim do registo aparece a chave de teste, sk_test_..., uma única vez. Copia-a. (Se a perderes, cria outra em Chaves de API.)

Confirma que a chave funciona:

Terminal
export SPOLLY_SECRET_KEY=sk_test_...      # a chave que copiaste
export SPOLLY_API_URL=https://payments-api.spolly.ao

curl -s "$SPOLLY_API_URL/v1/conta" -H "Authorization: Bearer $SPOLLY_SECRET_KEY"

A resposta diz a tua loja e "modo":"test". Um 401 chave_invalida quer dizer que a chave não foi copiada inteira.

Passo 02

Instala o SDK

Go

Terminal
mkdir loja && cd loja
go mod init loja
go get github.com/spolly-ao/spolly-payments-go@v0.1.0

JavaScript

Terminal
mkdir loja && cd loja
npm init -y
npm install github:spolly-ao/spolly-payments-js#v0.1.0

Os dois SDK não têm dependências: só a biblioteca padrão de cada língua. São públicos e instalam-se diretamente do GitHub da Spolly (spolly-ao/spolly-payments-go e spolly-ao/spolly-payments-js, etiqueta v0.1.0), sem credenciais; em JavaScript o pacote fica com o nome @spolly-ao/spolly-payments, que é o que se importa.

Passo 03

Escreve a loja

Um programa só, com três rotas: POST /comprar cria a sessão e manda o comprador para o checkout; POST /webhooks/spolly verifica o evento e marca a encomenda como paga; GET /obrigado é o regresso, que só mostra o estado.

Go: main.go

main.go
// main.go: a loja mínima. Cria a sessão, recebe o webhook, mostra o regresso.
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"sync"

	spolly "github.com/spolly-ao/spolly-payments-go"
	"github.com/spolly-ao/spolly-payments-go/webhook"
)

func main() {
	var opcoes []spolly.Option
	if u := os.Getenv("SPOLLY_API_URL"); u != "" {
		opcoes = append(opcoes, spolly.WithBaseURL(u))
	}
	cliente := spolly.NewClient(os.Getenv("SPOLLY_SECRET_KEY"), opcoes...)
	verificador := webhook.Verifier{Secrets: []string{os.Getenv("SPOLLY_WEBHOOK_SECRET")}}
	loja := os.Getenv("LOJA_URL") // o endereço público desta loja, por exemplo o do túnel

	var mu sync.Mutex
	pagas := map[string]bool{}    // na tua loja, é a tabela das encomendas
	tratados := map[string]bool{} // os eventos já tratados, pelo id

	// A página da loja, com o botão de pagar.
	http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, `<form method="post" action="/comprar">
  <input name="encomenda" value="1042"> <button>Pagar 15.000,00 Kz</button>
</form>`)
	})

	// 1. O comprador carrega em "Pagar": cria-se a sessão e manda-se para o checkout.
	http.HandleFunc("POST /comprar", func(w http.ResponseWriter, r *http.Request) {
		encomenda := r.FormValue("encomenda")
		sessao, err := cliente.CreateCheckoutSession(r.Context(), &spolly.CheckoutSessionParams{
			Amount:      1500000, // 15.000,00 Kz, em unidades menores
			Currency:    spolly.AOA,
			Description: "Encomenda " + encomenda,
			SuccessURL:  loja + "/obrigado", // volta com ?session_id=cs_...
			CancelURL:   loja + "/",
			Metadata:    map[string]string{"encomenda": encomenda},
		}) // o SDK põe a Idempotency-Key sozinho
		if err != nil {
			log.Print(err)
			http.Error(w, "não foi possível iniciar o pagamento", http.StatusBadGateway)
			return
		}
		http.Redirect(w, r, sessao.URL, http.StatusSeeOther)
	})

	// 2. O webhook: é aqui, e só aqui, que a encomenda passa a paga.
	http.HandleFunc("POST /webhooks/spolly", func(w http.ResponseWriter, r *http.Request) {
		evento, _, err := verificador.ParseRequest(r) // lê o corpo bruto e verifica a assinatura
		if err != nil {
			http.Error(w, "assinatura inválida", http.StatusBadRequest)
			return
		}
		mu.Lock()
		defer mu.Unlock()
		if tratados[evento.ID] {
			return // a mesma entrega outra vez: já está feito, responde 200
		}
		if evento.Type == webhook.EventPaymentSucceeded {
			var pagamento struct {
				ID       string            `json:"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
			}
			encomenda := pagamento.Metadata["encomenda"]
			if encomenda == "" {
				// Um pagamento sem a encomenda (o evento de ensaio do painel, por
				// exemplo): não é desta loja marcar, e responde-se 200 na mesma.
				log.Printf("evento %s sem encomenda (%s): ignorado", evento.ID, pagamento.ID)
			} else {
				pagas[encomenda] = true
				log.Printf("encomenda %s paga (%s, evento %s)", encomenda, pagamento.ID, evento.ID)
			}
		}
		tratados[evento.ID] = true
	})

	// 3. O regresso: só mostra o estado, lido do lado do servidor. Não marca nada.
	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 {
			log.Print(err)
			http.Error(w, "não foi possível ler o pagamento", http.StatusBadGateway)
			return
		}
		if sessao.Paid() {
			fmt.Fprintln(w, "Pagamento recebido. Obrigado!")
		} else {
			fmt.Fprintln(w, "A confirmar o pagamento. Avisamos quando entrar.")
		}
	})

	log.Print("a loja está em http://localhost:4242")
	log.Fatal(http.ListenAndServe(":4242", nil))
}

JavaScript: loja.mjs

loja.mjs
// loja.mjs: a loja mínima. Cria a sessão, recebe o webhook, mostra o regresso.
import { createServer } from "node:http";
import { SpollyPayments, constructEvent, isPaid } from "@spolly-ao/spolly-payments";

const spolly = new SpollyPayments(process.env.SPOLLY_SECRET_KEY, { baseURL: process.env.SPOLLY_API_URL });
const segredo = process.env.SPOLLY_WEBHOOK_SECRET;
const loja = process.env.LOJA_URL; // o endereço público desta loja, por exemplo o do túnel

const pagas = new Set(); // na tua loja, é a tabela das encomendas
const tratados = new Set(); // os eventos já tratados, pelo id

async function corpoBruto(req) {
  const partes = [];
  for await (const parte of req) partes.push(parte);
  return Buffer.concat(partes);
}

createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  try {
    // A página da loja, com o botão de pagar.
    if (req.method === "GET" && url.pathname === "/") {
      res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      res.end('<form method="post" action="/comprar"><input name="encomenda" value="1042"> <button>Pagar 15.000,00 Kz</button></form>');
      return;
    }

    // 1. O comprador carrega em "Pagar": cria-se a sessão e manda-se para o checkout.
    if (req.method === "POST" && url.pathname === "/comprar") {
      const encomenda = new URLSearchParams((await corpoBruto(req)).toString()).get("encomenda");
      const sessao = await spolly.createCheckoutSession({
        amount: 1500000, // 15.000,00 Kz, em unidades menores
        currency: "AOA",
        description: `Encomenda ${encomenda}`,
        success_url: `${loja}/obrigado`, // volta com ?session_id=cs_...
        cancel_url: `${loja}/`,
        metadata: { encomenda },
      }); // o SDK põe a Idempotency-Key sozinho
      res.writeHead(303, { location: sessao.url }).end();
      return;
    }

    // 2. O webhook: é aqui, e só aqui, que a encomenda passa a paga.
    if (req.method === "POST" && url.pathname === "/webhooks/spolly") {
      let evento;
      try {
        // Com o corpo bruto, tal como chegou: um JSON relido já não bate.
        evento = constructEvent(await corpoBruto(req), req.headers["spolly-signature"], segredo);
      } catch {
        res.writeHead(400).end("assinatura inválida");
        return;
      }
      if (!tratados.has(evento.id)) {
        if (evento.type === "payment.succeeded") {
          const pagamento = evento.data.object;
          const encomenda = pagamento.metadata?.encomenda;
          if (!encomenda) {
            // Um pagamento sem a encomenda (o evento de ensaio do painel, por
            // exemplo): não é desta loja marcar, e responde-se 200 na mesma.
            console.log(`evento ${evento.id} sem encomenda (${pagamento.id}): ignorado`);
          } else {
            pagas.add(encomenda);
            console.log(`encomenda ${encomenda} paga (${pagamento.id}, evento ${evento.id})`);
          }
        }
        tratados.add(evento.id);
      }
      res.writeHead(200).end(); // a mesma entrega outra vez também leva 200
      return;
    }

    // 3. O regresso: só mostra o estado, lido do lado do servidor. Não marca nada.
    if (req.method === "GET" && url.pathname === "/obrigado") {
      const sessao = await spolly.getCheckoutSession(url.searchParams.get("session_id"));
      res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
      res.end(isPaid(sessao) ? "Pagamento recebido. Obrigado!" : "A confirmar o pagamento. Avisamos quando entrar.");
      return;
    }

    res.writeHead(404).end();
  } catch (erro) {
    console.error(erro);
    res.writeHead(502).end("não foi possível falar com o Spolly Payments");
  }
}).listen(4242, () => console.log("a loja está em http://localhost:4242"));

Passo 04

Regista o endpoint do webhook

  1. Abre o túnel para a porta 4242 da tua máquina (por exemplo cloudflared tunnel --url http://localhost:4242) e copia o endereço público que ele te dá: a API recusa endereços da rede interna, como localhost e 127.0.0.1. Esse endereço é o LOJA_URL.
  2. No painel, em Webhooks (com a faixa a dizer Modo teste), acrescenta um endpoint com o URL <LOJA_URL>/webhooks/spolly e o evento payment.succeeded.
  3. Copia o segredo whsec_..., que aparece uma vez.

Passo 05

Corre a loja

Terminal
export SPOLLY_SECRET_KEY=sk_test_...
export SPOLLY_WEBHOOK_SECRET=whsec_...
export SPOLLY_API_URL=https://payments-api.spolly.ao
export LOJA_URL=https://o-teu-tunel.exemplo    # o endereço do passo 4

go run .            # em Go
node loja.mjs       # em JavaScript

No painel, em Webhooks, carrega em Enviar evento de ensaio: o resultado deve ser Entregue, com o código 200. Se for 400, o segredo não é o deste endpoint. O evento de ensaio é assinado a sério mas traz dados de ensaio (pay_ensaio, sem o teu metadado encomenda): a loja responde 200 sem marcar encomenda nenhuma, e escreve evento evt_... sem encomenda (pay_ensaio): ignorado. É o esperado.

Passo 06

Paga em modo teste

  1. Abre http://localhost:4242 e carrega em Pagar 15.000,00 Kz. O browser vai para o checkout, com a faixa Modo teste. (O checkout fala português ou inglês conforme o browser; o botão no canto troca.)
  2. Escolhe Multicaixa Express, escreve um telemóvel qualquer (9 dígitos a começar por 9, por exemplo 923456789) e carrega em pagar.
  3. No quadro Modo teste: decida o resultado, carrega em Aprovar.

Três coisas acontecem, e é assim que sabes que a integração está certa:

  • No terminal da loja aparece encomenda 1042 paga (pay_..., evento evt_...): foi o webhook, verificado, que a marcou.
  • O checkout mostra Pagamento recebido e leva o comprador de volta ao /obrigado, que diz Pagamento recebido. Obrigado!
  • No painel, em Pagamentos, a sessão aparece paga, e em Eventos o payment.succeeded aparece entregue.

Experimenta também Recusar: chega payment.failed, e o comprador pode tentar outra vez na mesma sessão. Mais ensaios em Modo teste.

Passo 07

Passa a produção

  1. Em Verificação, entrega os documentos da empresa e o IBAN. A equipa aprova, e recebes um email.
  2. Muda o painel para produção, cria uma chave sk_live_ em Chaves de API e um endpoint https:// em Webhooks.
  3. No teu servidor, troca SPOLLY_SECRET_KEY e SPOLLY_WEBHOOK_SECRET. O código é o mesmo.

Antes de abrir a loja

Confirma que a encomenda só passa a paga no webhook (nunca no /obrigado), que um evento repetido não a processa duas vezes, e que a chave secreta não está no código. Ver Confirmar o pagamento e Webhooks.