# Webhooks

> Payloads de webhook firmados, verificación de la firma HMAC-SHA256, el calendario de reintentos y los registros de entrega.

Los webhooks mandan cada envío aceptado a tu servidor en cuanto llega. Las entregas se firman con el esquema [Standard Webhooks](https://www.standardwebhooks.com), se reintentan durante unos tres días y quedan registradas intento a intento en el panel. Tienes handlers listos en [Recibir webhooks](https://ikiform.com/es/docs/guides/webhook-handlers.md).

## Añade un endpoint

1. En el panel, abre **Webhooks** y elige **Add endpoint**.
2. Escribe una URL HTTPS y elige un formulario, o todos los formularios del espacio de trabajo.
3. Copia el secreto de firma. Empieza con `whsec_` y solo se muestra completo una vez.
4. Envía un evento de prueba para comprobar tu handler.

### URL de endpoints

- Solo `https:`, en el puerto predeterminado 443, sin usuario ni contraseña en la URL, de hasta 2048 caracteres.
- Un nombre de host público. Se rechazan las direcciones IP, `localhost` y los nombres que terminan en `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa` o `.onion`, igual que ikiform.com, workers.dev y pages.dev.
- El nombre de host solo debe resolver a direcciones públicas. Se rechazan los rangos privados, de loopback, link-local y de NAT de operador.
- La URL se comprueba al guardarla y otra vez antes de cada entrega. Un endpoint que empieza a resolver a una dirección privada se desactiva.
- Un espacio de trabajo puede tener hasta 3 endpoints. Enterprise no tiene límite.

## Eventos

| Tipo | Se envía cuando | Se reintenta |
| --- | --- | --- |
| `submission.created` | Se acepta un envío, o un envío marcado como spam se marca como no spam | Sí |
| `endpoint.test` | Eliges **Send test** en la página del endpoint | No, un solo intento |

El spam nunca dispara un webhook. Tampoco los envíos retenidos porque el espacio de trabajo superó su límite mensual.

## La petición

Cada entrega es un `POST` con un cuerpo JSON y tres cabeceras de firma, enviado desde el servicio de entregas de Ikiform con el user agent `Ikiform-Webhooks/1.0`.

`Cabeceras`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| Cabecera | Valor |
| --- | --- |
| `webhook-id` | El id de la entrega. Es el mismo en cada reintento de la misma entrega, así que úsalo para ignorar duplicados |
| `webhook-timestamp` | Segundos Unix en que se firmó este intento. Cambia en cada intento |
| `webhook-signature` | `v1,` más un HMAC-SHA256 en base64. Varios valores separados por espacios mientras se rota un secreto |

### Payloads

**submission.created** (`Body`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`Body`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "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."
  }
}
```

**Truncado** (`Over 256 KB`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| Campo | Descripción |
| --- | --- |
| `id` | El id de la entrega, igual que `webhook-id` |
| `type` | `submission.created` o `endpoint.test` |
| `timestamp` | Cuándo se creó la entrega, en ISO 8601 UTC. Es el mismo en cada reintento |
| `api_version` | La versión del payload con la que se creó el endpoint, actualmente `2026-10-17` |
| `data.submission_id` | El id del envío. Obtenlo con la [API REST](https://ikiform.com/es/docs/api.md#get-submission) |
| `data.form` | El `id` y el `name` del formulario |
| `data.fields` | Los valores guardados. Con campos definidos, son los valores validados y convertidos |
| `data.meta` | Las mismas claves que el `meta` de la API REST. Todas las claves están presentes y cualquier valor puede ser `null`. La IP del visitante y su hash nunca se envían |
| `data.meta.user_agent` | El user agent del navegador, de hasta 512 caracteres |
| `data.meta.origin`, `referrer` | De dónde vino el envío, cuando el navegador los envió |
| `data.meta.country` | Código de país de dos letras, de Cloudflare |
| `data.meta.subject`, `reply_to` | De los campos `_subject` y `_replyto` |
| `data.created_at` | Cuándo se guardó el envío |
| `data.truncated` | `true` cuando el cuerpo superaría los 256 KB, en cuyo caso `fields` está vacío |

Cualquier valor de `meta` puede ser `null`. Trata los valores de los campos como entrada no confiable, igual que un envío de formulario a tu propio servidor.

## Verifica la firma

La firma es un HMAC-SHA256 de `{webhook-id}.{webhook-timestamp}.{raw body}`, con la parte del secreto después de `whsec_` decodificada de base64 como clave, luego codificado en base64 y enviado después de `v1,` en `webhook-signature`.

- Usa el cuerpo de la petición sin procesar, exactamente como llegó. Analizar y volver a serializar el JSON cambia los bytes.
- La cabecera puede contener varias firmas separadas por espacios. Acepta la petición si coincide cualquier firma `v1`.
- Compara en tiempo constante y rechaza las marcas de tiempo que se alejen más de 5 minutos de la hora actual.

### Con el SDK

`verifyWebhook` hace todo lo anterior con Web Crypto y devuelve el evento ya analizado, o lanza `WebhookVerificationError` con un `reason`. Acepta un objeto `Headers` o un objeto simple como el `req.headers` de Node.

`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!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### Sin el SDK

Cualquier librería de Standard Webhooks también funciona. A mano son unas pocas líneas:

**Node** (`verify.js`)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify(body: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### Verificador de firmas

El verificador interactivo de esta página comprueba un secreto, las tres cabeceras del webhook y un cuerpo sin procesar con el `verifyWebhook` del SDK, todo en el navegador. También puede generar una entrega de ejemplo firmada correctamente. Nada de lo que pegues sale de la página.

## Respuesta

| Tu respuesta | Qué hace Ikiform |
| --- | --- |
| Cualquier `2xx` en menos de 15 segundos | Marca la entrega como correcta |
| `410 Gone` | Marca la entrega como fallida y desactiva el endpoint al instante |
| `429` o `503` con `Retry-After` | Reintenta después de ese tiempo, pero no más tarde que el siguiente paso del calendario |
| Una redirección | No se sigue. Cuenta como un fallo |
| Cualquier otro estado, un timeout o un error de red | Reintenta según el calendario de abajo |

Haz el trabajo lento después de responder, por ejemplo en una cola. Los primeros 4 KB del cuerpo de tu respuesta se guardan en el registro de entregas.

## Reintentos

Un intento fallido se reintenta según este calendario, cada espera con hasta un 20% de jitter aleatorio en cada sentido. Después del 9.º intento, unos 3 d 6 h después del primero, la entrega se marca como fallida.

| Intento | Espera desde el intento anterior | Tiempo desde el primer intento |
| --- | --- | --- |
| 1 | Al instante | 0 |
| 2 | 5 s | 5 s |
| 3 | 5 min | 5 min |
| 4 | 30 min | 35 min |
| 5 | 2 h | 2 h 35 min |
| 6 | 5 h | 7 h 35 min |
| 7 | 10 h | 17 h 35 min |
| 8 | 1 d | 1 d 18 h |
| 9 | 1 d 12 h | 3 d 6 h |

### Endpoints desactivados

Si todas las entregas a un endpoint fallan durante 5 días, el endpoint se desactiva y los propietarios del espacio de trabajo reciben un email. Una respuesta `410 Gone` o un destino que pasa a ser privado lo desactiva al instante. Vuelve a activarlo desde la página del endpoint cuando esté arreglado.

## Registros de entrega

La página de cada endpoint enumera sus entregas con su estado. Despliega una para ver cada intento: cuándo se ejecutó, si fue automático, manual o una prueba, el código de estado, la duración y los primeros 4 KB de tu respuesta o el error. Reintenta cualquier entrega desde ahí. Los intentos se conservan 30 días y las entregas 90 días.

## Rotar secretos

**Roll signing secret** crea un secreto nuevo. Durante las siguientes 24 horas, cada entrega se firma con el secreto antiguo y con el nuevo, así que `webhook-signature` contiene dos valores y puedes desplegar el secreto nuevo sin perder eventos.
