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, se reintentan durante unos tres días y quedan registradas intento a intento en el panel. Tienes handlers listos en Recibir webhooks.
Añade un endpoint
- En el panel, abre Webhooks y elige Add endpoint.
- Escribe una URL HTTPS y elige un formulario, o todos los formularios del espacio de trabajo.
- Copia el secreto de firma. Empieza con
whsec_y solo se muestra completo una vez. - 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,
localhosty los nombres que terminan en.local,.internal,.intranet,.lan,.home.arpao.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.
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
{
"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
}
}| 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 |
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
HMAC-SHA256, con el secreto decodificado de base64 después de whsec_ como clave
webhook-signature: v1,base64(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.
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:
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);
});
}Verificador de firmas
Usa verifyWebhook del SDK con Web Crypto en tu navegador. Nada de lo que pegues sale de esta 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.
- 1ahora0 s
- 25 s5 s
- 35 min5 min
- 430 min35 min
- 52 h2 h 35 min
- 65 h7 h 35 min
- 710 h17 h 35 min
- 81 d1 d 18 h
- 91 d 12 h3 d 6 h
Las barras oscuras muestran la espera más corta y las claras la más larga, con un jitter del 20% en cada sentido. Después del intento 9, la entrega se marca como fallida.
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.