Imagina la siguiente escena: construyes un sistema en Node.js que recibe webhooks de una pasarela de pagos. Cada vez que un cliente paga, el servicio externo te envía una notificación con el ID de la orden. Todo parece marchar de maravilla; los logs de tu servidor dicen que recibiste el evento correctamente, el estatus fue 200 OK y la base de datos se actualizó.
Sin embargo, días después un cliente furioso te reclama que su suscripción no se activó, mientras que a otro usuario le regalaste un paquete premium por el que no pagó. Revisas los registros del servidor y ves algo desconcertante: el log dice que procesaste el ID 9007199254740993, pero en la base de datos se modificó la fila con ID 9007199254740992.
¿Qué demonios pasó aquí? ¿Es un fantasma en la red? No, es un comportamiento clásico de JavaScript relacionado con cómo maneja los números enteros de gran tamaño.
En este artículo te voy a explicar en español de a dev por qué ocurre este fallo tan peligroso, cómo detectarlo a tiempo y la forma correcta de solucionarlo para que no pierdas lana ni paciencia en tus proyectos.
El problema de fondo: El límite numérico de JavaScript
En JavaScript no existen tipos de datos numéricos separados para enteros y decimales como en C++, Java o C#. Todos los números en JavaScript (a menos que uses BigInt) son de tipo Number, los cuales se almacenan bajo el estándar de coma flotante de doble precisión de 64 bits (IEEE 754).
Esto significa que JavaScript solo puede representar de forma exacta y segura enteros dentro de un rango específico:
- Número seguro más pequeño:
-9007199254740991(-(2^53 - 1)) - Número seguro más grande:
9007199254740991(2^53 - 1)
En JavaScript, este límite máximo está guardado en la constante Number.MAX_SAFE_INTEGER.
¿Qué pasa cuando superas ese límite?
Cuando una base de datos moderna (como PostgreSQL o MySQL) utiliza columnas tipo BIGINT o usa identificadores de 64 bits (como los Snowflake IDs de Twitter/Discord o IDs de pasarelas de pago), los números fácilmente superan el límite de 9007199254740991.
Si le pides a JavaScript que procese un número mayor a Number.MAX_SAFE_INTEGER, el motor del lenguaje intentará redondearlo al entero par más cercano. Perderás precisión sin recibir ningún mensaje de error.
Abre la consola de tu navegador o Node.js y prueba esto:
console.log(9007199254740993);
// Resultado en consola: 9007199254740992
console.log(9007199254740993 === 9007199254740992);
// Resultado: true
¡Ojo aquí! Para JavaScript, ¡esos dos números distintos son exactamente iguales! Si un webhook te envía el ID 9007199254740993 y ejecutas UPDATE ordenes SET pagado = true WHERE id = payload.id;, estarás actualizando la orden 9007199254740992.
Paso a paso: ¿Cómo el parseo de JSON te traiciona?
El problema real con los webhooks ocurre durante la deserialización del texto JSON que llega en la petición HTTP.
Cuando la API externa te envía un cuerpo JSON como este:
{
"evento": "pago_completado",
"orden_id": 9007199254740993
}
Notarás que orden_id viene enviado como un número (sin comillas). Al momento en que tu servidor en Express o Node.js ejecuta internamente JSON.parse(textoRecibido), JavaScript convierte automáticamente esa secuencia de dígitos en un objeto Number, corrompiendo el ID antes de que tu código de aplicación pueda siquiera tocarlo.
Simulación del error en Node.js
// Simulamos el cuerpo del texto crudo (raw body) recibido por el webhook
const rawWebhookPayload = '{"orden_id": 9007199254740993, "monto": 150.00}';
// Convertimos el JSON utilizando el método nativo
const datos = JSON.parse(rawWebhookPayload);
console.log("ID procesado:", datos.orden_id);
// Imprime: ID procesado: 9007199254740992 (¡El ID cambió!)
¿Cómo solucionar este problema en producción?
Para evitar que tus webhooks modifiquen la fila equivocada, debes seguir estas alternativas según sea tu caso:
Solución 1: Pedir a la API externa que mande los IDs como String (Lo ideal)
Si la API es tuya o la puedes configurar, asegúrate de que todos los identificadores de 64 bits se envíen siempre entre comillas dentro del JSON:
{
"orden_id": "9007199254740993"
}
Cuando JSON.parse() lee un texto entre comillas, lo almacena como una cadena de caracteres (String) y no aplica ninguna operación matemática ni redondeo. Las cadenas pueden tener una longitud prácticamente infinita sin perder información.
Solución 2: Parsear el JSON usando json-bigint (Si no controlas la API externa)
Cuando trabajas con pasarelas de pago de terceros que insisten en enviar enteros sin comillas, no puedes usar JSON.parse() nativo.
Debes interceptar el texto plano de la petición (Raw Body) y parsearlo con una librería capaz de manejar enteros grandes, como json-bigint.
Primero, instala la librería en tu proyecto:
npm install json-bigint
Luego, utilízala en tu código indicando que convierta los números grandes en cadenas de texto:
const JSONbig = require('json-bigint')({ storeAsString: true });
const rawWebhookPayload = '{"orden_id": 9007199254740993, "monto": 150.00}';
// Parseamos usando la librería especial
const datos = JSONbig.parse(rawWebhookPayload);
console.log("ID seguro como String:", datos.orden_id);
// Imprime: ID seguro como String: "9007199254740993"
console.log(typeof datos.orden_id);
// Imprime: string
Con esto, al consultar tu base de datos (por ejemplo, PostgreSQL o MySQL), pasarás el valor "9007199254740993" como texto o BigInt, asegurando que se actualice la fila exacta.
Errores comunes que debes evitar
- Convertir el ID manualmente con
Number(id): Si ya recibiste un string como"9007199254740993"y hacesNumber(id), volverás a romper el valor. - Usar middleware
express.json()a ciegas: El middleware tradicional de Express convierte la entrada usandoJSON.parseestándar. Si recibes IDs grandes numéricos, debes usarexpress.raw()y parsear el cuerpo tú mismo conjson-bigint. - Confiar únicamente en los logs impresos por objetos parseados: Si imprimes
console.log(datos)después deJSON.parse(), el log mostrará el número ya redondeado. Para auditar errores, siempre guarda en logs la cadena cruda que llegó en el Request HTTP original.
Buenas prácticas para backend developers
- Trata los IDs siempre como Cadenas de Texto (
String): En desarrollo backend, los identificadores únicos casi nunca se usan para hacer operaciones matemáticas (no vas a sumarID_A + ID_B). Por lo tanto, no tienen por qué ser representados como números en tu capa de aplicación. - Aprovecha el tipo nativo
BigInten JavaScript moderno: Si necesitas realizar operaciones con enteros gigantes en Node.js, usa la sintaxis nativa agregando unanal final (ejemplo:9007199254740993n). - Valida las firmas del Webhook usando el texto plano (
raw body): La mayoría de servicios de pago (como Stripe) requieren el cuerpo en texto crudo para validar la firma de seguridad (HMAC). Aprovecha esa misma lectura para parsear los IDs de forma segura.
Ejercicio práctico: Detecta si tu sistema es vulnerable
Crea un archivo llamado test-id.js en tu computadora e intenta correr este código con Node.js (node test-id.js):
function procesarWebhookPrueba(jsonTexto) {
// 1. Simula el parseo seguro
const JSONbig = require('json-bigint')({ storeAsString: true });
const datosSeguros = JSONbig.parse(jsonTexto);
// 2. Simula el parseo inseguro nativo
const datosInseguros = JSON.parse(jsonTexto);
console.log("=== COMPARACIÓN DE RESULTADOS ===");
console.log("Valor original en texto:", jsonTexto);
console.log("Parseo Nativo (Peligroso):", datosInseguros.id_usuario);
console.log("Parseo Seguro (Correcto): ", datosSeguros.id_usuario);
if (String(datosInseguros.id_usuario) !== datosSeguros.id_usuario) {
console.log("⚠️ ¡ALERTA! Se detectó pérdida de precisión en el parseo nativo.");
} else {
console.log("✅ El ID es seguro.");
}
}
// Ejecutamos con un ID largo simulado de 64-bits
procesarWebhookPrueba('{"id_usuario": 9876543210987654321}');
Si al ejecutarlo ves la alerta de pérdida de precisión, ¡ya sabes cómo corregirlo en tus proyectos reales antes de subir a producción!
Fuente original
Este artículo está inspirado y basado en la experiencia técnica relatada en el siguiente artículo en inglés:
The Webhook Updated the Wrong Row. The Logs Showed the Perfect ID.