Webhooks de autocuración: Usando LLMs locales para reintentar y corregir automáticamente cargas útiles fallidas en el Tunnel Edge
> Deja de permitir que las fallas en los webhooks lleguen a colas de mensajes muertas. Aprende cómo usar LLMs locales en el tunnel edge para reparar y corregir automáticamente cargas útiles fallidas en tiempo real.

Quick answer
> Webhooks de autocuración: Corrección de cargas útiles de API fallidas: quick comparison answer
Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.
Which tunnel tool is best for public webhook testing?
Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.
When should I choose a private network tool instead?
Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.
Los webhooks mantienen sincronizados los sistemas modernos, desde eventos de pago y notificaciones de repositorios hasta buses de eventos internos. También fallan de maneras que son tediosas de depurar. En desarrollo local, la falla generalmente no es la red. Tu manejador rechaza una carga útil porque un campo tiene el tipo incorrecto, falta una propiedad requerida, o la versión de la API del proveedor ya no coincide con lo que tu código espera.
Este artículo describe un patrón en tiempo de desarrollo: un pequeño proxy se sitúa entre tu tunnel y tu app, captura fallos de validación (400/422), solicita a un local modelo de lenguaje que repare la carga útil, y reintenta una vez. Cubre qué puede y qué no puede arreglar, cómo interactúa con las firmas de webhook, y dónde no es la herramienta adecuada. Los hechos sobre proveedores y herramientas fueron verificados contra su documentación en octubre de 2026. Cambian, así que re-verifica antes de confiar en ellos.
1. Por qué fallan los webhooks en desarrollo local
Modos comunes de fallo
- Desviación en la versión de la API. Stripe fija las cargas útiles de webhook a una versión de API. Según documentación de versionado de Stripe, los eventos usan la versión establecida cuando se creó el endpoint del webhook, o la predeterminada de la cuenta si no se estableció ninguna. Las versiones de Stripe son basadas en fechas y ahora llevan nombres de versiones, por ejemplo
2024-12-18.acacia,2025-08-27.basil, y2026-09-30.endive. Las versiones mensuales dentro de una versión nombrada son compatibles hacia atrás, mientras que una nueva versión mayor puede incluir cambios que rompen. Un fallo común en local es una incompatibilidad: tu SDK fija una versión, mientras que el endpoint, y por ende la carga útil, usa otra. - Incompatibilidades de tipos. Un ID o monto llega como cadena cuando tu validador espera un entero, o viceversa.
- Campos faltantes o renombrados. Un campo requerido está ausente, o una clave obsoleta ha sido reemplazada.
- Cuerpos mal formados. Raro en la práctica, pero un JSON truncado o mal escapado fallará al parsear antes de que se ejecute cualquier chequeo de esquema.
- Estrictez local. Tu manejador de desarrollo puede validar más estrictamente que los ejemplos de payloads del proveedor contra los cuales fue escrito.
Qué hacen los proveedores cuando tu endpoint rechaza un evento
| Proveedor | Reintentos automáticos | Recuperación manual |
|---|---|---|
| Stripe | En modo en vivo, hasta 3 días con retroceso exponencial. En sandbox, tres intentos en unas horas. Stripe reintenta en respuestas no-2xx. | stripe events resend <event_id> --webhook-endpoint=<endpoint_id> funciona para eventos hasta 30 días de antigüedad, según la documentación de Stripe. |
| GitHub | Ninguno. GitHub no reentrega automáticamente entregas fallidas. | Reentrega desde la lista de “Entregas recientes” del webhook, o vía API REST, para entregas de los últimos 3 días. |
Dos detalles importan en este diseño. Primero, reintentar un evento cuya carga útil no es compatible con tu manejador produce el mismo rechazo cada vez, así que un 422 causado por un desajuste de esquema no se arreglará solo. Segundo, no existe una “cola de mensajes muertos” universal en el proveedor. Una DLQ es algo que tú construyes, o que obtienes de un gateway de webhooks. GitHub, por ejemplo, simplemente registra el fallo.
El tiempo también importa. GitHub espera una respuesta 2xx dentro de 10 segundos y considera una respuesta más lenta como una entrega fallida. Stripe te indica devolver un 2xx rápidamente. Cualquier método que añada una llamada al modelo en la ruta de la solicitud debe respetar estos límites.
Prueba primero las herramientas más simples
Antes de agregar un modelo, considera lo que ya existe:
- Repetir. El Inspector de Tráfico de ngrok te permite repetir una solicitud capturada en lugar de esperar a que el proveedor reintente. La CLI de Hookdeck reenvía eventos a localhost, mantiene en cola los eventos fallidos, y te permite reintentarlos desde su panel o interfaz de terminal. La CLI de Stripe puede reenviar eventos a un endpoint local con
stripe listen --forward-to localhost:4242/webhook, sin necesidad de túnel. - Transformaciones. Las transformaciones de Hookdeck modifican la estructura y encabezados del payload en tránsito. Esto es la versión determinista de lo que construye este artículo.
- Corregir el manejador. Si el payload del proveedor es correcto y tu código está mal, la corrección adecuada está en tu código.
Un parche con LLM es útil en un caso más estrecho. Quieres seguir probando un flujo con un payload que no puedes regenerar fácilmente, y quieres un diff registrado que muestre exactamente cómo difiere el payload de tu esquema.
2. Arquitectura: un proxy de sanación entre el tunnel y tu app
Un tunnel transporta tráfico desde una URL pública a tu máquina. Ni ngrok ni cloudflared están diseñados para llamar a un modelo de lenguaje por ti. La política de tráfico de ngrok está organizada en torno a reglas y acciones como verify-webhook, y cloudflared es un túnel de propósito general. Así que el paso de reparación pertenece en un pequeño proxy que tú controlas, colocado entre el agente del tunnel y tu aplicación:
[ Proveedor de Webhook ]
│ POST /webhook
▼
[ URL pública del tunnel (ngrok / cloudflared / otro) ]
│
▼
[ Proxy de sanación :3001 ] ──(en 400/422)──► [ Ollama :11434 ]
│ │
│◄────────── JSON corregido ───────────────┘
▼
[ Tu app :3000 ]
Apuntas el tunnel al proxy en lugar de a la app, por ejemplo ngrok http 3001 o cloudflared tunnel --url http://localhost:3001.
Ciclo de vida de la solicitud
- El proxy reenvía la solicitud original, sin cambios, a tu app.
- Si la app responde
2xx, o cualquier estado distinto de400o422, el proxy devuelve esa respuesta tal cual. - En
400/422con cuerpo JSON, el proxy envía la carga útil y el texto de error de la app a un modelo local. - El proxy verifica la salida del modelo contra las guardas (sección 5). Si falla, descarta la corrección y devuelve el error original.
- Si pasa, reintenta una vez con el cuerpo corregido y devuelve la respuesta si tiene éxito.
- Registra la diferencia, para que puedas corregir la verdadera discrepancia en tu código o esquema.
Esto funciona mejor en capas. Si usas ngrok, su acción verify-webhook puede validar la firma del proveedor en el borde, para más de 50 proveedores, y rechaza fallos con un 403 antes de que lleguen a tu máquina. Verifica primero, luego sana.
3. Elegir y limitar el modelo local
Tiempo de ejecución y modelos
Ollama ofrece una API REST en http://localhost:11434 por defecto, y se enlaza a 127.0.0.1 a menos que cambies OLLAMA_HOST. Mantenlo en loopback. Para esta tarea estrecha, modelos pequeños son suficientes. Tamaños según el README de Ollama para opciones comunes:
| Modelo | Parámetros | Tamaño de descarga |
|---|---|---|
| Llama 3.2 | 3B | aproximadamente 2.0 GB |
| Phi-4 Mini | 3.8B | aproximadamente 2.5 GB |
| Gemma 3 | 4B | aproximadamente 3.3 GB |
| Llama 3.1 | 8B | aproximadamente 4.7 GB |
Ten en cuenta que “Llama 3” propiamente tiene tamaños de 8B y 70B. Los tamaños de 1B y 3B corresponden a Llama 3.2. Phi-3 todavía está en la librería, pero ha sido reemplazado por Phi-4 y Phi-4 Mini, y la librería sigue añadiendo familias como Gemma 4 y Qwen 3.5. Ejecuta ollama list y prueba dos o tres candidatos con tus cargas útiles, porque la elección correcta depende de tu hardware.
Limitar la salida, y validarla igual
El parámetro format de Ollama acepta un esquema JSON, que hace que la generación en tiempo de ejecución se limite a esa forma. La documentación de salidas estructuradas también recomienda pasar el esquema en el texto del prompt para enmarcar el modelo. Nota que las salidas estructuradas no son soportadas en el servicio en la nube de Ollama, así que esto aplica solo en ejecuciones locales. llama.cpp ofrece la misma capacidad mediante un campo json_schema en la solicitud o una gramática GBNF escrita a mano.
El decodificado restringido garantiza que la salida se analice y coincida con el esquema. No garantiza que el contenido sea correcto. Algunos puntos prácticos:
format: "json"solo pide JSON. Con modelos pequeños, pasa un esquema real donde tengas uno, y valida el resultado contra él.- Establece
temperature: 0y unaseedfija. Esto reduce la variación entre ejecuciones, pero no la elimina completamente. - Un esquema le dice al modelo qué forma está permitida. No le dice qué valor es verdadero.
4. Un proxy de sanación mínimo
El boceto abajo es en Node.js sin dependencias (v20 o superior). Es una ilustración, no código en producción. Lo probé con una app simulada y un modelo simulado para verificar su flujo: una sanación exitosa, y un parche vetado. Debes probarlo con tu modelo Ollama real y tu manejador.
// heal-proxy.mjs
import http from "node:http";
import crypto from "node:crypto";
const APP = process.env.APP_URL ?? "http://127.0.0.1:3000";
const OLLAMA = process.env.OLLAMA_URL ?? "http://127.0.0.1:11434";
const MODEL = process.env.HEAL_MODEL ?? "llama3.2";
const DEV_SECRET = process.env.DEV_SIGNING_SECRET ?? "dev-only-secret";
const PORT = Number(process.env.PORT ?? 3001);
const HEALABLE = new Set([400, 422]);
const PROTECTED = [/^id$/i, /_id$/i, /amount/i, /uuid/i]; // pueden ser retipados, nunca reasignados
const SYSTEM = `Eres responsable de reparar cargas útiles JSON de webhook. Recibes {"payload": ..., "validation_error": ...}.
Solo cambia los campos indicados en validation_error. Nunca cambies identificadores, montos o marcas de tiempo.
Responde solo con la carga útil corregida en JSON y nada más.`;
const readBody = (req) =>
new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (c) => chunks.push(c)).on("end", () => resolve(Buffer.concat(chunks))).on("error", reject);
});
function forward(path, method, headers, body) {
const h = { ...headers };
for (const k of ["host", "content-length", "connection"]) delete h[k];
return fetch(APP + path, { method, headers: h, body });
}
function leaves(v, path = "", out = {}) {
if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) leaves(x, path ? `${path}.${k}` : k, out);
else out[path] = v;
return out;
}
// Devuelve una razón si el parche debe ser rechazado, o null.
function guard(original, patched, errorText) {
const a = leaves(original), b = leaves(patched);
const changed = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((p) => a[p] !== b[p]);
if (changed.length === 0) return "el modelo devolvió una carga útil sin cambios";
for (const p of changed) {
const leaf = p.split(".").pop();
const isProtected = PROTECTED.some((re) => re.test(leaf));
if (isProtected && !(p in a && p in b && String(a[p]) === String(b[p]))) return `campo protegido tocado: ${p}`;
if (!errorText.includes(leaf)) return `${p} no mencionado en el error de validación`;
}
return null;
}
async function heal(payload, errorText) {
const res = await fetch(`${OLLAMA}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: MODEL,
stream: false,
format: "json",
options: { temperature: 0, seed: 7 },
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: JSON.stringify({ payload, validation_error: errorText }) },
],
}),
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
return JSON.parse(data.message.content);
}
http
.createServer(async (req, res) => {
const raw = await readBody(req);
let upstream = await forward(req.url, req.method, req.headers, raw);
let body = Buffer.from(await upstream.arrayBuffer());
if (HEALABLE.has(upstream.status) && /json/.test(req.headers["content-type"] ?? "")) {
try {
const original = JSON.parse(raw.toString("utf8"));
const errorText = body.toString("utf8").slice(0, 2000);
const patched = await heal(original, errorText);
const veto = guard(original, patched, errorText);
if (veto) {
console.warn("[heal] vetoed:", veto);
} else {
const patchedRaw = Buffer.from(JSON.stringify(patched));
const headers = { ...req.headers, "x-auto-patched": "true" };
for (const k of ["stripe-signature", "x-hub-signature", "x-hub-signature-256"]) delete headers[k];
headers["x-dev-signature"] =
"sha256=" + crypto.createHmac("sha256", DEV_SECRET).update(patchedRaw).digest("hex");
const retry = await forward(req.url, req.method, headers, patchedRaw); // exactamente un reintento
console.log("[heal] estado del reintento", retry.status, "parche:", JSON.stringify(patched));
if (retry.ok) {
upstream = retry;
body = Buffer.from(await retry.arrayBuffer());
}
}
} catch (err) {
console.warn("[heal] saltado:", err.message);
}
}
const out = {};
upstream.headers.forEach((v, k) => {
if (!["content-encoding", "content-length", "transfer-encoding", "connection"].includes(k)) out[k] = v;
});
res.writeHead(upstream.status, out).end(body);
})
.listen(PORT, "127.0.0.1", () => console.log(`proxy de sanación en :${PORT} -→ ${APP}`));
Qué hacen las guardas
- Un solo reintento. No hay bucle. Si la solicitud corregida también falla, se devuelve el fallo original, y el proveedor ve lo que habría visto de todos modos.
- Campos protegidos. Los identificadores y montos pueden ser retipados (
"4900"a4900) pero nunca reasignados. - Ediciones basadas en errores. El modelo solo puede tocar campos indicados en tu error de validación. Esto bloquea reescrituras creativas de datos no relacionados.
- Un límite de tiempo. La llamada al modelo se aborta después de 8 segundos, y cualquier fallo pasa al respuesta original.
- Solo en loopback. El proxy escucha en
127.0.0.1, y el agente del tunnel corre en la misma máquina.
5. Prompting y un ejemplo práctico
El prompt del sistema arriba es deliberadamente corto y restrictivo. Ten en cuenta tres cosas al adaptarlo:
- Describe la tarea como edición, no generación: “cambia solo los campos indicados en el error”.
- Da al modelo el texto de error real de tu app. Es la señal más confiable que tienes.
- Cuando puedas, pasa tu esquema JSON real en el campo
formaten lugar de confiar solo en el prompt.
Aquí un evento personalizado hipotético (no un evento real de Stripe) rechazado por un manejador local:
{
"event": "subscription.updated",
"data": {
"customer_id": "cust_99281",
"amount_due": "4900",
"status": "active"
}
}
El manejador responde con 422: ValidationError: amount_due must be an integer, received string. Missing required field 'currency'. En mi prueba, el proxy produjo este cuerpo corregido y el reintento tuvo éxito:
{
"event": "subscription.updated",
"data": {
"customer_id": "cust_99281",
"amount_due": 4900,
"status": "active",
"currency": "usd"
}
}
Las dos ediciones no son igualmente confiables. Convertir "4900" a 4900 es mecánico. El valor de currency es una suposición. El modelo no tenía información de que la moneda es USD, y la guarda permitió la edición solo porque el error mencionaba el campo. En un sandbox de desarrollo puede estar bien. Para cualquier pago, los valores inventados son exactamente lo que no quieres. Dos mitigaciones siguen:
- Haz las correcciones mecánicas sin modelo. La coerción de tipos contra un esquema JSON es determinista y puede hacerse primero. Usa el LLM solo para lo que reste.
- No dejes que el modelo invente valores requeridos. O proporciona valores predeterminados desde la configuración, para que una persona decida qué significa una
currencyfaltante, o rechaza el parche y muestra la diferencia para revisión.
6. Firmas: la parte que se rompe si la ignoras
Los proveedores firman los bytes crudos exactos del cuerpo de la solicitud, así que cualquier cambio invalida la firma.
- Stripe envía un encabezado
Stripe-Signaturecon format=<timestamp>,v1=<signature>. La firma es un HMAC-SHA256 sobre el timestamp y el cuerpo crudo. Las librerías cliente también rechazan eventos cuyo timestamp está fuera de una ventana de tolerancia, comúnmente cinco minutos. - GitHub envía
X-Hub-Signature-256, un digest HMAC-SHA256 en hexadecimal con prefijosha256=. El encabezadoX-Hub-Signaturemás antiguo usa SHA-1 y solo se mantiene por razones de compatibilidad.
Por esto, un proxy de sanación no puede pasar la firma original con un cuerpo modificado. Un patrón más seguro que desactivar la verificación es:
- Verificar la original en el borde, contra el cuerpo crudo sin modificar. La acción
verify-webhookde ngrok hace esto y también verifica el timestamp para prevenir replays. Alternativamente, verificar en el proxy antes de sanar. - Sanar, pero solo después de verificar que la éxito.
- Eliminar los encabezados de firma del proveedor en la solicitud corregida y agregar tu propia firma de desarrollo sobre el nuevo cuerpo, además de un encabezado marcador como
X-Auto-Patched: true. El esquema arriba hace esto. - Aceptar solo la firma de desarrollo en desarrollo. Tu manejador debe verificar la firma del proveedor normalmente, y aceptar la firma de desarrollo solo cuando se ejecuta localmente. Nunca envíes esa omisión a staging o producción.
Esto mantiene la propiedad que importa: solo el tráfico autenticado del proveedor puede ser sanado.
7. Seguridad, privacidad y rendimiento
Qué protege y qué no con “local”
Ejecutar el modelo localmente significa que las cargas fallidas no se envían a una API de IA de terceros. Eso es una ventaja real cuando las cargas contienen datos personales. Pero no es toda la historia:
- El proveedor del túnel aún transporta la carga entre el proveedor y tu máquina, y puede registrarla dependiendo de sus configuraciones.
- El proxy y el modelo corren con los permisos de tu usuario, así que trata la máquina como parte del límite de confianza.
- Mantén Ollama ligado a loopback. Enlazarlo a
0.0.0.0expone un servidor de modelos sin autenticación a tu red. - Si enviar datos a un LLM alojado plantea cuestiones de cumplimiento (GDPR, SOC 2, HIPAA), depende de tus contratos y clasificación de datos. Es una pregunta a hacer, no una violación automática. Usar datos sintéticos o en modo prueba en desarrollo evita la pregunta.
Las cargas útiles son entrada no confiable
El cuerpo de un webhook es texto influenciado por atacantes. Si un campo string contiene instrucciones, un modelo puede seguirlas. Las restricciones de salida, el guardián de diferencias a nivel de campo, y la lista de campos protegidos reducen el riesgo, pero no lo eliminan. No des herramientas al modelo, y nunca ejecutes ni interpolés su salida en ningún lugar que no sea el JSON solicitado.
Latencia y timeouts
- Cargar un modelo frío toma tiempo. Ollama mantiene un modelo en memoria por cinco minutos después del último uso por defecto, y
keep_alivecambia eso. - GitHub te da 10 segundos en total, así que programa la llamada al modelo en consecuencia. El esquema aborta a los 8 segundos.
- Si tu manejador tarda mucho, considera reconocer inmediatamente al proveedor con un
2xxy correr el proceso de sanación y reintento en segundo plano. La desventaja es que el proveedor ya no ve tu resultado real. Para desarrollo local, eso suele estar bien.
Interruptor de circuito
Limita la sanación a un intento por entrega, como arriba. Si recibes fallos repetidos del mismo evento, deja de sanarlo e investiga. En ese momento, el log de diferencias te indica que tu código o esquema está desactualizado.
8. Dónde encaja este patrón, y dónde no
Encaja cuando:
- desarrollas contra un proveedor y necesitas mantener un flujo pese a un desajuste en la carga útil;
- quieres evidencia automática y registrada de cómo difieren las cargas del proveedor de tu esquema;
- las correcciones son pequeñas y estructurales (tipos, claves renombradas, campos opcionales ausentes).
No encaja cuando:
- el sistema maneja dinero real, derechos, o decisiones de seguridad. Nunca “sanes” cargas en producción;
- la corrección real es fijar la versión de la API del endpoint y actualizar deliberadamente. Con Stripe, prueba una nueva versión antes de comprometerte, y haz que la versión del webhook coincida con tu SDK;
- una transformación determinista (coerción de esquema, o una transformación en gateway como Hookdeck) haría el trabajo sin riesgo de modelo.
El beneficio más duradero es el log de diferencias. Cada parche registrado es un registro preciso y timestamped de dónde un API superior y tu código discrepan. Convierte esas entradas en tickets, actualiza el manejador o esquema, y eventualmente la sanación no tendrá nada que hacer.
9. Conclusión
Las fallas en webhooks en desarrollo son mayormente desajustes entre lo que envía un proveedor y lo que acepta tu manejador. Los proveedores no los repararán por ti. Stripe reintenta el mismo payload hasta tres días en modo en vivo, y GitHub no reintenta en absoluto. Un pequeño proxy que captura respuestas 400/422, pide a un modelo local una corrección mínima, limitada por esquema, verifica esa corrección con guardas estrictas, reintenta una vez, y registra la diferencia puede mantener un ciclo de desarrollo en marcha.
Es una conveniencia para desarrollo, no un sustituto de manejadores correctos, versiones de API fijadas, o verificación de firmas. Verifica primero el original, mantiene la autoridad del modelo limitada, trata todo lo que produce como una suposición, y usa las diferencias que genera para arreglar el problema real.
Fuentes
- Stripe: Webhooks y reintentos de eventos
- Stripe: Versionado de API
- GitHub: Mejores prácticas para usar webhooks
- GitHub: Reenvío de webhooks
- GitHub: Validación de entregas de webhook
- ngrok: Acción de Verificación de tráfico de Webhook
- ngrok: Recibir e inspeccionar webhooks localmente
- Ollama: Salidas estructuradas
- Ollama: Biblioteca de modelos y README
- llama.cpp: Gramáticas GBNF
- Hookdeck: Conceptos básicos y transformaciones
- Hookdeck CLI
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.