Cargando
Miniatura del artículo

Arquitecto de automatización con IA: Día 23

Índice de contenido

Resumen:
Los mensajes de WhatsApp de Trigo Lento entran hoy a Make por primera vez, y con ellos llegan tres preguntas de arquitectura: qué módulo los recibe, cómo se sabe que vienen de verdad de Twilio y qué pasa cuando llegan en ráfagas a las once de la mañana. En esta entrada, Valeria clasifica los tipos de módulo de Make por su función y su consumo de créditos, convierte el cuerpo que envía Twilio al contrato del pedido, construye y dibuja el escenario TL Pedidos WhatsApp v1, simula 1 000 horas pico para elegir entre ejecución secuencial y paralela y verifica la firma X-Twilio-Signature con HMAC-SHA1. Al terminar, usted sabrá configurar un webhook personalizado con respuesta explícita, calcular si su escenario responde antes de que el emisor deje de esperar y rechazar los mensajes que no traen una firma válida.
¿Cómo citar el presente artículo?
Romero, J. (23 de febrero de 2026). Arquitecto de automatización con IA: Día 23. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_23/.
Arquitecto de automatización con IA: Día 23 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_23/.

Planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude revisados en octubre de 2026. Los límites de los webhooks de Make (300 peticiones cada 10 segundos, 667 elementos de cola por cada 10 000 créditos y 180 segundos para enviar la respuesta) y los de Twilio (15 segundos de tiempo total por petición y un reintento predeterminado solo ante fallos de conexión) se tomaron de su documentación oficial en octubre de 2026. El blueprint de esta entrada tiene la estructura de exportación documentada por Make (estructura verificada contra la documentación, revisado en octubre de 2026); la expresión exacta del filtro de firma debe contrastarse con el registro de un mensaje real antes de activar el escenario.

Día 23. Disparadores, acciones y módulos en Make

Introducción


Ayer, en el Día 22 (Make por dentro: escenarios, módulos, paquetes y créditos), Valeria aprendió a leer un blueprint campo por campo, a contar créditos como la suma de los paquetes que pasan por cada módulo y a configurar un escenario, y Trigo Lento ganó su primer escenario documentado, TL Formulario v1, que lleva los pedidos del formulario web a la hoja de cálculo. El formulario, sin embargo, es el canal pequeño: trae el $15%$ de los encargos. El grande es WhatsApp, con el $55%$ de los encargos y unos $60$ mensajes al día, y hasta hoy ninguno de esos mensajes ha pasado por Make, aunque el mapa del Día 21 (Sesión práctica: el mapa de automatización de Trigo Lento) lo puso en el primer lugar de la lista.

Es lunes 23 de febrero, pasadas las diez. Valeria tiene en la laptop la cuenta de pruebas de Twilio, el número de WhatsApp de Trigo Lento conectado en modo de prueba y un escenario vacío en Make que solo tiene un óvalo verde: el webhook. Mientras lo configura, el teléfono del mostrador vibra tres veces seguidas. Es la señora Robles: “Hola”, “quiero un pastel de chocolate”, “para el sábado a las 13:00”. Tres mensajes en cuarenta segundos, de una sola clienta, que Valeria, a mano, lee como un único pedido. Un escenario no los verá así: verá tres ejecuciones, quizá al mismo tiempo, quizá en desorden.

Don Ernesto, que ha seguido la explicación con los brazos cruzados, plantea dos objeciones seguidas: “Si cualquiera que conozca esa dirección de internet puede mandarle a Make un mensaje que diga que es de un cliente, ¿qué impide que alguien nos encargue diez pasteles de broma? Y a las once, cuando escriben cinco personas a la vez, ¿se forman en fila o se atropellan?” Las dos son preguntas de arquitectura, no de herramientas. La primera se responde con una firma criptográfica que solo Twilio puede producir; la segunda, con una simulación de la cola del webhook, porque la respuesta depende de cuánto tarde el escenario y de cómo lleguen los mensajes, y no de un promedio.

En la presente entrada, descubrirá los tipos de módulo de Make (disparadores instantáneos y de sondeo, acciones, búsquedas, iteradores, agregadores, transformadores y universales) y cuánto cobra cada uno. Configurará un webhook personalizado con su estructura de datos, su cola y una respuesta explícita, comparará la programación inmediata con los sondeos por intervalos y recorrerá el camino de un mensaje desde el teléfono del cliente hasta Make, campo por campo. Construirá el blueprint de TL Pedidos WhatsApp v1, lo dibujará con Python, simulará $1,000$ horas pico para comparar la ejecución secuencial con la paralela, medirá cuántos mensajes recibirían su respuesta después de los $15$ segundos que espera Twilio y verificará la firma X-Twilio-Signature antes de tocar un solo dato del cliente.

Objetivos de aprendizaje


Al terminar esta entrada, usted podrá:

  • Distinguir los tipos de módulo de Make: disparadores instantáneos y de sondeo, acciones, búsquedas, iteradores, agregadores, transformadores y universales.
  • Configurar un webhook personalizado con su estructura de datos y una respuesta explícita.
  • Recibir un mensaje de WhatsApp a través de Twilio y verificar su firma.
  • Simular la cola de un webhook en la hora pico y elegir entre ejecución secuencial y paralela.

Tipos de módulo


En el Día 22 vio que un escenario es una cadena de módulos y que cada módulo procesa los paquetes que le llegan. Hoy toca mirar los módulos por dentro, porque no todos hacen lo mismo ni cobran lo mismo. La documentación de Make y su editor visual agrupan los módulos por su papel en el escenario, y conviene aprender esa clasificación antes de arrastrar el primero al lienzo, porque el papel decide tres cosas: dónde puede ir el módulo (solo hay un disparador y va siempre al principio), cuántos paquetes produce (uno, ninguno o muchos) y cuántos créditos consume.

Comencemos, como todos los días de la serie, por las importaciones y la semilla global:

python
# Importaciones base, semilla de la serie y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import re
import hmac
import hashlib
import base64
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 120)
pd.set_option('display.max_columns', 12)
carpeta = Path('trigo_lento') / 'dia_23'
(carpeta / 'flujos' / 'make').mkdir(parents = True, exist_ok = True)
MENSAJES_MES = 1_560          # 60 mensajes de WhatsApp al día, 26 días (A.5)
print('numpy', np.__version__, '| pandas', pd.__version__)
print('Carpeta del día:', carpeta.as_posix())
numpy 2.4.6 | pandas 3.0.3
Carpeta del día: trigo_lento/dia_23

El entorno es el mismo de los días anteriores, con hmac, hashlib y base64 añadidos para la firma de Twilio, y todos los archivos del día se guardan en trigo_lento/dia_23/.

Siete papeles y un consumo

La Tabla 1 reúne los tipos de módulo con un ejemplo de Trigo Lento, el identificador interno que aparece en el blueprint exportado y lo que consume cada uno. Los identificadores son los que la serie usa desde el Día 11 y se contrastan con la exportación real antes de publicar cada escenario, porque Make puede cambiarlos cuando una aplicación sube de versión. El siguiente bloque construye la tabla y, de paso, calcula cuánto cuesta cada forma de programar el disparador, que se comenta en la sección de programación:

python
# Tipos de módulo de Make con su ejemplo en Trigo Lento y su consumo de créditos en Python:
tipos = pd.DataFrame([
    ('Disparador instantáneo', 'Mensaje de WhatsApp que Twilio entrega', 'gateway:CustomWebHook', '1 por ejecución'),
    ('Disparador de sondeo', 'Filas nuevas en la hoja de pedidos', 'google-sheets:watchRows', '1 por comprobación'),
    ('Acción', 'Escribir el borrador en la hoja', 'google-sheets:addRow', '1 por paquete'),
    ('Búsqueda', 'Leer la ficha del cliente', 'datastore:GetRecord', '1 por paquete'),
    ('Iterador', 'Un paquete por renglón del pedido', 'builtin:BasicFeeder', '1 por ejecución'),
    ('Agregador', 'Juntar renglones en un resumen', 'builtin:BasicAggregator', '1 por ejecución'),
    ('Transformador', 'Interpretar un JSON o fijar variables', 'json:ParseJSON', '1 por paquete'),
    ('Universal', 'Llamar a una API sin conector', 'http:ActionSendData', '1 por paquete'),
    ('Respuesta de webhook', 'Devolver 200 a Twilio', 'gateway:WebhookRespond', '1 por paquete'),
    ('Router y filtro', 'Repartir por tipo de mensaje', 'builtin:BasicRouter', '0'),
], columns = ['tipo', 'ejemplo en Trigo Lento', 'identificador', 'créditos'])
print(tipos.to_string(index = False))

# Programación: instantánea frente a sondeos cada T minutos (30 días, como el Día 3):
def sondeos_al_mes(t_min, dias = 30):
    return 1440 / t_min * dias

def fraccion_vacias(t_min, mensajes_dia = 60, horas_abierto = 12, dias_abierto = 26, dias = 30):
    # Las comprobaciones fuera del horario están siempre vacías; dentro, siguen un Poisson:
    lam = mensajes_dia / (horas_abierto * 60)              # mensajes por minuto con la tienda abierta
    abiertas = horas_abierto * 60 / t_min * dias_abierto
    vacias = abiertas * np.exp(-lam * t_min) + (sondeos_al_mes(t_min, dias) - abiertas)
    return vacias / sondeos_al_mes(t_min, dias)

programacion = pd.DataFrame([{'modo': 'Inmediata (webhook)', 'créditos_disparador': MENSAJES_MES,
                              'latencia_media_min': 0.0, 'vacías_%': 0.0}] +
    [{'modo': f'Cada {t} min (sondeo)', 'créditos_disparador': int(sondeos_al_mes(t)),
      'latencia_media_min': t / 2, 'vacías_%': round(100 * fraccion_vacias(t), 1)} for t in (15, 5, 1)])
print(programacion.to_string(index = False))
                  tipo                 ejemplo en Trigo Lento           identificador           créditos
Disparador instantáneo Mensaje de WhatsApp que Twilio entrega   gateway:CustomWebHook    1 por ejecución
  Disparador de sondeo     Filas nuevas en la hoja de pedidos google-sheets:watchRows 1 por comprobación
                Acción        Escribir el borrador en la hoja    google-sheets:addRow      1 por paquete
              Búsqueda              Leer la ficha del cliente     datastore:GetRecord      1 por paquete
              Iterador      Un paquete por renglón del pedido     builtin:BasicFeeder    1 por ejecución
             Agregador         Juntar renglones en un resumen builtin:BasicAggregator    1 por ejecución
         Transformador  Interpretar un JSON o fijar variables          json:ParseJSON      1 por paquete
             Universal          Llamar a una API sin conector     http:ActionSendData      1 por paquete
  Respuesta de webhook                  Devolver 200 a Twilio  gateway:WebhookRespond      1 por paquete
       Router y filtro           Repartir por tipo de mensaje     builtin:BasicRouter                  0
                modo  créditos_disparador  latencia_media_min  vacías_%
 Inmediata (webhook)                 1560                 0.0       0.0
Cada 15 min (sondeo)                 2880                 7.5      69.1
 Cada 5 min (sondeo)                 8640                 2.5      85.2
 Cada 1 min (sondeo)                43200                 0.5      96.5

Tabla 1. Tipos de módulo de Make con su ejemplo en Trigo Lento y su consumo de créditos.

TipoEjemplo en Trigo LentoIdentificador en el blueprintCréditos
Disparador instantáneoMensaje de WhatsApp que Twilio entregagateway:CustomWebHook1 por ejecución
Disparador de sondeoFilas nuevas en la hoja de pedidosgoogle-sheets:watchRows1 por comprobación, haya o no datos
AcciónEscribir el borrador en la hojagoogle-sheets:addRow1 por paquete
BúsquedaLeer la ficha del clientedatastore:GetRecord1 por paquete
IteradorUn paquete por renglón del pedidobuiltin:BasicFeeder1 por ejecución; multiplica lo que sigue
AgregadorJuntar renglones en un resumenbuiltin:BasicAggregator1 por ejecución
TransformadorInterpretar un JSON o fijar variablesjson:ParseJSON, util:SetVariables1 por paquete
UniversalLlamar a una API sin conectorhttp:ActionSendData1 por paquete
Respuesta de webhookDevolver 200 a Twiliogateway:WebhookRespond1 por paquete
Router y filtroRepartir por tipo de mensajebuiltin:BasicRouter0

La tabla se lee por columnas, y cada una enseña algo distinto:

  1. En primer lugar, los disparadores son de dos especies. El instantáneo no hace nada hasta que alguien le avisa: Twilio llama a una dirección de Make y el escenario arranca. El de sondeo pregunta cada cierto tiempo si hay algo nuevo, y cada pregunta cuesta un crédito aunque la respuesta sea “no”. Es la distinción del Día 3 entre webhook y sondeo, ahora con nombre de módulo.
  2. En segundo lugar, acciones, búsquedas, transformadores y universales cobran por paquete. Si les llega un paquete, consumen un crédito; si les llegan cinco, consumen cinco. Por eso el iterador, que convierte un pedido de cinco renglones en cinco paquetes, encarece todo lo que va detrás de él, como calculó el Día 22 con los $7$ créditos del formulario de cinco renglones.
  3. En tercer lugar, los routers y los filtros no cobran. Decidir es gratis en Make; lo que cuesta es actuar. Esta regla será la base del diseño de hoy: la comprobación de la firma de Twilio irá en un filtro, de modo que un mensaje falso se detenga antes de los módulos que cobran.

El crédito por ejecución

El Día 22 escribió el consumo de un escenario como la suma de los paquetes que atraviesan cada módulo. Para una ejecución concreta, la fórmula es:

$$ C_{ej} = \sum_{m \in M} p_m, \qquad C_{mes} = \sum_{k=1}^{N} C_{ej,k} $$

La lógica detrás de esta fórmula es múltiple:

  • $M$ es el conjunto de módulos del escenario y $p_m$ el número de paquetes que procesa el módulo $m$ en esa ejecución; un módulo que no se alcanza, porque un filtro detuvo el paquete antes, tiene $p_m = 0$.
  • Los routers y los filtros no forman parte de la suma: no consumen créditos aunque evalúen todos los paquetes.
  • El consumo del mes, $C_{mes}$, suma las $N$ ejecuciones del mes; si todas recorren el mismo camino con un paquete, es simplemente $N$ veces el número de módulos del camino.
  • En conclusión, un escenario se abarata poniendo las decisiones lo antes posible, para que los paquetes que no deben seguir se detengan antes de los módulos que cobran.

La caja de herramientas completa, con un color por familia de módulos, es la que dibuja la Figura 1.

La caja de herramientas de Make: los disparadores en verde, los módulos que actúan en azul, los que transforman en amarillo y, en gris, los routers y filtros, que deciden sin cobrar créditos.

Figura 1. La caja de herramientas de Make: los disparadores en verde, los módulos que actúan en azul, los que transforman en amarillo y, en gris, los routers y filtros, que deciden sin cobrar créditos.

Fíjese en que la figura separa lo que mueve paquetes de lo que solo decide. Esa frontera es la que un arquitecto de automatización mira primero al revisar un escenario ajeno: dónde están los filtros respecto de los módulos que cobran y respecto de los que tocan datos personales.

Webhooks personalizados


El disparador de TL Pedidos WhatsApp v1 es un webhook personalizado (en Make, Custom webhook, identificador gateway:CustomWebHook). Al crearlo, Make genera una dirección única del tipo https://hook.<región>.make.com/<identificador>; quien conozca esa dirección puede enviarle peticiones HTTP, y cada petición se convierte en un paquete para el escenario. Esa frase, “quien conozca esa dirección”, es el origen de la primera objeción de Don Ernesto, y por eso la dirección se trata como un secreto: no se publica, no se pega en un chat y no aparece en el blueprint exportado (en el blueprint, el webhook es solo "hook": 0).

Tres ajustes del webhook importan hoy:

  • La estructura de datos. Make puede deducir la forma de los paquetes a partir de una petición de ejemplo, o usted puede declararla. Con una estructura declarada, Make valida los datos que llegan y rechaza con un error 400 los que no la cumplen. Es el contrato del Día 4 aplicado en la puerta: un campo obligatorio que falta no entra al escenario.
  • Las cabeceras de la petición. Por omisión, Make solo ofrece para el mapeo los parámetros y el cuerpo. La opción de obtener las cabeceras (Get request headers) las añade como una lista de nombres y valores. Sin ella, la cabecera X-Twilio-Signature sería invisible y no se podría verificar la firma.
  • Las restricciones por dirección IP. Make permite aceptar solo peticiones de ciertas direcciones IP. Twilio no publica un rango fijo para sus webhooks, así que en Trigo Lento esta defensa no sirve y la autenticidad se comprueba con la firma.

La cola del webhook

Un webhook de Make no ejecuta el escenario directamente: encola cada petición, y el escenario va tomando los elementos de la cola. La documentación de Make fija tres límites que hay que conocer antes de la hora pico:

  1. Ritmo. Make procesa hasta $300$ peticiones cada $10$ segundos por webhook, unas $30$ por segundo; por encima, responde con un error 429.
  2. Tamaño de la cola. Cada $10,000$ créditos del plan dan derecho a $667$ elementos en la cola de cada webhook, con un máximo absoluto de $10,000$. Con el plan Core de Trigo Lento, la cola admite $667$ mensajes; si se llena, Make rechaza los siguientes con un error 400 y el texto “Queue is full”.
  3. Abandono. Un webhook que pasa más de $120$ horas sin estar asignado a ningún escenario se desactiva y responde 410. Un escenario de pruebas olvidado durante cinco días deja de recibir mensajes sin avisar a nadie.

La respuesta explícita

Si el escenario no tiene un módulo de respuesta, Make contesta por su cuenta en cuanto encola la petición: un 200 con el texto “Accepted”. Para muchos emisores eso basta; para Twilio no es lo ideal, porque Twilio espera una respuesta en TwiML, su formato XML de instrucciones, y registra una advertencia cuando recibe texto plano. El módulo Webhook response (gateway:WebhookRespond) permite elegir el código de estado, el cuerpo y las cabeceras. En TL Pedidos WhatsApp v1 devuelve un 200 con el cuerpo <Response></Response> (una respuesta TwiML vacía: “recibido, no envíe nada”) y la cabecera Content-Type: text/xml.

La documentación del módulo añade dos reglas que condicionan el diseño:

  • Make espera hasta $180$ segundos a que el escenario llegue al módulo de respuesta; si no llega a tiempo, contesta “200 Accepted”. Para Make, $180$ segundos es mucho; para Twilio, que deja de esperar a los $15$, es una eternidad, como verá en la sección sobre responder a tiempo.
  • Si el módulo de respuesta va a mitad del escenario, los errores de los módulos posteriores ya no desactivan el escenario, porque el emisor ya recibió su respuesta. La documentación recomienda ponerlo al final para enterarse de los errores; la sección sobre responder a tiempo explica cuándo conviene saltarse esa recomendación.

Programación


Todo escenario tiene una programación, que dice cuándo se ejecuta. Make ofrece cinco formas: inmediata (solo para disparadores instantáneos: el escenario arranca en cuanto llega un elemento a la cola), por intervalos (cada $T$ minutos), en días y horas fijos (todos los días a las 07:00, de lunes a sábado, ciertos días del mes), una sola vez y a demanda (solo cuando alguien pulsa el botón o lo llama otra automatización). El plan gratuito limita el intervalo mínimo a $15$ minutos; los de pago permiten bajar a $1$ minuto.

Para los mensajes de WhatsApp hay dos candidatas reales: el webhook con programación inmediata o un disparador de sondeo que pregunte a Twilio cada $T$ minutos si hay mensajes nuevos. El Bloque 2 ya calculó las dos con los $1,560$ mensajes al mes de Trigo Lento. Las cuentas siguen dos fórmulas que el Día 3 presentó para cualquier plataforma:

$$ N_{sondeo} = \frac{1440}{T} \cdot D, \qquad \bar{L} = \frac{T}{2} $$

La lógica detrás de estas fórmulas es múltiple:

  • $N_{sondeo}$ es el número de comprobaciones al mes: un día tiene $1,440$ minutos, se pregunta cada $T$ y el escenario está activo $D = 30$ días. Cada comprobación cuesta un crédito, haya o no mensajes.
  • $\bar{L}$ es la latencia media: un mensaje llega en un instante cualquiera entre dos comprobaciones y espera, en promedio, la mitad del intervalo.
  • El webhook no tiene comprobaciones vacías: consume un crédito de disparador por mensaje, $1,560$ al mes, con una latencia de segundos.
  • En conclusión, el sondeo intercambia créditos por latencia, y para mensajes que llegan solo en horario de tienda casi todas sus comprobaciones son vacías.

Las cifras de la salida del Bloque 2 lo confirman. Con $T = 15$ minutos, el sondeo hace $2,880$ comprobaciones al mes, el $69.1%$ vacías (el Día 3 obtuvo un $68.3%$ por simulación, con otro supuesto de horario), y la clienta espera de media $7.5$ minutos a que alguien se entere de su mensaje. Con $T = 5$, las comprobaciones suben a $8,640$ y la espera baja a $2.5$ minutos. Con $T = 1$, la espera es de medio minuto, pero las $43,200$ comprobaciones superan cuatro veces los $10,000$ créditos del plan Core antes de procesar un solo mensaje. El webhook con programación inmediata gana en las dos columnas: $1,560$ créditos y latencia de segundos.

La comparación cabe en la imagen del rayo y el reloj de la Figura 2.

Rayo frente a reloj: el webhook inmediato consume 1 560 créditos de disparador al mes con latencia de segundos; el sondeo cada 15 minutos hace 2 880 comprobaciones, el 69.1 % vacías, y cada minuto, 43 200.

Figura 2. Rayo frente a reloj: el webhook inmediato consume 1 560 créditos de disparador al mes con latencia de segundos; el sondeo cada 15 minutos hace 2 880 comprobaciones, el 69.1 % vacías, y cada minuto, 43 200.

La programación por intervalos no desaparece de Trigo Lento: es la correcta para el resumen de las 07:00 del Día 3 y para la sincronización cada $10$ minutos del Día 16, donde lo que se busca es un ritmo fijo y no una reacción. La regla práctica es sencilla: reaccione con webhooks a lo que ocurre fuera, y programe por intervalos lo que usted decide cuándo hacer.

Del cliente a Make


Un mensaje de WhatsApp de la señora Robles recorre cuatro tramos antes de llegar al escenario. Sale de su teléfono hacia los servidores de WhatsApp; Meta lo entrega a Twilio, que es el proveedor que Trigo Lento usa en este módulo para tener un número de WhatsApp Business con API; Twilio lo convierte en una petición HTTP POST y la envía a la dirección configurada en el número, que es el webhook de Make; y Make lo encola y ejecuta el escenario. La respuesta hace el camino inverso solo hasta Twilio: el 200 le dice a Twilio que el mensaje fue recibido, no a la clienta.

La petición de Twilio no llega en JSON, sino en el formato de los formularios web, application/x-www-form-urlencoded: pares nombre=valor unidos por &, con los espacios como + y los caracteres especiales como % seguido de su código hexadecimal. Make la convierte en paquete sin ayuda, pero conviene haberla visto cruda una vez, porque la firma se calcula sobre esos parámetros y no sobre lo que se ve en el panel de Make. El siguiente bloque decodifica un cuerpo de ejemplo con los campos que Twilio documenta para un mensaje entrante de WhatsApp y lo convierte en un borrador del contrato del Día 4. Todos los identificadores y teléfonos son ficticios, con el formato de prueba de la serie:

python
# Cuerpo de ejemplo de Twilio (x-www-form-urlencoded) y su conversión al contrato del pedido en Python:
CUERPO = ('SmsMessageSid=SM0000000000000000000000000000a023&NumMedia=0&ProfileName=Laura+Robles'
          '&MessageType=text&SmsSid=SM0000000000000000000000000000a023&WaId=525500000012'
          '&SmsStatus=received&Body=Hola%2C+quiero+un+pastel+de+chocolate+para+el+s%C3%A1bado+a+las+13%3A00'
          '&To=whatsapp%3A%2B525500000001&NumSegments=1&ReferralNumMedia=0'
          '&MessageSid=SM0000000000000000000000000000a023&AccountSid=AC00000000000000000000000000000000'
          '&From=whatsapp%3A%2B525500000012&ApiVersion=2010-04-01')

def decodificar_formulario(cuerpo):
    # "+" es un espacio y %XX es un byte en hexadecimal (UTF-8):
    def valor(texto):
        texto = texto.replace('+', ' ')
        return re.sub(r'(%[0-9A-Fa-f]{2})+',
                      lambda m: bytes.fromhex(m.group(0).replace('%', '')).decode('utf-8'), texto)
    return {valor(k): valor(v) for k, v in (par.split('=', 1) for par in cuerpo.split('&'))}

twilio = decodificar_formulario(CUERPO)
NUMERO_TRIGO_LENTO = 'whatsapp:+525500000001'

def a_contrato(m):
    # Borrador del pedido: lo que el mensaje ya trae y lo que falta por extraer:
    telefono = m['From'].removeprefix('whatsapp:')
    return {'id_mensaje': m['MessageSid'], 'canal': 'whatsapp',
            'cliente': {'nombre': m['ProfileName'], 'telefono': telefono},
            'clave_cliente': hashlib.sha256(m['WaId'].encode()).hexdigest()[:12],
            'notas': m['Body'], 'adjuntos': int(m['NumMedia']), 'destino_ok': m['To'] == NUMERO_TRIGO_LENTO}

borrador = a_contrato(twilio)
destinos = {'MessageSid': 'id_mensaje (clave de idempotencia)', 'From': 'cliente.telefono (sin "whatsapp:")',
            'To': 'comprobar que es el número de Trigo Lento', 'Body': 'notas (texto a extraer el Día 30)',
            'NumMedia': 'adjuntos (fotos: revisión humana)', 'ProfileName': 'cliente.nombre (provisional)',
            'WaId': 'clave_cliente = SHA-256 del número', 'AccountSid': 'comprobar que es la cuenta propia'}
tabla2 = pd.DataFrame([{'campo': c, 'valor': twilio[c][:34], 'destino en el contrato': d}
                       for c, d in destinos.items()])
print(tabla2.to_string(index = False))
obligatorios = ['id_pedido', 'canal', 'cliente', 'renglones', 'fecha_entrega', 'total_centavos']
presentes = [c for c in obligatorios if c in borrador]
print('Campos recibidos:', len(twilio), '| obligatorios del contrato presentes:', presentes,
      f'({len(presentes)} de {len(obligatorios)})')
print('Destino correcto:', borrador['destino_ok'], '| clave del cliente:', borrador['clave_cliente'])
      campo                              valor                    destino en el contrato
 MessageSid SM0000000000000000000000000000a023        id_mensaje (clave de idempotencia)
       From             whatsapp:+525500000012        cliente.telefono (sin "whatsapp:")
         To             whatsapp:+525500000001 comprobar que es el número de Trigo Lento
       Body Hola, quiero un pastel de chocolat         notas (texto a extraer el Día 30)
   NumMedia                                  0         adjuntos (fotos: revisión humana)
ProfileName                       Laura Robles              cliente.nombre (provisional)
       WaId                       525500000012        clave_cliente = SHA-256 del número
 AccountSid AC00000000000000000000000000000000         comprobar que es la cuenta propia
Campos recibidos: 15 | obligatorios del contrato presentes: ['canal', 'cliente'] (2 de 6)
Destino correcto: True | clave del cliente: 439777f64904

El cuerpo trae $15$ campos, y solo ocho tienen un destino en Trigo Lento. La Tabla 2 los recoge con su papel.

Tabla 2. Campos del webhook de Twilio para WhatsApp y su destino en el contrato del pedido.

Campo de TwilioEjemplo (ficticio)Destino en el contratoRegla
MessageSidSM...a023id_mensajeClave de idempotencia: el mismo mensaje nunca crea dos borradores
Fromwhatsapp:+525500000012cliente.telefonoSe quita el prefijo whatsapp:; debe cumplir ^\+52[0-9]{10}$
Towhatsapp:+525500000001NingunoDebe ser el número de Trigo Lento; si no, se descarta
Body“Hola, quiero un pastel…”notasTexto libre; los renglones se extraerán con Claude el Día 30
NumMedia0adjuntosSi es mayor que cero, revisión humana: Make no interpreta fotos
ProfileNameLaura Roblescliente.nombreProvisional: es el nombre que la persona puso en su perfil
WaId525500000012clave_clienteSe guarda su SHA-256, no el número, como clave de búsqueda
AccountSidAC000...000NingunoDebe ser la cuenta propia de Trigo Lento

La salida del bloque deja tres lecciones:

  1. El borrador cumple $2$ de los $6$ campos obligatorios del contrato: canal y cliente. Faltan id_pedido, renglones, fecha_entrega y total_centavos, que están dentro de la frase “un pastel de chocolate para el sábado a las 13:00” pero que ningún módulo de Make sabe extraer todavía. Por eso TL Pedidos WhatsApp v1 no crea pedidos, crea borradores con estado “por revisar”; el Día 25 transformará los datos y el Día 30 pedirá a Claude que extraiga los renglones.
  2. El número del cliente no sirve como clave de búsqueda en claro. La clave del cliente es el SHA-256 de WaId, del que se muestran los primeros doce caracteres (439777f64904). Es la seudonimización del Día 6: el almacén de clientes se puede consultar sin que el número aparezca en la clave, y el historial de ejecuciones guarda menos datos personales.
  3. El mensaje dice a dónde iba. To y AccountSid permiten descartar un mensaje dirigido a otro número u otra cuenta. Son comprobaciones baratas, pero no sustituyen a la firma: cualquiera que copie un mensaje legítimo puede copiar también esos dos campos.

El viaje completo, con los tiempos que cada tramo puede consumir, es el diagrama de secuencia de la Figura 3.

Diagrama de secuencia de un mensaje de WhatsApp: del cliente a Twilio y al webhook de Make, la firma, el borrador y la respuesta 200, con los dos relojes: Twilio espera 15 segundos y Make 180.

Figura 3. Diagrama de secuencia de un mensaje de WhatsApp: del cliente a Twilio y al webhook de Make, la firma, el borrador y la respuesta 200, con los dos relojes: Twilio espera 15 segundos y Make 180.

El diagrama hace visible el detalle que guiará el resto de la entrada: hay dos relojes. El de Twilio, que espera $15$ segundos, y el de Make, que espera $180$. El escenario tiene que caber en el más corto.

TL Pedidos WhatsApp v1


Con los campos claros, Valeria diseña la primera versión del escenario de pedidos. La ficha del módulo pide cuatro pasos (webhook, buscar cliente, crear pedido y responder 200), y la seguridad añade uno más, al principio, porque la firma no se puede comprobar sin la clave de Twilio y la clave no puede vivir en el blueprint. El escenario queda así:

  1. Webhook de Twilio (gateway:CustomWebHook), con la opción de cabeceras activada.
  2. Leer la configuración (datastore:GetRecord) del almacén de datos de Make, el registro twilio, que guarda la dirección del webhook y la clave para verificar firmas. El almacén de datos vive en la cuenta de Make y no viaja con el blueprint: el blueprint solo lo referencia con "datastore": 0.
  3. Filtro “Firma de Twilio válida”, que compara la cabecera X-Twilio-Signature con la firma calculada. No cobra.
  4. Buscar al cliente (datastore:GetRecord) por el SHA-256 de su WaId.
  5. Escribir el borrador (google-sheets:addRow) en la pestaña “Borradores” de la hoja de pedidos, con MessageSid como identificador.
  6. Responder 200 (gateway:WebhookRespond) con la respuesta TwiML vacía.

El blueprint exportado, con las conexiones en $0$, el almacén en $0$ y el identificador de la hoja sustituido por un valor de ejemplo, es el siguiente. Se guarda como trigo_lento/dia_23/flujos/make/pedidos_whatsapp_v1.blueprint.json, la carpeta del día dentro del proyecto:

json
{
  "name": "TL Pedidos WhatsApp v1",
  "flow": [
    {
      "id": 1,
      "module": "gateway:CustomWebHook",
      "version": 1,
      "parameters": {"hook": 0, "maxResults": 1},
      "mapper": {},
      "metadata": {"designer": {"x": 0, "y": 0}}
    },
    {
      "id": 2,
      "module": "datastore:GetRecord",
      "version": 1,
      "parameters": {"datastore": 0},
      "mapper": {"key": "twilio"},
      "metadata": {"designer": {"x": 300, "y": 0}}
    },
    {
      "id": 3,
      "module": "datastore:GetRecord",
      "version": 1,
      "parameters": {"datastore": 0},
      "filter": {
        "name": "Firma de Twilio válida",
        "conditions": [[{
          "a": "{{get(map(1.__IMTHEADERS__; \"value\"; \"name\"; \"x-twilio-signature\"); 1)}}",
          "b": "{{sha1(2.data.url_webhook + \"AccountSid\" + 1.AccountSid + \"ApiVersion\" + 1.ApiVersion + \"Body\" + 1.Body + \"From\" + 1.From + \"MessageSid\" + 1.MessageSid + \"MessageType\" + 1.MessageType + \"NumMedia\" + 1.NumMedia + \"NumSegments\" + 1.NumSegments + \"ProfileName\" + 1.ProfileName + \"ReferralNumMedia\" + 1.ReferralNumMedia + \"SmsMessageSid\" + 1.SmsMessageSid + \"SmsSid\" + 1.SmsSid + \"SmsStatus\" + 1.SmsStatus + \"To\" + 1.To + \"WaId\" + 1.WaId; \"base64\"; 2.data.auth_token)}}",
          "o": "text:equal"
        }]]
      },
      "mapper": {"key": "{{sha256(1.WaId)}}"},
      "metadata": {"designer": {"x": 600, "y": 0}}
    },
    {
      "id": 4,
      "module": "google-sheets:addRow",
      "version": 2,
      "parameters": {"__IMTCONN__": 0},
      "mapper": {
        "mode": "select",
        "spreadsheetId": "/ID_DE_EJEMPLO",
        "sheetId": "Borradores",
        "includesHeaders": true,
        "insertDataOption": "INSERT_ROWS",
        "valueInputOption": "USER_ENTERED",
        "insertUnformatted": false,
        "values": {
          "0": "{{1.MessageSid}}",
          "1": "whatsapp",
          "2": "{{ifempty(3.data.nombre; 1.ProfileName)}}",
          "3": "{{replace(1.From; \"whatsapp:\"; emptystring)}}",
          "4": "{{1.Body}}",
          "5": "{{1.NumMedia}}",
          "6": "por revisar",
          "7": "{{now}}"
        }
      },
      "metadata": {"designer": {"x": 900, "y": 0}}
    },
    {
      "id": 5,
      "module": "gateway:WebhookRespond",
      "version": 1,
      "parameters": {},
      "mapper": {
        "status": "200",
        "body": "<Response></Response>",
        "headers": [{"key": "Content-Type", "value": "text/xml"}]
      },
      "metadata": {"designer": {"x": 1200, "y": 0}}
    }
  ],
  "metadata": {
    "instant": true,
    "version": 1,
    "scenario": {"roundtrips": 1, "maxErrors": 3, "autoCommit": true, "autoCommitTriggerLast": true, "sequential": true, "confidential": true, "dataloss": false, "dlq": false, "freshVariables": false},
    "designer": {"orphans": []}
  }
}

Tres detalles del blueprint merecen una lectura despacio:

  • El filtro va en el módulo 3, no entre módulos. En el formato de exportación, un filtro es una propiedad filter del módulo al que protege: el paquete solo entra al módulo 3 si cumple la condición. Su condición es una lista de grupos en OR, cada uno con condiciones en AND; aquí hay un único grupo con una única condición de igualdad de texto (text:equal).
  • La cadena firmada se escribe campo por campo, en orden alfabético. La expresión b concatena la dirección del webhook y cada nombre de parámetro seguido de su valor, de AccountSid a WaId, y la pasa a sha1 con codificación base64 y la clave leída en el módulo 2. Es la receta de Twilio que el Bloque 8 implementa en Python. Si Twilio añade un parámetro nuevo, la firma calculada deja de coincidir y el filtro rechaza el mensaje: el escenario falla cerrado, que es lo seguro.
  • Los ajustes del escenario no son los predeterminados. sequential vale true (las ejecuciones se procesan una detrás de otra) y confidential vale true (el historial no guarda el contenido de los paquetes). La sección de la cola justifica el primero; el segundo es la regla ética del Día 22 para los escenarios que manejan mensajes de clientes.

Leer el blueprint con Python sirve para lo mismo que el Día 22: comprobar que el archivo dice lo que creemos que dice y dibujarlo con la paleta de la serie. La función nodos_desde_blueprint recorre flow, dibuja cada filtro como un rombo delante del módulo que protege y, si encuentra un router, desciende por sus routes (en la versión 1 no hay ninguno, pero la función queda lista para el Día 24):

python
# Lectura del blueprint TL Pedidos WhatsApp v1 y dibujo con la paleta de la serie en Python:
import textwrap
from matplotlib.patches import Ellipse, FancyBboxPatch, Polygon, RegularPolygon, FancyArrowPatch

with open(carpeta / 'flujos' / 'make' / 'pedidos_whatsapp_v1.blueprint.json', encoding = 'utf-8') as f:
    blueprint = json.load(f)

FORMAS = {   # paleta de formas de la serie: tipo -> (forma, relleno)
    'disparador': ('ovalo', '#3a9d5d'), 'accion': ('rectangulo', '#2a78d6'),
    'decision': ('rombo', '#eb6834'), 'ia': ('rectangulo', '#7b5ea7'),
    'error': ('rectangulo', '#c8423b'), 'humano': ('hexagono', '#8a8986'),
}
NOMBRES = {'gateway:CustomWebHook': 'Webhook de Twilio', 'datastore:GetRecord': 'Leer registro',
           'google-sheets:addRow': 'Escribir borrador', 'gateway:WebhookRespond': 'Responder 200'}

def tipo_de(modulo):
    if modulo.startswith('gateway:CustomWebHook'):
        return 'disparador'
    if modulo in ('builtin:BasicRouter', 'Filtro'):
        return 'decision'
    if modulo.startswith('builtin:') and modulo != 'builtin:BasicFeeder':
        return 'error'
    return 'accion'

def nodos_desde_blueprint(bp, x0 = 0.0, y0 = 0.0, previo = None, nodos = None, aristas = None):
    # Recorre flow[]; un filtro se dibuja como rombo antes del módulo; los routers abren ramas en routes:
    nodos = [] if nodos is None else nodos
    aristas = [] if aristas is None else aristas
    x = x0
    for m in bp['flow']:
        if 'filter' in m:
            nombre = f"filtro_{m['id']}"
            nodos.append({'id': nombre, 'tipo': 'decision', 'texto': 'Filtro:\n' + textwrap.fill(m['filter']['name'], 12),
                          'x': x, 'y': y0})
            if previo:
                aristas.append((previo, nombre, ''))
            previo, x = nombre, x + 2.5
        texto = f"{m['id']}. {NOMBRES.get(m['module'], m['module'].split(':')[-1])}\n({m['module'].split(':')[0]})"
        nodos.append({'id': m['id'], 'tipo': tipo_de(m['module']), 'texto': texto, 'x': x, 'y': y0})
        if previo:
            aristas.append((previo, m['id'], ''))
        for e in m.get('onerror', []):
            nodos.append({'id': e['id'], 'tipo': 'error', 'texto': e['module'], 'x': x, 'y': y0 - 1.6})
            aristas.append((m['id'], e['id'], 'error'))
        for i, ruta in enumerate(m.get('routes', [])):
            nodos_desde_blueprint(ruta, x + 2.5, y0 - 1.6 * i, m['id'], nodos, aristas)
        previo, x = m['id'], x + 2.5
    return nodos, aristas

def dibujar_flujo(nodos, aristas, titulo, ax = None, ancho = 2.1, alto = 1.25, recorte = 68):
    if ax is None:
        fig, ax = plt.subplots(figsize = (13, 3.6))
    for n in nodos:
        forma, color = FORMAS[n['tipo']]
        x, y = n['x'], n['y']
        if forma == 'ovalo':
            figura = Ellipse((x, y), ancho, alto)
        elif forma == 'rombo':
            figura = Polygon([(x - ancho / 1.6, y), (x, y + alto / 1.3), (x + ancho / 1.6, y), (x, y - alto / 1.3)])
        elif forma == 'hexagono':
            figura = RegularPolygon((x, y), 6, radius = ancho / 2, orientation = np.pi / 6)
        else:
            figura = FancyBboxPatch((x - ancho / 2, y - alto / 2), ancho, alto, boxstyle = 'round,pad=0.03')
        figura.set_facecolor(color)
        figura.set_edgecolor('white')
        figura.set_linewidth(2)
        ax.add_patch(figura)
        ax.text(x, y, n['texto'], ha = 'center', va = 'center', fontsize = 8.5, color = 'white')
    posicion = {n['id']: (n['x'], n['y']) for n in nodos}
    for origen, destino, etiqueta in aristas:
        (x1, y1), (x2, y2) = posicion[origen], posicion[destino]
        ax.add_patch(FancyArrowPatch((x1, y1), (x2, y2), arrowstyle = '-|>', mutation_scale = 12,
                                     shrinkA = recorte, shrinkB = recorte + 2, color = '#52514e', linewidth = 1.3))
        if etiqueta:
            ax.text((x1 + x2) / 2 + 0.15, (y1 + y2) / 2, etiqueta, ha = 'left', fontsize = 8, color = '#52514e')
    xs = [n['x'] for n in nodos]
    ax.set_xlim(min(xs) - 1.4, max(xs) + 1.4)
    ax.set_ylim(-1.2, 1.2)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_title(titulo, fontsize = 16)
    return ax

nodos, aristas = nodos_desde_blueprint(blueprint)
for m in blueprint['flow']:
    print(f"{m['id']}  {m['module']:<24} v{m['version']}  tipo: {tipo_de(m['module']):<10}"
          f"  filtro: {m['filter']['name'] if 'filter' in m else '-'}")
print('Nodos dibujados:', len(nodos), '| aristas:', len(aristas),
      '| ajustes:', {k: blueprint['metadata']['scenario'][k] for k in ('sequential', 'confidential', 'maxErrors')})
dibujar_flujo(nodos, aristas, blueprint['name'] + ': dibujado desde su blueprint')
plt.tight_layout()
plt.show()
1  gateway:CustomWebHook    v1  tipo: disparador  filtro: -
2  datastore:GetRecord      v1  tipo: accion      filtro: -
3  datastore:GetRecord      v1  tipo: accion      filtro: Firma de Twilio válida
4  google-sheets:addRow     v2  tipo: accion      filtro: -
5  gateway:WebhookRespond   v1  tipo: accion      filtro: -
Nodos dibujados: 6 | aristas: 5 | ajustes: {'sequential': True, 'confidential': True, 'maxErrors': 3}

Gráfica: TL Pedidos WhatsApp v1. Lectura del blueprint TL Pedidos WhatsApp v1 y dibujo con la paleta de la serie

El dibujo muestra $6$ nodos y $5$ aristas: el óvalo verde del webhook, el rombo naranja del filtro y cuatro rectángulos azules de acción. El rombo está entre la lectura de la configuración y la búsqueda del cliente, que es justo donde tiene que estar: ningún dato del cliente se lee antes de saber que el mensaje es auténtico, y el único módulo que cobra antes del filtro es el que trae la clave.

Por la fórmula del crédito por ejecución, un mensaje auténtico consume $5$ créditos (los cinco módulos, con un paquete cada uno) y uno con firma falsa consume $2$ (el webhook y la configuración). El filtro no suma. El Bloque 10 hará la cuenta del mes completo.

La cola del webhook en la hora pico


Volvamos a la segunda pregunta de Don Ernesto. De 10:00 a 12:00 llegan unos $9$ mensajes por hora, pero no llegan repartidos: llegan en ráfagas, como los tres de la señora Robles. Un cliente escribe dos, tres o cuatro mensajes seguidos, separados por unos segundos, y luego pasan diez minutos sin nada. Make ofrece dos formas de procesarlos:

  • En paralelo (lo predeterminado en los escenarios con webhook instantáneo): cada mensaje arranca su propia ejecución en cuanto llega, aunque otra siga en marcha.
  • En secuencia (sequential: true): cada ejecución espera a que termine la anterior, y los mensajes que llegan mientras tanto aguardan en la cola del webhook.

La teoría de colas da una primera estimación. Con llegadas de Poisson a ritmo $\lambda$ y un tiempo de servicio medio $s$, la utilización y la espera media en cola de un servidor único (modelo M/M/1) son:

$$ \rho = \lambda, s, \qquad W_q = \frac{\rho, s}{1-\rho}, \qquad L_q = \lambda, W_q $$

La lógica detrás de estas fórmulas es múltiple:

  • $\rho$ es la fracción del tiempo que el escenario está ocupado: con $\lambda = 9$ mensajes por hora ($0.0025$ por segundo) y $s = 4$ segundos, $\rho = 0.01$; el escenario está ocupado el $1%$ del tiempo.
  • $W_q$ es la espera media antes de empezar: crece sin límite cuando $\rho$ se acerca a $1$. Con $s = 4$ segundos sale $0.04$ segundos; con un escenario lento de $60$ segundos (por ejemplo, uno que esperara a un modelo de lenguaje), $\rho = 0.15$ y $W_q = 10.59$ segundos.
  • $L_q = \lambda W_q$ es la ley de Little: el número medio de mensajes en cola es el ritmo de llegada por la espera media.
  • En conclusión, el modelo supone llegadas independientes, y las ráfagas no lo son: la fórmula da un suelo, no una predicción. Hace falta simular.

El siguiente bloque simula $1,000$ horas pico de dos horas. En cada una llegan clientes a un ritmo de $3$ por hora; cada cliente escribe $1$ más un número de Poisson de media $2$ mensajes, separados por esperas exponenciales de $20$ segundos de media. El tiempo de servicio sigue una lognormal con mediana de $4$ segundos (el escenario v1: leer, buscar, escribir y responder) o de $60$ segundos (el escenario lento). Para que la comparación sea justa, las cuatro configuraciones reciben exactamente los mismos mensajes y el mismo ruido. Además de la espera, el bloque cuenta dos riesgos que solo aparecen en paralelo: los solapes (un mensaje de un cliente empieza mientras otro mensaje del mismo cliente sigue en ejecución) y el desorden (el segundo mensaje de un cliente termina antes que el primero):

python
# Simulación de la cola del webhook en la hora pico: secuencial frente a paralelo en Python:
PICO = 2 * 3600                     # de 10:00 a 12:00, en segundos

def generar_pico(rng):
    # Ráfagas: 3 clientes por hora; cada uno escribe 1 + Poisson(2) mensajes separados ~20 s:
    llegadas, clientes = [], []
    for c in range(rng.poisson(3 * PICO / 3600)):
        t = rng.uniform(0, PICO)
        for _ in range(1 + rng.poisson(2)):
            llegadas.append(t)
            clientes.append(c)
            t += rng.exponential(20)
    orden = np.argsort(llegadas, kind = 'stable')
    return np.array(llegadas)[orden], np.array(clientes, dtype = int)[orden]

def ejecutar(llegadas, servicio, secuencial):
    inicio, fin, libre = np.empty_like(llegadas), np.empty_like(llegadas), 0.0
    for i, (t, s) in enumerate(zip(llegadas, servicio)):
        inicio[i] = max(t, libre) if secuencial else t
        fin[i] = inicio[i] + s
        libre = fin[i]
    return inicio, fin

def metricas(llegadas, clientes, inicio, fin):
    cola_max = max((np.sum((llegadas <= t) & (inicio > t)) for t in llegadas), default = 0)
    solapes = inversiones = 0
    for c in np.unique(clientes):
        idx = np.where(clientes == c)[0]
        solapes += int(np.sum(inicio[idx[1:]] < fin[idx[:-1]]))      # mismo cliente en dos ejecuciones
        inversiones += int(np.sum(fin[idx[1:]] < fin[idx[:-1]]))    # el segundo mensaje termina antes
    return cola_max, solapes, inversiones

CONFIGS = {'Secuencial, 4 s': (4, True), 'Paralelo, 4 s': (4, False),
           'Secuencial, 60 s': (60, True), 'Paralelo, 60 s': (60, False)}
DIAS_SIM = 1_000
picos = [generar_pico(rng) for _ in range(DIAS_SIM)]
ruido = [rng.lognormal(0, 0.35, size = len(p[0])) for p in picos]   # mismo ruido para comparar configuraciones
resultados, guardados = {}, {}
for nombre, (mediana, secuencial) in CONFIGS.items():
    esperas, respuestas, colas, solapes, inversiones = [], [], [], 0, 0
    for (llegadas, clientes), z in zip(picos, ruido):
        inicio, fin = ejecutar(llegadas, mediana * z, secuencial)
        cm, so, inv = metricas(llegadas, clientes, inicio, fin)
        esperas.append(inicio - llegadas)
        respuestas.append(fin - llegadas)
        colas.append(cm)
        solapes += so
        inversiones += inv
    esperas, respuestas = np.concatenate(esperas), np.concatenate(respuestas)
    guardados[nombre] = respuestas
    resultados[nombre] = {'espera_media_s': round(esperas.mean(), 2), 'espera_p95_s': round(np.percentile(esperas, 95), 1),
                          'cola_máx': max(colas), 'días_cola>2_%': round(100 * np.mean(np.array(colas) > 2), 1),
                          'solapes_cliente': solapes, 'desorden': inversiones}
n_mensajes = sum(len(p[0]) for p in picos)
print(f'Días simulados: {DIAS_SIM} | mensajes en la hora pico: {n_mensajes} '
      f'({n_mensajes / DIAS_SIM / 2:.2f} por hora)')
print(pd.DataFrame.from_dict(resultados, orient = 'index').to_string())

# Referencia analítica M/M/1 con llegadas de Poisson (sin ráfagas):
lam = 9 / 3600
for s in (4, 60):
    rho = lam * s
    print(f'Servicio de {s} s: rho = {rho:.3f} | espera M/M/1 = {rho * s / (1 - rho):.2f} s')

# Figura: la cola de un día con muchas ráfagas:
dia = int(np.argmax([len(p[0]) for p in picos[:50]]))
llegadas, clientes = picos[dia]
reloj = np.linspace(0, PICO + 600, 4000)
fig, ejes = plt.subplots(2, 1, figsize = (11, 6.2), sharex = True)
for nombre, color in [('Secuencial, 4 s', '#2a78d6'), ('Secuencial, 60 s', '#eb6834')]:
    mediana, secuencial = CONFIGS[nombre]
    inicio, fin = ejecutar(llegadas, mediana * ruido[dia], secuencial)
    en_cola = [np.sum((llegadas <= t) & (inicio > t)) for t in reloj]
    ejes[0].step(reloj / 60, en_cola, where = 'post', color = color, linewidth = 2, label = nombre)
for nombre, color in [('Paralelo, 4 s', '#2a78d6'), ('Paralelo, 60 s', '#eb6834')]:
    mediana, secuencial = CONFIGS[nombre]
    inicio, fin = ejecutar(llegadas, mediana * ruido[dia], secuencial)
    activas = [np.sum((inicio <= t) & (fin > t)) for t in reloj]
    ejes[1].step(reloj / 60, activas, where = 'post', color = color, linewidth = 2, label = nombre)
ejes[0].set_ylabel('Mensajes en cola')
ejes[1].set_ylabel('Ejecuciones a la vez')
ejes[1].set_xlabel('Minutos desde las 10:00')
for ax in ejes:
    ax.legend(loc = 'upper right', frameon = False)
    ax.grid(color = '#e6e5e1')
    ax.set_axisbelow(True)
ejes[0].set_title(f'Hora pico simulada (día {dia}, {len(llegadas)} mensajes)', fontsize = 16)
plt.tight_layout()
plt.show()
Días simulados: 1000 | mensajes en la hora pico: 17368 (8.68 por hora)
                  espera_media_s  espera_p95_s  cola_máx  días_cola>2_%  solapes_cliente  desorden
Secuencial, 4 s             0.37           3.0         3            0.8                0         0
Paralelo, 4 s               0.00           0.0         0            0.0             2135       411
Secuencial, 60 s           79.53         258.7        14           78.6                0         0
Paralelo, 60 s              0.00           0.0         0            0.0            10832      3419
Servicio de 4 s: rho = 0.010 | espera M/M/1 = 0.04 s
Servicio de 60 s: rho = 0.150 | espera M/M/1 = 10.59 s

Gráfica: La cola del webhook en la hora pico. Simulación de la cola del webhook en la hora pico: secuencial frente a paralelo

La simulación genera $17,368$ mensajes, $8.68$ por hora, muy cerca de los $9$ del supuesto. La Tabla 3 resume las cuatro configuraciones.

Tabla 3. Secuencial frente a paralelo en 1 000 horas pico simuladas: espera, riesgo de duplicados y orden de los mensajes.

ConfiguraciónEspera media (s)Espera p95 (s)Cola máximaDías con cola de más de 2Solapes del mismo clienteMensajes en desorden
Secuencial, 4 s0.373.030.8 %00
Paralelo, 4 s0000 %2 135411
Secuencial, 60 s79.53258.71478.6 %00
Paralelo, 60 s0000 %10 8323 419

Las cifras se leen por pares:

  1. Con el escenario v1, la secuencia casi no cuesta. La espera media es de $0.37$ segundos, el percentil $95$ de $3.0$ segundos y la cola solo superó dos mensajes en el $0.8%$ de los días, con un máximo de $3$. La simulación confirma el ejemplo de la ficha: con $4$ segundos por mensaje, la cola casi nunca pasa de dos. La espera simulada es nueve veces la del modelo M/M/1 ($0.04$ segundos) porque los mensajes de una ráfaga sí se esperan entre ellos.
  2. El paralelo con el escenario v1 tiene un precio oculto. No hay espera, pero hubo $2,135$ solapes y $411$ mensajes en desorden en mil días. Cada solape es una ventana en la que dos ejecuciones del mismo cliente buscan su ficha a la vez; si ambas deciden que el cliente no existe y lo crean, el resultado es un cliente duplicado. Cada desorden es un borrador que dice “para el sábado a las 13:00” registrado antes que el “quiero un pastel de chocolate” al que completa.
  3. Con un escenario de $60$ segundos, la secuencia se atasca. La espera media sube a $79.53$ segundos, el percentil $95$ a $258.7$ y la cola llega a $14$ mensajes; en el $78.6%$ de los días la cola pasa de dos. Las ráfagas convierten un $15%$ de ocupación media en minutos de espera.
  4. Y el paralelo lento multiplica los riesgos: $10,832$ solapes y $3,419$ mensajes en desorden.

La figura del bloque muestra el día $46$, el de más mensajes entre los cincuenta primeros ($43$, más del doble de lo normal). Arriba, la cola secuencial: con $4$ segundos apenas asoma un mensaje en espera; con $60$ segundos, la ráfaga de los minutos $87$ a $100$ forma una cola de $13$ mensajes que tarda veinte minutos en vaciarse. Abajo, el paralelo: con $60$ segundos llega a haber $7$ ejecuciones a la vez, cada una con su crédito y su riesgo.

La decisión de Valeria para la versión 1 es ejecución secuencial: con un escenario de $4$ segundos la espera es despreciable, y a cambio no hay solapes ni desorden. La lección de las filas de $60$ segundos queda anotada para el Día 30: cuando Claude entre en el escenario, no podrá hacerlo en el camino que espera Twilio.

Atención aquí: la ejecución paralela es la opción predeterminada de los escenarios con webhook instantáneo, y no avisa de nada: los escenarios funcionan perfectamente en las pruebas, donde los mensajes llegan de uno en uno. Los duplicados aparecen semanas después, en una mañana con ráfagas, y el Día 29 contará la anatomía de un webhook real que duplicó 37 pedidos. Si su escenario busca un registro y lo crea cuando no existe, active la ejecución secuencial o use una clave de idempotencia (aquí, MessageSid) que la base de datos rechace si ya existe; mejor, las dos.

Responder a tiempo


La cola explica cuánto espera un mensaje antes de que el escenario lo procese. Falta la pregunta que de verdad le importa al emisor: ¿cuánto tarda Twilio en recibir su 200? Twilio aplica a cada petición de webhook un tiempo total máximo de $15$ segundos (el valor predeterminado es también el máximo que permite configurar) y, por omisión, reintenta una sola vez y solo si no pudo conectarse. Si la conexión se establece pero la respuesta no llega en $15$ segundos, Twilio corta, registra un error y no reintenta. Ese comportamiento se puede cambiar con los parámetros de conexión que se añaden al final de la URL (por ejemplo, #rp=rt para reintentar también cuando no hay respuesta a tiempo), y ahí aparece el peligro: un reintento de un mensaje que Make sí recibió y sí procesó es un duplicado.

Para un mensaje concreto, el tiempo hasta el 200 es la espera en cola más el tiempo hasta el módulo de respuesta:

$$ T_{200} = W + s_{resp}, \qquad P_{venc} = P\left(T_{200} > 15\ \mathrm{s}\right) $$

La lógica detrás de esta fórmula es múltiple:

  • $W$ es la espera en la cola del webhook, que en ejecución secuencial depende de lo que tarden los mensajes anteriores, no del propio.
  • $s_{resp}$ es el tiempo desde que empieza la ejecución hasta que el módulo de respuesta envía el 200: todo el escenario si el módulo va al final, medio segundo si va al principio.
  • $P_{venc}$ es la probabilidad de que Twilio deje de esperar; cada mensaje vencido es un error en su registro y, con la política rt, un reintento.
  • En conclusión, mover la respuesta al principio acorta $s_{resp}$, pero no $W$: en secuencia, un mensaje sigue esperando a los anteriores antes de que su ejecución pueda siquiera responder.

El siguiente bloque calcula $T_{200}$ para los mismos $17,368$ mensajes en cuatro escenarios y repite cada uno con la política de reintento rt, en la que cada mensaje vencido vuelve a entrar a la cola $15$ segundos después:

python
# Tiempo hasta la respuesta 200 y reintentos del emisor con el límite de 15 s de Twilio en Python:
LIMITE_TWILIO = 15.0      # tiempo total por petición (tt, predeterminado y máximo), revisado en octubre de 2026
RESPUESTA_TEMPRANA = 0.5  # segundos hasta el módulo de respuesta si va al principio

def tiempos_200(mediana, secuencial, temprana = False, reintentos = False):
    tiempos, duplicados = [], 0
    for (llegadas, clientes), z in zip(picos, ruido):
        servicio = mediana * z
        inicio, fin = ejecutar(llegadas, servicio, secuencial)
        t200 = (inicio + RESPUESTA_TEMPRANA if temprana else fin) - llegadas
        if reintentos:   # política rt: Twilio repite una vez lo que no respondió en 15 s
            vencidos = t200 > LIMITE_TWILIO
            duplicados += int(vencidos.sum())
            todas = np.concatenate([llegadas, llegadas[vencidos] + LIMITE_TWILIO])
            serv = np.concatenate([servicio, servicio[vencidos]])
            orden = np.argsort(todas, kind = 'stable')
            ini2, fin2 = ejecutar(todas[orden], serv[orden], secuencial)
            t2 = (ini2 + RESPUESTA_TEMPRANA if temprana else fin2) - todas[orden]
            t200 = t2[orden.argsort()][:len(llegadas)]
        tiempos.append(t200)
    return np.concatenate(tiempos), duplicados

ESCENARIOS = {'Secuencial 4 s, respuesta al final': (4, True, False),
              'Secuencial 60 s, respuesta al final': (60, True, False),
              'Secuencial 60 s, respuesta al principio': (60, True, True),
              'Paralelo 60 s, respuesta al principio': (60, False, True)}
filas, curvas = [], {}
for nombre, (mediana, secuencial, temprana) in ESCENARIOS.items():
    t, _ = tiempos_200(mediana, secuencial, temprana)
    _, dup = tiempos_200(mediana, secuencial, temprana, reintentos = True)
    curvas[nombre] = t
    filas.append({'escenario': nombre, 'p50_s': round(np.median(t), 1), 'p95_s': round(np.percentile(t, 95), 1),
                  'vencidos_%': round(100 * np.mean(t > LIMITE_TWILIO), 2), 'duplicados_rt': dup})
print(pd.DataFrame(filas).to_string(index = False))

fig, ax = plt.subplots(figsize = (10, 5.2))
bordes = np.logspace(np.log10(0.2), np.log10(1200), 60)
for (nombre, t), color in zip(curvas.items(), ['#2a78d6', '#eb6834', '#7b5ea7', '#3a9d5d']):
    ax.hist(t, bins = bordes, histtype = 'step', linewidth = 2, color = color, label = nombre)
ax.axvline(LIMITE_TWILIO, color = '#c8423b', linestyle = '--', linewidth = 2)
ax.text(LIMITE_TWILIO * 1.08, 25_000, 'Twilio deja de esperar: 15 s', color = '#c8423b', fontsize = 10)
ax.set_xscale('log')
ax.set_yscale('log')
ax.set_ylim(0.8, 40_000)
ax.set_xlabel('Segundos desde que Twilio envía el mensaje hasta recibir el 200 (escala logarítmica)')
ax.set_ylabel('Mensajes (escala logarítmica)')
ax.set_title('Tiempo hasta la respuesta 200 en la hora pico', fontsize = 16)
ax.legend(loc = 'upper right', bbox_to_anchor = (1, 0.86), frameon = False, fontsize = 9)
ax.grid(color = '#e6e5e1')
ax.set_axisbelow(True)
plt.tight_layout()
plt.show()
                              escenario  p50_s  p95_s  vencidos_%  duplicados_rt
     Secuencial 4 s, respuesta al final    4.2    8.3        0.07             13
    Secuencial 60 s, respuesta al final  119.9  326.8      100.00          17368
Secuencial 60 s, respuesta al principio   55.5  259.2       67.47          11719
  Paralelo 60 s, respuesta al principio    0.5    0.5        0.00              0

Gráfica: Responder a tiempo. Tiempo hasta la respuesta 200 y reintentos del emisor con el límite de 15 s de Twilio

Tabla 4. Tiempo hasta la respuesta 200 y duplicados con la política de reintento rt en 1 000 horas pico.

EscenarioMediana (s)p95 (s)Respuestas después de 15 sDuplicados con rt
Secuencial 4 s, respuesta al final4.28.30.07 %13
Secuencial 60 s, respuesta al final119.9326.8100 %17 368
Secuencial 60 s, respuesta al principio55.5259.267.47 %11 719
Paralelo 60 s, respuesta al principio0.50.50 %0

La Tabla 4 cuenta cuatro historias:

  1. El escenario v1 responde a tiempo. Mediana de $4.2$ segundos, percentil $95$ de $8.3$, y solo el $0.07%$ de los mensajes (una docena en mil días) pasa de $15$ segundos. Con la política predeterminada de Twilio esos mensajes no se duplican: Twilio registra el error y Make los procesa igual. Con rt habría $13$ duplicados, que MessageSid permite detectar.
  2. Un escenario de $60$ segundos con la respuesta al final es incompatible con Twilio. El $100%$ de los mensajes vence; con rt, cada mensaje se procesaría dos veces. Los $180$ segundos de Make no ayudan: es el reloj corto el que manda.
  3. Mover la respuesta al principio, en secuencia, no basta. La mediana baja a $55.5$ segundos, pero el $67.47%$ sigue venciendo, porque la respuesta temprana solo llega cuando la ejecución empieza, y la ejecución espera a la cola. Es la conclusión de la fórmula, ahora con números.
  4. Responder al principio en paralelo resuelve el reloj y abre la puerta a los solapes. El 200 sale en medio segundo siempre, pero el paralelo lento acumulaba $10,832$ solapes en la Tabla 3.

El histograma, con los dos ejes en escala logarítmica, enseña la forma de cada escenario: la campana azul del v1 queda entera a la izquierda de la línea roja de $15$ segundos; las del escenario lento quedan a la derecha. La conclusión de arquitectura es la del Día 3, ahora con nombre de módulo: el camino que espera el emisor debe ser corto y constante. Si algo lento tiene que ocurrir (Claude, una API externa, un archivo grande), el escenario rápido acusa recibo y deja el trabajo en un almacén o en la cola de otro escenario, que lo procesa sin que nadie espere al teléfono. El Día 30 aplicará exactamente esa separación.

Verificar que el mensaje viene de Twilio


Falta la primera pregunta de Don Ernesto: ¿qué impide que alguien encargue diez pasteles de broma? La dirección del webhook es secreta, pero los secretos se filtran: una captura de pantalla, una exportación sin limpiar, un registro de errores. La defensa que no depende de que la dirección siga en secreto es la firma. En cada petición, Twilio añade la cabecera X-Twilio-Signature, calculada con una clave que solo conocen Twilio y Trigo Lento (el Auth Token de la cuenta, o una clave de firma específica). Quien no tenga la clave no puede producir una firma válida, y quien altere un solo carácter del mensaje la invalida.

El Día 3 firmó y verificó webhooks con HMAC-SHA256 sobre un identificador, una marca de tiempo y el cuerpo. Twilio usa otra receta, más antigua, que hay que seguir al pie de la letra:

$$ \mathrm{firma} = \mathrm{Base64}\Big(\mathrm{HMAC\text{-}SHA1}\big(K,; U ,\Vert, k_1 v_1 ,\Vert, k_2 v_2 ,\Vert \cdots \Vert, k_n v_n\big)\Big) $$

La lógica detrás de esta fórmula es múltiple:

  • $U$ es la dirección completa a la que Twilio envió la petición, exactamente como está configurada, con su protocolo y sin barras de más.
  • $k_1, \dots, k_n$ son los nombres de los parámetros del cuerpo en orden alfabético, y cada uno va seguido de su valor $v_i$, ya decodificado; $\Vert$ es la concatenación.
  • $K$ es la clave secreta; HMAC-SHA1 produce un resumen de $20$ bytes, que Base64 convierte en un texto de $28$ caracteres.
  • En conclusión, la firma depende de la dirección, de todos los parámetros y de la clave: cambiar cualquiera de las tres cosas produce otra firma.

El siguiente bloque implementa la receta y la pone a prueba con una clave de prueba que no es real, sobre el mensaje del Bloque 3. La comparación se hace con hmac.compare_digest, que tarda lo mismo acierte o falle, para no regalar pistas a quien intente adivinar la firma carácter a carácter:

python
# Verificación de la firma X-Twilio-Signature con hmac y hashlib.sha1 en Python:
URL_WEBHOOK = 'https://webhook.ejemplo.invalid/trigo-lento/whatsapp'   # en producción, la URL de Make
CLAVE_PRUEBA = 'clave_de_prueba_que_no_es_real'                      # en producción, el Auth Token de Twilio

def firma_twilio(url, parametros, clave):
    # URL completa + cada nombre y valor, en orden alfabético de nombre; HMAC-SHA1; base64:
    cadena = url + ''.join(k + parametros[k] for k in sorted(parametros))
    digesto = hmac.new(clave.encode('utf-8'), cadena.encode('utf-8'), hashlib.sha1).digest()
    return base64.b64encode(digesto).decode('ascii')

def firma_valida(url, parametros, cabecera, clave):
    return hmac.compare_digest(firma_twilio(url, parametros, clave), cabecera)

cabecera = firma_twilio(URL_WEBHOOK, twilio, CLAVE_PRUEBA)      # la que enviaría Twilio
alterado = dict(twilio, Body = twilio['Body'].replace('13:00', '11:00'))
con_campo_nuevo = dict(twilio, ChannelMetadata = '{}')
pruebas = {'Mensaje original': (URL_WEBHOOK, twilio),
           'Cuerpo alterado (otra hora)': (URL_WEBHOOK, alterado),
           'Campo nuevo de Twilio': (URL_WEBHOOK, con_campo_nuevo),
           'URL con barra final': (URL_WEBHOOK + '/', twilio),
           'Otra clave': (URL_WEBHOOK, twilio)}
print('X-Twilio-Signature:', cabecera, f'({len(cabecera)} caracteres)')
for nombre, (url, params) in pruebas.items():
    clave = 'otra_clave_cualquiera' if nombre == 'Otra clave' else CLAVE_PRUEBA
    print(f'{nombre:<28} -> {"válida" if firma_valida(url, params, cabecera, clave) else "RECHAZADA"}')
print('Longitud de la cadena firmada:', len(URL_WEBHOOK + ''.join(k + twilio[k] for k in sorted(twilio))))
X-Twilio-Signature: 0Ax16s2aPnPpEMV7BQ43U4oWyh8= (28 caracteres)
Mensaje original             -> válida
Cuerpo alterado (otra hora)  -> RECHAZADA
Campo nuevo de Twilio        -> RECHAZADA
URL con barra final          -> RECHAZADA
Otra clave                   -> RECHAZADA
Longitud de la cadena firmada: 472

La firma del mensaje original es 0Ax16s2aPnPpEMV7BQ43U4oWyh8=, de $28$ caracteres, calculada sobre una cadena de $472$. Las cinco pruebas recorren los cinco destinos posibles:

  1. El mensaje original pasa.
  2. El cuerpo alterado se rechaza. Cambiar “13:00” por “11:00” en el texto, una broma de dos caracteres, invalida la firma.
  3. Un campo nuevo se rechaza. Si Twilio añadiera mañana un parámetro, la firma que envía lo incluiría y la que calcula una lista fija de campos no. Es el fallo cerrado del filtro de Make: incómodo, porque obliga a actualizar la expresión, pero seguro, porque nunca deja pasar un mensaje sin verificar.
  4. Una barra final en la dirección se rechaza. Es el error más frecuente en la práctica: la dirección configurada en Twilio y la usada para verificar deben ser idénticas, carácter a carácter.
  5. Otra clave se rechaza, que es lo que le pasaría al bromista de Don Ernesto.

Atención aquí: la clave que firma los webhooks de Twilio es un secreto de primer nivel; con ella, cualquiera puede fabricar mensajes que su escenario aceptará como auténticos. En TL Pedidos WhatsApp v1 vive en un registro del almacén de datos de Make, fuera del blueprint, con acceso solo para el equipo de Trigo Lento; nunca en el mapper, en una nota del escenario ni en una hoja de cálculo. El Día 17 (Seguridad I: OAuth, secretos y mínimo privilegio en las conexiones) mostró cómo inventariar y rotar secretos: si la clave se filtra, se rota en Twilio y se actualiza el registro, y los mensajes firmados con la clave vieja empiezan a rechazarse de inmediato. Pruebe siempre con una clave de prueba y números ficticios, como en esta entrada.

Prueba con un mensaje imitado

Antes de conectar el número de WhatsApp real, Valeria prueba el escenario enviándole peticiones que imitan a Twilio desde su laptop: una con la firma correcta y otra con la firma de un cuerpo distinto. La dirección del webhook y la clave de prueba se leen de variables de entorno, y el bloque reutiliza el mensaje del Bloque 3 y la función firma_twilio del Bloque 8. Este bloque llama al webhook real y no se ejecutó para escribir la entrada; la salida es un ejemplo comentado:

python
# Envío de un mensaje de prueba al webhook de Make imitando a Twilio en Python:
import os
import time
import requests

# La URL del webhook y la clave de prueba viven en variables de entorno, nunca en el código:
url = os.environ['MAKE_WEBHOOK_TL_PEDIDOS']
clave = os.environ['TWILIO_AUTH_TOKEN_PRUEBA']

mensaje = dict(twilio, Body = 'Prueba automática: 2 baguettes para mañana a las 9:00',
               ProfileName = 'Cliente de prueba', MessageSid = 'SM0000000000000000000000000000b009')
casos = {'firma correcta': firma_twilio(url, mensaje, clave),
         'firma alterada': firma_twilio(url, dict(mensaje, Body = 'otro texto'), clave)}
for nombre, firma in casos.items():
    inicio = time.perf_counter()
    r = requests.post(url, data = mensaje, timeout = 15,          # el mismo límite que aplica Twilio
                      headers = {'X-Twilio-Signature': firma})
    print(f'{nombre:<15} {r.status_code} | {r.headers.get("Content-Type")} | '
          f'{r.text[:40]!r} | {time.perf_counter() - inicio:.1f} s')

# Salida de ejemplo (puede variar):
# firma correcta  200 | text/xml; charset=utf-8 | '<Response></Response>' | 3.6 s
# firma alterada  200 | text/plain; charset=utf-8 | 'Accepted' | 1.1 s

La salida de ejemplo enseña la diferencia entre los dos casos. Con la firma correcta, el escenario recorre los cinco módulos y responde, en unos $3.6$ segundos, con la respuesta TwiML vacía y text/xml. Con la firma alterada, el filtro detiene el paquete, el módulo de respuesta nunca se alcanza y Make contesta con su respuesta predeterminada, “Accepted”, en texto plano. Para Twilio, las dos son un 200: la versión 1 rechaza el mensaje falso en silencio, sin escribir nada y sin gastar más que $2$ créditos. Devolver un 403 explícito necesita un router con dos rutas, que es el tema del Día 24.

Validación y créditos antes de activar

El último paso antes de activar el escenario es una revisión automática del blueprint, en la línea del validador del Día 22, con dos comprobaciones nuevas propias de un escenario que recibe mensajes de clientes: que la firma se verifique antes de leer cualquier dato del cliente y que el escenario sea secuencial y confidencial. El bloque cierra con la cuenta de créditos del mes:

python
# Validación del blueprint v1 y créditos del mes en Make Core en Python:
def recorrer(flow):
    for m in flow:
        yield m
        for e in m.get('onerror', []):
            yield e
        for ruta in m.get('routes', []):
            yield from recorrer(ruta['flow'])

texto = json.dumps(blueprint, ensure_ascii = False)
modulos = list(recorrer(blueprint['flow']))
ids = [m['id'] for m in modulos]
filtro_en = next(i for i, m in enumerate(blueprint['flow']) if 'filter' in m)
primer_dato_cliente = next(i for i, m in enumerate(blueprint['flow']) if '1.WaId' in json.dumps(m.get('mapper', {})))
verificaciones = {
    'ids únicos': len(ids) == len(set(ids)),
    'posiciones enteras': all(isinstance(m['metadata']['designer'][k], int) for m in modulos for k in ('x', 'y')),
    'conexiones en 0': all(m.get('parameters', {}).get('__IMTCONN__', 0) == 0 for m in modulos),
    'sin claves ni URL reales': not re.search(r'sk-|hook\.\w+\.make\.com|AC[0-9a-f]{32}|\+52\d{10}', texto),
    'firma antes de leer al cliente': filtro_en <= primer_dato_cliente,
    'respuesta al final': blueprint['flow'][-1]['module'] == 'gateway:WebhookRespond',
    'ejecución secuencial': blueprint['metadata']['scenario']['sequential'],
    'datos confidenciales': blueprint['metadata']['scenario']['confidential'],
}
for nombre, ok in verificaciones.items():
    print(f'{nombre:<32} {"OK" if ok else "FALLA"}')

# Créditos: un mensaje auténtico recorre los 5 módulos; uno con firma inválida, solo 2 (el filtro no cobra):
CREDITOS_VALIDO, CREDITOS_RECHAZADO = len(blueprint['flow']), filtro_en
mes_v1 = MENSAJES_MES * CREDITOS_VALIDO
sincronizacion = 1_560                     # Día 16: sincronización cada 10 minutos
total = mes_v1 + sincronizacion
print(f'Créditos por mensaje: {CREDITOS_VALIDO} (válido) y {CREDITOS_RECHAZADO} (rechazado)')
print(f'TL Pedidos WhatsApp v1: {mes_v1:,} créditos al mes | estimación del Día 12: 7,644 | diferencia: {mes_v1 - 7_644:+}')
print(f'Con la sincronización del Día 16: {total:,} de 10,000 ({total / 100:.1f} %) | margen: {10_000 - total:,}')
print('Cola del webhook en Core: 667 elementos | máximo simulado en la hora pico:',
      max(r['cola_máx'] for r in resultados.values()))
ids únicos                       OK
posiciones enteras               OK
conexiones en 0                  OK
sin claves ni URL reales         OK
firma antes de leer al cliente   OK
respuesta al final               OK
ejecución secuencial             OK
datos confidenciales             OK
Créditos por mensaje: 5 (válido) y 2 (rechazado)
TL Pedidos WhatsApp v1: 7,800 créditos al mes | estimación del Día 12: 7,644 | diferencia: +156
Con la sincronización del Día 16: 9,360 de 10,000 (93.6 %) | margen: 640
Cola del webhook en Core: 667 elementos | máximo simulado en la hora pico: 14

Las ocho verificaciones salen bien, y las cifras de créditos merecen un comentario:

  • Cada mensaje auténtico cuesta $5$ créditos y cada mensaje rechazado, $2$. Con $1,560$ mensajes al mes, la versión 1 consume $7,800$ créditos, $156$ más que la estimación del Día 12 para el flujo de mensajes completo ($7,644$). La comparación no es entre iguales: la versión 1 paga un crédito más por mensaje que aquel flujo, el de leer la configuración para verificar la firma ($1,560$ créditos), y todavía no paga los $3$ créditos por encargo que aquel preveía ($1,404$); la diferencia neta es de $156$.
  • Junto con la sincronización cada $10$ minutos del Día 16, el plan Core queda al $93.6%$: $9,360$ de $10,000$ créditos, con un margen de $640$. Es poco, y es una advertencia temprana: cuando el escenario crezca con el router del Día 24 y la llamada a Claude del Día 30, cada módulo nuevo se pagará $1,560$ veces al mes. Valeria lo apunta para la revisión de costes del módulo.
  • La cola del plan Core admite $667$ mensajes, y la peor hora pico simulada formó una de $14$. Ni con el escenario lento estaría cerca del límite; el riesgo real de la cola no es su tamaño, sino el tiempo que hace esperar a Twilio.

Consideraciones finales


El día empezó con dos preguntas de Don Ernesto y termina con dos respuestas con números. Al bromista lo detiene un filtro que compara la cabecera X-Twilio-Signature con una firma HMAC-SHA1 que solo puede calcular quien tenga la clave; el filtro no cobra, va antes de cualquier dato del cliente y falla cerrado si algo cambia. A las once de la mañana, los mensajes se forman en fila: con un escenario de $4$ segundos la espera media es de $0.37$ segundos, el $99.93%$ de los mensajes recibe su 200 antes de que Twilio deje de esperar y no hay solapes ni desorden. Un webhook no es una puerta abierta: es una puerta con mirilla, y el escenario decide a quién abre antes de dejarlo pasar.

Antes de dar por terminado el escenario, conviene hacerse algunas preguntas:

  • ¿Qué tarda más de $15$ segundos en su escenario, y está en el camino que espera el emisor?
  • ¿Qué pasaría si el mismo mensaje llegara dos veces? Si la respuesta es “dos borradores”, falta una clave de idempotencia.
  • ¿Dónde vive la clave que verifica las firmas, y quién más puede leerla?
  • ¿Cuántos créditos cuesta cada módulo nuevo multiplicado por los mensajes del mes, y cuánto margen queda?

Las cifras de hoy tienen límites claros. Las ráfagas, los tiempos de servicio y los $17,368$ mensajes son de juguete, generados con supuestos declarados; los tiempos reales de Make y de Google Sheets varían con la carga de sus servidores, y un día de lluvia o una promoción cambian el ritmo de llegada. La expresión del filtro de firma sigue la documentación de Make y de Twilio, pero su prueba definitiva es el registro de un mensaje real, que Valeria revisará en el laboratorio del Día 26. Ninguno de esos límites cambia las conclusiones: decidir es gratis y actuar cuesta, el reloj corto manda y la secuencia es la opción segura mientras el escenario sea rápido.

Un gran libro que habla de esto es Enterprise Integration Patterns (Patrones de integración empresarial), en el que Gregor Hohpe y Bobby Woolf describen, con un vocabulario que sigue vigente veinte años después, los canales, los mensajes, las colas, los consumidores competidores, los receptores idempotentes y el reenvío garantizado. Cada módulo de Make de hoy tiene un patrón con nombre en ese libro: el webhook es un adaptador de canal, el filtro es un filtro de mensajes y la ejecución secuencial es un consumidor único que preserva el orden. Leerlo ayuda a reconocer el problema antes de buscar la herramienta.

Conclusiones clave


Módulos

  • Make clasifica sus módulos por papel: disparadores instantáneos y de sondeo, acciones, búsquedas, iteradores, agregadores, transformadores, universales y respuestas de webhook; el papel decide dónde va el módulo, cuántos paquetes produce y cuánto cobra.
  • El consumo de una ejecución es $C_{ej} = \sum_m p_m$; routers y filtros no cobran, así que las decisiones se ponen lo antes posible.
  • Un sondeo cada $15$ minutos hace $2,880$ comprobaciones al mes, el $69.1%$ vacías, con $7.5$ minutos de espera media; el webhook inmediato consume $1,560$ créditos de disparador con latencia de segundos.

Webhooks

  • Un webhook de Make encola: admite $300$ peticiones cada $10$ segundos y $667$ elementos de cola por cada $10,000$ créditos, y sin módulo de respuesta contesta “200 Accepted”.
  • Twilio entrega el mensaje como formulario con $15$ campos; el borrador cumple $2$ de los $6$ obligatorios del contrato, usa MessageSid como clave de idempotencia y el SHA-256 de WaId como clave del cliente.
  • La firma $\mathrm{Base64}(\mathrm{HMAC\text{-}SHA1}(K, U \Vert k_1 v_1 \cdots))$ rechaza cuerpos alterados, campos nuevos, direcciones distintas y claves falsas; en TL Pedidos WhatsApp v1 va en un filtro antes de leer al cliente.

Rendimiento

  • En $1,000$ horas pico simuladas, la ejecución secuencial con $4$ segundos por mensaje espera $0.37$ segundos de media y la cola casi nunca pasa de dos; la paralela no espera, pero produce $2,135$ solapes y $411$ mensajes en desorden.
  • Twilio espera $15$ segundos y Make $180$: manda el reloj corto. El v1 responde con mediana de $4.2$ segundos y solo el $0.07%$ vence; un escenario de $60$ segundos vence siempre, y responder al principio en secuencia no lo arregla ($67.47%$).
  • El v1 cuesta $5$ créditos por mensaje, $7,800$ al mes; con la sincronización del Día 16, el plan Core queda al $93.6%$ y cada módulo nuevo se pagará $1,560$ veces.

Mañana, en el Día 24: Routers y filtros: decidir sin código. Hoy el escenario rechaza en silencio los mensajes falsos y trata igual un pedido, una pregunta por el horario y una queja; mañana Valeria pondrá un router con rutas ordenadas y una ruta de respaldo, escribirá filtros con grupos de condiciones y probará con cientos de mensajes en Python que ninguna ruta deja huecos ni se solapa con otra.

Referencias


  1. Make (2026). Webhooks. Centro de ayuda de Make. https://help.make.com/webhooks
  2. Make (2026). Webhooks (Custom webhook y Webhook response). Documentación de aplicaciones de Make. https://apps.make.com/gateway
  3. Make (2026). Referencia de la API: escenarios y blueprints. https://developers.make.com/api-documentation
  4. Twilio (2026). Webhooks Security. Documentación de Twilio. https://www.twilio.com/docs/usage/webhooks/webhooks-security
  5. Twilio (2026). Webhooks (HTTP callbacks): Connection Overrides. Documentación de Twilio. https://www.twilio.com/docs/usage/webhooks/webhooks-connection-overrides
  6. Twilio (2026). Mensajes entrantes de WhatsApp: parámetros de la petición. Documentación de Twilio. https://www.twilio.com/docs/whatsapp
  7. Hohpe, G. y Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley.
  8. Krawczyk, H., Bellare, M. y Canetti, R. (1997). HMAC: Keyed-Hashing for Message Authentication. RFC 2104. https://www.rfc-editor.org/rfc/rfc2104
  9. Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383-387.
comments powered by Disqus