# Verifica las firmas de los webhooks de formularios

> Comprueba que un webhook de formulario viene de verdad de Ikiform: el esquema HMAC-SHA256 de Standard Webhooks, un vector de prueba, cuerpos sin procesar, marcas de tiempo y rotación de secretos.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/webhook-signature-verification

Recalcula tú mismo la firma y compárala con la de la petición. Ikiform sigue Standard Webhooks: la firma es un HMAC-SHA256 en base64 de `webhook-id`, `webhook-timestamp` y el cuerpo sin procesar unidos por puntos, con la parte decodificada de base64 de tu secreto `whsec_` como clave, y se envía como `v1,<signature>`. Acepta la petición solo si una firma `v1` coincide en una comparación en tiempo constante y la marca de tiempo está dentro de 5 minutos.

Un endpoint de webhook es una URL pública que acepta peticiones POST de cualquiera. Sin comprobar la firma, cualquiera que descubra la URL puede meter envíos falsos en tu CRM, tu base de datos o el chat de tu equipo. La comprobación son unas pocas líneas de código, y escribir esas líneas exactamente bien es de lo que trata esta guía.

## Cómo funciona la firma

Ikiform y tu servidor comparten un secreto, que se muestra una sola vez al añadir el endpoint. Antes de cada intento de entrega, Ikiform calcula un hash de la petición con ese secreto y pone el resultado en una cabecera. Tu servidor repite el cálculo con lo que ha recibido. Solo alguien con el secreto puede producir un valor que coincida, y cualquier cambio en el cuerpo, el id o la marca de tiempo lo rompe.

Diagrama: Cómo firma Ikiform una entrega de webhook y cómo la comprueba tu servidor

1. Ikiform serializa el evento como JSON.
2. Une con puntos el id de la entrega, la marca de tiempo Unix del intento y el cuerpo.
3. Calcula un HMAC-SHA256 de esa cadena con cada secreto activo del endpoint.
4. Envía cada resultado, codificado en base64 y con el prefijo v1, en la cabecera webhook-signature.
5. Tu handler lee el cuerpo sin procesar y las tres cabeceras antes de analizar nada.
6. Calcula el mismo HMAC con su copia del secreto.
7. Compara en tiempo constante con cada firma v1 y comprueba que la marca de tiempo esté dentro de 5 minutos.
8. Si coincide, responde 2xx y procesa el evento. Si no, responde 400 y no hace nada.

Ningún secreto viaja con la petición. La firma demuestra que el remitente conocía el secreto y que los bytes no cambiaron por el camino.

## El esquema, con exactitud

Ikiform usa [Standard Webhooks](https://www.standardwebhooks.com), una especificación abierta que comparten varios proveedores de webhooks. Cada entrega lleva tres cabeceras:

| Cabecera | Valor | Notas |
| --- | --- | --- |
| `webhook-id` | `dlv_...` | El id de la entrega. Es el mismo en cada reintento, así que también sirve como clave de idempotencia |
| `webhook-timestamp` | Segundos Unix, como `1792300000` | Cuándo se firmó este intento. Cambia en cada reintento |
| `webhook-signature` | `v1,` más base64 | Uno o más valores separados por espacios. Dos mientras se rota un secreto |

`Qué se firma`:

```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 una entrega, en este orden:

1. Lee el cuerpo sin procesar como bytes o como una cadena exacta. No lo analices todavía.
2. Lee las tres cabeceras. Si falta alguna, rechaza la petición.
3. Comprueba que la marca de tiempo sea un entero a menos de 5 minutos de tu reloj, en cualquier sentido. Si no, rechaza la petición.
4. Quita `whsec_` de tu secreto y decodifica el resto de base64. Esos 32 bytes son la clave del HMAC, no la cadena en sí.
5. Calcula el HMAC-SHA256 de `{webhook-id}.{webhook-timestamp}.{raw body}` y codifícalo en base64 con el alfabeto estándar y relleno.
6. Divide `webhook-signature` por los espacios. Divide cada entrada por la primera coma y salta todo lo que no tenga la versión `v1`.
7. Compara cada valor `v1` con el tuyo en tiempo constante. Si alguno coincide, la entrega es auténtica.
8. Solo entonces analiza el JSON y actúa en consecuencia.

## Un vector de prueba

Antes de desplegar un handler, demuestra que calcula el valor correcto. Estas entradas son el cuerpo real de una entrega `endpoint.test` firmado con un secreto inventado. Tu código tiene que producir exactamente la firma de la última fila. Cada guía por lenguaje incluye un test unitario que las usa.

| Entrada | Valor |
| --- | --- |
| Secreto | `whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=` |
| `webhook-id` | `dlv_01k7v2d4n8r2p6s0w3y5b7c9e1` |
| `webhook-timestamp` | `1792300000` |
| Cuerpo sin procesar | Los bytes exactos del script de abajo, sin espacios ni salto de línea |
| `webhook-signature` esperado | `v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=` |

Puedes comprobarlo sin escribir código. Esto se ejecuta en una terminal de macOS o Linux e imprime la parte en base64 de la firma:

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

Vale la pena un test para cada uno de estos dos casos, porque son los dos fallos reales más comunes:

| Cambio | Firma | ¿Debe aceptarla tu handler? |
| --- | --- | --- |
| El mismo cuerpo formateado con sangría de dos espacios | `v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=` | No. Mismo JSON, bytes distintos |
| `webhook-timestamp` un segundo después | `v1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=` | No. La marca de tiempo está firmada |
| Firmada con un segundo secreto durante una rotación | `v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` | Sí, si la cabecera también tiene un valor que coincide con tu secreto |

Durante la rotación de un secreto, la cabecera lleva los dos valores. Tu handler, con cualquiera de los dos secretos, debe aceptar esto:

`Dos firmas`:

```http
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
```

> La marca de tiempo del vector es fija, así que un verificador real la rechaza por demasiado antigua. Los tests deben pasar una hora actual fija, como hacen las guías por lenguaje. El [verificador de firmas](https://ikiform.com/es/docs/webhooks.md#verifier) de la página de webhooks comprueba cualquier secreto, cabeceras y cuerpo en el navegador.

## Formas de verificar

| Enfoque | Punto fuerte | Inconvenientes |
| --- | --- | --- |
| `verifyWebhook` de `@ikiform/sdk/webhooks` | Una sola llamada que lo comprueba todo y devuelve un evento tipado. Funciona en Node, Workers, Deno y Bun | Solo JavaScript y TypeScript |
| Una librería de Standard Webhooks | Código mantenido para Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# y Elixir | Una dependencia más. Revisa cómo quiere cada una que le pases las cabeceras; algunas distinguen mayúsculas y minúsculas |
| Tu propio código HMAC | Sin dependencias, unas 20 líneas, y puedes probar cada rama | Fácil de equivocarse en detalles sutiles: comparación de cadenas, bytes de clave incorrectos o un cuerpo ya analizado |
| Una ruta secreta o un token en la query | Sencillo | Se filtra por logs, proxies y herramientas de seguimiento de errores, no demuestra nada sobre el cuerpo y no puede caducar. No es un sustituto |

Ikiform firma cada entrega, y la firma es la forma admitida de autenticarla. Su documentación no publica una lista fija de direcciones IP de envío, así que una lista de IP permitidas no es una comprobación en la que puedas confiar.

## Verifica en tu framework

En los stacks de JavaScript, el SDK hace el trabajo. Lo que cambia entre frameworks es dónde vive la ruta y cómo obtener el cuerpo antes de que algo lo analice. Elige el tuyo:

**Next.js**

Un route handler del App Router. `request.text()` es el cuerpo sin procesar; llamar antes a `request.json()` lo perdería. La [guía de servidor propio](https://ikiform.com/blog/form-submissions-to-your-own-server.md) añade deduplicación y `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**

Un endpoint con un export `POST`. Necesita renderizado bajo demanda, así que mantén `prerender = false` y un adaptador. `getSecret` de `astro:env/server` lee `IKIFORM_WEBHOOK_SECRET` en tiempo de ejecución y solo en el servidor; `import.meta.env` lo incrustaría en el 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**

Una server route: la misma ruta basada en archivos, con una función `server.handlers.POST` que recibe el `Request` estándar.

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

Una resource route: un módulo con un `action` y sin export por defecto, registrado en `routes.ts`. Responde directamente a Ikiform en lugar de renderizar una 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**

Un endpoint `+server.ts`. La comprobación CSRF integrada de SvelteKit solo bloquea los content types de formularios entre sitios, e Ikiform envía `application/json`, así que no hace falta cambiar la configuración. Declara `IKIFORM_WEBHOOK_SECRET` en `src/env.ts` con `defineEnvVars` para que `$app/env/private` pueda leerlo.

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

Una server route de Nitro. `readRawBody` devuelve el cuerpo como cadena antes de que nada lo analice. Define `NUXT_IKIFORM_WEBHOOK_SECRET` y declara `ikiformWebhookSecret` en `runtimeConfig` dentro de `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;
  }
});
```

Cada handler responde `204` si todo va bien y `400` con el motivo del fallo en caso contrario. Haz el trabajo lento después de responder; Ikiform espera 15 segundos antes de contar un intento como fallido.

## Por qué importa el cuerpo sin procesar

La firma cubre bytes, no significado. `{"a":1}` y `{ "a": 1 }` son el mismo JSON y bytes distintos, así que un cuerpo que se ha analizado y vuelto a serializar casi nunca coincide. Lo mismo vale para cualquier cosa que toque el cuerpo al entrar: un middleware que analiza JSON, un proxy que lo recodifica o un framework que te da un objeto en lugar de una cadena.

Lee el cuerpo una vez, como bytes o como cadena, verifícalo y luego analiza esa misma cadena. Cada guía por lenguaje muestra dónde está el cuerpo sin procesar en sus frameworks, y la [guía de servidor propio](https://ikiform.com/blog/form-submissions-to-your-own-server.md) tiene una tabla para los más comunes de Node y Python.

## Compara en tiempo constante

Una comparación de cadenas normal se detiene en el primer carácter distinto. En teoría, un atacante que pueda medir el tiempo de miles de peticiones averigua cuántos caracteres iniciales de un intento son correctos y construye una firma válida carácter a carácter. Una comparación en tiempo constante tarda lo mismo coincida lo que coincida. Todos los lenguajes tienen una:

| Lenguaje | Comparación en tiempo constante |
| --- | --- |
| Node.js | `crypto.timingSafeEqual(a, b)`, que lanza un error si las longitudes difieren, así que comprueba antes la longitud |
| Python | `hmac.compare_digest(a, b)` |
| Go | `hmac.Equal(a, b)` |
| PHP | `hash_equals($known, $user)` |
| Ruby | `OpenSSL.secure_compare(a, b)`, o `ActiveSupport::SecurityUtils.secure_compare` en Rails |

## Marcas de tiempo y ataques de repetición

Una petición válida capturada una vez sigue siendo válida para siempre a menos que algo la haga caducar. La marca de tiempo firmada es ese algo: rechaza cualquier entrega a más de 5 minutos de tu reloj, en cualquier sentido. Como Ikiform vuelve a firmar cada intento con una marca de tiempo nueva, un reintento tres días después sigue pasando; solo falla una petición antigua copiada.

Dentro de esos 5 minutos sigue siendo posible un ataque de repetición, y también un duplicado legítimo: Ikiform reintenta cuando no recibe un `2xx` a tiempo, aunque tu handler haya terminado. Guarda `webhook-id` con una restricción única y salta los ids que ya hayas procesado. Eso cierra ambos casos.

> Mantén sincronizado el reloj del servidor. Un reloj que se desvía más de 5 minutos rechaza todas las entregas con `timestamp_too_old` o `timestamp_too_new`. Las plataformas gestionadas suelen mantener los relojes sincronizados; en tu propia VM, usa NTP.

## Rotar el secreto

Rota el secreto cuando se vaya alguien que lo conocía, cuando pueda haber acabado en un log o en un commit, o de forma periódica. **Roll signing secret** en la página del endpoint crea uno nuevo, y durante 24 horas cada entrega se firma con los dos:

Diagrama: Qué contiene webhook-signature antes, durante y después de rotar un secreto

1. Antes de la rotación, cada entrega lleva una firma, hecha con el secreto antiguo.
2. Eliges Roll signing secret y copias el secreto nuevo.
3. Durante las siguientes 24 horas, cada entrega lleva dos firmas, así que un servidor con cualquiera de los dos secretos la acepta. Despliega el secreto nuevo en esta ventana.
4. Pasadas 24 horas, las entregas solo llevan la firma nueva, y un servidor que aún tenga el secreto antiguo las rechaza.

Tu handler no necesita código especial para esto, siempre que compruebe cada entrada v1 en lugar de solo la primera.

Si el secreto antiguo sigue desplegado pasadas 24 horas, todas las entregas fallan. Ikiform reintenta cada una 8 veces a lo largo de unos tres días, así que, en cuanto despliegues el secreto correcto, usa **Retry** en el registro de entregas para las que ya se dieron por perdidas.

## Después de pasar la comprobación

Una firma válida demuestra que la petición vino de Ikiform y que no se modificó. No demuestra que el contenido sea seguro ni nuevo. Tres hábitos completan el trabajo:

- **Salta las repeticiones.** Guarda `webhook-id`, o el `id` del payload, que tiene el mismo valor, con una restricción única. Un reintento de una entrega que ya procesaste no hace nada.
- **Responde primero, trabaja después.** Devuelve `2xx` en cuanto el evento esté guardado o en cola. Ikiform cuenta como fallo todo lo que tarde más de 15 segundos y reintenta, lo que vuelve a traer el problema de los duplicados.
- **Trata los campos como no fiables.** `data.fields` contiene lo que un visitante escribió en un formulario público. Escápalo antes de mostrarlo en HTML, en un mensaje de chat o en un email, y nunca lo pases a una shell ni a una consulta sin parametrizar.

### Pruebas antes de salir a producción

Ikiform solo entrega a URLs `https:` públicas en el puerto 443, así que `localhost` no puede recibir un webhook. Ejecuta un túnel como `cloudflared tunnel --url http://localhost:3000` y añade la URL que imprime como un segundo endpoint mientras trabajas. **Send test** en la página del endpoint envía un evento `endpoint.test` una vez, sin reintentos, firmado exactamente igual que una entrega real. Elimina el endpoint de prueba cuando termines; tiene su propio secreto, así que nada más cambia.

Si una entrega falla y no ves por qué, copia sus cabeceras y su cuerpo de tus logs en el [verificador de firmas](https://ikiform.com/es/docs/webhooks.md#verifier). Ejecuta la misma comprobación en el navegador y te dice qué paso falló.

## Cinco formas en que falla la verificación

| Error | Síntoma | Solución |
| --- | --- | --- |
| Usar toda la cadena `whsec_...` como clave | `no_matching_signature` en todas las entregas | Quita `whsec_` y decodifica de base64. La clave son 32 bytes |
| Verificar un cuerpo ya analizado | `no_matching_signature`, a veces solo con algunos payloads | Verifica el cuerpo sin procesar; analízalo después |
| Comprobar solo la primera firma | Fallos que empiezan justo después de una rotación | Recorre cada entrada `v1` separada por espacios |
| Hex en lugar de base64 | `no_matching_signature` | Standard Webhooks usa base64 estándar con relleno, no hex ni base64url |
| Buscar las cabeceras distinguiendo mayúsculas | `missing_headers` detrás de algunos proxies | Los nombres de cabecera no distinguen mayúsculas y minúsculas. Ikiform los envía en minúsculas; búscalos sin distinguir mayúsculas |

No hay integraciones de un clic. Cada herramienta se conecta mediante un [webhook](https://ikiform.com/es/docs/webhooks.md) firmado, directamente o a través de una herramienta de automatización como [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) o [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). Un espacio de trabajo puede tener 3 endpoints, cada uno con su propio secreto. El payload completo, el calendario de reintentos y el registro de entregas están en [Webhooks](https://ikiform.com/es/docs/webhooks.md).

## Elige tu caso

Cada página por lenguaje tiene handlers idiomáticos, una opción con librería y un test unitario que ejecuta el vector de prueba de esta página.

- [Node.js](https://ikiform.com/es/guides/webhook-signature-verification/nodejs.md): Verifica las firmas de los webhooks de Ikiform en Node.js: cuerpos sin procesar en Express y Fastify, el SDK, el paquete standardwebhooks y una versión probada con node:crypto.
- [Python](https://ikiform.com/es/guides/webhook-signature-verification/python.md): Verifica las firmas de los webhooks de Ikiform en Flask, FastAPI y Django, con el paquete standardwebhooks o la librería estándar, más un test de pytest.
- [Go net/http](https://ikiform.com/es/guides/webhook-signature-verification/go.md): Verifica las firmas de los webhooks de Ikiform en Go con net/http: lee el cuerpo una vez, compruébalo con crypto/hmac, pruébalo o usa la librería de Standard Webhooks.
- [PHP](https://ikiform.com/es/guides/webhook-signature-verification/php.md): Verifica las firmas de los webhooks de Ikiform en PHP puro con hash_hmac y hash_equals, y en Laravel con la librería de Standard Webhooks y una excepción de CSRF.
- [Ruby](https://ikiform.com/es/guides/webhook-signature-verification/ruby.md): Verifica las firmas de los webhooks de Ikiform en Sinatra con OpenSSL y un test de Minitest, y en Rails con la gema standardwebhooks y request.raw_post.

## Comprueba que funciona

Hazlo primero en un endpoint de staging y luego otra vez en producción. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde `localhost`. Para salir a producción, elige Pro desde 6 $/mes para 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los [precios](https://ikiform.com/es/pricing).

1. Pasa el vector de prueba por tu código: debe producir la firma esperada, aceptar la cabecera con dos firmas y rechazar el cuerpo formateado.
2. En el panel, abre **Webhooks**, añade la URL `https:` pública de tu endpoint y copia el secreto `whsec_` en el entorno de tu servidor.
3. Elige **Send test** en la página del endpoint. El registro de entregas muestra tu `204` y el evento llega a tus logs.
4. Cambia un carácter del secreto en tu servidor y envía otra prueba. El registro muestra `400` con `no_matching_signature`.
5. Envía el formulario de verdad y comprueba que la entrega `submission.created` tiene éxito.

## Solución de problemas

| Problema | Qué hacer |
| --- | --- |
| `400 no_matching_signature` | Secreto incorrecto, un cuerpo ya analizado o una clave que no se decodificó de base64. Pasa el vector de prueba por el mismo código para ver cuál es. |
| `400 timestamp_too_old` o `timestamp_too_new` | El reloj del servidor va desfasado, o estás reenviando a mano una petición guardada. Sincroniza el reloj y usa **Retry** en el registro de entregas, que vuelve a firmar. |
| `400 missing_headers` | Un proxy o el framework eliminó las cabeceras `webhook-*`, o la petición no vino de Ikiform. Registra en el log cada nombre de cabecera que ve tu handler. |
| Funciona con eventos de prueba, falla con envíos reales | Los payloads reales contienen caracteres no ASCII y textos más largos. Tu código decodifica o recodifica el cuerpo antes de verificarlo. Verifica los bytes exactos. |
| Todas las entregas fallan justo después de rotar el secreto | El handler solo comprueba la primera firma, o pasaron las 24 horas con el secreto antiguo desplegado. Comprueba cada entrada `v1` y despliega el secreto nuevo. |
| El registro de entregas muestra un timeout, pero tu handler se ejecutó | Ikiform espera 15 segundos. Responde primero, haz el trabajo lento después y salta los valores de `webhook-id` repetidos. |

## Preguntas

### ¿Cómo verifico la firma de un webhook?

Recalcula el HMAC-SHA256 del id del webhook, la marca de tiempo y el cuerpo sin procesar unidos por puntos, usando como clave la parte decodificada de base64 de tu secreto whsec_. Codifica el resultado en base64 y compáralo en tiempo constante con cada valor v1 de la cabecera webhook-signature.

### ¿Por qué la firma de mi webhook nunca coincide?

Casi siempre es una de tres cosas: el cuerpo se analizó y se volvió a serializar antes de verificarlo, se usó toda la cadena whsec_ como clave en lugar de sus bytes decodificados de base64, o el secreto es de otro endpoint.

### ¿Qué es Standard Webhooks?

Una especificación abierta para firmar webhooks con HMAC-SHA256 y las cabeceras webhook-id, webhook-timestamp y webhook-signature. Ikiform la sigue, así que cualquier librería de Standard Webhooks puede verificar las entregas de Ikiform.

### ¿Por qué rechazar marcas de tiempo antiguas?

Si no, una petición capturada seguiría siendo válida para siempre. Rechazar todo lo que se aleje más de 5 minutos de tu reloj frena la repetición de peticiones antiguas, e Ikiform firma cada reintento con una marca de tiempo nueva, así que los reintentos reales siguen pasando.

### ¿Qué pasa con las entregas cuando roto el secreto?

Durante 24 horas cada entrega se firma con el secreto antiguo y con el nuevo, así que puedes desplegar el nuevo sin perder eventos. Después, solo funciona el secreto nuevo.

### ¿Mi handler debe devolver 401 o 400 ante una firma incorrecta?

Cualquiera de los dos sirve para Ikiform; cualquier estado distinto de 2xx cuenta como fallo y se reintenta. No devuelvas 410, que desactiva el endpoint de inmediato.

## Más guías

- [Node.js](https://ikiform.com/es/guides/webhook-signature-verification/nodejs.md): Verifica las firmas de los webhooks de Ikiform en Node.js: cuerpos sin procesar en Express y Fastify, el SDK, el paquete standardwebhooks y una versión probada con node:crypto.
- [Python](https://ikiform.com/es/guides/webhook-signature-verification/python.md): Verifica las firmas de los webhooks de Ikiform en Flask, FastAPI y Django, con el paquete standardwebhooks o la librería estándar, más un test de pytest.
- [Go net/http](https://ikiform.com/es/guides/webhook-signature-verification/go.md): Verifica las firmas de los webhooks de Ikiform en Go con net/http: lee el cuerpo una vez, compruébalo con crypto/hmac, pruébalo o usa la librería de Standard Webhooks.
- [PHP](https://ikiform.com/es/guides/webhook-signature-verification/php.md): Verifica las firmas de los webhooks de Ikiform en PHP puro con hash_hmac y hash_equals, y en Laravel con la librería de Standard Webhooks y una excepción de CSRF.
- [Ruby](https://ikiform.com/es/guides/webhook-signature-verification/ruby.md): Verifica las firmas de los webhooks de Ikiform en Sinatra con OpenSSL y un test de Minitest, y en Rails con la gema standardwebhooks y request.raw_post.
- [Lista de espera para un nuevo SaaS](https://ikiform.com/es/guides/waitlist.md): Crea una lista de espera previa al lanzamiento en tu propia landing page: el formulario, el antispam, los duplicados, los emails de confirmación y una exportación limpia el día del lanzamiento.
- [Formulario de contacto en un sitio estático](https://ikiform.com/es/guides/contact-form-static-site.md): Añade un formulario de contacto que funcione a un sitio estático sin servidor: a dónde va el POST, el spam, las respuestas por email y código para cada host y framework.
- [Envía un formulario a tu email](https://ikiform.com/es/guides/send-form-to-email.md): Recibe cada envío de formulario en tu bandeja sin servidor de correo: por qué fallan mailto: y mail() de PHP, y una configuración que filtra spam y te deja responder.
