Para verificar a assinatura de um webhook, recalcule um HMAC sobre exatamente os bytes que o remetente assinou, usando o segredo que você compartilha com ele, e compare seu valor com o cabeçalho de assinatura em tempo constante. Depois confira se o timestamp assinado é recente. Nos webhooks do Elido isso significa HMAC-SHA256 com chave igual ao seu segredo whsec_... inteiro, sobre {X-Webhook-Timestamp}.{raw body}, codificado em hex, com o prefixo v1=, e comparado com X-Elido-Signature.
Esse é o algoritmo inteiro. As falhas vêm dos detalhes ao redor: um parser de corpo que rodou cedo demais, um segredo que foi decodificado, um digest hex comparado com um base64. Este post cobre a verificação de assinaturas HMAC de webhook em geral e depois código funcional do Elido para Node, Python, Go e um nó Code do n8n, além da janela de replay e da rotação de segredo que a maioria dos guias rápidos deixa de fora.
Se você ainda não configurou um endpoint, comece por webhooks para eventos de link, que lista todos os tipos de evento e o envelope do payload. Esta página continua a partir do momento em que uma requisição assinada chega ao seu servidor.
Como funcionam as assinaturas HMAC de webhook
Um endpoint de webhook é uma URL pública. Qualquer pessoa que o encontre pode enviar por POST um corpo JSON que parece um evento real, então o receptor precisa de prova de origem. O HMAC oferece isso a baixo custo: remetente e receptor compartilham um segredo, o remetente calcula HMAC-SHA256(secret, message) e coloca o resultado em um cabeçalho, e o receptor faz o mesmo cálculo e compara. Sem o segredo, ninguém consegue produzir um valor que coincida, e mudar um único byte da mensagem muda o digest inteiro.
Três detalhes diferem entre provedores, e cada um quebra a verificação se você errar:
- O que entra na mensagem. O GitHub assina só o corpo bruto. Stripe e Elido assinam um timestamp, um ponto e o corpo. A especificação Standard Webhooks assina um ID de mensagem, o timestamp e o corpo.
- Como o digest é codificado. Hex ou base64, com um prefixo de esquema como
v1=ousha256=. - Qual é a chave. Alguns provedores decodificam em base64 o segredo depois do prefixo. O Elido não: a chave é a string
whsec_completa como bytes UTF-8.
Colocar o timestamp dentro da mensagem assinada importa. Isso impede que um atacante combine um corpo antigo, validamente assinado, com um cabeçalho de timestamp novo, e é isso que torna aplicável uma janela de replay.
O que o Elido assina e quais cabeçalhos o carregam
Toda entrega para um endpoint event ou siem é um POST com Content-Type: application/json e um corpo no formato {"type", "workspace_id", "data", "timestamp"}. Os tipos de endpoint em formato de chat (Discord, Telegram, Sentry) se autenticam pela URL e não carregam cabeçalhos HMAC, então tudo abaixo vale apenas para os dois primeiros tipos.
| Cabeçalho | Valor | O que fazer com ele |
|---|---|---|
X-Elido-Signature | v1= + 64 caracteres hex em minúsculas | Compare com o valor que você calculou |
X-Webhook-Signature | Mesmo valor acima | Alias antigo; leia um ou outro, não os dois |
X-Webhook-Timestamp | Segundos Unix, p. ex. 1789000000 | Parte da mensagem assinada; confira sua idade |
X-Elido-Signature-Previous | v1= + hex, assinado com o segredo antigo | Presente apenas durante a janela de tolerância da rotação |
X-Webhook-Event | Nome do evento, p. ex. link.created | Roteie o evento (depois de verificar) |
X-Webhook-Delivery | ID numérico da entrega, estável entre novas tentativas | Chave de deduplicação para processamento idempotente |
Você recebe o segredo na criação: whsec_ seguido de 64 caracteres hex. Ele é retornado uma única vez na resposta de criação e nunca mais, então vai direto para o seu cofre de segredos.
Aqui está um vetor de teste contra o qual você pode rodar seu código: com o segredo whsec_test_only_do_not_use, o timestamp 1789000000 e o corpo {"type":"link.created","workspace_id":42}, o valor correto do cabeçalho é:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Você pode reproduzi-lo a partir de um shell, que é meu primeiro movimento sempre que um receptor discorda do remetente:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Uma armadilha aqui: o campo timestamp do próprio corpo é o momento em que o evento aconteceu, enquanto o timestamp assinado é o do cabeçalho X-Webhook-Timestamp, definido quando a requisição é enviada. Não os confunda.
Validar a assinatura de um webhook em Node
No Express, a correção para a maioria das falhas é uma linha: monte express.raw() na rota do webhook para que req.body seja um Buffer com os bytes exatos recebidos. Registre esta rota antes de qualquer app.use(express.json()) global, porque, depois que o parser JSON consome o stream, o parser bruto não tem mais nada para ler.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;
function matches(expected, got) {
const a = Buffer.from(expected);
const b = Buffer.from(got ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post(
"/webhooks/elido",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.get("X-Webhook-Timestamp") ?? "";
if (
!/^\d+$/.test(ts) ||
Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
) {
return res.status(400).send("stale or missing timestamp");
}
const expected =
"v1=" +
createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const ok = [
req.get("X-Elido-Signature"),
req.get("X-Elido-Signature-Previous"),
].some((got) => matches(expected, got));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// enqueue event, then acknowledge fast
res.sendStatus(200);
},
);
A checagem de comprimento não é decoração. timingSafeEqual lança uma exceção com buffers de comprimentos diferentes em vez de retornar false. Se você usa o SDK TypeScript do guia rápido de API e SDKs, webhooks.verify() em @elido/sdk faz o mesmo HMAC e a mesma comparação em tempo constante. Passe { maxSkewSec: 300 } explicitamente, porém: sem essa opção ele não confere a idade do timestamp.
Verificação HMAC SHA256 de webhook em Python e Go
A biblioteca padrão do Python resolve. Com FastAPI, await request.body() retorna os bytes brutos; no Flask, chame request.get_data() antes que qualquer coisa toque em request.json.
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()
def verify(raw: bytes, ts: str, candidates: list) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
return False
digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return any(c and hmac.compare_digest(c, expected) for c in candidates)
@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
raw = await request.body()
h = request.headers
sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
# enqueue event
return {"ok": True}
Em Go, leia o corpo uma vez, limite o tamanho e use hmac.Equal de crypto/hmac, que compara em tempo constante. Monto a mensagem a partir da string do cabeçalho exatamente como recebida, em vez de reformatar o inteiro já interpretado.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const toleranceSec = 300
// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, false
}
tsHeader := r.Header.Get("X-Webhook-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return nil, false
}
if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
return nil, false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
return body, true
}
}
return nil, false
}
As três versões respondem 401 para uma assinatura inválida e só interpretam o JSON depois que a verificação passa. O Elido conta qualquer resposta não 2xx como tentativa falha, então, se o seu verificador estiver errado, entregas legítimas se acumulam como 401 no log de entregas do endpoint. Esse é o primeiro lugar a olhar depois de um deploy.
Quer ver isso com tráfego real? Crie um endpoint em um workspace pela página do recurso de webhooks e aponte-o para um túnel local; o log de entregas mostra o código de status que o seu verificador devolveu a cada tentativa.
Verificar a assinatura do webhook dentro de um nó Code do n8n
O n8n faz a mesma checagem sem nenhum serviço extra. Ative a opção Raw Body no nó Webhook, que guarda a requisição intacta como dados binários, e adicione um nó Code logo depois. O módulo crypto embutido é permitido no nó Code do n8n, então isto roda como está:
const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");
const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("stale delivery");
}
const expected = Buffer.from(
"v1=" +
crypto
.createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(raw)
.digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
const got = Buffer.from(h[name] ?? "");
return (
got.length === expected.length && crypto.timingSafeEqual(got, expected)
);
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];
Um erro lançado interrompe a execução, então nada adiante roda com um evento forjado. Um problema de auto-hospedagem: se a sua instância define N8N_BLOCK_ENV_ACCESS_IN_NODE=true, o nó Code não consegue ler $env, o segredo chega vazio e toda entrega falha na checagem. O guia do n8n auto-hospedado cobre o lado do proxy reverso, e o post do nó de encurtador de URL no n8n mostra o que construir quando os eventos chegam.
Como o Elido difere de Stripe, GitHub e Standard Webhooks
Se você já escreveu um verificador para outro provedor, não o copie às cegas. Conferido com a documentação de cada fornecedor em 2026-10-25:
- Stripe coloca
t=e um ou mais valoresv1=em um único cabeçalhoStripe-Signature, assinatimestamp.bodycom HMAC-SHA256 em hex, e suas bibliotecas adotam por padrão uma tolerância de cinco minutos (docs do Stripe). O Elido separa o timestamp em seu próprio cabeçalho. - GitHub assina só o corpo bruto, envia
sha256=mais hex emX-Hub-Signature-256e manda usar uma comparação em tempo constante (docs do GitHub). Não há timestamp para conferir. - Standard Webhooks usa
webhook-id,webhook-timestampewebhook-signature, assinaid.timestamp.body, codifica em base64 com o prefixov1,e deixa o valor da tolerância por sua conta (especificação). OX-Webhook-Deliverydo Elido faz o papel de ID, mas não faz parte da mensagem assinada.
O esquema do próprio Elido é o de signing.Sign no api-core: a mensagem é o timestamp Unix, um ponto e o corpo, a chave é a string do segredo como armazenada, e o resultado é v1= mais hex em minúsculas. O Elido não tem evento link.clicked, então não espere entregas no nível de clique neste canal.
Janelas de replay e rotação de segredo
Uma assinatura válida prova quem enviou uma requisição, não quando. Alguém que capture uma entrega assinada, de uma linha de log ou de um proxy mal configurado, poderia enviá-la de novo uma semana depois e o HMAC ainda coincidiria. A checagem do timestamp fecha essa brecha, e ela é sua tarefa: o worker de entrega do Elido assina o timestamp, mas não impõe nenhuma janela do seu lado. Eu uso 300 segundos. Mantenha o relógio do receptor sincronizado com NTP, pois um servidor que deriva alguns minutos começa a rejeitar tráfego legítimo.
As novas tentativas não ultrapassam a janela. Cada tentativa recebe um X-Webhook-Timestamp novo e uma assinatura nova, enquanto X-Webhook-Delivery permanece o mesmo. Essa divisão dá as duas defesas: o timestamp limita por quanto tempo uma requisição capturada continua utilizável, e um índice único no ID da entrega impede que uma nova tentativa legítima seja processada duas vezes. O post sobre limites de taxa e idempotência cobre o mesmo padrão do lado da API de entrada.
A rotação funciona por POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, ou pelo botão Rotate na página do endpoint. A resposta traz o novo segredo uma única vez, mais grace_window_days (7) e previous_expires_at. Durante esses sete dias cada entrega carrega duas assinaturas:
X-Elido-Signature, feita com o segredo novoX-Elido-Signature-Previous, feita com o antigo
É por isso que cada trecho acima confere os dois cabeçalhos contra o único segredo que possui. Um receptor que ainda usa o segredo antigo coincide com o segundo cabeçalho; depois que você implanta o novo, coincide com o primeiro. Nada falha no meio. Trate o cabeçalho da chave anterior como uma ponte, não como uma garantia, e distribua o novo segredo cedo na semana.
Por que a verificação de assinatura de webhook falha
Quando ajudo alguém a depurar isso, é quase sempre a mesma lista curta. Percorra-a em ordem:
- JSON reserializado.
JSON.stringify(req.body)oujson.dumps(payload)produz bytes diferentes dos que o remetente passou pelo hash: ordem das chaves, espaçamento, barras escapadas, escapes unicode. Calcule o hash do corpo bruto. Se o seu framework já o interpretou, corrija a ordem dos middlewares em vez de tentar reconstruir a string. - Os bytes errados da chave. O Elido usa a string
whsec_...inteira como chave HMAC. Remover o prefixo, decodificar o resto de hex ou decodificá-lo de base64 (o que as bibliotecas Standard Webhooks fazem) dá uma chave diferente. Uma quebra de linha final vinda deechopara um arquivo de segredos faz o mesmo, e é por isso que o exemplo em Python chama.strip(). - Incompatibilidade de codificação. Compare
v1=mais hex em minúsculas com o cabeçalho. Um digest base64, uma string hex em maiúsculas ou um prefixo ausente nunca coincidem. - O timestamp errado. Use a string do cabeçalho
X-Webhook-Timestamp, não o campotimestampdo corpo, e não um número que você interpretou e reformatou.
Mais duas menores: comparar com == funciona, mas vaza informação de tempo, então use a função de tempo constante que sua linguagem oferece; e um proxy que descomprime ou recodifica corpos também quebra a verificação, embora seja raro em POSTs JSON simples.
Quando os dois lados ainda discordam, registre o timestamp, o comprimento do corpo e os primeiros caracteres hex do seu digest, e rode a linha do openssl mostrada antes com as mesmas entradas. O lado que coincidir com o openssl é o correto. Para ver onde a assinatura se encaixa entre os outros controles que vale checar em qualquer provedor, veja a lista de segurança para encurtadores de URL.
Leia o conteúdo principal: webhooks para eventos de link.
Relacionados no blog
- Webhooks para eventos de link - tipos de evento, envelope do payload e a política de novas tentativas.
- Webhooks vs polling para rastreamento de cliques - quando push vence pull, e quando não.
- Automação de links auto-hospedada com n8n - proxy reverso, modo de fila e checagens de assinatura em uma só stack.
- API de encurtador de URL: limites de taxa, novas tentativas, idempotência - os padrões de deduplicação vistos do outro lado.
- Lista de segurança para encurtadores de URL - nove controles para verificar em qualquer provedor.
Perguntas frequentes
Como verifico a assinatura de um webhook?
Recalcule o HMAC sobre exatamente o que o remetente assinou, usando o segredo compartilhado, e compare o resultado com o cabeçalho de assinatura em tempo constante. No Elido isso significa HMAC-SHA256 sobre o valor de X-Webhook-Timestamp, um ponto e o corpo bruto, codificado em hex com o prefixo v1=. Rejeite a requisição se nada coincidir ou se o timestamp estiver vencido.
Por que a verificação da assinatura do meu webhook continua falhando?
Quase sempre porque você calculou o hash de bytes diferentes dos do remetente. Um parser de corpo JSON rodou primeiro e você reserializou o objeto, ou decodificou o segredo, ou comparou hex com base64. Calcule o hash dos bytes brutos da requisição, use a string do segredo exatamente como foi emitida e registre os dois valores lado a lado.
O que é HMAC em um webhook?
HMAC é um hash com chave: o remetente mistura um segredo que compartilha com você a um hash SHA-256 da mensagem. Só quem tem o segredo consegue produzir um valor que coincida, então uma assinatura válida prova que a requisição veio do remetente e que o corpo não foi alterado no caminho.
Como evito ataques de replay em webhooks?
Assine o timestamp junto com o corpo e rejeite qualquer requisição cujo timestamp tenha mais de alguns minutos; cinco minutos é uma janela comum. Depois armazene o ID da entrega e ignore IDs que você já processou. O Elido assina o timestamp, mas deixa a verificação de atualidade para o seu receptor.
Devo usar hex ou base64 para a assinatura de um webhook HMAC-SHA256?
O que o remetente documentar, porque as duas codificações do mesmo digest nunca são iguais. O Elido envia hex em minúsculas depois do prefixo v1=. O Shopify e a especificação Standard Webhooks usam base64, e o GitHub usa hex depois de sha256=. Codifique seu digest da mesma forma antes de comparar.
Como faço a rotação de um segredo de webhook sem perder eventos?
Use um remetente que assine com as duas chaves por um tempo. Depois que você faz a rotação do segredo de um endpoint do Elido, cada entrega traz X-Elido-Signature com a chave nova e X-Elido-Signature-Previous com a antiga por sete dias. Aceite qualquer um dos cabeçalhos, implante o novo segredo e o antigo expira sozinho.
Experimente Elido
Cole uma URL, obtenha um link curto
Sem cadastro. O link vive 30 dias. Cadastre-se para mantê-lo para sempre.
Sem registo. Crie uma conta grátis e obtenha 50 links por mês.