(09) Webhooks
Webhooks
Configurar o endpoint, verificar a assinatura, retentativas, reenvio e rotação do segredo.
Configurar o endpoint
Um endpoint é um endereço do teu servidor que recebe os eventos por POST. Cria-se no painel, em Webhooks, com o URL e os eventos que queres receber (no mínimo payment.succeeded). No fim aparece o segredo do endpoint, whsec_..., uma única vez: guarda-o no teu servidor, ao lado da chave da API. É com ele que verificas que cada evento vem mesmo de nós.
- Os endpoints são do modo do painel: os de teste recebem os eventos das sessões de teste, e os de produção os de produção. Até 16 por modo.
- Em produção o URL é
https://. Em teste também pode serhttp://. - A API de produção recusa endereços da rede interna (
localhost,127.0.0.1,10.x,192.168.x), em teste e em produção. Para receber eventos no teu computador enquanto desenvolves, usa um túnel que te dê um endereço público (ocloudflared tunnelou ongrok, por exemplo) e regista esse endereço. Uma API de desenvolvimento local, que corre comPAYMENTS_WEBHOOKS_PERMITIR_REDE_INTERNA=1, aceita-os, e aíhttp://127.0.0.1:4242/webhooks/spollyserve sem túnel. O formulário de Webhooks do painel diz qual é o caso da API em que estás. - O botão Enviar evento de ensaio envia já um evento assinado ao endpoint, e mostra o código e o excerto da resposta do teu servidor: é a forma rápida de saber se a verificação está certa.
O que chega
POST /webhooks/spolly HTTP/1.1
Content-Type: application/json
Spolly-Signature: t=1792152000,v1=5f2b8c...e41a
Spolly-Event-Id: evt_L4ngjqw3E8Xpt9aFgNVCY1
Spolly-Event-Type: payment.succeeded
{"id":"evt_L4ngjqw3E8Xpt9aFgNVCY1","type":"payment.succeeded","created_at":"...","mode":"test",
"merchant_id":"mer_...","api_version":"2026-10-16","data":{"object":{"id":"pay_...", ...}}}O corpo é o envelope do evento (a forma de cada tipo está na referência dos eventos). Spolly-Event-Id e Spolly-Event-Type repetem o id e o type do corpo, para poderes encaminhar antes de ler o JSON; a verdade é o corpo assinado.
Verificar a assinatura
Antes de fazer seja o que for com um evento, verifica a assinatura sobre o corpo bruto, tal como chegou. Um JSON lido e escrito outra vez muda (espaços, ordem das chaves) e a assinatura deixa de bater: no Express, por exemplo, a rota do webhook usa express.raw(), e não express.json().
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 (Node 22 ou mais recente)
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);
});Porque é que uma verificação falha
Os dois SDK recusam pelas mesmas razões. Em Go, o erro é um *webhook.VerificationError e compara-se com errors.Is; em JavaScript, é um WebhookVerificationError com o código em erro.code. Em qualquer delas, responde 400 e não faças nada com o evento.
| Em JavaScript (erro.code) | Em Go (errors.Is) | O que quer dizer |
|---|---|---|
no_secret | webhook.ErrNoSecret | Não se deu nenhum segredo com que verificar: a variável do whsec_ está vazia no teu servidor. |
no_header | webhook.ErrNoHeader | O pedido não trouxe o cabeçalho Spolly-Signature: não veio de nós. |
malformed_header | webhook.ErrMalformedHeader | O cabeçalho Spolly-Signature não tem a forma t=<segundos>,v1=<hex>: falta o t, aparece mais de uma vez, ou um v1 não é um HMAC-SHA256 em hexadecimal. |
no_signature | webhook.ErrNoSignature | O cabeçalho não traz nenhuma assinatura v1. |
signature_mismatch | webhook.ErrSignatureMismatch | Nenhuma assinatura bate com os segredos dados: o segredo não é o deste endpoint, ou o corpo foi alterado (lido e escrito outra vez) antes de verificar. |
timestamp_outside_tolerance | webhook.ErrTimestampOutsideTolerance | A assinatura bate, mas o t está a mais de 5 minutos do teu relógio: um evento repetido por alguém, ou o relógio do teu servidor errado. |
invalid_payload | webhook.ErrInvalidPayload | O corpo não é um envelope de evento: não é JSON, ou falta o id evt_ ou o type. |
| (só em Go) | webhook.ErrBodyTooLarge | O corpo passa 1 MiB (webhook.MaxBodyBytes): o ParseRequest não o lê até ao fim. |
Noutra linguagem
# 1. Do cabeçalho Spolly-Signature, tira o t e cada v1:
# t=1792152000,v1=<hex>[,v1=<hex>]
# 2. Calcula HMAC-SHA256 com o segredo inteiro (com o prefixo whsec_) como chave,
# sobre "<t>.<corpo bruto>", em hexadecimal.
# 3. Aceita se algum v1 for igual (comparação em tempo constante) e se t estiver
# a menos de 5 minutos do teu relógio.
# Para conferir à mão, no terminal:
printf '%s.%s' "$T" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO_WHSEC"Responder, e as retentativas
Responde 2xx em menos de 10 segundos, logo que o evento ficou guardado; o trabalho pesado (o email ao cliente, a fatura) faz-se depois. Qualquer outra coisa conta como falha: um código de erro, um redirecionamento (não se seguem), ou o silêncio.
| Quando falha | O que fazemos |
|---|---|
| A primeira vez | Tentamos outra vez daqui a 30 segundos. |
| As seguintes | O intervalo dobra a cada tentativa, até ao máximo de 6 horas. |
| Três dias depois do evento | Paramos. O evento fica dead no painel, e a nossa equipa recebe um alarme. |
Com vários endpoints, só os que falharam voltam a ser tentados. A mesma entrega pode chegar mais de uma vez (uma retentativa depois de uma resposta que se perdeu, ou um reenvio teu), sempre com o mesmo id: deduplica pelo id do evento, e responde 200 a um que já trataste.
Ver e reenviar
Em Eventos, no painel, estão os eventos que enviámos, com o corpo, cada tentativa (o código, o tempo, o excerto da tua resposta, o erro) e o estado. O botão Reenviar volta a entregar o mesmo evento, com o mesmo id, a todos os endpoints que o subscrevem: serve para recuperar um evento que ficou dead enquanto o teu servidor estava em baixo.
Rodar o segredo
Em Webhooks, Rodar o segredo cria um segredo novo, que aparece uma vez e assina a partir desse momento. O anterior continua a assinar durante 24 horas: nesse tempo, cada evento leva duas assinaturas no cabeçalho (t=...,v1=<nova>,v1=<antiga>), e basta uma bater.
- Roda o segredo no painel e copia o novo.
- Configura o teu servidor com os dois (os SDK aceitam uma lista:
Secretsem Go, umarrayem JavaScript), e publica. - Quando estiver publicado em todo o lado, tira o antigo. Passadas as 24 horas, ele deixa de assinar.
Se o segredo escapou
Roda-o e tira o antigo do teu servidor logo, sem esperar pelas 24 horas: um pedido assinado só com o segredo antigo passa a ser recusado por ti.