Webhooks em Go parecem simples no primeiro contato: criar uma rota HTTP, fazer parse do JSON e salvar alguma coisa no banco. Em produção, porém, um webhook é uma fronteira sensível entre o seu sistema e um serviço externo. Ele pode chegar duas vezes, chegar fora de ordem, ser reenviado depois de timeout, carregar assinatura inválida, ter payload grande demais ou disparar trabalho lento demais para caber dentro da requisição.
Esse cenário aparece em pagamentos, antifraude, CRM, e-mail transacional, GitHub Apps, ferramentas internas, marketplaces, plataformas de assinatura e integrações B2B. Go é uma ótima escolha para esse tipo de endpoint porque a biblioteca padrão já entrega net/http, crypto/hmac, crypto/sha256, context, timeouts e testes rápidos. O ponto é desenhar o fluxo com disciplina operacional, não apenas escrever um handler que “funciona no Postman”.
Este guia mostra como receber webhooks em Go com assinatura HMAC, body bruto, idempotência, fila, retry, observabilidade e testes. Ele complementa os guias de autenticação e autorização em Go, idempotência, retry e DLQ, mensageria em Go e testes de tabela em Go.
O fluxo seguro para webhooks
Um endpoint de webhook saudável costuma seguir esta ordem:
- Limitar método, tamanho do body e tempo de leitura.
- Ler o body bruto exatamente como chegou.
- Validar assinatura, timestamp e segredo correto.
- Fazer parse do JSON apenas depois da assinatura passar.
- Extrair um identificador idempotente do evento.
- Registrar o recebimento em uma tabela ou chave deduplicada.
- Enfileirar processamento assíncrono.
- Responder rápido com
2xxquando o evento foi aceito.
Essa ordem é importante. Se você faz parse do JSON antes de validar assinatura, já gastou CPU e memória com uma requisição possivelmente falsa. Se você usa o JSON reserializado para validar assinatura, provavelmente vai quebrar a verificação, porque espaços, ordem de campos e encoding podem mudar. A assinatura deve ser calculada sobre o body bruto recebido.
Também evite fazer todo o processamento dentro do handler. O provedor do webhook normalmente espera resposta rápida. Se você chama banco, API externa, serviço de e-mail, cache e sistema legado antes de responder, aumenta a chance de timeout. Quando o provedor não recebe confirmação, ele reenviará o mesmo evento. Sem idempotência, você transforma uma lentidão em duplicidade de efeito.
Handler mínimo com body bruto e HMAC
O exemplo abaixo usa um header fictício X-Signature com valor hexadecimal de HMAC-SHA256. Cada provedor tem seu próprio formato: Stripe, GitHub, Slack, Mercado Pago e outros mudam nome de header, prefixo, timestamp e algoritmo. A estrutura, porém, é parecida.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type Store interface {
MarkReceived(r *http.Request, eventID string, raw []byte) (bool, error)
}
type Queue interface {
Publish(r *http.Request, event Event) error
}
type Handler struct {
Secret []byte
Store Store
Queue Queue
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body inválido", http.StatusBadRequest)
return
}
if err := verifySignature(raw, r.Header.Get("X-Signature"), h.Secret); err != nil {
http.Error(w, "assinatura inválida", http.StatusUnauthorized)
return
}
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "json inválido", http.StatusBadRequest)
return
}
if event.ID == "" || event.Type == "" {
http.Error(w, "evento sem id ou type", http.StatusBadRequest)
return
}
first, err := h.Store.MarkReceived(r, event.ID, raw)
if err != nil {
http.Error(w, "erro interno", http.StatusInternalServerError)
return
}
if !first {
w.WriteHeader(http.StatusOK)
return
}
if err := h.Queue.Publish(r, event); err != nil {
http.Error(w, "erro ao enfileirar", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusAccepted)
}
func verifySignature(raw []byte, got string, secret []byte) error {
sig, err := hex.DecodeString(got)
if err != nil {
return fmt.Errorf("decode signature: %w", err)
}
mac := hmac.New(sha256.New, secret)
mac.Write(raw)
expected := mac.Sum(nil)
if !hmac.Equal(sig, expected) {
return errors.New("signature mismatch")
}
return nil
}
Em código real, prefira passar r.Context() para camadas internas em vez de *http.Request. O exemplo mantém a assinatura curta para destacar o fluxo. Veja também o guia de context, timeout e cancelamento em Go para decidir quando o contexto da requisição deve controlar o enqueue e quando o worker precisa de um contexto próprio.
Idempotência não é opcional
Todo provedor sério de webhooks pode reenviar eventos. Isso acontece por timeout, erro 5xx, queda de rede, deploy no meio do recebimento ou política de retry do fornecedor. Portanto, o seu sistema precisa responder à pergunta: “se este mesmo evento chegar de novo, qual efeito será repetido?”
O padrão mais simples é criar uma tabela webhook_events com chave única no identificador do evento:
CREATE TABLE webhook_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
raw_payload JSONB NOT NULL
);
No PostgreSQL, INSERT ... ON CONFLICT DO NOTHING permite descobrir se o evento é novo sem corrida:
INSERT INTO webhook_events (event_id, event_type, raw_payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING;
Se a operação inseriu uma linha, você enfileira. Se não inseriu, o evento já foi visto e o handler pode retornar 200. Esse comportamento evita que retries do provedor criem dois pagamentos, duas assinaturas, dois e-mails ou duas mudanças de status.
Nem sempre o event_id sozinho basta. Alguns provedores enviam eventos diferentes para o mesmo recurso: payment.created, payment.approved, payment.refunded. Para esses casos, use o ID do evento para deduplicação do recebimento e uma regra separada no processamento para lidar com ordem, versão ou timestamp do recurso.
Por que enfileirar o processamento
O handler de webhook deve ser pequeno. Ele valida, registra e entrega trabalho para outro componente. O worker é quem chama APIs internas, atualiza agregados, envia e-mail, recalcula relatórios ou publica eventos de domínio.
Esse desenho traz três benefícios:
- Resposta rápida para o provedor, reduzindo retries desnecessários.
- Controle de concorrência no worker, sem derrubar banco ou serviços externos.
- Retry e DLQ em uma camada feita para isso, não no request HTTP.
Você pode começar com uma fila simples em PostgreSQL, Redis Streams, RabbitMQ, NATS, Kafka, SQS ou até um worker local em projetos pequenos. A escolha depende do volume e da criticidade. Se o webhook muda dinheiro, assinatura ou acesso do usuário, trate como evento confiável e leia o guia de outbox pattern em Go antes de acoplar banco e broker de qualquer jeito.
Validação de timestamp e rotação de segredo
Assinatura HMAC prova que quem enviou conhece o segredo, mas não impede replay se alguém capturar uma requisição válida e reenviar depois. Por isso, muitos provedores assinam também um timestamp. O servidor deve rejeitar eventos com diferença grande, por exemplo mais de cinco minutos, desde que você confie no relógio da infraestrutura.
Outro cuidado é rotação de segredo. Em vez de trocar o segredo de uma vez e quebrar todos os eventos em trânsito, aceite temporariamente dois segredos: o atual e o anterior. Registre qual segredo validou o evento e remova o antigo depois da janela de transição. Nunca coloque segredo no Git; use variáveis de ambiente, Vault, Secrets Manager ou o mecanismo de segredo da sua plataforma.
Também evite logs que vazam payload sensível. Webhooks de pagamento, usuário, fraude e CRM podem carregar e-mail, telefone, documento, endereço ou dados contratuais. Faça log de event_id, event_type, provedor, status de validação e latência. Se precisar guardar payload bruto, controle acesso e retenção.
Testes que pegam erros reais
Teste de webhook não deve cobrir apenas o caso feliz. Escreva testes de tabela para:
- Método diferente de
POST. - Body acima do limite.
- Assinatura ausente, malformada e inválida.
- JSON inválido.
- Evento sem
id. - Evento duplicado.
- Falha ao enfileirar.
O pacote httptest resolve bem esse cenário. Para gerar assinatura válida no teste, use a mesma função HMAC com segredo controlado. Isso evita fixture mágica e garante que o teste quebre se alguém mudar o formato de assinatura sem perceber.
func sign(raw []byte, secret []byte) string {
mac := hmac.New(sha256.New, secret)
mac.Write(raw)
return hex.EncodeToString(mac.Sum(nil))
}
Também vale ter pelo menos um teste com o payload real documentado pelo provedor. Muitos bugs aparecem em detalhes de campo opcional, número como string, enum novo ou objeto aninhado inesperado.
Observabilidade e operação
Um webhook sem observabilidade vira caixa-preta. Exponha métricas para recebidos, rejeitados por assinatura, duplicados, enfileirados, falhas de enqueue, tempo de resposta do handler e atraso do worker. Em logs estruturados, inclua provider, event_id, event_type, status e duration_ms. O guia de slog em Go mostra como padronizar esses campos.
Em incidentes, o runbook deve responder rápido:
- Como pausar processamento sem rejeitar recebimento?
- Como reprocessar eventos já recebidos?
- Como listar eventos em DLQ?
- Como validar se o provedor está reenviando por timeout?
- Como trocar segredo sem derrubar integrações ativas?
Essas perguntas parecem exagero até o primeiro deploy que começa a retornar 500 para um provedor de pagamento. Depois disso, elas viram checklist básico de backend.
Erros comuns em webhooks Go
O erro mais comum é validar assinatura sobre o JSON reformatado. Sempre assine o body bruto.
O segundo é retornar 200 antes de persistir o evento. Se o processo cai depois da resposta, o provedor considera entregue e você perde o evento. Confirme apenas depois de registrar o recebimento ou enfileirar de forma durável.
O terceiro é tratar duplicidade como erro. Quando um evento repetido chega, normalmente a resposta correta é 200, não 409 ou 500. O provedor quer saber se você aceitou o estado atual, não se aquela tentativa foi a primeira.
O quarto é misturar regra de negócio no handler. O handler deve ser porta de entrada; a interpretação do evento pertence ao worker ou ao serviço de domínio. Isso deixa testes menores e evita timeout.
O quinto é não pensar em carreira. Muitas vagas Go backend pedem APIs, segurança, integrações, filas, observabilidade e bancos. Saber explicar um webhook com HMAC, idempotência e processamento assíncrono mostra maturidade de produção. Para comparar como esse tema aparece em outras stacks brasileiras, acompanhe também o portal Python Dev Brasil, onde integrações, dados e automação também aparecem com frequência.
Checklist final
Antes de publicar um endpoint de webhook em Go, revise:
- O body tem limite de tamanho.
- A assinatura usa comparação constante com
hmac.Equal. - O timestamp assinado tem janela aceitável.
- Há suporte planejado para rotação de segredo.
- O evento tem chave idempotente persistida com constraint única.
- O processamento lento acontece fora do handler.
- Existem métricas, logs estruturados e alerta para rejeição anormal.
- Há caminho de reprocessamento e DLQ.
- Testes cobrem assinatura inválida, duplicidade e falha de fila.
Com esse desenho, webhooks deixam de ser “uma rota externa” e viram uma integração operável. O código Go continua simples, mas o sistema passa a resistir aos problemas que realmente aparecem em produção: duplicidade, latência, replay, deploy no meio do tráfego e fornecedores reenviando eventos até alguém responder direito.