Referência
webhook
Hooks, Options, Emit, Subscribe, Verify, Store e o painel — a API do pacote webhook, com os padrões e o que cada campo muda.
import "github.com/emersonjoe/trilha/webhook" — avisar outra aplicação de que algo aconteceu, sem fazer o visitante esperar o servidor de outra pessoa, sem deixar o receptor incapaz de distinguir a sua chamada da de um estranho, e sem perder o evento porque o parceiro reiniciou às três da manhã.
O remetente#
func New(o Options) *Hooks // não inicia nada
func (h *Hooks) Setup(a *trilha.App) error // workers + relógio + Shutdown no app
func (h *Hooks) Shutdown(ctx context.Context) error
func (h *Hooks) Events() []stringO Setup liga os workers e o relógio. É o relógio que transforma "tenta de novo em doze horas" numa linha com hora marcada, em vez de uma goroutine dormindo que um deploy esqueceria. O Shutdown espera as tentativas em curso; o que está pendente continua pendente, porque é uma linha e não uma goroutine.
Options | Padrão | O que faz |
|---|---|---|
Store Store | Memory() | onde assinaturas e entregas moram |
Events []string | — | a lista fechada do que esta aplicação emite |
Env trilha.Env | Prod | se http:// vale; passe a.Env() |
Workers int | 2 | quantas entregas saem ao mesmo tempo |
Timeout time.Duration | 10 s | uma tentativa |
Backoff []time.Duration | 1 m, 5 m, 30 m, 2 h, 12 h | as esperas; o tamanho é quantas tentativas existem |
Tick time.Duration | 15 s | de quanto em quanto o relógio procura o que venceu |
HTTP *http.Client | sem redirecionamento | um 302 é um destino que ninguém conferiu |
AllowPrivateURL bool | false | desliga a checagem de endereço — veja abaixo |
O valor zero de Env é Prod, que é o lado seguro para um campo que alguém esquece.
Emitindo#
func (h *Hooks) Emit(c *trilha.Ctx, event string, payload any) errorGrava uma entrega por assinatura que ouve o evento, e volta. Não espera a rede, que é a razão inteira de existir. O erro dele é sobre esta aplicação — evento desconhecido (webhook.ErrUnknownEvent), store que não gravou — e nunca sobre o parceiro.
A lista de Events é fechada: um erro de digitação num Emit é erro onde está escrito, e não um evento que ninguém assina — que, de fora, é indistinguível de um parceiro que não está ouvindo. Contexto nil é um job de fundo; de um manipulador, passe c e o tenant e a linha de auditoria vêm junto.
O trilha openapi lê essas chamadas: um Emit com o nome do evento escrito nele vira uma entrada na seção webhooks do documento, com o schema do payload como corpo e os quatro cabeçalhos da entrega descritos. Um struct que já é componente de uma rota entra ali por $ref, não copiado — então quem integra do outro lado lê um documento em vez do seu código. Um Emit cujo nome vem de uma variável não é documentado: um documento não pode dizer um nome que só existe na hora de rodar.
O que vai na rede#
X-Webhook-Id: dlv_… estável entre as tentativas de uma entrega
X-Webhook-Event: documento.processado
X-Webhook-Timestamp: 1757343845
X-Webhook-Signature: sha256=… HMAC-SHA256 de "timestamp.corpo"func Sign(secret, timestamp string, body []byte) stringOs nomes são constantes — webhook.HeaderEvent, webhook.HeaderTimestamp e webhook.HeaderSignature — então um receptor escrito em Go os lê do pacote em vez de redigitar a string em dois lugares.
O horário está dentro da string assinada. Assinar só o corpo faria toda entrega de um evento ser idêntica byte a byte para sempre, então uma requisição capturada poderia ser repetida um ano depois e a assinatura ainda conferiria.
2xx dentro do Timeout é Delivered. Qualquer outra coisa espera e tenta de novo; depois do último backoff vira Failed, guardando o último status e o primeiro quilobyte da resposta — que é o que transforma "falhou" em "422, campo destinatario obrigatório".
Administrando#
func (h *Hooks) Subscribe(c *trilha.Ctx, s Subscription) (trilha.Secret, error)
func (h *Hooks) Revoke(c *trilha.Ctx, id string) error
func (h *Hooks) Retry(c *trilha.Ctx, deliveryID string) (string, error)
func (h *Hooks) Ping(c *trilha.Ctx, subscriptionID string) (string, error)
func (h *Hooks) Handle(c *trilha.Ctx) error // um POST, despachado pelo "action"
func TakeSecret(c *trilha.Ctx) string // uma vez, logo depois do SubscribeO Subscribe devolve o segredo uma vez. O que fica guardado é um trilha.Secret — cifrado na coluna, mascarado no log — e uma aplicação que conseguisse mostrar de novo seria uma que o mantém legível, o que faz o segredo valer exatamente o que vale o backup do banco. Ele atravessa o redirecionamento num cookie assinado próprio (webhook.SecretCookie, que é também o nome a apagar se você escrever o redirecionamento na mão); o TakeSecret lê e apaga.
O Revoke não apaga: as entregas já feitas apontam para aquela assinatura, e uma tela que não consegue dizer de qual endereço era a falha não serve para nada depois. O Subscriptions traz as revogadas por isso; quem para a entrega é o Subscription.Wants.
O Retry devolve um id novo apontando para o antigo, e manda os mesmos bytes — então um parceiro que confere o X-Webhook-Id vê outra entrega do mesmo evento, e não um corpo que mudou sob o mesmo nome. A tentativa que falhou continua lá.
O Ping passa por tudo que uma entrega de verdade passa, porque um teste que toma atalho passa para uma configuração que não vai funcionar.
Recebendo#
func Verify(r *http.Request, secret string) ([]byte, error)
var Tolerance = 5 * time.Minute
var ErrSignature = errors.New("webhook: signature does not check out")Lê o corpo, confere a assinatura e a idade, e devolve os bytes — essa assinatura existe para quem chama nunca ficar com um reader já consumido. A idade é conferida nos dois sentidos: um horário do futuro é um relógio errado ou uma assinatura que alguém está preparando. A comparação é em tempo constante, e o ErrSignature é um erro só para todas as formas de falhar, porque dizer qual parte falhou diz a um atacante o quanto ele chegou perto.
Responda 2xx antes de fazer o trabalho. Um receptor que processa primeiro é um receptor que o remetente considera fora do ar e reenvia. O X-Webhook-Id é estável entre as tentativas de uma entrega: guarde-o e recuse repetido, porque "pelo menos uma vez" é o que o remetente promete e "exatamente uma vez" é o que o receptor decide.
Recebendo o webhook de outra pessoa#
func VerifyHMAC(r *http.Request, o HMACOpts) ([]byte, error)
type HMACOpts struct {
Header string // padrão X-Hub-Signature-256
Prefix string // padrão "sha256="; HMACNoPrefix para o hex nu
Secret string // o segredo compartilhado, vindo de uma Connection selada
MaxBody int64 // padrão 1 MiB
Hash func() hash.Hash // padrão sha256.New
}
const HMACNoPrefix = "\x00"O Verify confere o esquema do próprio Trilha, que mais ninguém fala. A Cloud API do WhatsApp, da Meta, assina o corpo cru e põe o hex em X-Hub-Signature-256: sha256=…; o GitHub manda a mesma coisa no mesmo cabeçalho; o Stripe muda o cabeçalho e o prefixo e mais nada. O VerifyHMAC é esse formato, e o HMACOpts zerado já é o da Meta e o do GitHub:
body, err := webhook.VerifyHMAC(c.Request(), webhook.HMACOpts{Secret: appSecret})
if err != nil {
return trilha.Errorf(http.StatusUnauthorized, "assinatura inválida")
}Ele lê o corpo uma vez, limitado pelo MaxBody, compara em tempo constante e devolve os bytes — a mesma assinatura do Verify, pelo mesmo motivo: quem chama ficar com um reader já consumido é o erro que os dois existem para tornar impossível. Toda forma de falhar é o mesmo ErrSignature: sem cabeçalho, um MAC que não é hex, um corpo alterado, um corpo acima do limite, um segredo vazio.
O Secret é uma string porque o HMAC do corpo é a única coisa que o Connections não consegue fazer no lugar da aplicação — então é aqui que o conn.Secret.Reveal() é escrito de propósito, e em nenhum outro lugar.
Não há carimbo de tempo aqui porque esses provedores não assinam um. O que impede o replay é o id de mensagem do próprio provedor, guardado e recusado na segunda vez — a regra do X-Webhook-Id aplicada ao id de outra pessoa. O trilha add channel-whatsapp escreve esse receptor inteiro.
A checagem de endereço#
https sempre; http só em Dev. Todo endereço para o qual o nome resolve tem de ser público — todo endereço, porque um nome que responde um IP público e um loopback passaria — e é conferido no cadastro e outra vez na entrega, porque um nome que resolvia para o parceiro naquele momento e resolve para 169.254.169.254 agora é o ataque, não o acidente.
Em Dev, loopback e rede privada passam: um receptor em localhost:4000 é como qualquer pessoa experimenta isto na primeira vez, e uma checagem que recusa isso é uma que alguém desliga inteira. Link-local (169.254.0.0/16, onde moram os metadados da nuvem), não especificado, multicast e reservado são recusados em todo ambiente.
O AllowPrivateURL desliga a checagem. Está escrito por extenso porque ligá-lo é desligar a defesa contra um webhook apontado para o próprio serviço de metadados; ele existe para os testes deste pacote e para uma aplicação cujos parceiros estão mesmo na mesma rede privada.
Store#
type Store interface {
SaveSubscription(ctx context.Context, s Subscription) error
Subscription(ctx context.Context, id string) (Subscription, error)
Subscriptions(ctx context.Context, tenant string) ([]Subscription, error)
SaveDelivery(ctx context.Context, d Delivery) error
Delivery(ctx context.Context, id string) (Delivery, error)
Deliveries(ctx context.Context, p ListParams) ([]Delivery, error)
Due(ctx context.Context, at time.Time, limit int) ([]string, error)
}
func Memory() StoreO Due é o que o relógio chama, e o que merece um índice atrás. Não há implementação SQL aqui — a mesma escolha de todo store deste framework — e a receita traz o arquivo inteiro.
A tela#
func ui.WebhooksPanel(c *trilha.Ctx, subs []ui.WebhookRow, deliveries []ui.DeliveryRow, o ui.WebhooksOpts) h.NodeO WebhooksOpts recebe Action (para onde os formulários postam; vazio desenha um painel só de leitura), CSRF, Events, Secret e DateFormat — uma func(*trilha.Ctx, time.Time) h.Node que desenha as datas das duas tabelas no formato da própria tela em vez do ui.Date (nil mantém o do kit; data zero continua sendo o traço) — e LogFormat, com a mesma assinatura, para o "quando" das entregas: um instante de log pede a hora, onde a data de uma assinatura é um dia. Nil cai no DateFormat. Ele desenha linhas simples, então a aplicação mapeia o que o módulo guarda para o que a tela mostra — e o ui.WebhookRow não tem campo para o segredo, que é o motivo de ele não conseguir vazar numa listagem.
O trilha add webhooks escreve o pacote, esta tela e o teste.
ui.WebhooksPanel(c, assinaturas, entregas, ui.WebhooksOpts{
Action: "/webhooks", CSRF: trilha.CSRFInput(c),
Events: app.Hooks.Events(), Secret: webhook.TakeSecret(c),
})Endereços
| Endereço | Eventos | Cadastrado | Estado | |
|---|---|---|---|---|
Sincronização de faturamentohttps://billing.example.com/hooks/trilha | order.paid, order.refunded | Ativo | ||
CRM antigohttps://old-crm.example.com/hooks | order.paid | Revogado |
Entregas
| Evento | Estado | Tentativas | Resposta | Quando | |
|---|---|---|---|---|---|
order.paidd1 | Entregue | 1 | 200 | ||
order.refundedd2 | Falhou | 3 | 422{"error":"unknown field amount_cents"} |
O que não está aqui#
Assinatura assimétrica e mais de um segredo ativo por assinatura — rotação é real e é a próxima coisa; hoje o caminho é um segundo Subscribe. Entrega entre processos: aqui é um remetente só, e duas réplicas mandam tudo duas vezes.