Guias

Guias13 min de leitura

Verificar assinaturas de webhook de formulário

Confira se um webhook de formulário veio mesmo do Ikiform: o esquema HMAC-SHA256 do Standard Webhooks, um vetor de teste, corpos brutos, timestamps e rotação de segredo.

Preet SutharFundador, Ikiform

Recalcule a assinatura você mesmo e compare com a que veio na requisição. O Ikiform segue o Standard Webhooks: a assinatura é um HMAC-SHA256 em base64 de webhook-id, webhook-timestamp e do corpo bruto unidos por pontos, com a parte decodificada de base64 do seu segredo whsec_ como chave, e é enviada como v1,<signature>. Aceite a requisição só se uma assinatura v1 bater numa comparação em tempo constante e o timestamp estiver dentro de 5 minutos.

Um endpoint de webhook é uma URL pública que aceita requisições POST de qualquer um. Sem conferir a assinatura, qualquer pessoa que descubra a URL pode enviar envios falsos para o seu CRM, o seu banco de dados ou o chat da sua equipe. A verificação são poucas linhas de código, e acertar essas linhas exatamente é o assunto deste guia.

Como funciona a assinatura

O Ikiform e o seu servidor compartilham um segredo, mostrado uma única vez quando você adiciona o endpoint. Antes de cada tentativa de entrega, o Ikiform calcula um hash da requisição com esse segredo e coloca o resultado num header. O seu servidor repete o cálculo sobre o que recebeu. Só quem tem o segredo consegue produzir um valor que bate, e qualquer mudança no corpo, no id ou no timestamp quebra o resultado.

Como o Ikiform assina uma entrega de webhook e como o seu servidor a confereIkiform, a cada tentativaseu handlercorpo JSONsubmission.createdid.timestamp.bodyo conteúdoassinadoHMAC-SHA256com cadasegredo ativowebhook-signaturev1,<base64>corpo bruto+ headersantes dequalquer parsemesmo HMACcom a sua cópiado segredocomparaçãoem tempoconstantealgum v1 bate,timestamprecente2xxdepois faz oparse e trata400nada é tratadonão bateComo o Ikiform assina uma entrega de webhook e como o seu servidor a confereIkiform, a cada tentativaseu handlercorpo JSONsubmission.createdid.timestamp.bodyo conteúdo assinadoHMAC-SHA256com cada segredoativowebhook-signaturev1,<base64>corpo bruto +headersantes de qualquerparsemesmo HMACcom a sua cópia dosegredocomparação emtempo constantealgum v1 bate,timestamp recente2xxdepois faz o parse etrata400nada étratadonão bate
  1. O Ikiform serializa o evento como JSON.
  2. Ele une o id da entrega, o timestamp Unix da tentativa e o corpo com pontos.
  3. Ele calcula um HMAC-SHA256 dessa string com cada segredo ativo do endpoint.
  4. Ele envia cada resultado, codificado em base64 e com o prefixo v1, no header webhook-signature.
  5. O seu handler lê o corpo bruto e os três headers antes de fazer o parse de qualquer coisa.
  6. Ele calcula o mesmo HMAC com a sua cópia do segredo.
  7. Ele compara em tempo constante com cada assinatura v1 e confere se o timestamp está dentro de 5 minutos.
  8. Se bater, ele responde 2xx e trata o evento. Caso contrário, responde 400 e não faz nada.
Nada secreto viaja com a requisição. A assinatura prova que o remetente conhecia o segredo e que os bytes não foram alterados no caminho.

O esquema, exatamente

O Ikiform usa o Standard Webhooks, uma especificação aberta compartilhada por vários provedores de webhook. Toda entrega traz três headers:

HeaderValorObservações
webhook-iddlv_...O id da entrega. É o mesmo em toda nova tentativa, então também serve como chave de idempotência
webhook-timestampSegundos Unix, como 1792300000Quando esta tentativa foi assinada. Muda a cada nova tentativa
webhook-signaturev1, mais base64Um ou mais valores separados por espaço. Dois enquanto um segredo está sendo trocado
O que é assinado
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature

Para verificar uma entrega, nesta ordem:

  1. Leia o corpo bruto como bytes ou como uma string exata. Não faça o parse ainda.
  2. Leia os três headers. Se algum estiver faltando, rejeite.
  3. Confira se o timestamp é um inteiro a no máximo 5 minutos do seu relógio, para mais ou para menos. Se não for, rejeite.
  4. Tire o whsec_ do seu segredo e decodifique o resto de base64. Esses 32 bytes são a chave do HMAC, não a string em si.
  5. Calcule o HMAC-SHA256 de {webhook-id}.{webhook-timestamp}.{raw body} e codifique em base64 com o alfabeto padrão e padding.
  6. Divida o webhook-signature nos espaços. Em cada item, divida na primeira vírgula; ignore tudo cuja versão não seja v1.
  7. Compare cada valor v1 com o seu em tempo constante. Se algum bater, a entrega é legítima.
  8. Só agora faça o parse do JSON e aja sobre ele.

Um vetor de teste

Antes de fazer o deploy de um handler, prove que ele calcula o valor certo. Estas entradas são o corpo real de uma entrega endpoint.test assinado com um segredo inventado. O seu código precisa produzir exatamente a assinatura da última linha. Cada guia de linguagem inclui um teste unitário que as usa.

EntradaValor
Segredowhsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
Corpo brutoOs bytes exatos do script abaixo, sem espaços nem quebra de linha
webhook-signature esperadov1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=

Dá para conferir sem escrever código. Isto roda num terminal no macOS ou no Linux e imprime a parte base64 da assinatura:

Terminal
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64

Mais dois casos valem um teste cada, porque são as duas falhas reais mais comuns:

MudançaAssinaturaO seu handler deve aceitar?
O mesmo corpo formatado com indentação de dois espaçosv1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=Não. Mesmo JSON, bytes diferentes
webhook-timestamp um segundo depoisv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=Não. O timestamp é assinado
Assinado com um segundo segredo durante uma trocav1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=Sim, se o header também tiver um valor que o seu segredo confirma

Durante uma troca de segredo, o header traz os dois valores. O seu handler, com qualquer um dos segredos, precisa aceitar isto:

Duas assinaturas
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
O timestamp do vetor é fixo, então um verificador de verdade o rejeita como antigo demais. Os testes precisam passar um horário atual fixo, como fazem os guias de linguagem. O verificador de assinatura na página de webhooks confere qualquer segredo, headers e corpo no navegador.

Formas de verificar

AbordagemBom emContras
verifyWebhook de @ikiform/sdk/webhooksUma chamada que confere tudo e devolve um evento tipado. Roda em Node, Workers, Deno e BunSó JavaScript e TypeScript
Uma biblioteca de Standard WebhooksCódigo mantido para Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# e ElixirMais uma dependência. Veja como cada uma quer receber os headers; algumas diferenciam maiúsculas de minúsculas
O seu próprio código HMACNenhuma dependência, cerca de 20 linhas, e você pode testar cada caminhoFácil de errar de forma sutil: comparação de strings, os bytes errados da chave ou um corpo já parseado
Um caminho secreto ou um token na querySimplesVaza por logs, proxies e rastreadores de erro, não prova nada sobre o corpo e não expira. Não é um substituto

O Ikiform assina toda entrega, e a assinatura é a forma suportada de autenticar uma. A documentação dele não publica uma lista fixa de IPs de envio, então uma allowlist de IPs não é uma verificação em que você possa confiar.

Verifique no seu framework

Em stacks JavaScript, o SDK faz o trabalho. O que muda entre frameworks é onde a rota fica e como pegar o corpo antes que algo faça o parse dele. Escolha o seu:

Um route handler do App Router. request.text() é o corpo bruto; chamar request.json() antes faria você perdê-lo. O guia de servidor próprio acrescenta deduplicação e after().

app/api/webhooks/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}

Todo handler responde 204 em caso de sucesso e 400 com o motivo da falha caso contrário. Faça o trabalho lento depois de responder; o Ikiform espera 15 segundos antes de contar uma tentativa como falha.

Por que o corpo bruto importa

A assinatura cobre bytes, não significado. {"a":1} e { "a": 1 } são o mesmo JSON e bytes diferentes, então um corpo que foi parseado e serializado de novo quase nunca bate. O mesmo vale para qualquer coisa que mexa no corpo na entrada: um middleware que faz o parse do JSON, um proxy que recodifica ou um framework que entrega um objeto em vez de uma string.

Leia o corpo uma vez, como bytes ou string, verifique e depois faça o parse dessa mesma string. Cada guia de linguagem mostra onde fica o corpo bruto nos seus frameworks, e o guia de servidor próprio tem uma tabela com os mais comuns de Node e Python.

Compare em tempo constante

Uma comparação de strings normal para no primeiro caractere diferente. Em teoria, um atacante que consegue medir o tempo de milhares de requisições descobre quantos caracteres iniciais de um palpite estão certos e monta uma assinatura válida um caractere por vez. Uma comparação em tempo constante leva o mesmo tempo, não importa o quanto bate. Toda linguagem tem uma:

LinguagemComparação em tempo constante
Node.jscrypto.timingSafeEqual(a, b), que lança erro se os tamanhos forem diferentes, então confira o tamanho antes
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.secure_compare(a, b), ou ActiveSupport::SecurityUtils.secure_compare no Rails

Timestamps e replays

Uma requisição válida capturada uma vez continua válida para sempre, a menos que algo a faça expirar. Esse algo é o timestamp assinado: rejeite qualquer entrega a mais de 5 minutos do seu relógio, para mais ou para menos. Como o Ikiform assina cada tentativa de novo com um timestamp novo, uma nova tentativa três dias depois ainda passa; só uma requisição antiga copiada falha.

Dentro desses 5 minutos, um replay ainda é possível, e uma duplicata legítima também: o Ikiform tenta de novo quando não recebe um 2xx a tempo, mesmo que o seu handler tenha terminado. Guarde o webhook-id com uma restrição de unicidade e ignore ids que você já tratou. Isso resolve os dois casos.

Mantenha o relógio do servidor sincronizado. Um relógio que se desvia mais de 5 minutos rejeita toda entrega com timestamp_too_old ou timestamp_too_new. Plataformas gerenciadas costumam manter os relógios sincronizados; na sua própria VM, rode NTP.

Trocando o segredo

Troque o segredo quando alguém que o conhecia sair, quando ele possa ter ido parar num log ou num commit, ou periodicamente. Roll signing secret (trocar o segredo de assinatura) na página do endpoint cria um novo, e por 24 horas toda entrega é assinada com os dois:

O que o webhook-signature contém antes, durante e depois de uma troca de segredoantesv1,<old>trocanovo segredomostrado umavezpróximas24 horasv1,<old>v1,<new>depoissó v1,<new>deploy do novoO que o webhook-signature contém antes, durante e depois de uma troca de segredoantesv1,<old>trocanovo segredo mostrado uma vezpróximas 24 horasv1,<old> v1,<new>depoissó v1,<new>deploy do novo
  1. Antes da troca, cada entrega traz uma assinatura, feita com o segredo antigo.
  2. Você escolhe Roll signing secret e copia o novo segredo.
  3. Nas 24 horas seguintes, cada entrega traz duas assinaturas, então um servidor com qualquer um dos segredos a aceita. Faça o deploy do novo segredo nessa janela.
  4. Depois de 24 horas, as entregas trazem só a nova assinatura, e um servidor que ainda tenha o segredo antigo as rejeita.
O seu handler não precisa de código especial para isso, desde que confira todos os itens v1 em vez de só o primeiro.

Se o segredo antigo ainda estiver no ar depois de 24 horas, toda entrega falha. O Ikiform tenta cada uma 8 vezes ao longo de cerca de três dias, então, assim que fizer o deploy do segredo certo, use Retry no log de entregas para o que já tiver desistido.

Depois que a verificação passa

Uma assinatura válida prova que a requisição veio do Ikiform e não foi alterada. Ela não prova que o conteúdo é seguro ou novo. Três hábitos completam o trabalho:

  • Ignore repetições. Guarde o webhook-id, ou o id do payload, que tem o mesmo valor, com uma restrição de unicidade. Uma nova tentativa de uma entrega que você já tratou não faz nada.
  • Responda primeiro, trabalhe depois. Devolva 2xx assim que o evento estiver armazenado ou numa fila. O Ikiform conta qualquer coisa mais lenta que 15 segundos como falha e tenta de novo, o que traz de volta o problema das duplicatas.
  • Trate os campos como não confiáveis. data.fields contém o que um visitante digitou num formulário público. Faça o escape antes de renderizar em HTML, numa mensagem de chat ou num e-mail, e nunca passe para um shell ou para uma query sem parametrizar.

Testando antes de ir para produção

O Ikiform só entrega para URLs https: públicas na porta 443, então localhost não consegue receber um webhook. Rode um túnel como cloudflared tunnel --url http://localhost:3000 e adicione a URL que ele imprime como um segundo endpoint enquanto trabalha. Send test na página do endpoint envia um evento endpoint.test uma vez, sem novas tentativas, assinado exatamente como uma entrega real. Apague o endpoint de teste quando terminar; ele tem o próprio segredo, então nada mais muda.

Se uma entrega falha e você não consegue ver o porquê, copie os headers e o corpo dela dos seus logs para o verificador de assinatura. Ele roda a mesma verificação no navegador e diz qual etapa falhou.

Cinco formas de a verificação dar errado

ErroSintomaCorreção
Usar a string whsec_... inteira como chaveno_matching_signature em toda entregaTire o whsec_ e decodifique de base64. A chave tem 32 bytes
Verificar um corpo já parseadono_matching_signature, às vezes só em alguns payloadsVerifique o corpo bruto; faça o parse depois
Conferir só a primeira assinaturaFalhas que começam logo depois de uma trocaPercorra todos os itens v1 separados por espaço
Hex em vez de base64no_matching_signatureO Standard Webhooks usa base64 padrão com padding, não hex nem base64url
Buscar headers pela capitalização exatamissing_headers atrás de alguns proxiesNomes de header não diferenciam maiúsculas de minúsculas. O Ikiform os envia em minúsculas; busque-os sem diferenciar maiúsculas

Não há integrações de um clique. Toda ferramenta é alcançada por um webhook assinado, diretamente ou por uma ferramenta de automação como Zapier ou n8n. Um workspace pode ter 3 endpoints, cada um com o próprio segredo. O payload completo, o cronograma de novas tentativas e o log de entregas estão em Webhooks.

Escolha o seu caso

Cada página de linguagem tem handlers idiomáticos, uma opção com biblioteca e um teste unitário que roda o vetor de teste desta página.

Confira se funciona

Faça isto primeiro num endpoint de staging e depois de novo em produção. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para ir para produção, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.

  1. Rode o vetor de teste no seu código: ele deve produzir a assinatura esperada, aceitar o header com duas assinaturas e rejeitar o corpo formatado.
  2. No painel, abra Webhooks, adicione a URL https: pública do seu endpoint e copie o segredo whsec_ para o ambiente do seu servidor.
  3. Escolha Send test na página do endpoint. O log de entregas mostra o seu 204 e o evento chega aos seus logs.
  4. Mude um caractere do segredo no seu servidor e envie outro teste. O log mostra 400 com no_matching_signature.
  5. Envie o formulário de verdade e confira se a entrega de submission.created deu certo.

Solução de problemas

ProblemaO que fazer
400 no_matching_signatureSegredo errado, um corpo já parseado ou uma chave que não foi decodificada de base64. Rode o vetor de teste pelo mesmo caminho de código para ver qual é.
400 timestamp_too_old ou timestamp_too_newO relógio do servidor está errado, ou você está reenviando à mão uma requisição salva. Sincronize o relógio e use Retry no log de entregas, que assina de novo.
400 missing_headersUm proxy ou framework descartou os headers webhook-*, ou a requisição não veio do Ikiform. Registre no log o nome de cada header que o seu handler recebe.
Funciona com eventos de teste, falha com envios reaisPayloads reais têm caracteres não ASCII e textos mais longos. O seu código decodifica ou recodifica o corpo antes de verificar. Verifique os bytes exatos.
Toda entrega falha logo depois de trocar o segredoO handler só confere a primeira assinatura, ou as 24 horas passaram com o segredo antigo no ar. Confira todos os itens v1 e faça o deploy do novo segredo.
O log de entregas mostra um timeout, mas o seu handler rodouO Ikiform espera 15 segundos. Responda primeiro, faça o trabalho lento depois e ignore valores de webhook-id repetidos.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.