1. Webhooks en ChatGPT App: quién llama a quién
En el mundo HTTP clásico todo es sencillo: tú eres el cliente, haces un POST a /api/..., el servidor responde y todos contentos. Con los webhooks es al revés: un servicio externo inicia por sí mismo una petición HTTP a tu backend cuando ocurre algo fuera de tu sistema.
En el ecosistema de ChatGPT Apps esto aparece en varios escenarios típicos. Por ejemplo, GiftGenius, tras crear un checkout vía ACP/Instant Checkout, recibe del proveedor de pagos una notificación payment_succeeded por webhook. O un servicio en background que genera imágenes de vista previa para regalos te envía image_ready cuando termina el renderizado. En estos casos ChatGPT y tu servidor MCP ya hicieron su parte; la pelota está en el tejado del tercer servicio, que te comunica el resultado a través de un webhook.
La clave: la iniciativa está fuera de tu sistema. La solicitud puede llegar en cualquier momento y tantas veces como sea. Por eso hay que pensar en el manejador del webhook como en el posible punto más expuesto — ahí toca todo Internet.
Pequeña tabla para contrastar:
| Tipo de llamada | Quién inicia | Ejemplo en GiftGenius |
|---|---|---|
| Petición API normal | Tú | El servidor MCP llama a la API de Stripe |
| Webhook | Mundo externo | Stripe envía payment_succeeded a ti |
2. Esquema simple: dónde está ChatGPT, dónde MCP y dónde el webhook
Esquemáticamente, el flujo se ve así:
sequenceDiagram
participant User as Usuario en ChatGPT
participant GPT as ChatGPT + modelo
participant App as GiftGenius (MCP/App)
participant PSP as Pasarela de pago (Stripe/ACP)
User->>GPT: "Quiero comprar un regalo"
GPT->>App: callTool(create_checkout)
App->>PSP: POST /checkout_sessions
PSP-->>App: 200 OK + checkout_session_id
App-->>GPT: ToolOutput (información de checkout)
PSP-->>App: POST /webhooks/payment_succeeded
App-->>PSP: 200 OK (evento aceptado)
App->>DB: marcar el pedido como pagado
La primera parte son solicitudes salientes normales, que ya sabes hacer. El webhook es la parte inferior del esquema, donde la pasarela de pago te llama a ti. Eso es lo que nos interesa hoy.
3. Manejador básico de webhook en Next.js (esqueleto)
Seguimos desarrollando nuestro GiftGenius de aprendizaje en Next.js 16. En la plantilla tenemos app/ con el UI y app/mcp/route.ts con el servidor MCP.
Es lógico extraer el manejador del webhook a una ruta HTTP independiente, por ejemplo: app/api/webhooks/commerce/route.ts.
El esqueleto mínimo luce así:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // 1. Leemos el cuerpo como texto
const headers = Object.fromEntries(req.headers); // 2. Tomamos las cabeceras
// 3. TODO: validación de la firma (lo añadiremos enseguida)
// 4. TODO: parseo de JSON y manejo del evento
return new Response("ok", { status: 200 }); // 5. Respondemos rápido 2xx
}
Aquí ya hay varias ideas importantes.
Primero, leemos el cuerpo como texto, no directamente con await req.json(). Muchos proveedores firman precisamente el flujo de bytes «crudo» del cuerpo y, si lo parseas (y más aún si lo reformatas) antes de comprobar la firma, esta ya no coincidirá.
Segundo, pensamos desde el principio en responder rápido con 2xx. Es mejor mover el trabajo pesado a un worker aparte o al menos a una función async después de registrar el evento en logs. Esto está directamente relacionado con los tiempos de espera y los reintentos, de los que hablaremos más adelante.
4. Firma de webhooks: cómo distinguir «Stripe» de «un tipo con curl»
Recordemos el TODO del esqueleto del manejador del webhook — «validación de la firma». Veamos cómo distinguir al Stripe real de «un tipo con curl».
El mayor acto de ingenuidad es pensar que, si la URL es compleja (/api/webhooks/stripe/super-secret-abc123), nadie la va a encontrar. Los secretos en la URL son, en esencia, security through obscurity: intentar esconderse tras una URL complicada, lo que ofrece una protección muy débil. La línea de defensa correcta es la firma criptográfica.
Prácticamente todos los proveedores serios (Stripe, ACP, muchos CRM) calculan una firma HMAC sobre el cuerpo de la petición y el momento de envío, y colocan el resultado en una cabecera. Tú, como receptor, haces lo mismo y comparas. Si no coincide ni un poco — descartas la petición como falsificación.
Receta general:
- Tienes un secreto de webhook que obtuviste en el panel del proveedor y guardaste en los secretos de entorno (por ejemplo, STRIPE_WEBHOOK_SECRET en Vercel env).
- El proveedor, al enviar la petición, calcula el HMAC sobre timestamp + '.' + rawBody.
- En una cabecera, por ejemplo Stripe-Signature, incluye el timestamp y una o varias firmas.
- En tu manejador tomas el timestamp, calculas tu HMAC con la misma regla y comparas.
Mini‑ejemplo en TypeScript usando crypto:
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // elegimos el algoritmo
.update(payload, "utf8") // texto crudo del cuerpo
.digest("hex"); // cadena hex
}
Ejemplo de verificación de la firma y frescura del evento:
const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });
const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
return new Response("timestamp too old", { status: 400 });
}
const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
process.env.STRIPE_WEBHOOK_SECRET!,
payload
);
if (!crypto.timingSafeEqual(
Buffer.from(expectedSig, "hex"),
Buffer.from(theirSig, "hex")
)) {
return new Response("invalid signature", { status: 400 });
}
Fíjate en timingSafeEqual — es una protección contra ataques temporales, en los que un atacante intenta adivinar la firma por la duración de la comparación.
Tras la verificación correcta de la firma, ya puedes hacer con tranquilidad JSON.parse(rawBody) o await req.json(), sabiendo que proviene de un proveedor real.
Capas adicionales de defensa como una lista de IP permitidas (allowlist; aceptar peticiones solo desde direcciones del proveedor) y un dominio separado para webhooks no sobran, pero es la firma criptográfica la que te da la confianza en la autenticidad.
5. Tiempos de espera, respuesta rápida y procesamiento asíncrono
A los webhooks les gustan quienes responden rápido. La mayoría de plataformas de pagos y comercio esperan que tu endpoint responda con 2xx en unos pocos segundos (a menudo hasta 10 segundos, a veces menos). Si «piensas» demasiado, consideran la llamada fallida y empiezan a repetir las solicitudes.
A lo bruto, esto sería así: verificas la firma, consultas la base de datos, llamas a otras tres APIs externas, calculas un informe, generas un PDF y solo entonces devuelves 200 OK. Si algo de eso se queda un poco colgado, la pasarela decidirá que el webhook falló y lo enviará otra vez. Resultado: crearás el pedido dos veces, mandarás dos correos, invocarás dos veces alguna herramienta de GPT — y te tocará deshacer el caos.
El patrón correcto es «recibir, registrar, aplazar»:
- Verificar la firma y los invariantes básicos (tipo de evento, campos obligatorios).
- Escribir el evento rápidamente en una tabla/cola (mínimas operaciones de BD).
- Devolver 2xx.
- Procesar el evento en background con un worker aparte.
Ejemplo simplificado de un manejador «medio correcto» sin cola separada, pero con fijación rápida:
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
if (!verifySignature(headers, rawBody)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
await saveWebhookEvent(event); // escritura rápida en BD
// Aquí puedes enviar la tarea al fondo vía setImmediate/queue,
// pero en el ejemplo didáctico nos conformamos con registrarlo:
// la llamamos sin await para que el 200 salga enseguida.
processWebhookEventLater(event).catch(console.error);
return new Response("ok", { status: 200 });
}
Atención: no hacemos await processWebhookEventLater(...). El manejador pone la tarea en background y devuelve 200 de inmediato para no chocar con los timeouts del webhook.
En producción real, en este punto suele aparecer una cola (por ejemplo, una tabla separada webhook_jobs o un servicio externo), y los workers irán procesando los eventos con calma, sin bloquear la recepción de nuevos.
6. Idempotencia y deduplicación: cómo no cobrar dos veces
En los ejemplos didácticos se dibujan flechas ideales: un evento → un procesamiento → pedido feliz. En la vida real los webhooks llegan como gatitos peludos — en paquetes y varias veces seguidas.
Las razones son simples: la red es poco fiable, hay timeouts, y muchos proveedores por diseño reenvían los eventos hasta recibir un 2xx claro. En pagos esto es especialmente importante: mejor reenviar payment_succeeded que perderlo para siempre.
Por eso tu lógica de negocio debe ser idempotente: el reprocesamiento del mismo evento no debe cambiar el resultado (o, al menos, no debe romper el sistema).
Patrón típico:
- El evento tiene un identificador estable, por ejemplo event.id o checkout_session_id.
- Lo guardas en una tabla de eventos procesados y aplicas un índice único a ese campo.
- En cada webhook compruebas primero: si ya existe un registro con ese id y estado «procesado», respondes 200 y no haces nada.
Mini‑ejemplo con pseudo‑ORM:
async function handlePaymentSucceeded(event: any) {
const existing = await db.webhookEvents.findUnique({
where: { providerId: event.id },
});
if (existing?.processedAt) {
return; // ya está todo hecho
}
await db.$transaction(async (tx) => {
await tx.webhookEvents.upsert({
where: { providerId: event.id },
update: { processedAt: new Date() },
create: {
provider: "stripe",
providerId: event.id,
type: event.type,
payload: event,
processedAt: new Date(),
},
});
await tx.orders.update({
where: { checkoutSessionId: event.data.object.id },
data: { status: "PAID" },
});
});
}
Aquí es importante la transacción: marcas el evento como procesado y cambias el pedido a la vez. Si algo falla por el medio, la transacción se deshace y, en el siguiente reintento del webhook, lo intentarás de nuevo sin duplicados.
También es buena práctica hacer idempotente la propia operación, por ejemplo:
- «poner el estado del pedido en PAID» en lugar de «aumentar el saldo en +100»;
- «crear el registro si no existe» en lugar de «añadir otra fila».
7. Validación de datos del webhook y PII: la firma no es el único filtro
Aunque el webhook esté firmado y venga de un servicio real, debes tratar sus datos con la misma desconfianza que la entrada de usuario o los argumentos de herramientas. En la lección anterior ya comentamos que los esquemas y la normalización son tu cortafuegos.
Un esquema para el evento, por ejemplo, puede ser así (a nivel de TypeScript/Zod):
import { z } from "zod";
const paymentSucceededSchema = z.object({
id: z.string(),
type: z.literal("payment_succeeded"),
data: z.object({
object: z.object({
id: z.string(), // checkout_session_id
amount_total: z.number(),
currency: z.string(),
metadata: z.record(z.string(), z.string()).optional(),
}),
}),
});
En el manejador validas así:
const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// a partir de aquí trabajas solo con parsed
Así te proteges de sorpresas como «el proveedor cambió el formato», «en el entorno de test el campo pasó a ser nullable», etc. Si algo no cuadra, registras el error en los logs y devuelves 400; el proveedor reenviará más tarde o enviará una alerta.
También es importante recordar la PII: los cuerpos de los webhooks suelen contener email, dirección de envío e incluso partes de datos de pago (tokenizados). Enmascararlos en los logs y no enviarlos en crudo a servicios APM/log externos es una práctica obligatoria, de la que ya hablamos en el tema de secretos y datos confidenciales.
Y desde luego no debes enviar sin filtro el JSON completo del webhook de vuelta a ChatGPT como ToolOutput — el modelo no debería ver todo lo que envía el proveedor de pagos, especialmente si no es necesario para la UX.
8. GiftGenius en la práctica: webhook de pago para ACP/Instant Checkout
Volvamos a nuestro GiftGenius. En el módulo de comercio y ACP ya vimos cómo el agente crea una sesión de checkout y cómo después, a través de Instant Checkout, se realiza el cargo. Desde el punto de vista de nuestro backend, después solo queda esperar el webhook order.paid (o checkout.session.completed en términos de Stripe) para:
- fijar el estado del pedido;
- lanzar la cadena «enviar email» / «preparar el envío»;
- dar al agente una respuesta fiable de «el pago se ha realizado».
Ejemplo de manejador sencillo en Next.js:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
if (!verifyCommerceSignature(headers, rawBody)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
if (event.type === "payment_succeeded") {
// Manejador idempotente de la sección anterior
await handlePaymentSucceeded(event);
}
return new Response("ok", { status: 200 });
}
La función verifyCommerceSignature implementa la lógica de firma HMAC, análoga a lo que vimos arriba. En un proyecto real tiene sentido crear un módulo por proveedor (verifyStripeSignature, verifyACPCheckoutSignature) para no mezclar esquemas.
Dentro de handlePaymentSucceeded:
- validas el objeto con el esquema (Zod);
- en una transacción marcas el evento como procesado y actualizas el pedido;
- opcionalmente pones una tarea en la cola para acciones «lentas»: emails, analítica, llamadas adicionales a APIs.
Este enfoque hace la cadena «ACP → webhook → GiftGenius» resistente a eventos repetidos, fallos temporales y datos extraños.
9. Dónde encajan los webhooks con MCP, ChatGPT y las herramientas
A primera vista, los webhooks viven aparte de la ChatGPT App: alguna ruta HTTP en el backend y ya. En realidad son una parte importante de la arquitectura global.
Normalmente, el acoplamiento se ve así:
- La herramienta MCP create_checkout es invocada por el modelo en ChatGPT.
- El servidor MCP contacta con la pasarela de pago, crea la sesión de checkout y devuelve en el ToolOutput la información del pedido y el estado de «espera de pago».
- El usuario completa el pago en el UI (Instant Checkout lo hace directamente en ChatGPT).
- La pasarela de pago envía un webhook a tu backend.
- El backend cambia el estado del pedido en la BD; en la siguiente llamada de herramientas o follow‑up del modelo ya se puede decir con propiedad: «Pedido pagado, aquí están los detalles».
A veces el backend puede iniciar un follow‑up de forma indirecta — por ejemplo, mediante un widget o una integración Realtime que, al recibir una señal del servidor, llama a sendFollowUpMessage. Pero incluso si no es así, el hecho del pago se guarda en tu sistema y, en la siguiente invocación de la herramienta, el backend leerá el nuevo estado de la BD y devolverá al modelo los datos actualizados para responder.
Lo importante es que el webhook es un punto de entrada que vive al mismo nivel que el servidor MCP y usa los mismos servicios (BD, colas, secretos). La lógica de seguridad es básicamente la misma: privilegios mínimos, datos de entrada validados y logging cuidadoso.
10. Errores típicos al trabajar con webhooks e integraciones externas
Error n.º 1: no verificar la firma del webhook.
A veces los desarrolladores se conforman con una URL «secreta» o con un simple Bearer my-secret en la cabecera. Si ese secreto se filtra, cualquiera puede enviarte webhooks a placer, crear pedidos, cambiar estados de pagos y hacer lo que sea. El enfoque correcto es la firma criptográfica del cuerpo (HMAC) y la verificación del timestamp. Eso hace la falsificación mucho más difícil que «adivinar la URL».
Error n.º 2: procesamiento pesado dentro de la petición del webhook.
Escribir en el manejador del webhook «crear pedido, llamar a dos APIs externas, generar un PDF, invocar un modelo de GPT, enviar 5 emails» es una forma segura de topar con timeouts y reintentos. En consecuencia, tú mismo generarás duplicados que luego habrá que deshacer. Es mucho más fiable confirmar rápido la recepción del evento (2xx), registrarlo en la BD o en una cola y procesarlo en background.
Error n.º 3: lógica de negocio no idempotente.
A menudo se ve código del tipo «en cada payment_succeeded aumentar el saldo en el importe». Si el webhook llega dos veces, el saldo se duplicará. Otra variante: crear dos veces el mismo pedido o enviar dos correos al usuario por duplicado. La idempotencia se logra con un identificador estable del evento, una tabla de eventos procesados, transacciones y operaciones del tipo «establecer estado» en lugar de «sumar más».
Error n.º 4: ausencia de esquemas y validación de los datos del webhook.
Incluso un webhook firmado puede no ser lo que esperabas: el proveedor cambió el formato, copias el JSON de la documentación pero en el entorno de test el campo se llama distinto, o simplemente te equivocas en los tipos. Si procesas ese JSON sin esquemas ni comprobaciones, los fallos romperán pedidos en silencio o lanzarán excepciones a mitad de la cadena. Usar Zod/JSON Schema a la entrada facilita el diagnóstico y permite descartar eventos no válidos de forma clara.
Error n.º 5: registrar cuerpos crudos de webhooks con PII.
En pleno modo depuración es fácil poner console.log(rawBody) y olvidarlo. En producción, eso se convierte en logs llenos de emails, direcciones y otra PII que viaja a servicios de logging de terceros. Desde el punto de vista de privacidad y regulaciones (historias tipo GDPR) es un disparo en el pie. Implanta un PII‑scrub desde el principio: enmascara campos sensibles y registra solo lo necesario para diagnosticar.
Error n.º 6: mezclar webhooks de test y de producción.
Situación típica: el mismo endpoint acepta eventos tanto del entorno de prueba como del de producción del proveedor. Al final, un pago de test cambia por sorpresa el estado de un pedido real o viceversa. Es más fiable separar las URL (por ejemplo, /webhooks/commerce/test y /webhooks/commerce/live) o, al menos, guardar el «modo» en la configuración y validarlo a la entrada.
Error n.º 7: dependencia total del flujo de ChatGPT de un webhook síncrono.
A veces apetece que, tras invocar la herramienta y crear la sesión de checkout, el modelo sepa el resultado del pago de inmediato. Pero los webhooks por definición son asíncronos, y el pago puede tardar. Construir el flujo como si todo ocurriera al instante es mala idea. Es mejor diseñar diálogos y herramientas para convivir bien con eventos diferidos: guardar el estado del pedido, permitir al usuario volver al chat y obtener la información actualizada más tarde.
GO TO FULL VERSION