Entrar no painel
Índice

(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 ser http://.
  • 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 (o cloudflared tunnel ou o ngrok, por exemplo) e regista esse endereço. Uma API de desenvolvimento local, que corre com PAYMENTS_WEBHOOKS_PERMITIR_REDE_INTERNA=1, aceita-os, e aí http://127.0.0.1:4242/webhooks/spolly serve 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

HTTP
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

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)

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);
});

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_secretwebhook.ErrNoSecretNão se deu nenhum segredo com que verificar: a variável do whsec_ está vazia no teu servidor.
no_headerwebhook.ErrNoHeaderO pedido não trouxe o cabeçalho Spolly-Signature: não veio de nós.
malformed_headerwebhook.ErrMalformedHeaderO 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_signaturewebhook.ErrNoSignatureO cabeçalho não traz nenhuma assinatura v1.
signature_mismatchwebhook.ErrSignatureMismatchNenhuma 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_tolerancewebhook.ErrTimestampOutsideToleranceA 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_payloadwebhook.ErrInvalidPayloadO corpo não é um envelope de evento: não é JSON, ou falta o id evt_ ou o type.
(só em Go)webhook.ErrBodyTooLargeO corpo passa 1 MiB (webhook.MaxBodyBytes): o ParseRequest não o lê até ao fim.

Noutra linguagem

Terminal
# 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 falhaO que fazemos
A primeira vezTentamos outra vez daqui a 30 segundos.
As seguintesO intervalo dobra a cada tentativa, até ao máximo de 6 horas.
Três dias depois do eventoParamos. 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.

  1. Roda o segredo no painel e copia o novo.
  2. Configura o teu servidor com os dois (os SDK aceitam uma lista: Secrets em Go, um array em JavaScript), e publica.
  3. 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.