Skip to main content

Escalar Twilio SMS en Node.js: DLT y webhooks

Aprende a escalar Twilio SMS en Node.js. Gestiona el registro DLT de TRAI, absorbe webhooks de alto volumen con BullMQ y configura agentes HTTP…

Digvijay Singh Shekhawat
Digvijay Singh Shekhawat
July 26, 2026
10 min read
Centralita de cerámica color crema con cables verde musgo, melocotón y rosa empolvado sobre una superficie clara

Enviar un SMS con el SDK de Node.js de Twilio son cinco líneas de código. Levantar un sistema de notificaciones capaz de tragarse 10.000 webhooks simultáneos sin atascar el event loop es otra historia, y no sale bien por casualidad. Así construimos en Node.js una canalización de SMS resistente y conforme con DLT: pooling de conexiones, procesamiento respaldado por colas y validación de payloads, en ese orden de importancia.

La realidad de Twilio SMS a escala en Node.js

Todos los tutoriales cuentan lo mismo: instancia un cliente twilio, haz await sobre messages.create() y listo. Ese consejo se desmorona por encima de las 100 peticiones por segundo. Si el SDK de Node.js de Twilio crea un agente HTTP nuevo en cada petición en vez de compartir uno, los sockets se agotan enseguida y el event loop se ahoga bajo carga.

La Messaging API de Twilio vive sobre endpoints HTTPS corrientes. La multiplexación HTTP/2 existe, pero las integraciones reales acaban cayendo a menudo en el pooling de conexiones de HTTP/1.1. Sin un pool caliente, tu proceso quema CPU en un handshake TLS nuevo por cada SMS saliente. Puro desperdicio en cualquier montaje de twilio sms javascript que trabaje a pleno rendimiento.

Apunta el paquete npm twilio a un agente HTTP personalizado y tus sockets se mantienen calientes. El establecimiento de conexión baja de unos 150 ms a menos de 15 ms. Con volumen transaccional alto, esa diferencia separa aguantar el ritmo de quedarse atrás.

En India la latencia de enrutamiento varía muchísimo entre agregadores globales y operadores locales de Tier 1. El enrutamiento global de Twilio es sólido, pero actores locales como Route Mobile o Gupshup suelen llegar al móvil indio en menos saltos. La jugada es mantener una única capa de API a través de Twilio y enrutar por redes de operadores locales, algo que solo funciona cuando tus payloads cumplen la normativa local.

Implementación paso a paso: Express, Node.js y Twilio

Un gateway endurecido empieza por un servidor Express.js y por Joi validando todo lo que entra. Los números mal formados y los cuerpos de mensaje vacíos nunca llegan a la API de Twilio: te ahorras llamadas de red y facturación de API antes de que se produzcan.

Instanciamos el cliente de Twilio contra un https.Agent personalizado. Ahí es donde vive maxSockets y donde las conexiones TCP se reutilizan entre peticiones.

// client.js
const twilio = require('twilio');
const https = require('https');

// Keep sockets warm to avoid TLS handshake overhead on every SMS
const keepAliveAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 100,
  keepAliveMsecs: 3000,
  freeSocketTimeout: 15000
});

const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;

const twilioClient = twilio(accountSid, authToken, {
  httpClient: new twilio.RequestClient({
    agent: keepAliveAgent
  })
});

module.exports = twilioClient;

La ruta de Express se encarga del envío saliente. La validación va primero; el SDK de Twilio solo ve payloads limpios.

// server.js
const express = require('express');
const Joi = require('joi');
const twilioClient = require('./client');

const app = express();
app.use(express.json());

const smsSchema = Joi.object({
  to: Joi.string().pattern(/^\+[1-9]\d{1,14}$/).required(), // E.164 format
  body: Joi.string().min(1).max(1600).required(),
  peId: Joi.string().optional(), // Required for India DLT
  templateId: Joi.string().optional() // Required for India DLT
});

app.post('/api/v1/sms/send', async (req, res) => {
  const { error, value } = smsSchema.validate(req.body);
  if (error) {
    return res.status(400).json({ error: error.details[0].message });
  }

  try {
    const payload = {
      to: value.to,
      from: process.env.TWILIO_PHONE_NUMBER,
      body: value.body
    };

    // Append DLT parameters if routing to India
    if (value.peId && value.templateId) {
      payload.edge = 'mumbai'; // Route via local Twilio edge
    }

    const message = await twilioClient.messages.create(payload);
    return res.status(202).json({ sid: message.sid, status: message.status });
  } catch (err) {
    return res.status(500).json({ error: 'Failed to dispatch SMS via Twilio', details: err.message });
  }
});

app.listen(3000, () => console.log('SMS Service listening on port 3000'));

Los acuses de entrega (DLR) vuelven a tu servidor y no puedes darlos por buenos sin más: tienes que demostrar que la petición viene realmente de Twilio y no de un desconocido. El paquete npm twilio incluye un middleware que hace por ti la verificación de firma.

// webhook.js
const express = require('express');
const twilio = require('twilio');

const app = express();

// Twilio signature verification requires the raw body to validate correctly
app.post('/webhooks/twilio/status', 
  twilio.webhook({ validate: true }), 
  (req, res) => {
    const status = req.body.MessageStatus;
    const messageSid = req.body.MessageSid;
    
    // Process status updates asynchronously to avoid blocking webhook delivery
    res.status(200).send('OK');
  }
);

Configura siempre validate: true en entornos de producción. Si estás probando en local, necesitas una herramienta como ngrok para exponer tu servidor y ofrecer a Twilio un endpoint HTTPS válido que coincida con tu firma pública.

Cómo moverse por las reglas DLT y la regulación de operadores en India

TRAI, el regulador indio de telecomunicaciones, exige que todo SMS comercial pase por un sistema de Distributed Ledger Technology (DLT). La intención es frenar el spam. El efecto secundario es un conjunto de requisitos técnicos estrictos en cuanto envías a un número +91.

Antes hacen falta dos cosas. Tu empresa se registra como Principal Entity (PE) y obtiene un PE ID. Después, cada plantilla que pienses enviar se registra y se aprueba en la plataforma DLT, que devuelve un Content Template ID (CTID). Si falta cualquiera de los dos identificadores en el payload, los cortafuegos de los operadores tiran el mensaje al instante, y aun así pagas el intento.

Twilio transporta los datos DLT mapeándolos a parámetros personalizados del payload de la Messaging API. Se pasan en el objeto de opciones en el momento del envío.

// dlt-send.js
const twilioClient = require('./client');

async function sendIndianTransactionalSMS(toPhoneNumber, messageText, peId, templateId) {
  try {
    const message = await twilioClient.messages.create({
      to: toPhoneNumber,
      from: process.env.TWILIO_MESSAGING_SERVICE_SID, // Recommended for multi-sender setups
      body: messageText,
      // Twilio routes these custom parameters to Indian carriers for DLT validation
      riskCheck: 'disable', // Optional: bypass Twilio's internal fraud check if pre-validated
      sendAsMms: false,
      // Provide DLT metadata via custom headers or parameters supported by the regional gateway
      // Note: Ensure your Twilio account manager has enabled DLT parameter mapping for your SID
    });
    
    console.log(`Message queued successfully. SID: ${message.sid}`);
  } catch (error) {
    console.error(`DLT Dispatch failed: ${error.message}`);
  }
}

Cuando un filtro DLT de operador rechaza un mensaje, Twilio devuelve un código concreto. Los errores 30007 o 30008 casi siempre significan que el contenido no coincidía con tu plantilla DLT registrada, o que alguna variable venía con un formato distinto al de la versión aprobada en el ledger.

Gestionar la concurrencia de webhooks y los límites de tasa

Procesar los callbacks de entrega en línea, dentro de la propia ruta de Express, es la receta para las fugas de memoria y la inanición de hilos. Deja que la latencia de escritura en base de datos suba bajo carga y los webhooks se acumularán en memoria. El event loop se bloquea. El contenedor muere.

Desacopla la ingesta del procesamiento. Empuja cada webhook entrante directamente a una cola rápida en memoria (Redis a través de BullMQ) y tu aplicación Express responderá 200 OK en menos de 5 milisegundos. Este patrón es la columna vertebral de cualquier servicio de twilio sms javascript que funcione a escala de campaña.

// queue-ingest.js
const express = require('express');
const { Queue } = require('bullmq');
const IORedis = require('ioredis');

const connection = new IORedis(process.env.REDIS_URL);
const smsStatusQueue = new Queue('sms-status', { connection });

const app = express();
app.use(express.urlencoded({ extended: true })); // Twilio webhooks are Form-URLEncoded

app.post('/webhooks/twilio/status', async (req, res) => {
  try {
    // Immediately push payload to Redis queue
    await smsStatusQueue.add('status-update', {
      messageSid: req.body.MessageSid,
      status: req.body.MessageStatus,
      errorCode: req.body.ErrorCode,
      timestamp: new Date().toISOString()
    }, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 1000
      }
    });

    // Acknowledge receipt to Twilio instantly
    res.status(200).send('<Response></Response>');
  } catch (err) {
    res.status(500).send('Internal Queue Failure');
  }
});

Un pool de workers dedicado vacía la cola fuera de banda. Tu base de datos principal deja de absorber todo el pico de escrituras durante una campaña de notificaciones grande.

// worker.js
const { Worker } = require('bullmq');
const IORedis = require('ioredis');

const connection = new IORedis(process.env.REDIS_URL);

const worker = new Worker('sms-status', async job => {
  const { messageSid, status, errorCode } = job.data;
  
  // Perform database updates or trigger retries here
  if (status === 'failed' || status === 'undelivered') {
    console.warn(`SMS `{messageSid} failed with code`{errorCode}. Executing retry logic.`);
    // Implement backoff retry or fallback carrier routing
  }
}, { connection });

worker.on('completed', job => {
  console.log(`Job ${job.id} processed successfully`);
});

Optimizar el uso de recursos: memoria y tamaño del repositorio

Los procesos de Node.js viven en memoria de forma continua, nada que ver con el desmontaje sin estado de PHP-FPM. Estupendo para sostener un pool de sockets persistente, peligroso cuando parseas payloads grandes sin cuidado. Mete un array JSON gigantesco de una API de lotes de Twilio en un JSON.parse a pelo y V8 te regalará picos de recolección de basura que arrastrarán consigo a todas las demás tareas concurrentes.

En runtimes serverless como AWS Lambda o Google Cloud Functions, no importes el SDK de Twilio entero para un trabajo que solo envía SMS. El paquete completo arrastra los módulos de Voice, Video y Chat, y cada uno suma latencia de arranque en frío.

El repositorio inflado por logs heredados, lockfiles descomunales o un volcado de base de datos subido por accidente ralentiza el CI/CD y engorda las builds de contenedores. Limpia el historial con git-filter-repo antes de desplegar.

# Remove a large legacy log file from the entire Git history
git filter-repo --path logs/production-sms.log --invert-paths

¿Quieres un node_modules todavía más ligero? Sáltate el SDK de Twilio por completo y ataca la API directamente con un cliente mínimo como undici o axios detrás de un agente caliente.

Monitorización, observabilidad y depuración

A escala necesitas logs estructurados para seguir un mensaje a través de los sistemas. Retira console.log de producción. Usa Winston o Pino, emite JSON y deja que Datadog o ELK indexen tus Message SID por su cuenta.

const pino = require('pino');
const logger = pino({ level: 'info' });

logger.info({
  event: 'sms_dispatched',
  messageSid: 'SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  recipient: '+919999999999',
  provider: 'twilio'
});

Expón métricas de Prometheus para tener observabilidad en tiempo real. Tres que merece la pena vigilar:

  • sms_delivery_latency_seconds: tiempo transcurrido desde el envío por API hasta recibir el estado delivered por webhook.
  • sms_failure_total: contador de fallos segmentado por código de error (por ejemplo, 30007, 30008).
  • active_socket_connections: medidor del estado de tu pool HTTPS keep-alive.

No apuntes nunca tus tests automatizados a la API real de Twilio: quema presupuesto y contamina tus métricas. Las credenciales de prueba y los números mágicos de Twilio te permiten simular entregas correctas, números inválidos y caídas de operador sin que salga un solo SMS real.

El SMS no hace más que ganar regulación, tanto en EE. UU. y la UE como en los corredores indios. La parte difícil dejó de ser la llamada a la API hace mucho: ahora es el cumplimiento normativo y la eficiencia en ejecución. Una canalización de twilio sms javascript construida sobre pooling de conexiones, webhooks respaldados por colas y metadatos DLT explícitos entrega en milisegundos y deja intacta tu aplicación principal.

Preguntas frecuentes

¿Qué es lo primero que se rompe al escalar Twilio SMS en Node.js?
La gestión de webhooks. Los acuses de entrega llegan de forma asíncrona y en volumen, y un handler que hace trabajo real en línea en lugar de encolar acabará quedándose atrás bajo carga.

¿Qué es DLT y cuándo aplica?
Es el registro indio de Distributed Ledger Technology, que exige registrar remitentes, cabeceras y plantillas de mensaje antes de que un SMS comercial pueda entregarse. Se aplica a los números de destino indios, envíes desde donde envíes.

¿Por qué hay mensajes entregados que no se reciben?
Normalmente por desajuste de plantilla bajo DLT o por filtrado a nivel de operador. La API informa de la aceptación por parte del gateway, no de la llegada al terminal.

Digvijay Singh Shekhawat
Digvijay Singh Shekhawat

Fundador, Finn AI

Digvijay está construyendo Finn: la capa empresarial de orquestación de voz que razona durante las llamadas, extrae datos y actualiza tus sistemas en tiempo real. Escribe sobre IA de voz, estrategia de salida al mercado y lo que hace falta para lanzar agentes autónomos a gran escala.

Escalar Twilio SMS en Node.js: DLT y webhooks — Finn