Arquitecto de automatización con IA: Día 26
Índice de contenido
Resumen:
Dos clientes de Trigo Lento olvidaron recoger su pastel y un tercero llegó un día antes. En este laboratorio guiado, Valeria construye en Make, módulo por módulo, el escenario TL Recordatorio de recogida v1: una programación diaria a las 18:00, una búsqueda de los pedidos de mañana, un filtro de consentimiento, teléfono y franja horaria, un almacén de datos que impide avisar dos veces, un envío simulado con su manejador de errores y el registro de cada aviso. Después exporta el blueprint, lo valida, reproduce su lógica en un simulador de Python (13 recordatorios y 40 créditos en la primera corrida, 0 recordatorios y 14 créditos si alguien la repite), simula un mes y una caída del envío y calcula los créditos con el filtro temprano y con el tardío. Al terminar, usted sabrá construir un escenario programado en Make con sus defensas, estimar su consumo antes de activarlo y aprobarlo con una lista de comprobación de 12 puntos.
¿Cómo citar el presente artículo?
Romero, J. (26 de febrero de 2026). Arquitecto de automatización con IA: Día 26. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_26/.
Arquitecto de automatización con IA: Día 26 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_26/.
Planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude revisados 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) y no procede de una cuenta real: el identificador del módulo de búsqueda de Google Sheets, los nombres de los operadores de filtro y las claves del manejador builtin:Break deben contrastarse exportando el escenario antes de activarlo. El calendario de reintentos automáticos, los requisitos del manejador de errores, el formato de la programación en la API y el endpoint de ejecución de escenarios se tomaron de la documentación de Make; el formato del mensaje de plantilla, de la documentación de la Cloud API de WhatsApp de Meta, ambos revisados en octubre de 2026.
Día 26. Laboratorio guiado en Make: el escenario de recordatorios de recogida, paso a paso
Introducción
Ayer, en el Día 25 (Transformar datos en Make: funciones, iteradores y agregadores), Valeria aprendió a transformar textos, números y fechas con las funciones de Make, a fijar la zona America/Mexico_City para que “pal sábado” no se convierta en el sábado equivocado y a dividir un pedido en renglones con un iterador para reconstruirlo con un agregador, y Trigo Lento ganó TL Pedidos WhatsApp v2.1, un escenario que ya entrega pedidos normalizados. Hoy no se aprende una pieza nueva de Make: se usan juntas las de los cuatro días anteriores para construir, de principio a fin, un escenario completo que nada tiene que ver con WhatsApp entrante.
El problema llegó ayer, miércoles 25 de febrero, y llegó tres veces. A las siete de la tarde, cuando Don Ernesto apagaba el horno, dos pasteles de chocolate seguían en la vitrina de encargos: sus dueñas habían olvidado pasar por ellos. Una contestó el teléfono al día siguiente con mucha pena; la otra no contestó. Y a las diez de la mañana, un señor llegó por la caja de pan dulce que había encargado para el jueves, convencido de que era miércoles. Tres encargos, tres malos ratos y, en el caso de los pasteles, unos $880$ MXN de producto que se quedó en el mostrador un día de más.
Valeria tiene la solución escrita desde hace una semana. El Día 18 (Flujos que se dibujan solos: generar automatizaciones con IA) usó el recordatorio de recogida como ejemplo de especificación: un mensaje de WhatsApp con plantilla de utilidad, solo para quien dio su consentimiento, con nombre y hora. Aquella especificación sirvió para que Claude dibujara un borrador de n8n y para aprender a validarlo; hoy Valeria la construye en Make, la plataforma del módulo 3, y la construye a mano, módulo a módulo, porque quiere entender cada decisión antes de confiarle sus clientes a un escenario que trabaja solo a las seis de la tarde.
Don Ernesto escucha el plan mientras cuenta la caja y pone una sola condición: “Un recordatorio está bien; dos ya son acoso. Y a las once de la noche no le escribe usted a nadie, ni aunque la máquina se equivoque.” Son dos requisitos de arquitectura disfrazados de sentido común. El primero pide idempotencia: el escenario puede ejecutarse dos veces, pero el cliente solo recibe un aviso por pedido. El segundo pide una franja horaria que se respete también cuando algo falla y el sistema reintenta. Los dos se resuelven hoy con piezas concretas de Make y con números.
En la presente entrada, descubrirá cómo se construye un escenario programado en Make paso a paso: la programación diaria, la búsqueda de los pedidos del día siguiente en la hoja de cálculo, los filtros de consentimiento, teléfono y franja que detienen los paquetes antes de que cuesten créditos, el almacén de datos que recuerda a quién ya se avisó, el envío simulado con su manejador de errores y el registro final. Verá el blueprint completo, lo validará con Python, lo reproducirá en un simulador que cuenta créditos módulo por módulo, simulará un mes de corridas con reejecuciones y una caída del envío, calculará el coste de colocar el filtro antes o después de la consulta al almacén y cerrará con una lista de comprobación de $12$ puntos y la ejecución a demanda por la API de Make.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Construir paso a paso un escenario programado en Make: búsqueda, filtros, almacén de datos, envío y manejador de errores.
- Filtrar por consentimiento y por horario antes de gastar créditos, y evitar recordatorios duplicados con un almacén de datos.
- Exportar el blueprint, validarlo contra las reglas de la serie y reproducir su lógica en un simulador de Python.
- Calcular el consumo de créditos del escenario y aprobarlo con una lista de comprobación antes de activarlo.
Lo que vamos a construir
Un laboratorio guiado empieza por el plano. La especificación del Día 18 se escribió para n8n y para un recordatorio el mismo día de la recogida, a las 9:00. Al llevarla a Make, Valeria la revisa con lo que pasó ayer y cambia tres cosas: el recordatorio se envía la víspera a las 18:00, porque a las 9:00 del día de la recogida el señor de la caja de pan dulce ya iba en camino con la fecha equivocada; la fuente es la hoja Pedidos, que desde el Día 22 recibe los pedidos de todos los canales, con sus columnas reales; y el registro de envíos pasa de una pestaña de la hoja a un almacén de datos de Make, que se consulta en cada corrida. La Tabla 1 compara las dos versiones.
Tabla 1. Especificación del recordatorio de recogida: la del Día 18 y la que se construye hoy en Make.
| Campo | Día 18 (n8n) | Día 26 (Make) |
|---|---|---|
| Disparador | De lunes a sábado a las 9:00, el mismo día | De domingo a viernes a las 18:00, la víspera, zona America/Mexico_City |
| Entradas | Hoja Encargos: id_pedido, nombre, telefono, fecha_recogida, hora_recogida, whatsapp_ok | Hoja Pedidos: id_pedido, nombre_pila, telefono, fecha_entrega, hora_recogida, consentimiento_whatsapp, baja |
| Pasos | Leer las filas de hoy, conservar las que tienen consentimiento, enviar la plantilla, anotar el envío | Buscar los pedidos de mañana, filtrar consentimiento, baja, teléfono y franja, consultar el almacén, enviar, registrar |
| Salidas | Pestaña Envios con sid, estado y fecha | Almacén recordatorios_enviados con id_pedido, enviado_en y estado_http |
| Errores | Tres intentos con 5 segundos de espera ante un 429 o un 5xx | Manejador builtin:Break con 3 reintentos cada 30 minutos y ejecuciones incompletas activadas |
| Paso humano | Valeria revisa, asigna credenciales y activa | Además, los pedidos sin teléfono válido van a la lista de llamadas de Valeria |
El escenario se llamará TL Recordatorio de recogida v1 y tendrá seis módulos: cuatro que trabajan y dos manejadores de errores. La Tabla 2 los presenta en el orden en que se añadirán, con el identificador interno que aparecerá en el blueprint y los créditos que consumirá en la corrida del jueves 26 de febrero, la del ejemplo de hoy, en la que $13$ pedidos pasan los filtros.
Tabla 2. Módulos de TL Recordatorio de recogida v1, su identificador en el blueprint (a contrastar exportando), su función y sus créditos en la corrida del 26 de febrero.
| Id | Identificador | Función | Créditos en la corrida | Paso en que se añade |
|---|---|---|---|---|
| 1 | google-sheets:filterRows | Buscar en la hoja Pedidos las filas con fecha_entrega de mañana | 1 | Paso 2 |
| 2 | datastore:GetRecord | Consultar si el pedido ya está en recordatorios_enviados; lo protege el filtro de consentimiento, teléfono y franja | 13 | Pasos 3 y 4 |
| 3 | http:ActionSendData | Enviar el cuerpo de la plantilla al webhook de prueba; lo protege el filtro “Aún no avisado” | 13 | Paso 5 |
| 4 | builtin:Break | Manejador de errores del envío: guarda el paquete y lo reintenta | 0 | Paso 5 |
| 5 | datastore:AddRecord | Registrar el pedido avisado en el almacén | 13 | Paso 4 |
| 6 | builtin:Break | Manejador de errores del registro | 0 | Paso 5 |
Dos decisiones de la tabla merecen una frase cada una. El envío es simulado hoy: el módulo HTTP apunta al webhook de prueba propio que Valeria montó el Día 21, y el mensaje real por WhatsApp llegará en el módulo 5, cuando la plantilla esté aprobada por Meta y la mensajería tenga presupuesto. Y los manejadores de errores no cobran créditos por sí mismos: lo que cuesta es volver a ejecutar el módulo que falló, como se verá en la sección de créditos.
Las cinco estaciones del escenario caben en un solo dibujo, que conviene tener a la vista durante todo el laboratorio.
Figura 1. El escenario en cinco estaciones: la programación de las 18:00, la búsqueda de los 18 pedidos de mañana, el filtro que deja pasar 13, la consulta al almacén y el envío con su registro; la corrida cuesta 40 créditos.
Comencemos, como todos los días de la serie, por las importaciones y la semilla global. El bloque declara también las dos constantes que el resto del día usa como reglas: los $10,000$ créditos del plan Core de Make y la franja de 9:00 a 20:00:
# Importaciones base, semilla de la serie y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import re
import sqlite3
from datetime import datetime, timedelta
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_26'
(carpeta / 'flujos' / 'make').mkdir(parents = True, exist_ok = True)
CREDITOS_CORE = 10_000 # créditos al mes del plan Core de Make (Tabla B del plan)
FRANJA = (9, 20) # de 9:00 a 20:00, hora de la Ciudad de México
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_26
El entorno es el de los días anteriores, con sqlite3 añadido para el almacén de datos simulado y datetime para las horas de cada corrida. Todos los archivos del día se guardan en trigo_lento/dia_26/.
Paso 1: la programación
Un escenario de recordatorios no espera a que pase nada fuera: decide él cuándo trabajar. Por eso no empieza con un webhook, como TL Pedidos WhatsApp v1 del Día 23, sino con una programación. En el lenguaje del blueprint, el escenario es no instantáneo: metadata.instant vale false, y el primer módulo, que en un escenario con webhook es el disparador, aquí es una búsqueda que se ejecuta cada vez que llega la hora programada.
Valeria elige la programación por días de la semana: a las 18:00, de domingo a viernes. El razonamiento es de calendario, no de tecnología. El recordatorio avisa de la recogida de mañana; Trigo Lento entrega de lunes a sábado, así que el domingo a las 18:00 se avisa de los pedidos del lunes y el viernes a las 18:00 de los del sábado. El sábado no hay corrida, porque el domingo no hay recogidas. Son $6$ corridas por semana, unas $26$ al mes, el mismo número de días de trabajo que usa la serie desde el Día 1.
Tres detalles de la programación se configuran fuera del lienzo y conviene revisarlos uno por uno:
- La zona horaria. Make ejecuta la programación y calcula
nowen la zona horaria configurada en la organización y en el perfil del usuario. El Día 25 mostró lo que pasa cuando un servidor vive en UTC y la panadería en la Ciudad de México: a las 23:30 de un viernes, el servidor ya está en el sábado. Para un recordatorio a las 18:00 el desfase sería de seis horas, y “mañana” podría calcularse sobre el día equivocado si alguien adelantara la corrida a medianoche. La zona de la organización debe serAmerica/Mexico_City, que no tiene horario de verano desde 2022. - La programación no viaja en el blueprint. Esto sorprende la primera vez: un blueprint exportado contiene los módulos, los filtros, los manejadores y los ajustes del escenario, pero no la hora a la que se ejecuta. En la API de Make, la programación se envía aparte, como una cadena JSON propia (con tipos como
immediately,indefinitely,once,daily,weekly,monthly,yearlyyon-demand), al crear o actualizar el escenario. Para Trigo Lento sería algo como{"type": "weekly", "days": [...], "time": "18:00"}; la numeración de los días de la semana debe comprobarse en la cuenta, porque la documentación no la fija con claridad. Por eso la programación se anota en la ficha del escenario del Día 19 y en la lista de comprobación de hoy. - La programación no es la única forma de ejecutarlo. El botón “Run once” del editor ejecuta el escenario en el momento, y la API permite ejecutarlo a demanda. Las dos formas son útiles para probar, y las dos son la razón de que el escenario tenga que ser idempotente: nadie garantiza que se ejecute solo una vez al día.
Esta última observación es la primera mitad de la objeción de Don Ernesto. Si Valeria pulsa “Run once” a las 18:40 para comprobar que todo salió bien, el escenario vuelve a buscar los mismos pedidos de mañana. Sin una memoria de lo que ya hizo, los $13$ clientes recibirían un segundo mensaje. El Paso 4 construye esa memoria.
Paso 2: buscar los pedidos de mañana
El primer módulo del escenario es una búsqueda en la hoja Pedidos de Google Sheets: el módulo de búsqueda de filas (Search Rows), con un filtro sobre la columna fecha_entrega. El valor que se busca es la fecha de mañana, que Make calcula con dos funciones del Día 25 anidadas:
{{formatDate(addDays(now; 1); "YYYY-MM-DD")}}
La expresión se lee de dentro hacia fuera: now es el instante de la corrida, addDays(now; 1) le suma un día y formatDate(...; "YYYY-MM-DD") lo escribe con el formato de la columna de la hoja, 2026-02-27. Si la hoja guardara las fechas en otro formato, como 27/02/2026, la búsqueda no encontraría nada y no daría error: devolvería cero filas y el escenario terminaría en silencio. Por eso el formato de fecha forma parte del contrato de datos del Día 4.
Para probar el escenario, Valeria prepara una copia de la hoja con los pedidos que de verdad tiene Trigo Lento para el viernes 27, pero con nombres de pila inventados y teléfonos con el formato de prueba de la serie (+52 55 0000 00XX). La hoja tiene las siete columnas de la Tabla 1: el identificador del pedido, el nombre de pila (el recordatorio no necesita el apellido), el teléfono en formato internacional, la fecha de entrega, la hora de recogida, el consentimiento para avisos por WhatsApp y la columna baja, que guarda la fecha en que el cliente pidió no recibir más mensajes. El siguiente bloque construye la hoja de prueba sin azar, para que el ejemplo sea exactamente reproducible:
# Hoja Pedidos de prueba con los 18 encargos del viernes 27 de febrero en Python:
nombres = ['Laura', 'Miguel', 'Sofía', 'Arturo', 'Elena', 'Ramiro', 'Paola', 'Héctor', 'Lucía',
'Tomás', 'Irene', 'Gerardo', 'Natalia', 'Óscar', 'Beatriz', 'Julián', 'Carmen', 'Rubén']
horas = ['09:30', '10:00', '10:30', '11:00', '11:30', '11:30', '12:00', '12:30', '13:00',
'13:00', '14:00', '15:00', '16:00', '16:30', '17:00', '18:00', '18:30', '19:00']
consentimiento = ['sí'] * 15 + ['no'] * 3
orden = [2, 15, 0, 7, 16, 3, 10, 5, 12, 1, 17, 8, 4, 13, 9, 6, 11, 14] # mezcla fija, sin azar
pedidos = pd.DataFrame({
'id_pedido': [f'TL-260227-{i + 1:02d}' for i in range(18)],
'nombre_pila': nombres,
'telefono': [f'+525500000{i + 1:03d}' for i in range(18)],
'fecha_entrega': '2026-02-27',
'hora_recogida': horas,
'consentimiento_whatsapp': [consentimiento[j] for j in orden],
'baja': '',
})
pedidos.loc[pedidos['id_pedido'] == 'TL-260227-07', 'baja'] = '2026-02-20' # pidió "BAJA" la semana pasada
pedidos.loc[pedidos['id_pedido'] == 'TL-260227-09', 'telefono'] = '' # encargo por teléfono fijo
pedidos.to_csv(carpeta / 'pedidos_2026-02-27.csv', index = False)
print(pedidos.head(6).to_string(index = False))
print('Filas:', len(pedidos), '| con consentimiento:', (pedidos['consentimiento_whatsapp'] == 'sí').sum(),
'| con baja:', (pedidos['baja'] != '').sum(), '| sin teléfono:', (pedidos['telefono'] == '').sum())
print('Consentimiento por pedido:', ''.join('S' if c == 'sí' else 'n' for c in pedidos['consentimiento_whatsapp']))
id_pedido nombre_pila telefono fecha_entrega hora_recogida consentimiento_whatsapp baja
TL-260227-01 Laura +525500000001 2026-02-27 09:30 sí
TL-260227-02 Miguel +525500000002 2026-02-27 10:00 no
TL-260227-03 Sofía +525500000003 2026-02-27 10:30 sí
TL-260227-04 Arturo +525500000004 2026-02-27 11:00 sí
TL-260227-05 Elena +525500000005 2026-02-27 11:30 no
TL-260227-06 Ramiro +525500000006 2026-02-27 11:30 sí
Filas: 18 | con consentimiento: 15 | con baja: 1 | sin teléfono: 1
Consentimiento por pedido: SnSSnSSSSSnSSSSSSS
La hoja tiene $18$ pedidos para el viernes 27, la media de Trigo Lento. De ellos, $15$ tienen consentimiento para avisos por WhatsApp, $1$ tiene una baja registrada (el pedido TL-260227-07, de un cliente que escribió “BAJA” la semana pasada y volvió a encargar en mostrador) y $1$ no tiene teléfono (el TL-260227-09, un encargo hecho por teléfono fijo). La cadena de la última línea resume el consentimiento pedido a pedido: tres “no” repartidos entre quince “sí”.
Una regla de créditos de este módulo vale para todo el día: una búsqueda cobra un crédito por corrida, devuelva una fila o dieciocho. El módulo procesa un único paquete de entrada (el de la programación) y produce tantos paquetes de salida como filas encuentra; los créditos se cuentan por las operaciones del módulo, no por los paquetes que entrega. Lo que sí multiplica la búsqueda es el coste de todo lo que viene después, porque cada fila es un paquete que recorrerá el resto del escenario. El identificador interno del módulo de búsqueda no está en la lista de identificadores que la serie fijó al planificar el módulo 3, y Valeria lo anota como el primero que hay que contrastar al exportar.
Paso 3: un paquete por pedido y los filtros
El plano original del laboratorio ponía un iterador (builtin:BasicFeeder) después de la búsqueda, para “convertir los resultados en un paquete por pedido”. Al construirlo, Valeria descubre que sobra. El iterador del Día 25 tenía sentido porque un pedido de WhatsApp llegaba como un paquete con un arreglo de renglones dentro, y había que separarlos. La búsqueda de filas, en cambio, ya entrega un paquete por fila: dieciocho pedidos son dieciocho paquetes. Poner un iterador detrás no tendría ningún arreglo que recorrer, y en el mejor caso cobraría un crédito por corrida sin hacer nada, $26$ créditos al mes. La lección es útil fuera de este escenario: un iterador solo hace falta cuando un paquete trae un arreglo dentro; los módulos de búsqueda ya iteran.
Con un paquete por pedido, lo siguiente es decidir cuáles merecen un recordatorio. Valeria pone un único filtro, con cinco condiciones en AND, en la entrada del módulo 2 (la consulta al almacén). La Tabla 3 las recoge con la expresión de Make y la razón de cada una.
Tabla 3. Condiciones del filtro "Consentimiento, teléfono y franja" (todas deben cumplirse; operadores a contrastar exportando).
| Condición | Expresión en el filtro | Operador | Por qué |
|---|---|---|---|
| Consentimiento | {{1.consentimiento_whatsapp}} igual a sí | text:equal | Solo se escribe a quien aceptó avisos por WhatsApp |
| Sin baja | {{1.baja}} no existe | notexist | Una baja se respeta aunque el consentimiento siga en “sí” |
| Teléfono válido | {{1.telefono}} cumple ^[+]52[0-9]{10}$ | text:pattern | Un teléfono vacío o mal escrito no se intenta: va a la lista de llamadas |
| Desde las 9:00 | {{formatDate(now; "H")}} mayor o igual que 9 | number:greaterorequal | Nadie recibe un aviso de madrugada |
| Antes de las 20:00 | {{formatDate(now; "H")}} menor que 20 | number:less | Ni de noche, aunque alguien pulse “Run once” a las 21:15 |
Tres observaciones sobre el filtro:
- La baja manda sobre el consentimiento. El cliente del pedido
TL-260227-07aceptó avisos hace meses y se dio de baja la semana pasada; la hoja conserva el “sí” de entonces y añade la fecha de la baja. Si el filtro solo mirara el consentimiento, le llegaría un mensaje que pidió no recibir. Es la regla ética de la serie: una baja se respeta de inmediato y de forma automática. - El teléfono se valida antes de gastar. El patrón exige el prefijo
+52y diez dígitos, el formato que normaliza el Día 25. El pedido sin teléfono no pasa, y no se pierde: la lista de llamadas de Valeria es una vista filtrada de la misma hoja (consentimiento “sí”, sin baja y sin teléfono válido), que no cuesta créditos porque no es un módulo. - La franja vive en el filtro. La corrida programada de las 18:00 siempre la cumple; la condición está para las corridas que nadie programó: un “Run once” nocturno, una ejecución a demanda por la API o un escenario que se reactiva tarde después de un error. El Paso 5 explica por qué los reintentos necesitan además otra defensa.
¿Por qué el filtro va antes de la consulta al almacén y no después? Porque los filtros no cobran y los módulos sí. El Día 24 lo llamó “filtrar temprano”: cada pedido que el filtro detiene es un pedido por el que no se paga la consulta. La sección de créditos lo convierte en una fórmula y en una figura.
Paso 4: no repetir
La primera mitad de la condición de Don Ernesto, “dos ya son acoso”, pide que el escenario recuerde a quién avisó. Make ofrece para eso los almacenes de datos (data stores): pequeñas tablas que viven en la cuenta de Make, con una clave por registro y una estructura de campos declarada. Valeria crea el almacén recordatorios_enviados con la clave id_pedido y tres campos: el propio id_pedido, enviado_en (fecha y hora del aviso) y estado_http (la respuesta del envío). No guarda ni el nombre ni el teléfono: para no repetir basta con saber qué pedido se avisó, y un almacén con menos datos personales es un almacén con menos riesgo.
El almacén entra en el escenario en dos momentos, que forman el patrón más útil del día:
- Antes de enviar, se consulta. El módulo 2 (
datastore:GetRecord) busca el registro con la clave del pedido. Si el pedido ya está, el módulo devuelve sus datos; si no está, la salida viene vacía. - Entre la consulta y el envío, un filtro decide. El filtro “Aún no avisado” deja pasar el paquete solo si la clave del registro no existe (operador
notexist): es decir, si la consulta no encontró nada. - Después de enviar, se registra. El módulo 5 (
datastore:AddRecord) escribe el registro con la clave del pedido, y lo hace después del envío, no antes, para que un envío fallido no quede marcado como hecho.
La metáfora que Valeria usa con su padre es la de una libreta junto a la caja: antes de llamar a un cliente se busca su pedido en la libreta, y solo después de colgar se apunta. Si a media llamada se corta la línea, no se apunta nada y la siguiente persona que mire la libreta sabrá que hay que volver a llamar.
Figura 2. La libreta de los ya avisados: el almacén se consulta antes de enviar y se firma después; a las 18:00 hay 13 envíos y 13 firmas, y en la reejecución de las 18:40, 13 consultas y ningún envío.
El orden “consultar, enviar, registrar” tiene un punto débil que conviene ver de frente: si el envío funciona y el registro falla, la libreta no se firma y la siguiente corrida volvería a avisar. El manejador de errores del módulo 5, que se describe en el Paso 5, cierra ese hueco reintentando solo el registro. Make ofrece además un módulo para comprobar la existencia de un registro sin traer sus datos, que sería una alternativa igual de válida a la consulta; Valeria usa la consulta porque el Día 27, JSON y variables en Make: estructuras de datos, Parse JSON y almacenes, explicará los almacenes a fondo y partirá de ella.
Paso 5: el envío simulado y su manejador de errores
El módulo 3 es un módulo HTTP (http:ActionSendData, “Make a request”) que hace un POST al webhook de prueba propio de Trigo Lento, el del Día 21, con un cuerpo JSON que tiene exactamente la forma de un mensaje de plantilla de la Cloud API de WhatsApp: el teléfono de destino, el nombre de la plantilla recordatorio_recogida, el idioma es_MX y tres parámetros (nombre de pila, número de pedido y hora de recogida). Que el cuerpo sea idéntico al real tiene una ventaja práctica: cuando el módulo 5 apunte el envío a la API de Meta o de Twilio, el cambio será la URL y la conexión, no el contenido.
La URL que aparece en el blueprint es un marcador que no existe (https://webhook.ejemplo.invalid/...); la dirección real del webhook de prueba se escribe en el editor de Make al importar, y nunca viaja en un archivo que se comparte. Es la regla del Día 17 aplicada a un escenario que, aunque sea de prueba, recibe teléfonos.
El manejador de errores
Un envío puede fallar por razones que no son culpa del escenario: el webhook de prueba se reinicia, la red tiene un mal minuto, el servicio de mensajería responde con un $500$. Sin manejador, el error detendría el paquete y, según los ajustes del escenario, podría desactivarlo. Con el manejador Break (builtin:Break), el comportamiento cambia. Según la documentación de Make revisada en octubre de 2026, Break saca del flujo el paquete que falló y guarda el error, sus datos y el resto del recorrido como una ejecución incompleta; los demás paquetes siguen su camino, y la ejecución incompleta se puede reintentar de forma automática o resolver a mano. Para que Break funcione, el escenario debe tener activado el ajuste de ejecuciones incompletas (dlq: true en el blueprint); sin él, un Break no tiene dónde guardar nada.
Valeria pone un Break detrás de cada uno de los dos módulos que tocan el exterior:
- En el envío (módulo 4): si el mensaje falla, el paquete se guarda y se reintenta. Como el registro va después, un envío fallido nunca queda apuntado en el almacén.
- En el registro (módulo 6): si el mensaje salió pero el registro falló, el reintento empieza en el módulo que falló, que es el registro, y no vuelve a enviar el mensaje. Esta es la propiedad que cierra el hueco del Paso 4, y la documentación de Make la describe así: la reanudación empieza por el módulo que causó el error.
Falta decidir cuántas veces y cada cuánto se reintenta, y aquí la segunda mitad de la condición de Don Ernesto (“a las once de la noche no le escribe usted a nadie”) se vuelve técnica. La documentación de Make describe un calendario de reintentos automáticos para los errores de conexión, de límite de peticiones y de tiempo agotado: ocho intentos a $1$, $11$, $21$, $51$, $81$ y $111$ minutos, y a $4$ horas $51$ minutos y $7$ horas $51$ minutos del error. Para la corrida de las 18:00, los dos últimos caerían a las 22:51 y a la 1:51. Y hay un detalle que lo agrava: como el reintento empieza en el módulo que falló, el filtro de franja del módulo 2, que va antes, no se vuelve a evaluar.
La defensa, entonces, no puede depender del filtro. Valeria configura el Break con un calendario que, por construcción, no sale de la franja: $3$ reintentos cada $30$ minutos. La regla general es una desigualdad sencilla:
$$ t_0 + n \cdot \Delta < t_{fin}, \qquad P_{sin\ resolver} = p^{,n+1} $$
La lógica detrás de estas fórmulas es múltiple:
- $t_0$ es la hora de la corrida (18:00), $n$ el número de reintentos y $\Delta$ el intervalo entre ellos; si el último reintento, $t_0 + n\Delta$, ocurre antes del fin de la franja $t_{fin}$ (20:00), ningún reintento automático escribe fuera de horario. Con $n = 3$ y $\Delta = 30$ minutos, el último es a las 19:30.
- $P_{sin\ resolver}$ es la probabilidad de que el envío original y sus $n$ reintentos fallen todos, si los fallos son independientes y cada intento falla con probabilidad $p$; con $p = 0.05$ y $n = 3$, $0.05^4 = 6.25 \cdot 10^{-6}$.
- Lo que no se resuelve no se pierde: queda como ejecución incompleta “sin resolver”, y Valeria la atiende a la mañana siguiente, dentro de la franja, o llama al cliente si su recogida es temprana.
- En conclusión, la franja se protege en dos capas: el filtro detiene las corridas que empiezan fuera de horario y el calendario del Break impide que los reintentos lleguen a salir de él. La independencia de los fallos es el supuesto débil, y el Bloque 9 lo pone a prueba con una caída real.
La regla de la franja se entiende mejor en un reloj de 24 horas.
Figura 3. La franja amable: de 9:00 a 20:00 se puede escribir; los reintentos del Break caen a las 18:30, 19:00 y 19:30, mientras que los del calendario automático llegarían a las 22:51 y a la 1:51.
Atención aquí: el calendario exacto que Make aplica a un error depende del tipo de error y de los ajustes del manejador, y la documentación indica que la ejecución incompleta se reanuda en el módulo que falló, de modo que los filtros anteriores no vuelven a evaluarse. Antes de activar el escenario, provoque un fallo a propósito (por ejemplo, apagando el webhook de prueba unos minutos) y compruebe en el historial a qué horas se reintenta. Un recordatorio de utilidad enviado a la 1:51 no solo molesta: es justo la clase de mensaje que lleva a un cliente a bloquear el número y a reportarlo, y los reportes de los usuarios pesan en la calidad de la cuenta de WhatsApp Business.
El escenario completo
Con el envío y sus manejadores, el escenario está completo. Este es su blueprint, con las conexiones y el almacén en $0$, el identificador de la hoja sustituido por un valor de ejemplo y la URL del envío como marcador. Se guarda como trigo_lento/dia_26/flujos/make/recordatorio_recogida_v1.blueprint.json:
{
"name": "TL Recordatorio de recogida v1",
"flow": [
{
"id": 1,
"module": "google-sheets:filterRows",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"mapper": {
"from": "drive",
"spreadsheetId": "/ID_DE_EJEMPLO",
"sheetId": "Pedidos",
"includesHeaders": true,
"tableFirstRow": "A1:Z1",
"valueRenderOption": "FORMATTED_VALUE",
"dateTimeRenderOption": "FORMATTED_STRING",
"filter": [[{"a": "D", "b": "{{formatDate(addDays(now; 1); \"YYYY-MM-DD\")}}", "o": "text:equal"}]],
"limit": 100
},
"metadata": {"designer": {"x": 0, "y": 0}}
},
{
"id": 2,
"module": "datastore:GetRecord",
"version": 1,
"parameters": {"datastore": 0},
"filter": {
"name": "Consentimiento, teléfono y franja",
"conditions": [[
{"a": "{{1.consentimiento_whatsapp}}", "b": "sí", "o": "text:equal"},
{"a": "{{1.baja}}", "o": "notexist"},
{"a": "{{1.telefono}}", "b": "^[+]52[0-9]{10}$", "o": "text:pattern"},
{"a": "{{formatDate(now; \"H\")}}", "b": "9", "o": "number:greaterorequal"},
{"a": "{{formatDate(now; \"H\")}}", "b": "20", "o": "number:less"}
]]
},
"mapper": {"key": "{{1.id_pedido}}"},
"metadata": {"designer": {"x": 300, "y": 0}}
},
{
"id": 3,
"module": "http:ActionSendData",
"version": 3,
"parameters": {"handleErrors": true, "useNewZLibDeCompress": true},
"filter": {
"name": "Aún no avisado",
"conditions": [[{"a": "{{2.key}}", "o": "notexist"}]]
},
"mapper": {
"url": "https://webhook.ejemplo.invalid/trigo-lento/recordatorios",
"method": "post",
"headers": [{"name": "Content-Type", "value": "application/json"}],
"bodyType": "raw",
"contentType": "application/json",
"data": "{\"messaging_product\": \"whatsapp\", \"to\": \"{{1.telefono}}\", \"type\": \"template\", \"template\": {\"name\": \"recordatorio_recogida\", \"language\": {\"code\": \"es_MX\"}, \"components\": [{\"type\": \"body\", \"parameters\": [{\"type\": \"text\", \"parameter_name\": \"nombre\", \"text\": \"{{1.nombre_pila}}\"}, {\"type\": \"text\", \"parameter_name\": \"pedido\", \"text\": \"{{1.id_pedido}}\"}, {\"type\": \"text\", \"parameter_name\": \"hora\", \"text\": \"{{1.hora_recogida}}\"}]}]}}",
"parseResponse": true,
"timeout": 30
},
"metadata": {"designer": {"x": 600, "y": 0}},
"onerror": [
{
"id": 4,
"module": "builtin:Break",
"version": 1,
"parameters": {},
"mapper": {"retry": true, "count": 3, "interval": 30},
"metadata": {"designer": {"x": 600, "y": 300}}
}
]
},
{
"id": 5,
"module": "datastore:AddRecord",
"version": 1,
"parameters": {"datastore": 0},
"mapper": {
"key": "{{1.id_pedido}}",
"overwrite": false,
"data": {"id_pedido": "{{1.id_pedido}}", "enviado_en": "{{formatDate(now; \"YYYY-MM-DD HH:mm\")}}", "estado_http": "{{3.statusCode}}"}
},
"metadata": {"designer": {"x": 900, "y": 0}},
"onerror": [
{
"id": 6,
"module": "builtin:Break",
"version": 1,
"parameters": {},
"mapper": {"retry": true, "count": 3, "interval": 30},
"metadata": {"designer": {"x": 900, "y": 300}}
}
]
}
],
"metadata": {
"instant": false,
"version": 1,
"scenario": {"roundtrips": 1, "maxErrors": 3, "autoCommit": true, "autoCommitTriggerLast": true, "sequential": false, "confidential": true, "dataloss": false, "dlq": true, "freshVariables": false},
"designer": {"orphans": []}
}
}
Cuatro detalles del blueprint conviene leerlos despacio:
metadata.instantvalefalse. Es la marca de un escenario programado; la hora y los días no aparecen en ninguna parte del archivo, como se adelantó en el Paso 1.- Los filtros son propiedades del módulo que protegen. El de consentimiento, teléfono y franja está dentro del módulo 2, y el de “Aún no avisado”, dentro del módulo 3: el paquete solo entra en un módulo si cumple su filtro. El primer filtro tiene un único grupo con cinco condiciones, que Make evalúa en AND.
- Los manejadores de error cuelgan de
onerror. Cada Break es un módulo con su propioid(4 y 6) dentro de la listaonerrordel módulo al que protege; sus clavesretry,counteintervaldescriben los $3$ reintentos cada $30$ minutos y son las primeras que hay que contrastar con una exportación real. - Los ajustes del escenario no son los predeterminados.
dlqvaletrue(ejecuciones incompletas, imprescindible para Break) yconfidentialvaletrue(el historial no guarda los teléfonos ni los nombres que pasan por el escenario).sequentialqueda enfalseporque, con una sola corrida programada al día, no hay ejecuciones que puedan pisarse.
Exportar, validar y simular
Un blueprint exportado es un documento, y los documentos se revisan antes de archivarlos. El validador del siguiente bloque es el del Día 22, ampliado con dos comprobaciones propias de un escenario programado con manejadores de errores: que toda directiva de onerror sea una de las cinco que documenta Make (builtin:Break, builtin:Resume, builtin:Ignore, builtin:Rollback y builtin:Commit) y que metadata.instant sea false. El bloque recorre además el blueprint con nodos_desde_blueprint, que ahora dibuja los manejadores de error debajo del módulo al que protegen, y lo dibuja con la paleta de la serie:
# Validación del blueprint TL Recordatorio de recogida 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' / 'recordatorio_recogida_v1.blueprint.json', encoding = 'utf-8') as f:
blueprint = json.load(f)
DIRECTIVAS = {'builtin:Break', 'builtin:Resume', 'builtin:Ignore', 'builtin:Rollback', 'builtin:Commit'}
CLAVES_ESCENARIO = ['roundtrips', 'maxErrors', 'autoCommit', 'autoCommitTriggerLast', 'sequential',
'confidential', 'dataloss', 'dlq', 'freshVariables']
PATRONES_SECRETO = {'clave de Claude': r'sk-ant-[\w-]+', 'token Bearer': r'(?i)bearer\s+\w+',
'URL de webhook de Make': r'https://hook\.[\w.]*make\.com/\w+',
'teléfono real': r'\+52\s?\d{2}\s?[1-9]\d{3}\s?\d{4}'}
def todos_los_modulos(flujo):
for m in flujo:
yield m
yield from todos_los_modulos(m.get('onerror', []))
for ruta in m.get('routes', []):
yield from todos_los_modulos(ruta['flow'])
def validar_blueprint(bp):
hallazgos, modulos = [], list(todos_los_modulos(bp['flow']))
ids = [m['id'] for m in modulos]
if len(ids) != len(set(ids)):
hallazgos.append('ids repetidos')
for m in modulos:
d = m['metadata']['designer']
if not all(type(d.get(c)) is int for c in ('x', 'y')):
hallazgos.append(f"módulo {m['id']}: posición no entera")
p = m.get('parameters') or {}
if p.get('__IMTCONN__', 0) != 0 or p.get('datastore', 0) != 0:
hallazgos.append(f"módulo {m['id']}: conexión o almacén reales")
texto = json.dumps(m.get('mapper') or {}, ensure_ascii = False)
hallazgos += [f"módulo {m['id']}: {n}" for n, pat in PATRONES_SECRETO.items() if re.search(pat, texto)]
for e in m.get('onerror', []):
if e['module'] not in DIRECTIVAS:
hallazgos.append(f"módulo {m['id']}: directiva desconocida {e['module']}")
if bp['metadata']['instant'] is not False:
hallazgos.append('metadata.instant debe ser false en un escenario programado')
faltan = [c for c in CLAVES_ESCENARIO if c not in bp['metadata']['scenario']]
if faltan:
hallazgos.append(f'metadata.scenario sin {faltan}')
return modulos, hallazgos
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 = {'google-sheets:filterRows': 'Buscar pedidos\nde mañana', 'datastore:GetRecord': 'Consultar\nalmacén',
'http:ActionSendData': 'Enviar\nrecordatorio', 'datastore:AddRecord': 'Registrar\nenvío',
'builtin:Break': 'Break:\n3 intentos'}
def tipo_de(modulo, primero = False):
if primero:
return 'disparador' # el primer módulo de un escenario programado es su punto de partida
if modulo in DIRECTIVAS:
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 que protege; onerror va debajo:
nodos = [] if nodos is None else nodos
aristas = [] if aristas is None else aristas
x = x0
for i, m in enumerate(bp['flow']):
if 'filter' in m:
nombre = f"filtro_{m['id']}"
nodos.append({'id': nombre, 'tipo': 'decision', 'texto': textwrap.fill(m['filter']['name'], 14, break_long_words = False),
'x': x, 'y': y0})
aristas.append((previo, nombre, ''))
previo, x = nombre, x + 2.5
nodos.append({'id': m['id'], 'tipo': tipo_de(m['module'], i == 0 and previo is None),
'texto': f"{m['id']}. {NOMBRES.get(m['module'], m['module'])}", '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': f"{e['id']}. {NOMBRES[e['module']]}",
'x': x, 'y': y0 - 1.9})
aristas.append((m['id'], e['id'], 'error'))
for ruta in m.get('routes', []):
nodos_desde_blueprint(ruta, x + 2.5, y0 - 1.9, 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 = 64):
if ax is None:
fig, ax = plt.subplots(figsize = (13, 4.6))
for n in nodos:
forma, color = FORMAS[n['tipo']]
x, y = n['x'], n['y']
if forma == 'ovalo':
figura = Ellipse((x, y), ancho * 1.05, alto * 1.1)
elif forma == 'rombo':
figura = Polygon([(x - ancho / 1.6, y), (x, y + alto / 1.15), (x + ancho / 1.6, y), (x, y - alto / 1.15)])
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]
r = recorte if abs(x2 - x1) >= abs(y2 - y1) else recorte * 0.6 # las flechas verticales son más cortas
ax.add_patch(FancyArrowPatch((x1, y1), (x2, y2), arrowstyle = '-|>', mutation_scale = 12,
shrinkA = r, shrinkB = r + 2, color = '#52514e', linewidth = 1.3))
if etiqueta:
ax.text((x1 + x2) / 2 + 0.15, (y1 + y2) / 2, etiqueta, ha = 'left', fontsize = 9, color = '#c8423b')
xs, ys = [n['x'] for n in nodos], [n['y'] for n in nodos]
ax.set_xlim(min(xs) - 1.4, max(xs) + 1.4)
ax.set_ylim(min(ys) - 1.0, max(ys) + 1.25)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(titulo, fontsize = 16)
return ax
modulos, hallazgos = validar_blueprint(blueprint)
for m in modulos:
filtro = m['filter']['name'] if 'filter' in m else '-'
print(f"{m['id']} {m['module']:<26} v{m['version']} filtro: {filtro:<34} onerror: "
f"{[e['module'] for e in m.get('onerror', [])] or '-'}")
print('Módulos revisados:', len(modulos), '| hallazgos:', hallazgos or 'ninguno',
'| instant:', blueprint['metadata']['instant'],
'| ajustes:', {k: blueprint['metadata']['scenario'][k] for k in ('dlq', 'confidential', 'sequential')})
nodos, aristas = nodos_desde_blueprint(blueprint)
print('Nodos dibujados:', len(nodos), '| aristas:', len(aristas))
dibujar_flujo(nodos, aristas, blueprint['name'] + ': dibujado desde su blueprint')
plt.tight_layout()
plt.show()
1 google-sheets:filterRows v2 filtro: - onerror: -
2 datastore:GetRecord v1 filtro: Consentimiento, teléfono y franja onerror: -
3 http:ActionSendData v3 filtro: Aún no avisado onerror: ['builtin:Break']
4 builtin:Break v1 filtro: - onerror: -
5 datastore:AddRecord v1 filtro: - onerror: ['builtin:Break']
6 builtin:Break v1 filtro: - onerror: -
Módulos revisados: 6 | hallazgos: ninguno | instant: False | ajustes: {'dlq': True, 'confidential': True, 'sequential': False}
Nodos dibujados: 8 | aristas: 7

El validador revisa $6$ módulos (los cuatro del flujo principal y los dos manejadores, que encuentra gracias al recorrido recursivo) y no encuentra ningún hallazgo: identificadores únicos, posiciones enteras, conexión y almacén en $0$, ningún secreto ni teléfono real en los mapper y solo directivas válidas. El dibujo tiene $8$ nodos y $7$ aristas. Se lee de izquierda a derecha como el escenario: el óvalo verde de la búsqueda, el rombo naranja del filtro de consentimiento, teléfono y franja, la consulta al almacén, el rombo de “Aún no avisado”, el envío y el registro; debajo, en rojo, los dos Break unidos por una flecha de error. Que los dos rombos estén antes de los módulos que cobran es, visualmente, todo el diseño del día.
El simulador del escenario
Make tiene un botón para probar un escenario, “Run once”, pero cada prueba gasta créditos y escribe en el almacén real. Para probar la lógica sin gastar nada, Valeria la reproduce en Python. El simulador ejecutar_escenario recibe la hoja, el almacén (una base sqlite3 con la tabla recordatorios_enviados), la hora de la corrida y el generador aleatorio, y recorre los módulos en el mismo orden que el blueprint, sumando un crédito por cada módulo que ejecuta un paquete. El envío usa simular_post, la función de la serie que imita una respuesta HTTP y que puede fallar con una probabilidad declarada; en este bloque no falla, porque se trata de probar los filtros y el almacén.
El bloque ejecuta tres corridas sobre la hoja del Bloque 2: la programada del jueves 26 de febrero a las 18:00, un “Run once” a las 18:40 de la misma tarde (Valeria quiere confirmar que todo salió bien) y otro a las 21:15 (alguien prueba algo después de cenar):
# Simulador del escenario con sqlite3 como almacén y simular_post como envío, crédito a crédito en Python:
class Respuesta:
def __init__(self, status_code, cuerpo):
self.status_code, self.headers, self._cuerpo = status_code, {'Content-Type': 'application/json'}, cuerpo
def json(self):
return self._cuerpo
def simular_post(url, cuerpo, rng, p_fallo = 0.0):
# Webhook de prueba propio: falla con probabilidad p_fallo (500); si no, acepta el mensaje:
if rng.random() < p_fallo:
return Respuesta(500, {'error': 'fallo simulado'})
return Respuesta(200, {'aceptado': True, 'a': cuerpo['to'][-4:]})
def abrir_almacen(ruta):
con = sqlite3.connect(ruta)
con.execute('CREATE TABLE IF NOT EXISTS recordatorios_enviados '
'(id_pedido TEXT PRIMARY KEY, enviado_en TEXT, estado_http INTEGER)')
return con
def pasa_filtro_1(fila, ahora):
# Filtro del módulo 2: consentimiento, sin baja, teléfono válido y dentro de la franja:
return (fila['consentimiento_whatsapp'] == 'sí' and not fila['baja']
and re.fullmatch(r'[+]52[0-9]{10}', fila['telefono'] or '') is not None
and FRANJA[0] <= ahora.hour < FRANJA[1])
def ejecutar_escenario(pedidos, almacen, ahora, rng, p_fallo = 0.0):
manana = (ahora + timedelta(days = 1)).strftime('%Y-%m-%d')
creditos = {'1 buscar': 1, '2 consultar': 0, '3 enviar': 0, '5 registrar': 0} # la búsqueda cobra siempre
cuenta = {'filas': 0, 'descartados': 0, 'ya_avisados': 0, 'enviados': 0, 'incompletos': 0}
for _, fila in pedidos[pedidos['fecha_entrega'] == manana].iterrows():
cuenta['filas'] += 1
if not pasa_filtro_1(fila, ahora):
cuenta['descartados'] += 1
continue
creditos['2 consultar'] += 1
if almacen.execute('SELECT 1 FROM recordatorios_enviados WHERE id_pedido = ?',
(fila['id_pedido'],)).fetchone():
cuenta['ya_avisados'] += 1 # filtro "Aún no avisado": no pasa y no cobra
continue
creditos['3 enviar'] += 1
r = simular_post('https://webhook.ejemplo.invalid/trigo-lento/recordatorios',
{'to': fila['telefono'], 'template': 'recordatorio_recogida'}, rng, p_fallo)
if r.status_code >= 400:
cuenta['incompletos'] += 1 # Break: el paquete queda como ejecución incompleta
continue
creditos['5 registrar'] += 1
almacen.execute('INSERT INTO recordatorios_enviados VALUES (?, ?, ?)',
(fila['id_pedido'], ahora.strftime('%Y-%m-%d %H:%M'), r.status_code))
cuenta['enviados'] += 1
almacen.commit()
return cuenta, creditos
ruta_almacen = carpeta / 'almacen_recordatorios.sqlite'
ruta_almacen.unlink(missing_ok = True)
almacen = abrir_almacen(ruta_almacen)
corridas = [('Programada, jueves 18:00', datetime(2026, 2, 26, 18, 0)),
('Run once, jueves 18:40', datetime(2026, 2, 26, 18, 40)),
('Run once, jueves 21:15', datetime(2026, 2, 26, 21, 15))]
historial = []
for nombre, ahora in corridas:
cuenta, creditos = ejecutar_escenario(pedidos, almacen, ahora, rng)
historial.append(cuenta)
print(f'{nombre:<26} {cuenta} | créditos {sum(creditos.values())} = {creditos}')
llamadas = pedidos[(pedidos['consentimiento_whatsapp'] == 'sí') & (pedidos['baja'] == '')
& (pedidos['telefono'] == '')]
print('Lista de llamadas de Valeria:', llamadas['id_pedido'].tolist(),
'| registros en el almacén:', almacen.execute('SELECT COUNT(*) FROM recordatorios_enviados').fetchone()[0])
Programada, jueves 18:00 {'filas': 18, 'descartados': 5, 'ya_avisados': 0, 'enviados': 13, 'incompletos': 0} | créditos 40 = {'1 buscar': 1, '2 consultar': 13, '3 enviar': 13, '5 registrar': 13}
Run once, jueves 18:40 {'filas': 18, 'descartados': 5, 'ya_avisados': 13, 'enviados': 0, 'incompletos': 0} | créditos 14 = {'1 buscar': 1, '2 consultar': 13, '3 enviar': 0, '5 registrar': 0}
Run once, jueves 21:15 {'filas': 18, 'descartados': 18, 'ya_avisados': 0, 'enviados': 0, 'incompletos': 0} | créditos 1 = {'1 buscar': 1, '2 consultar': 0, '3 enviar': 0, '5 registrar': 0}
Lista de llamadas de Valeria: ['TL-260227-09'] | registros en el almacén: 13
Las tres corridas responden a las tres preguntas del día:
- La corrida programada envía $13$ recordatorios y cuesta $40$ créditos. La búsqueda encuentra $18$ filas; el filtro descarta $5$ (tres sin consentimiento, uno con baja y uno sin teléfono); los $13$ que pasan cuestan una consulta, un envío y un registro cada uno: $1 + 13 + 13 + 13 = 40$.
- El “Run once” de las 18:40 no envía nada y cuesta $14$ créditos. Los $13$ pedidos vuelven a pasar el filtro y se consultan en el almacén ($13$ créditos), pero ya están registrados y el filtro “Aún no avisado” los detiene. Cero duplicados: la condición de Don Ernesto se cumple por diseño, no por disciplina.
- El “Run once” de las 21:15 cuesta $1$ crédito y no envía nada. La búsqueda se paga, pero el filtro de franja descarta las $18$ filas antes del primer módulo que cobra por paquete.
La lista de llamadas de Valeria tiene un único pedido, el TL-260227-09, y el almacén termina con $13$ registros, uno por recordatorio. Respecto del plano original, la corrida cuesta un crédito menos ($40$ y no $41$) porque no hay iterador, y la reejecución también ($14$ y no $15$).
Un mes de corridas
Una tarde no dice mucho de un escenario que trabajará todo el año. El siguiente bloque simula un mes: las $26$ corridas programadas de marzo de 2026 (del domingo 1 al lunes 30, sin sábados), con un número de pedidos de Poisson de media $18$ para el día siguiente, un $70%$ de clientes con consentimiento y un $2%$ de bajas entre ellos, los dos como supuestos declarados. Para poner a prueba el almacén, cada día hay una probabilidad del $20%$ de que alguien pulse “Run once” otra vez a las 18:40, otro supuesto de juguete:
# Un mes simulado de 26 corridas programadas, con reejecuciones manuales, en Python:
P_CONSENTIMIENTO, P_BAJA, P_RECORRIDA = 0.70, 0.02, 0.20 # supuestos declarados
dias_mes = [d for d in pd.date_range('2026-03-01', '2026-03-30') if d.dayofweek != 5] # sin sábados
almacen_mes = abrir_almacen(':memory:')
recorre = rng.random(len(dias_mes)) < P_RECORRIDA # días en que alguien pulsa "Run once" otra vez
filas_mes = []
for d, otra_vez in zip(dias_mes, recorre):
n = rng.poisson(18)
manana = (d + timedelta(days = 1)).strftime('%Y-%m-%d')
acepta = rng.random(n) < P_CONSENTIMIENTO
baja = acepta & (rng.random(n) < P_BAJA)
hoja = pd.DataFrame({'id_pedido': [f'TL-{manana[2:].replace("-", "")}-{i + 1:02d}' for i in range(n)],
'nombre_pila': 'Cliente', 'telefono': [f'+5255000{i:05d}' for i in range(n)],
'fecha_entrega': manana, 'hora_recogida': '12:00',
'consentimiento_whatsapp': np.where(acepta, 'sí', 'no'),
'baja': np.where(baja, '2026-02-15', '')})
c1, k1 = ejecutar_escenario(hoja, almacen_mes, d + timedelta(hours = 18), rng)
fila = {'fecha': d.strftime('%d-%m'), 'pedidos': n, 'enviados': c1['enviados'],
'descartados': c1['descartados'], 'omitidos': 0, 'créditos': sum(k1.values()), 'recorrida': False}
if otra_vez:
c2, k2 = ejecutar_escenario(hoja, almacen_mes, d + timedelta(hours = 18, minutes = 40), rng)
fila.update(omitidos = c2['ya_avisados'], recorrida = True)
fila['créditos'] += sum(k2.values())
filas_mes.append(fila)
mes = pd.DataFrame(filas_mes)
print(mes.head(8).to_string(index = False))
print(f"Corridas programadas: {len(mes)} | reejecuciones: {mes['recorrida'].sum()} | pedidos: {mes['pedidos'].sum()}")
print(f"Enviados: {mes['enviados'].sum()} | descartados por consentimiento o baja: {mes['descartados'].sum()}"
f" | omitidos por estar en el almacén: {mes['omitidos'].sum()} | duplicados enviados: 0")
print(f"Créditos del mes: {mes['créditos'].sum()} ({100 * mes['créditos'].sum() / CREDITOS_CORE:.1f} % de Core)")
fig, ax = plt.subplots(figsize = (11, 5))
x = np.arange(len(mes))
ax.bar(x, mes['enviados'], color = '#2a78d6', label = 'Enviados')
ax.bar(x, mes['descartados'], bottom = mes['enviados'], color = '#a5a5a5', label = 'Descartados: sin consentimiento o con baja')
ax.bar(x, mes['omitidos'], bottom = mes['enviados'] + mes['descartados'], color = '#eb6834',
label = 'Omitidos en la reejecución: ya estaban en el almacén')
ax.set_xticks(x)
ax.set_xticklabels(mes['fecha'], rotation = 60, ha = 'right', fontsize = 8)
ax.set_ylabel('Pedidos del día siguiente')
ax.set_title('Un mes de recordatorios simulados (marzo de 2026)', fontsize = 16)
ax.legend(loc = 'upper left', frameon = False, fontsize = 9)
ax.set_ylim(0, (mes['enviados'] + mes['descartados'] + mes['omitidos']).max() + 9)
ax.grid(axis = 'y', color = '#e6e5e1')
ax.set_axisbelow(True)
plt.tight_layout()
plt.show()
fecha pedidos enviados descartados omitidos créditos recorrida
01-03 16 13 3 0 40 False
02-03 18 11 7 0 34 False
03-03 20 16 4 0 49 False
04-03 19 11 8 0 34 False
05-03 13 7 6 0 22 False
06-03 17 10 7 0 31 False
08-03 21 11 10 0 34 False
09-03 18 15 3 0 46 False
Corridas programadas: 26 | reejecuciones: 2 | pedidos: 501
Enviados: 328 | descartados por consentimiento o baja: 173 | omitidos por estar en el almacén: 28 | duplicados enviados: 0
Créditos del mes: 1040 (10.4 % de Core)

El mes simulado tiene $501$ pedidos, $328$ recordatorios enviados, $173$ pedidos descartados por falta de consentimiento o por baja y $2$ reejecuciones, en las que el almacén omitió $28$ avisos que ya se habían enviado esa tarde. Duplicados enviados: $0$. El consumo total es de $1,040$ créditos, el $10.4%$ del plan Core, con las reejecuciones incluidas.
En la figura, cada barra es una corrida. El azul son los recordatorios enviados; el gris, los pedidos que el filtro descartó; el naranja, los avisos que la reejecución habría duplicado sin almacén y que el filtro “Aún no avisado” detuvo. Las dos barras naranjas, del 12 y del 16 de marzo, son la imagen exacta de la objeción de Don Ernesto: $13$ y $15$ clientes que, sin la libreta, habrían recibido dos mensajes en cuarenta minutos.
Créditos, pruebas y lista de comprobación
El escenario funciona en el simulador. Antes de activarlo en Make faltan cuatro comprobaciones: cuánto cuesta al mes, qué se envía exactamente, qué pasa cuando el envío falla de verdad y si cumple la lista de requisitos que Trigo Lento exige a cualquier automatización que habla con sus clientes.
Créditos por corrida
Con el filtro antes de la consulta al almacén, el consumo de una corrida se escribe con una fórmula de dos términos:
$$ C_{temprano} = 1 + 3k, \qquad C_{tardío} = 1 + n + 2k $$
La lógica detrás de estas fórmulas es múltiple:
- $n$ es el número de pedidos del día siguiente que devuelve la búsqueda y $k$ el número de ellos que pasan el filtro de consentimiento, baja, teléfono y franja; con $70%$ de consentimiento, $k \approx 0.7,n$.
- El $1$ es la búsqueda, que cobra una vez por corrida. El $3k$ son la consulta al almacén, el envío y el registro de cada pedido que pasa el filtro (en una corrida sin reejecución, todos los que pasan se envían).
- Si el filtro de consentimiento se pusiera después de la consulta al almacén, la consulta se pagaría para los $n$ pedidos y el envío y el registro para los $k$: $C_{tardío} = 1 + n + 2k$. La diferencia, $n - k$, son los pedidos sin consentimiento, que se pagan sin hacer nada con ellos.
- En conclusión, el filtro temprano ahorra exactamente un crédito por cada pedido descartado, y el ahorro crece con el volumen y con la proporción de clientes que no aceptaron avisos.
El siguiente bloque evalúa las dos fórmulas con la media de Trigo Lento, las aplica al mes simulado del Bloque 6, suma el resultado a los créditos que el Día 23 ya había comprometido y calcula lo que costará la mensajería cuando el envío sea real:
# Créditos por corrida y al mes con filtro temprano y con filtro tardío en Python:
def creditos_temprano(n, k):
return 1 + 3 * k # búsqueda + (consulta, envío y registro) por cada pedido que pasa
def creditos_tardio(n, k):
return 1 + n + 2 * k # la consulta al almacén se paga para todos los pedidos
n_medio, k_medio = 18, 18 * P_CONSENTIMIENTO
for nombre, f in [('Filtro temprano', creditos_temprano), ('Filtro tardío', creditos_tardio)]:
por_corrida = f(n_medio, k_medio)
print(f'{nombre:<16} k = {k_medio:.1f} | {por_corrida:.1f} créditos por corrida | {26 * por_corrida:,.1f} al mes '
f'| {100 * 26 * por_corrida / CREDITOS_CORE:.1f} % de Core')
simulado_tardio = sum(creditos_tardio(r['pedidos'], r['enviados']) for _, r in mes.iterrows())
programadas = sum(creditos_temprano(r['pedidos'], r['enviados']) for _, r in mes.iterrows())
print(f'Mes simulado (solo corridas programadas): temprano {programadas} | tardío {simulado_tardio} '
f'| diferencia {simulado_tardio - programadas}')
dia_23 = 9_360 # pedidos v1 más sincronización del Día 16 (Día 23)
total = dia_23 + 26 * creditos_temprano(n_medio, k_medio)
print(f'Con el Día 23: {total:,.1f} de {CREDITOS_CORE:,} créditos ({100 * total / CREDITOS_CORE:.1f} %)')
TARIFA = 0.0085 + 0.005 # plantilla de utilidad en México + recargo de Twilio (Tabla C)
print(f'Mensajería en el M5: {26 * k_medio:.1f} mensajes = {26 * k_medio * TARIFA:.2f} USD al mes '
f'| sin mirar el consentimiento: {468 * TARIFA:.2f} USD')
n = np.arange(0, 36)
fig, ax = plt.subplots(figsize = (10, 5.2))
ax.plot(n, creditos_temprano(n, P_CONSENTIMIENTO * n), color = '#2a78d6', linewidth = 2.5,
label = 'Filtro temprano: C = 1 + 3k')
ax.plot(n, creditos_tardio(n, P_CONSENTIMIENTO * n), color = '#eb6834', linewidth = 2.5,
label = 'Filtro tardío: C = 1 + n + 2k')
ax.scatter(mes['pedidos'], [creditos_temprano(r['pedidos'], r['enviados']) for _, r in mes.iterrows()],
color = '#2a78d6', s = 28, alpha = 0.7, label = 'Corridas del mes simulado (temprano)')
ax.axvline(18, color = '#8a8986', linestyle = '--', linewidth = 1.2)
ax.text(18.4, 4, '18 pedidos de media', color = '#52514e', fontsize = 9)
ax.set_xlabel('Pedidos del día siguiente (n)')
ax.set_ylabel('Créditos por corrida')
ax.set_title('Créditos por corrida según dónde va el filtro', fontsize = 16)
ax.legend(loc = 'upper left', frameon = False)
ax.grid(color = '#e6e5e1')
ax.set_axisbelow(True)
plt.tight_layout()
plt.show()
Filtro temprano k = 12.6 | 38.8 créditos por corrida | 1,008.8 al mes | 10.1 % de Core
Filtro tardío k = 12.6 | 44.2 créditos por corrida | 1,149.2 al mes | 11.5 % de Core
Mes simulado (solo corridas programadas): temprano 1010 | tardío 1183 | diferencia 173
Con el Día 23: 10,368.8 de 10,000 créditos (103.7 %)
Mensajería en el M5: 327.6 mensajes = 4.42 USD al mes | sin mirar el consentimiento: 6.32 USD

Con $18$ pedidos de media y $k = 12.6$, el filtro temprano cuesta $38.8$ créditos por corrida y $1,008.8$ al mes, el $10.1%$ de los $10,000$ créditos del plan Core; el tardío, $44.2$ y $1,149.2$, el $11.5%$. En el mes simulado, con los pedidos y los consentimientos de cada día, la diferencia es de $173$ créditos, exactamente los $173$ pedidos descartados. La figura muestra las dos rectas: parten juntas con cero pedidos y se separan a razón de $0.3$ créditos por pedido; los puntos azules, las $26$ corridas simuladas, se dispersan alrededor de la recta temprana porque el consentimiento real de cada día no es exactamente el $70%$.
Dos cifras de la salida exigen una decisión:
- Make Core ya no alcanza. El Día 23 dejó el plan Core al $93.6%$, con $9,360$ créditos comprometidos entre
TL Pedidos WhatsApp v1y la sincronización del Día 16. Sumar el recordatorio lo lleva a $10,368.8$ créditos, el $103.7%$. Los días 24 y 25 cambiaron el escenario de pedidos, así que la cifra exacta habrá que recalcularla, pero el orden de magnitud no cambia: el margen del plan Core se agotó. Valeria lo lleva a la revisión de costes del cierre del módulo, con tres opciones sobre la mesa: subir el nivel de créditos del plan (con el precio de la página oficial, no de memoria), espaciar la sincronización del Día 16 o mover algún flujo a n8n en el módulo 4. - La mensajería costará $4.42$ USD al mes. Cuando el envío sea real, cada recordatorio es una plantilla de utilidad de Meta para México ($0.0085$ USD) más el recargo de Twilio ($0.005$ USD), según la tabla de costes de la serie revisada en octubre de 2026. Con $327.6$ recordatorios al mes, la cuenta es la de la fórmula siguiente; si se escribiera a los $468$ encargos sin mirar el consentimiento, serían $6.32$ USD: el filtro ético es también el barato.
$$ M = 26 \cdot \bar{k} \cdot (t_{util} + t_{Twilio}) = 26 \cdot 12.6 \cdot (0.0085 + 0.005) = 4.42\ \mathrm{USD} $$
La lógica detrás de esta fórmula es múltiple:
- $26$ es el número de corridas al mes y $\bar{k} = 12.6$ los recordatorios medios por corrida, que dan $327.6$ mensajes.
- $t_{util}$ es la tarifa de Meta por plantilla de utilidad entregada en México y $t_{Twilio}$ el recargo por mensaje del proveedor; las dos se revisan cada trimestre.
- En conclusión, el coste de la mensajería es proporcional a $\bar{k}$, que depende del consentimiento: cada cliente que no aceptó avisos es un mensaje que ni se envía ni se paga.
El mensaje de la plantilla
El cuerpo que el módulo 3 envía tiene la forma de un mensaje de plantilla de la Cloud API de WhatsApp. Valeria lo guarda aparte, con un teléfono de prueba y los valores del pedido de la señora Laura, como trigo_lento/dia_26/plantillas/recordatorio_recogida.json, porque es el mismo documento que tendrá que aprobar Meta y el mismo que el módulo 5 enviará de verdad:
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "+525500000001",
"type": "template",
"template": {
"name": "recordatorio_recogida",
"language": {"code": "es_MX"},
"components": [
{
"type": "body",
"parameters": [
{"type": "text", "parameter_name": "nombre", "text": "Laura"},
{"type": "text", "parameter_name": "pedido", "text": "TL-260227-01"},
{"type": "text", "parameter_name": "hora", "text": "09:30"}
]
}
]
}
}
Cada campo tiene una razón. messaging_product vale siempre whatsapp; to es el teléfono en formato internacional (aquí, uno de prueba); type es template, porque fuera de la ventana de atención de $24$ horas solo se pueden enviar plantillas aprobadas, y un recordatorio la víspera casi nunca cae dentro de esa ventana. La plantilla se llama recordatorio_recogida, está en la categoría de utilidad (avisa de un pedido en curso; no promociona nada) y en el idioma es_MX. Sus tres parámetros usan nombres (parameter_name) en lugar de posiciones, una opción que la documentación de Meta admite para las plantillas creadas con parámetros con nombre, y que hace el cuerpo más legible y más difícil de desordenar.
El texto aprobado de la plantilla sería algo como: “Hola, {{nombre}}. Le recordamos que su pedido {{pedido}} está listo para recoger mañana a las {{hora}} en Trigo Lento. Si ya no desea estos avisos, responda BAJA.” Solo lleva nombre de pila, número de pedido y hora; ni dirección, ni importe, ni productos. Es la minimización de datos del Día 6 aplicada a un mensaje que viaja por un tercero.
Reintentos y franja horaria
El Paso 5 eligió $3$ reintentos cada $30$ minutos con una fórmula que suponía fallos independientes. El siguiente bloque pone el supuesto a prueba con los $328$ recordatorios del mes simulado. Cada intento de envío falla con probabilidad $p = 0.05$, y en la corrida número $10$ (el 12 de marzo) el webhook de prueba está caído de 18:00 a 21:30, un fallo que no es independiente: afecta a todos los mensajes de esa tarde. El bloque compara dos políticas con exactamente los mismos fallos: el calendario automático de ocho reintentos que documenta Make y el Break de Valeria. Lo que no se resuelve queda como ejecución incompleta para la mañana siguiente:
# Fallos del envío, reintentos y hora efectiva del recordatorio frente a la franja de 9:00 a 20:00 en Python:
P_FALLO = 0.05
CALENDARIO_MAKE = [0, 1, 11, 21, 51, 81, 111, 291, 471] # minutos: intento original y 8 reintentos automáticos
BREAK_3X30 = [0, 30, 60, 90] # intento original y 3 reintentos cada 30 minutos
CAIDA = (10, 18 * 60, 21 * 60 + 30) # corrida 10: webhook de prueba caído de 18:00 a 21:30
def hora_efectiva(dia, minuto_inicio, calendario, rng):
# Devuelve (minuto del envío, intentos) o (None, intentos) si se agotan los reintentos:
for intento, espera in enumerate(calendario, start = 1):
t = minuto_inicio + espera
caido = dia == CAIDA[0] and CAIDA[1] <= t < CAIDA[2]
if not caido and rng.random() >= P_FALLO:
return t, intento
return None, len(calendario)
resultados, puntos = [], {}
for nombre, calendario in [('Calendario automático (8 reintentos)', CALENDARIO_MAKE),
('Break: 3 reintentos cada 30 min', BREAK_3X30)]:
rng_politica = np.random.default_rng(2026) # mismos fallos para las dos políticas
horas_envio, en_franja, fuera, sin_resolver, extra = [], 0, 0, 0, 0
for dia, r in mes.iterrows():
for i in range(r['enviados']):
t, intentos = hora_efectiva(dia, 18 * 60 + i // 6, calendario, rng_politica)
extra += intentos - 1 # cada reintento vuelve a cobrar el envío
if t is None:
sin_resolver += 1 # queda en la lista de Valeria para las 9:00
continue
horas_envio.append((dia, t / 60))
if FRANJA[0] * 60 <= t % 1440 < FRANJA[1] * 60 and t < 1440:
en_franja += 1
else:
fuera += 1
puntos[nombre] = horas_envio
resultados.append({'política': nombre, 'enviados': len(horas_envio), 'en franja': en_franja,
'fuera de franja': fuera, 'sin resolver': sin_resolver, 'créditos extra': extra,
'último envío': f'{int(max(h for _, h in horas_envio) % 24):02d}:'
f'{int(round(max(h for _, h in horas_envio) % 1 * 60)):02d}'})
print(pd.DataFrame(resultados).to_string(index = False))
print(f'Probabilidad de agotar 3 reintentos con fallos independientes: {P_FALLO ** 4:.2e}')
fig, ejes = plt.subplots(1, 2, figsize = (12, 5), sharey = True)
for ax, (nombre, horas_envio) in zip(ejes, puntos.items()):
ax.axhspan(FRANJA[0], FRANJA[1], color = '#3a9d5d', alpha = 0.12, label = 'Franja permitida: 9:00 a 20:00')
xs = np.array([d for d, _ in horas_envio]) + rng.uniform(-0.25, 0.25, len(horas_envio))
hs = np.array([h for _, h in horas_envio])
dentro = (hs >= FRANJA[0]) & (hs < FRANJA[1])
ax.scatter(xs[dentro], hs[dentro], s = 12, color = '#2a78d6', alpha = 0.6, label = 'Envío dentro de la franja')
ax.scatter(xs[~dentro], hs[~dentro], s = 30, color = '#c8423b', marker = 'x', label = 'Envío fuera de la franja')
ax.axvspan(CAIDA[0] - 0.5, CAIDA[0] + 0.5, color = '#eb6834', alpha = 0.15)
ax.set_title(nombre, fontsize = 13)
ax.set_xlabel('Corrida del mes (0 a 25)')
ax.grid(color = '#e6e5e1')
ax.set_axisbelow(True)
ejes[0].set_ylabel('Hora efectiva del envío (más de 24 = madrugada)')
ejes[0].set_ylim(8, 26.5)
ejes[1].legend(loc = 'upper right', frameon = False, fontsize = 9)
fig.suptitle('Hora efectiva del recordatorio con fallos simulados y una caída', fontsize = 16)
plt.tight_layout()
plt.show()
política enviados en franja fuera de franja sin resolver créditos extra último envío
Calendario automático (8 reintentos) 328 315 13 0 105 22:53
Break: 3 reintentos cada 30 min 315 315 0 13 53 18:32
Probabilidad de agotar 3 reintentos con fallos independientes: 6.25e-06

Tabla 4. Dos políticas de reintento frente a fallos simulados del envío y una caída de tres horas y media.
| Política | Enviados | Dentro de la franja | Fuera de la franja | Sin resolver (a la mañana) | Créditos extra | Último envío |
|---|---|---|---|---|---|---|
| Calendario automático (8 reintentos) | 328 | 315 | 13 | 0 | 105 | 22:53 |
| Break: 3 reintentos cada 30 min | 315 | 315 | 0 | 13 | 53 | 18:32 |
La Tabla 4 y la figura cuentan la misma historia desde dos ángulos:
- Con fallos independientes, las dos políticas son equivalentes. En las $25$ corridas sin caída, todos los mensajes se envían entre las 18:00 y las 18:32, y cada fallo aislado se resuelve en el primer reintento: al minuto con el calendario automático y a los $30$ minutos con el Break ($14$ mensajes llegan a las 18:30 en lugar de a las 18:00). La probabilidad de agotar tres reintentos, $6.25 \cdot 10^{-6}$, no se materializa en ningún mensaje.
- La caída rompe el supuesto. El 12 de marzo, los $13$ recordatorios fallan durante tres horas y media. Con el calendario automático, los seis primeros reintentos (hasta las 19:51) caen dentro de la caída y el séptimo, a las 22:51, encuentra el webhook en pie: $13$ mensajes enviados entre las 22:51 y las 22:53, las cruces rojas de la figura. Es exactamente el mensaje de las once de la noche que Don Ernesto prohibió.
- Con el Break de Valeria, nada sale de la franja. Los $13$ mensajes agotan sus tres reintentos a las 19:30 y quedan sin resolver. A la mañana siguiente, Valeria los ve en la lista de ejecuciones incompletas y decide: reanudarlos a las 9:00 si la recogida es por la tarde, o llamar a los clientes que recogen antes.
- Los reintentos cuestan. Cada reintento vuelve a ejecutar el envío y cobra un crédito: $105$ créditos extra con el calendario largo y $53$ con el Break. Son cifras pequeñas frente a los $1,040$ del mes, pero no son cero, y en la caída cada mensaje paga sus reintentos.
La conclusión de diseño es la que Don Ernesto intuyó sin fórmulas: un reintento es un mensaje que llega tarde, y un mensaje que llega tarde también tiene que respetar el horario. La probabilidad de agotar los reintentos es diminuta con fallos independientes y del $100%$ en una caída; por eso el calendario se elige pensando en la caída, no en el promedio.
La lista de comprobación
Antes de activar el escenario, Valeria pasa la lista de comprobación de la Tabla 5, que reúne lo aprendido en el día y lo que la serie exige a cualquier automatización con datos de clientes. Algunas comprobaciones se pueden hacer sobre el blueprint; otras, sobre la configuración que Valeria declara de su cuenta de Make y de Meta, porque no viajan en el archivo (la zona horaria, la programación, la estructura del almacén y el estado de la plantilla). El siguiente bloque automatiza las doce:
# Lista de comprobación automática antes de activar el escenario en Python:
configuracion = { # lo que Valeria declara de la cuenta de Make y de Meta (no viaja en el blueprint)
'zona_horaria': 'America/Mexico_City', 'programacion': {'type': 'weekly', 'time': '18:00', 'dias': 'domingo a viernes'},
'almacen': {'nombre': 'recordatorios_enviados', 'clave': 'id_pedido', 'campos': ['id_pedido', 'enviado_en', 'estado_http']},
'plantilla_aprobada': False, 'prueba_run_once': False, 'inventario_dia_19': {'dueña': 'Valeria', 'ficha': 'F-07'},
}
plantilla = json.loads((carpeta / 'plantillas' / 'recordatorio_recogida.json').read_text(encoding = 'utf-8'))
texto_bp = json.dumps(blueprint, ensure_ascii = False)
filtros = {m['filter']['name']: m['filter'] for m in modulos if 'filter' in m}
condiciones = [c for f in filtros.values() for grupo in f['conditions'] for c in grupo]
envio = next(m for m in modulos if m['module'] == 'http:ActionSendData')
registro = next(m for m in modulos if m['module'] == 'datastore:AddRecord')
lista = [
('Zona horaria de la organización', configuracion['zona_horaria'] == 'America/Mexico_City'),
('Filtros probados en el simulador', len(filtros) == 2 and historial[0]['enviados'] == 13
and historial[1]['enviados'] == historial[2]['enviados'] == 0),
('Almacén creado con su estructura', configuracion['almacen']['clave'] == 'id_pedido'),
('Plantilla de utilidad aprobada (M5)', configuracion['plantilla_aprobada']),
('Sin teléfonos reales ni URL real', not re.search(PATRONES_SECRETO['teléfono real'], texto_bp)
and not re.search(PATRONES_SECRETO['URL de webhook de Make'], texto_bp)
and plantilla['to'].startswith('+5255000000')),
('__IMTCONN__ y almacén en 0', not [h for h in hallazgos if 'conexión' in h]),
('Datos confidenciales activados', blueprint['metadata']['scenario']['confidential']),
('Ejecuciones incompletas activadas', blueprint['metadata']['scenario']['dlq']
and bool(envio.get('onerror')) and bool(registro.get('onerror'))),
('Prueba con Run once sobre datos de prueba', configuracion['prueba_run_once']),
('Franja de 9:00 a 20:00 en el filtro', sum(c['o'].startswith('number:') for c in condiciones) == 2),
('Baja respetada', any(c['a'] == '{{1.baja}}' and c['o'] == 'notexist' for c in condiciones)),
('Dueña y ficha en el inventario del Día 19', bool(configuracion['inventario_dia_19']['ficha'])),
]
for i, (punto, ok) in enumerate(lista, start = 1):
print(f'{i:>2}. {punto:<44} {"cumplido" if ok else "PENDIENTE"}')
print(f'Cumplidos: {sum(ok for _, ok in lista)} de {len(lista)} | el escenario se activa solo con 12 de 12')
1. Zona horaria de la organización cumplido
2. Filtros probados en el simulador cumplido
3. Almacén creado con su estructura cumplido
4. Plantilla de utilidad aprobada (M5) PENDIENTE
5. Sin teléfonos reales ni URL real cumplido
6. __IMTCONN__ y almacén en 0 cumplido
7. Datos confidenciales activados cumplido
8. Ejecuciones incompletas activadas cumplido
9. Prueba con Run once sobre datos de prueba PENDIENTE
10. Franja de 9:00 a 20:00 en el filtro cumplido
11. Baja respetada cumplido
12. Dueña y ficha en el inventario del Día 19 cumplido
Cumplidos: 10 de 12 | el escenario se activa solo con 12 de 12
Tabla 5. Lista de comprobación antes de activar TL Recordatorio de recogida v1.
| Punto | Comprobación | Cómo se comprueba | Estado |
|---|---|---|---|
| 1 | Zona horaria de la organización en America/Mexico_City | Configuración declarada | Cumplido |
| 2 | Filtros probados | Simulador: 13 envíos, luego 0 y 0 | Cumplido |
| 3 | Almacén recordatorios_enviados creado con clave id_pedido | Configuración declarada | Cumplido |
| 4 | Plantilla de utilidad recordatorio_recogida aprobada por Meta | Pendiente hasta el módulo 5 | Pendiente |
| 5 | Sin teléfonos reales ni URL real de webhook en el blueprint ni en la plantilla | Expresiones regulares sobre los dos archivos | Cumplido |
| 6 | __IMTCONN__ y almacén en 0 | Validador del Bloque 4 | Cumplido |
| 7 | Datos confidenciales activados | confidential: true | Cumplido |
| 8 | Ejecuciones incompletas activadas y Break en el envío y el registro | dlq: true y onerror en los módulos 3 y 5 | Cumplido |
| 9 | Prueba con “Run once” sobre datos de prueba en Make | Pendiente: se hace en la cuenta, no en Python | Pendiente |
| 10 | Franja de 9:00 a 20:00 en el filtro | Dos condiciones numéricas sobre la hora | Cumplido |
| 11 | Baja respetada | Condición notexist sobre baja | Cumplido |
| 12 | Dueña y ficha en el inventario del Día 19 | Configuración declarada | Cumplido |
El resultado es $10$ de $12$, y los dos pendientes son los que ningún programa puede marcar por Valeria: la aprobación de la plantilla por Meta, que llegará con el módulo 5, y la prueba con “Run once” en la cuenta real, con la copia de la hoja de prueba y el webhook de prueba, que confirmará los identificadores de los módulos, los nombres de los operadores y el calendario de reintentos. La regla de Trigo Lento es que un escenario que escribe a clientes se activa con $12$ de $12$; hasta entonces, TL Recordatorio de recogida v1 queda construido, documentado y apagado, que es exactamente el estado correcto para un escenario cuyo envío todavía es simulado.
Ejecutar a demanda por la API
Cuando la prueba en la cuenta esté lista, Valeria no quiere depender del botón del editor para repetirla: quiere poder lanzar el escenario desde su cuaderno de Python, con los mismos datos de prueba, y leer el resultado. La API de Make ofrece para eso un endpoint de ejecución de escenarios, POST /api/v2/scenarios/{scenarioId}/run, que según su referencia (revisada en octubre de 2026) exige que el escenario esté activo, un token con el permiso scenarios:run y, con responsive: true, espera hasta $40$ segundos a que la ejecución termine para devolver su estado; si tarda más, la llamada devuelve un error de tiempo agotado aunque la ejecución sigue en marcha. El token vive en una variable de entorno y tiene ese permiso y ningún otro, el mínimo privilegio del Día 17. Este bloque llama a la API real y no se ejecutó para escribir la entrada; la salida es un ejemplo comentado:
# Ejecución a demanda de TL Recordatorio de recogida v1 por la API de Make en Python:
import os
import requests
# El token de la API y el identificador del escenario viven en variables de entorno, nunca en el código:
ZONA = os.environ.get('MAKE_ZONA', 'us1.make.com') # la zona de su organización (eu1, eu2, us1, us2...)
ESCENARIO = os.environ['MAKE_ESCENARIO_RECORDATORIO']
TOKEN = os.environ['MAKE_API_TOKEN'] # token con el permiso scenarios:run y nada más
url = f'https://{ZONA}/api/v2/scenarios/{ESCENARIO}/run'
respuesta = requests.post(url, json = {'responsive': True}, timeout = 60,
headers = {'Authorization': f'Token {TOKEN}'})
print('Estado HTTP:', respuesta.status_code)
cuerpo = respuesta.json()
estados = {1: 'correcta', 2: 'correcta con avisos', 3: 'fallida'}
print('Ejecución:', cuerpo.get('executionId'), '|', estados.get(cuerpo.get('status'), cuerpo.get('status')))
# Salida de ejemplo (puede variar):
# Estado HTTP: 200
# Ejecución: 7f3c0000000000000000000000000026 | correcta
La respuesta de ejemplo trae el identificador de la ejecución y su estado ($1$ correcta, $2$ correcta con avisos y $3$ fallida, según los tipos del SDK de Make). Con el identificador, Valeria puede buscar la ejecución en el historial del escenario. Una advertencia práctica: ejecutar a demanda es una corrida del escenario, con su búsqueda, sus filtros y sus créditos; si se lanza fuera de la franja, el filtro la detiene por $1$ crédito, y si se lanza dos veces, el almacén impide el duplicado por $14$. Las defensas del día protegen también contra el propio cuaderno de Valeria.
Consideraciones finales
El laboratorio empezó con dos pasteles en la vitrina y una condición de Don Ernesto, y termina con un escenario que la cumple por construcción. “Un recordatorio está bien; dos ya son acoso”: el almacén recordatorios_enviados se consulta antes de enviar y se firma después, y en un mes simulado con dos reejecuciones detuvo $28$ duplicados sin dejar pasar ninguno. “A las once de la noche no le escribe usted a nadie”: el filtro de franja detiene las corridas fuera de horario por $1$ crédito, y el Break con $3$ reintentos cada $30$ minutos impide que una caída convierta los recordatorios de las 18:00 en mensajes de las 22:51. Un escenario profesional no es el que funciona cuando todo sale bien, sino el que hace lo correcto cuando se ejecuta dos veces, cuando falla y cuando alguien lo lanza a deshoras.
Antes de dar por terminado un escenario programado, conviene hacerse algunas preguntas:
- ¿Qué pasa si el escenario se ejecuta dos veces el mismo día? Si la respuesta es “se envía dos veces”, falta una memoria como el almacén.
- ¿A qué hora caería el último reintento de la corrida más tardía, y está dentro del horario en que su negocio puede escribir a un cliente?
- ¿Qué filtros van antes de los módulos que cobran, y cuántos créditos al mes ahorran?
- ¿Qué parte de la configuración no viaja en el blueprint (programación, zona horaria, almacenes, conexiones) y dónde está documentada?
Las cifras de hoy tienen límites claros. La hoja de $18$ pedidos es una copia de prueba con nombres inventados; el $70%$ de consentimiento, el $2%$ de bajas, el $20%$ de reejecuciones, el $5%$ de fallos y la caída de tres horas y media son supuestos de juguete; y el blueprint tiene la estructura documentada por Make, pero sus identificadores y operadores no proceden de una exportación real y deben contrastarse en la prueba con “Run once”. El hallazgo de que el plan Core ya no alcanza, en cambio, no depende de los supuestos: con cualquier cifra razonable de consentimiento, el recordatorio suma unos mil créditos a un plan que el Día 23 dejó al $93.6%$.
Un gran libro que habla de esto es The Art of Software Testing (El arte de probar software), en el que Glenford Myers, con Corey Sandler y Tom Badgett en la tercera edición, defiende una idea que este laboratorio aplica a un escenario sin código: probar es ejecutar un programa con la intención de encontrar errores, no de confirmar que funciona. Las tres corridas del simulador, la reejecución, la corrida nocturna y la caída de tres horas y media son exactamente eso: casos elegidos para que el escenario falle si tiene que fallar. Para el vocabulario de la idempotencia y de los reintentos, Enterprise Integration Patterns, de Gregor Hohpe y Bobby Woolf, sigue siendo la referencia: el almacén de hoy es su receptor idempotente (Idempotent Receiver), y el Break, una forma de su canal de mensajes fallidos.
Conclusiones clave
Construcción paso a paso
- Un escenario programado es no instantáneo (
metadata.instant: false) y su programación, de domingo a viernes a las 18:00 enAmerica/Mexico_City, se configura en la cuenta: no viaja en el blueprint. - La búsqueda de filas cobra $1$ crédito por corrida y ya entrega un paquete por fila; un iterador solo hace falta cuando un paquete trae un arreglo dentro.
- Un único filtro con cinco condiciones en AND (consentimiento, sin baja, teléfono válido, desde las 9:00 y antes de las 20:00) va antes del primer módulo que cobra por paquete.
No duplicar
- El almacén
recordatorios_enviadosse consulta antes de enviar, un filtronotexistdecide y el registro se escribe después del envío, nunca antes. - En el simulador, la corrida programada envía $13$ recordatorios por $40$ créditos y la reejecución de las 18:40 envía $0$ por $14$; en un mes simulado, el almacén detuvo $28$ duplicados.
- Break guarda el paquete que falla como ejecución incompleta y lo reanuda en el módulo que falló: si falla el registro, se reintenta el registro y no el mensaje; con $3$ reintentos cada $30$ minutos, el último cae a las 19:30.
Coste y verificación
- Los créditos por corrida son $C = 1 + 3k$ con el filtro temprano y $1 + n + 2k$ con el tardío: $38.8$ frente a $44.2$ por corrida y $1,008.8$ frente a $1,149.2$ al mes; con el escenario de pedidos del Día 23, el plan Core llega al $103.7%$.
- La mensajería real costará $4.42$ USD al mes con plantillas de utilidad y Twilio, frente a $6.32$ USD si se ignorara el consentimiento.
- Con una caída de tres horas y media, el calendario automático de ocho reintentos envía $13$ mensajes a las 22:51; el Break de Valeria no envía ninguno fuera de la franja y deja $13$ para la mañana. La lista de comprobación queda en $10$ de $12$, y el escenario se activa solo con $12$.
Mañana, en el Día 27: JSON y variables en Make: estructuras de datos, Parse JSON y almacenes. Hoy el almacén de recordatorios guarda tres campos y se consulta por su clave; mañana Valeria declarará sus estructuras de datos, convertirá textos JSON en paquetes con Parse JSON y guardará en almacenes y variables lo que los escenarios de pedidos necesitan recordar entre una ejecución y la siguiente.
Referencias
- Make (2026). Programación de escenarios, almacenes de datos, filtros y manejo de errores (Break y reintento automático de ejecuciones incompletas). Centro de ayuda de Make. https://help.make.com/automatic-retry-of-incomplete-executions
- Make (2026). Referencia de la API: crear un escenario (blueprint y programación como cadenas) y ejecutar un escenario. https://developers.make.com/api-documentation/api-reference/scenarios/post--scenarios--scenarioid--run
- Meta (2026). WhatsApp Cloud API: plantillas y envío de mensajes de plantilla. https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates
- Hohpe, G. y Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley.
- Myers, G. J., Sandler, C. y Badgett, T. (2011). The Art of Software Testing (3.ª ed.). Wiley.

