# 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.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/webhook-signature-verification

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.

Diagrama: Como o Ikiform assina uma entrega de webhook e como o seu servidor a confere

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](https://www.standardwebhooks.com), 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 |

`O que é assinado`:

```text
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.

| 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:

`Terminal`:

```bash
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ç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:

`Duas assinaturas`:

```http
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](https://ikiform.com/pt-br/docs/webhooks.md#verifier) na página de webhooks confere qualquer segredo, headers e corpo no navegador.

## 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:

**Next.js**

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](https://ikiform.com/blog/form-submissions-to-your-own-server.md) acrescenta deduplicação e `after()`.

`app/api/webhooks/ikiform/route.ts`:

```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;
  }
}
```

**Astro**

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.

`src/pages/api/webhooks/ikiform.ts`:

```ts
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;
  }
};
```

**TanStack Start**

Uma server route: a mesma rota baseada em arquivos, com uma função `server.handlers.POST` que recebe o `Request` padrão.

`src/routes/api/webhooks/ikiform.ts`:

```ts
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;
        }
      },
    },
  },
});
```

**React Router**

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.

`app/routes.ts`:

```ts
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;
```

`app/routes/webhooks.ikiform.ts`:

```ts
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;
  }
}
```

**SvelteKit**

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.

`src/routes/webhooks/ikiform/+server.ts`:

```ts
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;
  }
};
```

**Nuxt**

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`.

`server/api/webhooks/ikiform.post.ts`:

```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](https://ikiform.com/blog/form-submissions-to-your-own-server.md) 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.

> 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:

Diagrama: O que o webhook-signature contém antes, durante e depois de uma troca de segredo

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](https://ikiform.com/pt-br/docs/webhooks.md#verifier). 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](https://ikiform.com/pt-br/docs/webhooks.md) assinado, diretamente ou por uma ferramenta de automação como [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) ou [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). 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](https://ikiform.com/pt-br/docs/webhooks.md).

## 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.js](https://ikiform.com/pt-br/guides/webhook-signature-verification/nodejs.md): Verifique 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.
- [Python](https://ikiform.com/pt-br/guides/webhook-signature-verification/python.md): Verifique 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/http](https://ikiform.com/pt-br/guides/webhook-signature-verification/go.md): Verifique 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.
- [PHP](https://ikiform.com/pt-br/guides/webhook-signature-verification/php.md): Verifique 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.
- [Ruby](https://ikiform.com/pt-br/guides/webhook-signature-verification/ruby.md): Verifique 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](https://ikiform.com/pt-br/pricing).

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

| 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

### Como verifico a assinatura de um webhook?

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.

### Por que a assinatura do meu webhook nunca bate?

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.

### O que é o Standard Webhooks?

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.

### Por que rejeitar timestamps antigos?

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.

### O que acontece com as entregas quando eu troco o segredo?

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.

### O meu handler deve devolver 401 ou 400 para uma assinatura inválida?

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.

## Mais guias

- [Node.js](https://ikiform.com/pt-br/guides/webhook-signature-verification/nodejs.md): Verifique 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.
- [Python](https://ikiform.com/pt-br/guides/webhook-signature-verification/python.md): Verifique 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/http](https://ikiform.com/pt-br/guides/webhook-signature-verification/go.md): Verifique 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.
- [PHP](https://ikiform.com/pt-br/guides/webhook-signature-verification/php.md): Verifique 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.
- [Ruby](https://ikiform.com/pt-br/guides/webhook-signature-verification/ruby.md): Verifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.
- [Lista de espera para um novo SaaS](https://ikiform.com/pt-br/guides/waitlist.md): Monte uma lista de espera pré-lançamento na sua própria landing page: o formulário, proteção contra spam, duplicados, e-mails de confirmação e uma exportação limpa no dia do lançamento.
- [Formulário de contato em site estático](https://ikiform.com/pt-br/guides/contact-form-static-site.md): Coloque um formulário de contato funcionando num site estático sem servidor: para onde vai o POST, spam, respostas por e-mail e código para todo host e framework.
- [Receber um formulário no e-mail](https://ikiform.com/pt-br/guides/send-form-to-email.md): Receba todo envio de formulário na caixa de entrada sem servidor de e-mail: por que mailto: e mail() do PHP falham, e uma configuração que filtra spam e permite responder.
