(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 (ocloudflaredou ongrok, 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
- 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. - 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.
- 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:
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
mkdir loja && cd loja
go mod init loja
go get github.com/spolly-ao/spolly-payments-go@v0.1.0JavaScript
mkdir loja && cd loja
npm init -y
npm install github:spolly-ao/spolly-payments-js#v0.1.0Os 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: 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: 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
- 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, comolocalhoste127.0.0.1. Esse endereço é oLOJA_URL. - No painel, em Webhooks (com a faixa a dizer Modo teste), acrescenta um endpoint com o URL
<LOJA_URL>/webhooks/spollye o eventopayment.succeeded. - Copia o segredo
whsec_..., que aparece uma vez.
Passo 05
Corre a loja
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 JavaScriptNo 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
- Abre
http://localhost:4242e 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.) - Escolhe Multicaixa Express, escreve um telemóvel qualquer (9 dígitos a começar por 9, por exemplo
923456789) e carrega em pagar. - 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.succeededaparece 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
- Em Verificação, entrega os documentos da empresa e o IBAN. A equipa aprova, e recebes um email.
- Muda o painel para produção, cria uma chave
sk_live_em Chaves de API e um endpointhttps://em Webhooks. - No teu servidor, troca
SPOLLY_SECRET_KEYeSPOLLY_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.