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.
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.
- O Ikiform serializa o evento como JSON.
- Ele une o id da entrega, o timestamp Unix da tentativa e o corpo com pontos.
- Ele calcula um HMAC-SHA256 dessa string com cada segredo ativo do endpoint.
- Ele envia cada resultado, codificado em base64 e com o prefixo v1, no header webhook-signature.
- O seu handler lê o corpo bruto e os três headers antes de fazer o parse de qualquer coisa.
- Ele calcula o mesmo HMAC com a sua cópia do segredo.
- Ele compara em tempo constante com cada assinatura v1 e confere se o timestamp está dentro de 5 minutos.
- Se bater, ele responde 2xx e trata o evento. Caso contrário, responde 400 e não faz nada.
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:
| Header | Valor | Observações |
|---|---|---|
webhook-id | dlv_... | O id da entrega. É o mesmo em toda nova tentativa, então também serve como chave de idempotência |
webhook-timestamp | Segundos Unix, como 1792300000 | Quando esta tentativa foi assinada. Muda a cada nova tentativa |
webhook-signature | v1, mais base64 | Um ou mais valores separados por espaço. Dois enquanto um segredo está sendo trocado |
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," + signaturePara verificar uma entrega, nesta ordem:
- Leia o corpo bruto como bytes ou como uma string exata. Não faça o parse ainda.
- Leia os três headers. Se algum estiver faltando, rejeite.
- 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.
- 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. - Calcule o HMAC-SHA256 de
{webhook-id}.{webhook-timestamp}.{raw body}e codifique em base64 com o alfabeto padrão e padding. - Divida o
webhook-signaturenos espaços. Em cada item, divida na primeira vírgula; ignore tudo cuja versão não sejav1. - Compare cada valor
v1com o seu em tempo constante. Se algum bater, a entrega é legítima. - 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.
| Entrada | Valor |
|---|---|
| Segredo | whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw= |
webhook-id | dlv_01k7v2d4n8r2p6s0w3y5b7c9e1 |
webhook-timestamp | 1792300000 |
| Corpo bruto | Os bytes exatos do script abaixo, sem espaços nem quebra de linha |
webhook-signature esperado | v1,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:
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 \
| base64Mais dois casos valem um teste cada, porque são as duas falhas reais mais comuns:
| Mudança | Assinatura | O seu handler deve aceitar? |
|---|---|---|
| O mesmo corpo formatado com indentação de dois espaços | v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY= | Não. Mesmo JSON, bytes diferentes |
webhook-timestamp um segundo depois | v1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks= | Não. O timestamp é assinado |
| Assinado com um segundo segredo durante uma troca | v1,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:
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=Formas de verificar
| Abordagem | Bom em | Contras |
|---|---|---|
verifyWebhook de @ikiform/sdk/webhooks | Uma chamada que confere tudo e devolve um evento tipado. Roda em Node, Workers, Deno e Bun | Só JavaScript e TypeScript |
| Uma biblioteca de Standard Webhooks | Código mantido para Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# e Elixir | Mais uma dependência. Veja como cada uma quer receber os headers; algumas diferenciam maiúsculas de minúsculas |
| O seu próprio código HMAC | Nenhuma dependência, cerca de 20 linhas, e você pode testar cada caminho | Fá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 query | Simples | Vaza 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().
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;
}
}Um endpoint com um export POST. Ele precisa de renderização sob demanda, então mantenha prerender = false e um adapter. getSecret de astro:env/server lê IKIFORM_WEBHOOK_SECRET em tempo de execução e só no servidor; import.meta.env o embutiria no build.
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: getSecret("IKIFORM_WEBHOOK_SECRET") ?? "",
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
};Uma server route: a mesma rota baseada em arquivos, com uma função server.handlers.POST que recebe o Request padrão.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/webhooks/ikiform")({
server: {
handlers: {
POST: async ({ 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, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
},
},
},
});Uma resource route: um módulo com uma action e sem export default, registrado em routes.ts. Ela responde ao Ikiform diretamente em vez de renderizar uma página.
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("webhooks/ikiform", "routes/webhooks.ikiform.ts"),
] satisfies RouteConfig;import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/webhooks.ikiform";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}Um endpoint +server.ts. A verificação CSRF embutida do SvelteKit só bloqueia content types de formulário cross-site, e o Ikiform envia application/json, então não é preciso mudar nenhuma configuração. Declare IKIFORM_WEBHOOK_SECRET em src/env.ts com defineEnvVars para que $app/env/private consiga lê-lo.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { IKIFORM_WEBHOOK_SECRET } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({ payload, headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
};Uma server route do Nitro. readRawBody devolve o corpo como string antes que algo faça o parse dele. Defina NUXT_IKIFORM_WEBHOOK_SECRET e declare ikiformWebhookSecret em runtimeConfig no nuxt.config.ts.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export default defineEventHandler(async (event) => {
const payload = (await readRawBody(event, "utf8")) ?? "";
const { ikiformWebhookSecret } = useRuntimeConfig(event);
try {
const delivery = await verifyWebhook({
payload,
headers: getRequestHeaders(event),
secret: ikiformWebhookSecret,
});
console.info(delivery.type, delivery.id);
setResponseStatus(event, 204);
return null;
} catch (error) {
if (error instanceof WebhookVerificationError) {
throw createError({ status: 400, message: error.reason });
}
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:
| Linguagem | Comparação em tempo constante |
|---|---|
| Node.js | crypto.timingSafeEqual(a, b), que lança erro se os tamanhos forem diferentes, então confira o tamanho antes |
| Python | hmac.compare_digest(a, b) |
| Go | hmac.Equal(a, b) |
| PHP | hash_equals($known, $user) |
| Ruby | OpenSSL.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.
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:
- Antes da troca, cada entrega traz uma assinatura, feita com o segredo antigo.
- Você escolhe Roll signing secret e copia o novo segredo.
- 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.
- Depois de 24 horas, as entregas trazem só a nova assinatura, e um servidor que ainda tenha o segredo antigo as rejeita.
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 oiddo 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
2xxassim 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.fieldsconté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
| Erro | Sintoma | Correção |
|---|---|---|
Usar a string whsec_... inteira como chave | no_matching_signature em toda entrega | Tire o whsec_ e decodifique de base64. A chave tem 32 bytes |
| Verificar um corpo já parseado | no_matching_signature, às vezes só em alguns payloads | Verifique o corpo bruto; faça o parse depois |
| Conferir só a primeira assinatura | Falhas que começam logo depois de uma troca | Percorra todos os itens v1 separados por espaço |
| Hex em vez de base64 | no_matching_signature | O Standard Webhooks usa base64 padrão com padding, não hex nem base64url |
| Buscar headers pela capitalização exata | missing_headers atrás de alguns proxies | Nomes 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.
- Node.jsVerifique assinaturas de webhook do Ikiform em Node.js: corpos brutos no Express e no Fastify, o SDK, o pacote standardwebhooks e uma versão testada com node:crypto.
- PythonVerifique assinaturas de webhook do Ikiform no Flask, FastAPI e Django, com o pacote standardwebhooks ou a biblioteca padrão, mais um teste com pytest.
- Go net/httpVerifique assinaturas de webhook do Ikiform em Go com net/http: leia o corpo uma vez, confira com crypto/hmac, teste, ou use a biblioteca do Standard Webhooks.
- PHPVerifique assinaturas de webhook do Ikiform em PHP puro com hash_hmac e hash_equals, e no Laravel com a biblioteca do Standard Webhooks e uma exceção de CSRF.
- RubyVerifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.
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.
- 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.
- No painel, abra Webhooks, adicione a URL
https:pública do seu endpoint e copie o segredowhsec_para o ambiente do seu servidor. - Escolha Send test na página do endpoint. O log de entregas mostra o seu
204e o evento chega aos seus logs. - Mude um caractere do segredo no seu servidor e envie outro teste. O log mostra
400comno_matching_signature. - Envie o formulário de verdade e confira se a entrega de
submission.createddeu certo.
Solução de problemas
| Problema | O que fazer |
|---|---|
400 no_matching_signature | Segredo 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_new | O 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_headers | Um 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 reais | Payloads 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 segredo | O 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 rodou | O Ikiform espera 15 segundos. Responda primeiro, faça o trabalho lento depois e ignore valores de webhook-id repetidos. |
Perguntas
Recalcule o HMAC-SHA256 do id do webhook, do timestamp e do corpo bruto unidos por pontos, usando a parte decodificada de base64 do seu segredo whsec_ como chave. Codifique o resultado em base64 e compare em tempo constante com cada valor v1 do header webhook-signature.
Quase sempre é uma de três coisas: o corpo foi parseado e serializado de novo antes da verificação, a string whsec_ inteira foi usada como chave em vez dos bytes decodificados de base64, ou o segredo pertence a outro endpoint.
Uma especificação aberta para assinar webhooks com HMAC-SHA256 e os headers webhook-id, webhook-timestamp e webhook-signature. O Ikiform a segue, então qualquer biblioteca de Standard Webhooks consegue verificar as entregas do Ikiform.
Senão, uma requisição capturada continuaria válida para sempre. Rejeitar qualquer coisa a mais de 5 minutos do seu relógio impede replays de requisições antigas, e o Ikiform assina toda nova tentativa com um timestamp novo, então as novas tentativas reais continuam passando.
Por 24 horas, cada entrega é assinada com o segredo antigo e com o novo, então você pode fazer o deploy do novo sem perder eventos. Depois disso, só o novo segredo funciona.
Qualquer um serve para o Ikiform; qualquer status diferente de 2xx conta como falha e gera nova tentativa. Não devolva 410, que desativa o endpoint na hora.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.