Arquitecto de automatización con IA: Día 36
Índice de contenido
Resumen:
Make piensa en paquetes que pasan de módulo en módulo; n8n piensa en listas de items que cada nodo recibe y entrega. En esta entrada, Valeria construye su primer workflow de verdad en el servidor de n8n de Trigo Lento: el mismo flujo de entrada de mensajes de WhatsApp que ya tenía en Make, ahora como TL Pedidos WhatsApp n8n v1. Simula con Python cómo viajan los items y cuántas veces se ejecuta cada nodo, escribe un pequeño evaluador para entender la sintaxis de las expresiones, lee el JSON exportado, lo valida y lo dibuja, lo prueba con datos fijados sin escribir a ningún cliente y compara su consumo con el de Make. Al terminar, usted sabrá leer y escribir expresiones de n8n, elegir los nodos núcleo para cada tarea, detectar en un JSON los errores de conexión más comunes y configurar reintentos que no rompan el reloj de 15 segundos de Twilio.
¿Cómo citar el presente artículo?
Romero, J. (8 de marzo de 2026). Arquitecto de automatización con IA: Día 36. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_36/.
Arquitecto de automatización con IA: Día 36 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_36/.
Precios, límites y políticas revisados en octubre de 2026: planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude. Los tipos de nodo (n8n-nodes-base.webhook, n8n-nodes-base.if, n8n-nodes-base.switch, n8n-nodes-base.set, n8n-nodes-base.code, n8n-nodes-base.googleSheets, n8n-nodes-base.respondToWebhook, n8n-nodes-base.errorTrigger), sus versiones disponibles, los ajustes de nodo retryOnFail, maxTries (de 2 a 5, 3 por omisión), waitBetweenTries (hasta 5 000 ms, 1 000 por omisión) y onError, la función de cadena hash() de las expresiones y el esquema de POST /api/v1/workflows se contrastaron con la documentación y el código fuente públicos de n8n, revisado en octubre de 2026. El servicio de Docker no respondía al escribir la entrada, de modo que el workflow del Bloque 4 y los datos fijados del Bloque 7 tienen la estructura verificada contra la documentación, revisado en octubre de 2026: no son la exportación de una instancia real y conviene contrastarlos con una exportación propia de la versión 2.43.7. Ocho de los once bloques son Python que se ejecuta sin red y sus salidas son las reales.
Día 36. Primer workflow en n8n: nodos, items y expresiones
Introducción
Ayer, en el Día 35 (Seguridad II: credenciales cifradas, respaldo y restauración de n8n), Valeria inventarió lo que había que proteger, guardó la clave de cifrado fuera del servidor, respaldó Postgres según la regla 3-2-1 y cronometró una restauración completa en 43 minutos, y Trigo Lento ganó un servidor de n8n que ya sabe levantarse de una caída. Hoy, por fin, ese servidor trabaja.
Es domingo 8 de marzo, poco después de las dos de la tarde. La panadería cerró, el obrador huele a horno apagándose y Valeria abre el editor de n8n en la laptop, sobre la mesa donde hace un mes estaba la libreta de encargos. El lienzo está vacío salvo por un botón con un signo de suma. Arrastra el primer nodo, un Webhook, y la pantalla le muestra algo que en Make no había visto así: a la derecha del nodo, una tabla con una sola fila que dice 1 item. Cuando conecta el segundo nodo y pulsa “Probar paso”, la tabla del segundo nodo dice 1 item de entrada y 1 item de salida. Cuando prueba un nodo que separa un pedido en renglones, dice 1 item de entrada y 3 items de salida, y el nodo siguiente, sin que ella haya puesto ningún iterador, se ejecuta tres veces.
Ese detalle cambia la manera de pensar el flujo. En Make, Valeria aprendió el Día 22 (Make por dentro: escenarios, módulos, paquetes y créditos) que cada módulo se ejecuta una vez por paquete y cobra un crédito por operación, y que para recorrer los renglones de un pedido hace falta un iterador que multiplica el consumo. En n8n la lista de items es la forma natural de los datos, cada nodo sabe recorrerla solo y el plan en la nube no cobra por nodo, sino por ejecución completa.
Don Ernesto, que ha venido a revisar la lista de compras del lunes, se asoma a la pantalla: “Valeria, usted ya tiene ese mismo robot funcionando en el otro programa, el de los círculos. ¿Para qué lo hace otra vez? Si el otro ya anota los pedidos en la hoja, a mí me suena a hacer dos veces el mismo pan.” La objeción es justa, y la respuesta tiene dos partes. La primera es de oficio: rehacer un flujo conocido en una herramienta nueva es la forma más segura de aprenderla, porque cualquier diferencia en el resultado se debe a la herramienta y no al diseño. La segunda es de números: el agente del Día 38 vivirá en n8n, y antes de construirlo hay que saber cuánto cuesta en cada plataforma el mismo tráfico. Esa cuenta, con la hoja de Make del Día 23 al lado, ocupa una sección completa de esta entrada.
En la presente entrada, descubrirá el modelo de datos de n8n: qué es un item, por qué cada nodo recibe y entrega listas y cuándo un nodo se ejecuta una vez por item. Escribirá expresiones con {{ $json.campo }}, $('Nodo').item.json y $now, y entenderá su sintaxis con un pequeño evaluador escrito en Python. Conocerá los nodos núcleo (Webhook, Edit Fields, IF, Switch, Code y Respond to Webhook) y construirá TL Pedidos WhatsApp n8n v1, el equivalente del primer flujo de Make. Después leerá su JSON con la función nodos_desde_n8n, lo dibujará, validará sus conexiones, lo probará con datos fijados sin escribir a ningún cliente y lo importará con la API pública. Al final traducirá los conceptos de Make a n8n, comparará el consumo del mismo tráfico en las dos plataformas y elegirá con una simulación cuántos reintentos aguanta el reloj de Twilio.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Explicar el modelo de datos de n8n: cada nodo recibe y emite una lista de items
{"json": ..., "binary": ...}y se ejecuta por item. - Escribir expresiones (
{{ $json.campo }},$('Nodo').item.json,$now) y usar los nodos núcleo. - Leer un workflow exportado con Python, validar sus conexiones y dibujarlo.
- Traducir los conceptos de Make a n8n y comparar el consumo para el mismo tráfico.
Items: la unidad de trabajo de n8n
La documentación de n8n lo resume en una frase: todos los datos que pasan de un nodo a otro son una lista de objetos. Cada objeto de esa lista es un item, y cada item tiene una forma fija: una clave json con los datos del registro y, si el registro trae archivos (una foto del pastel, un PDF de una factura), una clave binary con ellos. Un mensaje de WhatsApp que entra por el webhook es un item; un pedido con tres renglones, separado en sus renglones, son tres items; la lista completa de encargos del día que lee un nodo de Google Sheets son tantos items como filas tenga la hoja.
La segunda regla es la que más se nota al construir: casi todos los nodos se ejecutan una vez por cada item que reciben. Si a un nodo Edit Fields le llegan tres items, calcula tres veces los campos que usted configuró, una con cada item, y entrega tres items. No hace falta un iterador, como en Make, porque la lista ya es la forma natural de los datos. Hay excepciones deliberadas: el nodo Code tiene un modo “Run Once for All Items” en el que recibe la lista completa y decide qué devolver, y los nodos que agregan (Aggregate, Summarize) convierten muchos items en uno.
Comencemos, como cada día, con las importaciones base, la semilla de la serie y los supuestos del día. Además de los datos fijos de Trigo Lento, el bloque carga el catálogo de doce productos del Día 1, porque la ejecución de juguete calculará subtotales con sus precios:
# Importaciones base, semilla de la serie y supuestos del día en Python:
import warnings
warnings.filterwarnings('ignore')
import ast
import copy
import hashlib
import json
import re
import textwrap
from datetime import datetime, timedelta, timezone
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, FancyArrowPatch, FancyBboxPatch, Polygon
# 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_colwidth', 60)
carpeta = Path('trigo_lento') / 'dia_36'
(carpeta / 'flujos' / 'n8n').mkdir(parents = True, exist_ok = True)
# Datos fijos de la serie (A.5) y cupos de los planes (Tabla B, revisado en octubre de 2026):
MENSAJES_MES = 1_560 # 60 mensajes de WhatsApp al día, 26 días al mes
CREDITOS_V1_MAKE = 5 # módulos de TL Pedidos WhatsApp v1 en Make (Día 23)
CUPO_MAKE_CORE = 10_000 # créditos al mes
CUPO_N8N_STARTER = 2_500 # ejecuciones al mes
catalogo = {p['clave']: p for p in json.loads(Path('trigo_lento/catalogo.json').read_text(encoding = 'utf-8'))}
print('numpy', np.__version__, '| pandas', pd.__version__)
print('Productos en el catálogo:', len(catalogo), '| carpeta del día:', carpeta.as_posix())
numpy 2.4.6 | pandas 3.0.3
Productos en el catálogo: 12 | carpeta del día: trigo_lento/dia_36
El catálogo tiene sus 12 productos y la carpeta del día queda lista. Las constantes CREDITOS_V1_MAKE = 5 y CUPO_N8N_STARTER = 2_500 se usarán al final para comparar las dos plataformas con el mismo tráfico.
La forma más clara de ver los items es pensar en el obrador los sábados, cuando sale la entrega de la Cafetería Brújula. Las charolas viajan juntas en un carrito de estación en estación: en la mesa de formado alguien trabaja charola por charola, en el horno entran todas a la vez, y en la mesa de empaque se juntan en una sola caja. En n8n, el carrito es la lista de items, cada estación es un nodo, y el modo de trabajo de cada estación (charola por charola o todas juntas) es lo que decide cuántas veces se ejecuta. La Figura 1 dibuja esa metáfora con los nodos de la ejecución de juguete.
Figura 1. Bandejas en un carrito: el pedido de la Cafetería Brújula entra como 1 item, se separa en 3, Calcular subtotal trabaja bandeja por bandeja (3 veces), el IF reparte 1 y 2, y Juntar pedido las vuelve a poner en una sola caja de 2 348 MXN.
Veamos ahora un pedido real de Marisol Peña, dueña de la Cafetería Brújula, que escribe por WhatsApp: “Buenos días, para mañana: 40 baguettes, 12 croissants y 1 pastel de zanahoria”. El siguiente bloque simula en Python lo que haría n8n con ese mensaje en seis nodos. Un nodo Code separa los renglones (modo “una vez para todos”), un Edit Fields calcula el subtotal de cada renglón con el precio del catálogo, un IF decide qué renglones necesitan 48 horas de anticipación porque son pasteles enteros, una acción avisa al obrador solo por los renglones de la rama verdadera, y un Aggregate junta los renglones en un resumen. La función correr guarda, para cada nodo, cuántos items entran, cuántos salen y cuántas veces se ejecutó:
# Items y ejecución por item: un pedido de la Cafetería Brújula recorre seis nodos en Python:
def item(**campos):
# La unidad de trabajo de n8n: un diccionario con "json" (y "binary" si hay archivos):
return {'json': campos, 'binary': {}}
MENSAJE = ('Buenos días, para mañana: 40 baguettes, 12 croissants y 1 pastel de zanahoria. '
'Gracias, Marisol (Cafetería Brújula)')
PALABRAS = {'baguettes': 'P02', 'croissants': 'P05', 'pastel de zanahoria': 'P08'}
ENTEROS = {'P08', 'P09', 'P12'} # pasteles y pay enteros: 48 horas de anticipación
def separar_renglones(items): # Code, modo "Run Once for All Items"
salida = []
for it in items:
for cantidad, palabra in re.findall(r'(\d+) (' + '|'.join(PALABRAS) + ')', it['json']['texto']):
salida.append(item(clave = PALABRAS[palabra], cantidad = int(cantidad)))
return salida
def calcular_subtotal(it): # Edit Fields (Set): se ejecuta una vez por item
precio = catalogo[it['json']['clave']]['precio_mxn']
return [item(**it['json'], precio = precio, subtotal = precio * it['json']['cantidad'])]
def requiere_anticipacion(it): # IF: dos salidas, verdadero y falso
return it['json']['clave'] in ENTEROS
def avisar_obrador(it): # una acción por cada item que llega
return [item(aviso = f"Hornear {it['json']['cantidad']} x {it['json']['clave']} con 48 h")]
def juntar_pedido(items): # Aggregate: muchos items en uno
return [item(renglones = len(items), total = sum(i['json']['subtotal'] for i in items))]
registro = []
def correr(nombre, items, funcion, modo):
# Un nodo recibe una lista de items y devuelve otra; "por_item" ejecuta la función con cada uno:
if modo == 'por_item':
salida = [s for it in items for s in funcion(it)]
veces = len(items)
else:
salida, veces = funcion(items), 1
registro.append({'nodo': nombre, 'modo': modo, 'entran': len(items), 'salen': len(salida), 'veces': veces})
return salida
entrada = correr('Webhook', [None], lambda _: [item(texto = MENSAJE)], 'una_vez')
renglones = correr('Separar renglones', entrada, separar_renglones, 'una_vez')
con_subtotal = correr('Calcular subtotal', renglones, calcular_subtotal, 'por_item')
verdadero = [it for it in con_subtotal if requiere_anticipacion(it)]
falso = [it for it in con_subtotal if not requiere_anticipacion(it)]
registro.append({'nodo': '¿Requiere anticipación?', 'modo': 'por_item', 'entran': len(con_subtotal),
'salen': len(verdadero) + len(falso), 'veces': len(con_subtotal)})
avisos = correr('Avisar al obrador', verdadero, avisar_obrador, 'por_item')
resumen = correr('Juntar pedido', con_subtotal, juntar_pedido, 'una_vez')
tabla_items = pd.DataFrame(registro)
print(tabla_items.to_string(index = False))
print('IF -> verdadero:', len(verdadero), '| falso:', len(falso), '| aviso:', avisos[0]['json']['aviso'])
print('Resumen:', resumen[0]['json'])
operaciones = int(tabla_items.query("nodo != '¿Requiere anticipación?'")['veces'].sum())
print(f'Operaciones de nodo: {operaciones} | créditos equivalentes en Make: {operaciones} | ejecuciones en n8n: 1')
# Items que entran y salen de cada nodo:
fig, ax = plt.subplots(figsize = (9, 4.2))
x = np.arange(len(tabla_items))
ax.bar(x - 0.2, tabla_items['entran'], 0.4, label = 'Items que entran', color = '#2a78d6')
ax.bar(x + 0.2, tabla_items['salen'], 0.4, label = 'Items que salen', color = '#3a9d5d')
for i, v in enumerate(tabla_items['veces']):
ax.text(i, max(tabla_items['entran'][i], tabla_items['salen'][i]) + 0.1, f'{v} vez' if v == 1 else f'{v} veces',
ha = 'center', fontsize = 9, color = '#52514e')
ax.set_xticks(x)
ax.set_xticklabels([textwrap.fill(n, 13, break_long_words = False) for n in tabla_items['nodo']], fontsize = 9)
ax.set_ylabel('Items')
ax.set_ylim(0, 4)
ax.legend(loc = 'upper right')
ax.set_title('Una ejecución, seis nodos: items por nodo', fontsize = 16)
plt.tight_layout()
plt.show()
nodo modo entran salen veces
Webhook una_vez 1 1 1
Separar renglones una_vez 1 3 1
Calcular subtotal por_item 3 3 3
¿Requiere anticipación? por_item 3 3 3
Avisar al obrador por_item 1 1 1
Juntar pedido una_vez 3 1 1
IF -> verdadero: 1 | falso: 2 | aviso: Hornear 1 x P08 con 48 h
Resumen: {'renglones': 3, 'total': 2348}
Operaciones de nodo: 7 | créditos equivalentes en Make: 7 | ejecuciones en n8n: 1

La tabla de salida es el modelo de datos de n8n en seis renglones. El Webhook recibe 1 item y entrega 1. El nodo que separa renglones recibe 1 y entrega 3: una baguette, un croissant y un pastel, cada uno en su item. A partir de ahí, Calcular subtotal se ejecuta 3 veces, una con cada item, sin que nadie haya escrito un ciclo. El IF también evalúa 3 veces y reparte: 1 item a la rama verdadera (el pastel de zanahoria, clave P08) y 2 a la falsa. El aviso al obrador solo se ejecuta para el item que llegó por la rama verdadera, y el Aggregate convierte los 3 items en uno con el total del pedido: $2,348$ MXN ($40 \cdot 38 + 12 \cdot 34 + 1 \cdot 420$).
La figura de barras lo muestra de un vistazo: los nodos que “abren” la lista (Separar renglones) tienen la barra verde más alta que la azul, los que la “cierran” (Juntar pedido) al revés, y los que trabajan item por item tienen las dos barras iguales y el rótulo “3 veces” encima.
La cuenta que importa para el presupuesto es la última línea. Si se suman las veces que se ejecutó cada nodo, salvo el IF (que en Make sería un filtro y no cobra), salen 7 operaciones. En Make cada operación de módulo es un crédito, así que el mismo pedido costaría unos 7 créditos; en n8n Cloud es 1 ejecución, porque la unidad de cobro es la corrida completa. En notación:
$$ V_n = |I_n|, \qquad O = \sum_{n \in \text{nodos}} V_n, \qquad E = 1 \text{ por corrida} $$
La lógica detrás de esta fórmula es múltiple:
- $I_n$ es la lista de items que recibe el nodo $n$ y $V_n$ el número de veces que se ejecuta; para los nodos que trabajan por item, $V_n = |I_n|$, y para los que trabajan “una vez para todos” (Code en ese modo, Aggregate), $V_n = 1$.
- $O$, la suma de ejecuciones de nodo, es lo que mide el trabajo real del servidor y se parece a lo que Make llama operaciones: por eso un pedido de tres renglones cuesta más créditos que uno de un renglón.
- $E$ es lo que cuenta n8n Cloud: una ejecución por corrida, tenga los nodos que tenga y procese los items que procese.
- En conclusión, en n8n la longitud de la lista cambia el trabajo del servidor, pero no la factura; en Make cambia las dos cosas.
Hay un matiz que conviene retener desde ahora porque reaparecerá en las expresiones. Cuando un nodo entrega items que salieron de otros (los subtotales salen de los renglones, que salen del mensaje), n8n guarda el parentesco entre cada item de salida y el item de entrada que lo produjo; la documentación lo llama paired items (items emparejados). Gracias a ese parentesco, un nodo al final del flujo puede preguntar “¿qué decía el mensaje original de este renglón?” sin que nadie haya copiado el mensaje en cada item.
Expresiones
Un nodo configurado con valores fijos hace siempre lo mismo. Lo que hace útil a un workflow es que sus parámetros dependan del item que se está procesando: el teléfono del cliente, el texto de su mensaje, la hora en que llegó. En n8n, esos valores variables se escriben con expresiones: fragmentos de JavaScript entre llaves dobles, {{ ... }}, que se evalúan una vez por item. En el JSON exportado, un parámetro que contiene una expresión empieza con el signo igual, "={{ $json.body.Body }}"; uno fijo no lo lleva, "whatsapp".
Dentro de las llaves hay variables que n8n pone a disposición del nodo. Las que la serie usará más se reúnen en la Tabla 1.
Tabla 1. Variables de expresión más usadas en los workflows de Trigo Lento.
| Variable | Qué devuelve | Ejemplo en Trigo Lento |
|---|---|---|
$json | El json del item actual | {{ $json.body.Body }}: el texto del mensaje |
$('Nodo').item.json | El item emparejado de otro nodo anterior | {{ $('Mensaje de Twilio').item.json.body.MessageSid }} |
$input.all() | La lista completa de items de entrada | Contar renglones dentro de un nodo Code |
$now | La fecha y hora actuales (Luxon), en la zona del workflow | {{ $now.toFormat('yyyy-MM-dd HH:mm') }} |
$today | La fecha de hoy a medianoche (Luxon) | {{ $today.plus({days: 1}).toISODate() }}: mañana |
$execution.id | El identificador de la ejecución | Rastrear un pedido en el historial |
$workflow.name | El nombre del workflow | Avisos del Error Workflow |
Tres ideas hacen que las expresiones dejen de parecer magia:
$jsonsiempre es el item actual del nodo que se está configurando, no el del webhook. Si un nodo Edit Fields transformó el mensaje en un borrador, en el nodo siguiente$json.bodyya no existe; existe$json.texto. Por eso, para leer algo de varios nodos atrás se usa$('Nombre del nodo').item.json, que sigue el parentesco de los items emparejados.- Las fechas son objetos de Luxon, la biblioteca de fechas de JavaScript que n8n incluye.
$nowrespeta la zona horaria del workflow, que en Trigo Lento esAmerica/Mexico_Citydesde el Día 34, y se le pueden pedir formatos (toFormat), sumas (plus), restas (minus) y conversiones (toISO,toISODate). - Las cadenas tienen funciones propias de n8n además de las de JavaScript. La que usará hoy es
hash(), que calcula una huella con el algoritmo que se le pida (md5por omisión,sha256,sha512y otros); con ella el número de teléfono se convierte en una clave seudónima sin escribir un nodo de código.
La Figura 2 desarma una expresión de fecha pieza por pieza: las llaves dobles que abren y cierran, la variable, los métodos encadenados y el resultado que se escribe en el campo.
Figura 2. Anatomía de una expresión de n8n: el signo igual del JSON, las llaves dobles, la variable, los métodos encadenados y el resultado que se escribe en el campo, con un ejemplo de fecha de Luxon y otro de texto.
Para entender cómo se resuelve una expresión, lo mejor es escribir un evaluador. El siguiente bloque implementa, en unas sesenta líneas de Python, un subconjunto pequeño de lo que hace n8n: reconoce las bases $json, $('Nodo').item.json y $now, recorre los accesos con punto o con corchetes, aplica algunos métodos de cadena (replace, toUpperCase, includes, slice, hash) y de Luxon (toFormat, plus, toISODate), y sustituye cada {{ ... }} de una plantilla de texto. No pretende reemplazar a n8n, que evalúa JavaScript completo; pretende que usted vea los pasos. La hora de $now se fija en el domingo 8 de marzo a las 10:15 de la Ciudad de México para que el resultado sea reproducible:
# Mini evaluador de expresiones de n8n para entender la sintaxis en Python:
class Fecha:
# Lo mínimo de un DateTime de Luxon: toFormat, plus, minus, toISO y toISODate:
TOKENS = [('yyyy', '%Y'), ('MM', '%m'), ('dd', '%d'), ('HH', '%H'), ('mm', '%M'), ('ss', '%S')]
def __init__(self, dt):
self.dt = dt
def toFormat(self, formato):
for luxon, python in self.TOKENS:
formato = formato.replace(luxon, python)
return self.dt.strftime(formato)
def plus(self, duracion):
return Fecha(self.dt + timedelta(**duracion))
def minus(self, duracion):
return Fecha(self.dt - timedelta(**duracion))
def toISO(self):
return self.dt.isoformat(timespec = 'milliseconds')
def toISODate(self):
return self.dt.date().isoformat()
AHORA = Fecha(datetime(2026, 3, 8, 10, 15, tzinfo = timezone(timedelta(hours = -6)))) # Ciudad de México
METODOS = {'toUpperCase': str.upper, 'toLowerCase': str.lower, 'trim': str.strip,
'replace': lambda s, a, b: s.replace(a, b, 1), 'includes': lambda s, t: t in s,
'slice': lambda s, i, j = None: s[i:j], 'split': lambda s, sep: s.split(sep),
'hash': lambda s, alg = 'md5': hashlib.new(alg, s.encode()).hexdigest()}
BASE = re.compile(r"\$\('([^']+)'\)\.item\.json|\$json|\$now")
PASO = re.compile(r"\.(\w+)\(([^()]*)\)|\.(\w+)|\['([^']*)'\]|\[(\d+)\]")
def resolver(expresion, json_actual, nodos):
m = BASE.match(expresion)
valor = nodos[m.group(1)] if m.group(1) else AHORA if m.group(0) == '$now' else json_actual
pos = m.end()
while pos < len(expresion):
p = PASO.match(expresion, pos)
metodo, argumentos, campo, clave, indice = p.groups()
if metodo:
args = ast.literal_eval('(' + re.sub(r'([{,]\s*)(\w+):', r"\1'\2':", argumentos) + ',)') if argumentos else ()
valor = getattr(valor, metodo)(*args) if isinstance(valor, Fecha) else METODOS[metodo](valor, *args)
elif campo == 'length':
valor = len(valor)
elif valor is None:
raise KeyError(f"no se puede leer '{campo or clave}' de un valor vacío")
else:
valor = valor[int(indice)] if indice else valor.get(campo or clave)
pos = p.end()
return valor
def evaluar(parametro, json_actual, nodos = {}):
# Un parámetro que empieza con "=" es una expresión; cada {{ ... }} se sustituye por su valor:
if not parametro.startswith('='):
return parametro
plantilla = parametro[1:]
try:
unica = re.fullmatch(r'\{\{\s*(.+?)\s*\}\}', plantilla)
if unica:
return resolver(unica.group(1), json_actual, nodos) # conserva el tipo
return re.sub(r'\{\{\s*(.+?)\s*\}\}', lambda m: str(resolver(m.group(1), json_actual, nodos)), plantilla)
except KeyError as error:
return f'ERROR: {error.args[0]}'
cuerpo = {'ProfileName': 'Clienta de prueba', 'From': 'whatsapp:+525500000036', 'WaId': '525500000036',
'Body': 'Quiero 2 conchas y 1 hogaza para el martes', 'MessageSid': 'SM...a036', 'NumMedia': '0'}
del_webhook = {'body': cuerpo}
del_set = {'nombre': 'Clienta de prueba', 'renglones': ['P04', 'P01']}
casos = [
('={{ $json.body.ProfileName }}', del_webhook),
("={{ $json.body.From.replace('whatsapp:', '') }}", del_webhook),
("={{ $json.body.WaId.hash('sha256').slice(0, 12) }}", del_webhook),
("={{ $json.body.Body.toUpperCase().includes('PASTEL') }}", del_webhook),
("={{ $json['renglones'].length }}", del_set),
("={{ $('Mensaje de Twilio').item.json.body.MessageSid }}", del_set),
("={{ $now.toFormat('yyyy-MM-dd HH:mm') }}", del_set),
('={{ $now.plus({days: 2}).toISODate() }}', del_set),
("=Pedido de {{ $json.nombre }} recibido el {{ $now.toFormat('dd/MM/yyyy') }}", del_set),
('={{ $json.body.Inexistente }}', del_webhook),
('={{ $json.cliente.nombre }}', del_set),
('whatsapp', del_set),
]
filas = []
for expresion, actual in casos:
resultado = evaluar(expresion, actual, {'Mensaje de Twilio': del_webhook})
filas.append({'expresión': expresion, 'resultado': repr(resultado), 'tipo': type(resultado).__name__})
print(pd.DataFrame(filas).to_string(index = False))
expresión resultado tipo
={{ $json.body.ProfileName }} 'Clienta de prueba' str
={{ $json.body.From.replace('whatsapp:', '') }} '+525500000036' str
={{ $json.body.WaId.hash('sha256').slice(0, 12) }} 'c0e2035f91b3' str
={{ $json.body.Body.toUpperCase().includes('PASTEL') }} False bool
={{ $json['renglones'].length }} 2 int
={{ $('Mensaje de Twilio').item.json.body.MessageSid }} 'SM...a036' str
={{ $now.toFormat('yyyy-MM-dd HH:mm') }} '2026-03-08 10:15' str
={{ $now.plus({days: 2}).toISODate() }} '2026-03-10' str
=Pedido de {{ $json.nombre }} recibido el {{ $now.toFormat('dd/MM/yyyy') }} 'Pedido de Clienta de prueba recibido el 08/03/2026' str
={{ $json.body.Inexistente }} None NoneType
={{ $json.cliente.nombre }} "ERROR: no se puede leer 'nombre' de un valor vacío" str
whatsapp 'whatsapp' str
Los doce casos recorren lo que pasa en la práctica:
- Las cinco primeras expresiones leen y transforman campos del item actual: el nombre del perfil, el teléfono sin el prefijo
whatsapp:, los 12 primeros caracteres del SHA-256 del número (c0e2035f91b3), una búsqueda de la palabra “PASTEL” que devuelve un booleano y la longitud de una lista, que devuelve un entero. Cuando el parámetro es una sola expresión, el resultado conserva su tipo. - La sexta lee un campo del nodo
Mensaje de Twiliodesde un item que ya no lo contiene: es la forma de mirar hacia atrás sin copiar datos. - La séptima y la octava manejan fechas: la hora actual con formato, 2026-03-08 10:15, y la fecha de dentro de dos días, 2026-03-10.
- La novena mezcla texto fijo y dos expresiones; cuando hay texto alrededor, el resultado es siempre una cadena.
- La décima pide un campo que no existe y obtiene un valor vacío (
Noneen Python,undefineden JavaScript); n8n escribe un campo vacío y sigue. - La undécima pide un campo de algo que no existe (
$json.cliente.nombrecuando no haycliente) y produce un error. En n8n ese error detiene el nodo, salvo que el nodo esté configurado para continuar; es el error de expresión más frecuente de un workflow nuevo. - La última no empieza con
=: es un valor fijo y se devuelve tal cual.
La diferencia entre un campo vacío y un error está en el segundo punto de la ruta. $json.a sobre un item sin a es vacío; $json.a.b es un error. Cuando un dato puede faltar, conviene comprobarlo antes con un IF o usar el operador de JavaScript ?. ($json.cliente?.nombre), que n8n admite y que devuelve vacío en lugar de fallar.
Los nodos núcleo
n8n tiene cientos de nodos de aplicaciones (Google Sheets, Gmail, Slack, Postgres, Twilio), pero casi todos los workflows de la serie se apoyan en media docena de nodos núcleo, que no hablan con ninguna aplicación y solo mueven, transforman o deciden sobre items. La Tabla 2 los reúne con el tipo que aparece en el JSON, la versión de nodo que usa la serie y lo que hacen con la lista de items.
Tabla 2. Nodos núcleo de n8n: tipo en el JSON, versión usada en la serie y efecto sobre los items.
| Nodo en el editor | type en el JSON | typeVersion | Items que entran y salen | Uso en Trigo Lento |
|---|---|---|---|---|
| Webhook | n8n-nodes-base.webhook | 2 | 0 a 1 por petición | Recibir el mensaje que Twilio reenvía |
| Edit Fields (Set) | n8n-nodes-base.set | 3.4 | n a n | Convertir el mensaje en el borrador del pedido |
| IF | n8n-nodes-base.if | 2.2 | n a verdadero + falso | Comprobar que el mensaje es de la cuenta propia |
| Switch | n8n-nodes-base.switch | 3.2 | n a k salidas | Repartir por tipo de mensaje (versión 2) |
| Code | n8n-nodes-base.code | 2 | n a m (libre) | Separar renglones de un pedido |
| Respond to Webhook | n8n-nodes-base.respondToWebhook | 1.1 | n a n | Devolver a Twilio la respuesta vacía en XML |
Las versiones de la tabla son las que usa la serie y existen en la versión instalada; n8n conserva las versiones anteriores de cada nodo para que los workflows viejos sigan funcionando, y un nodo nuevo arrastrado al lienzo toma la versión más reciente (hoy, por ejemplo, la 2.4 del IF o la 3.5 del Switch). Lo importante al leer un JSON es que typeVersion decide qué parámetros espera el nodo: una exportación con una versión que su instalación no conoce no se puede importar.
Webhook
El Webhook es el disparador que recibe una petición HTTP y la convierte en un item. Su json trae cuatro partes: headers (las cabeceras, incluida la firma de Twilio), params, query (lo que va después del signo de interrogación de la URL) y body (el cuerpo; Twilio envía un formulario y n8n lo convierte en un objeto con los mismos quince campos que vio el Día 23). Tiene dos URL: la de prueba, /webhook-test/..., que solo escucha mientras usted pulsa “Escuchar evento de prueba” en el editor, y la de producción, /webhook/..., que escucha cuando el workflow está publicado.
El parámetro más importante para Trigo Lento es responseMode, que decide cuándo contesta n8n. Con “Immediately” (onReceived) responde en cuanto recibe; con “When Last Node Finishes” (lastNode) responde con la salida del último nodo; con “Using ‘Respond to Webhook’ Node” (responseNode) espera a que un nodo Respond to Webhook diga qué responder. La serie usa el tercero por la misma razón que el Día 23 puso un módulo de respuesta explícita en Make: Twilio espera una respuesta en XML y deja de esperar a los 15 segundos.
Edit Fields (Set)
Edit Fields crea, cambia o elimina campos de cada item. En el JSON aparece como n8n-nodes-base.set, por su nombre antiguo. Cada asignación tiene un name, un value (fijo o expresión) y un type (string, number, boolean, array u object); el tipo importa, porque "0" y 0 no son lo mismo para un IF posterior. Por omisión, el nodo entrega solo los campos que usted definió; la opción de incluir los demás campos permite conservar el item original y añadirle algo. En Trigo Lento es el traductor entre el formulario de Twilio y el contrato del pedido.
IF
El IF evalúa una o varias condiciones sobre cada item y lo manda a una de dos salidas: main[0] si se cumple (verdadero) y main[1] si no (falso). Las condiciones se combinan con and u or, cada una con un valor izquierdo (normalmente una expresión), un operador con su tipo (string/equals, number/gt, boolean/true) y un valor derecho. Una diferencia con el filtro de Make merece atención: el filtro de Make descarta lo que no pasa; el IF de n8n lo entrega por la rama falsa, y si esa rama no está conectada, los items simplemente terminan ahí. En un workflow que tiene que responder a Twilio, una rama falsa sin conectar es un error que se verá enseguida en el validador.
Switch
El Switch es el router de n8n: evalúa reglas en orden y manda cada item a la salida de la primera regla que cumple, o a una salida de respaldo si ninguna cumple. Las salidas se pueden renombrar (pedido, consulta, queja) y se numeran en el JSON como main[0], main[1], y así sucesivamente. TL Pedidos WhatsApp n8n v1 no lo usa, porque su equivalente de Make tampoco tenía router; el Switch entrará en la versión 2, cuando el workflow separe pedidos, preguntas y quejas como hizo el Día 24 en Make.
Code
El nodo Code ejecuta JavaScript o Python sobre los items. Tiene dos modos: “Run Once for All Items”, que recibe la lista completa en $input.all() y devuelve otra lista (es el modo del nodo que separó los renglones en el Bloque 2), y “Run Once for Each Item”, que se ejecuta una vez por item como cualquier otro nodo. Es la salida de emergencia cuando ningún nodo hace lo que se necesita, y por eso mismo conviene usarlo poco: el código dentro de un workflow es más difícil de revisar y de probar que un nodo configurado. Desde la versión 2.0, además, el nodo Code no puede leer variables de entorno salvo que el administrador lo permita expresamente, una decisión de seguridad que afecta al workflow de hoy.
Respond to Webhook
Respond to Webhook devuelve la respuesta HTTP que el Webhook dejó pendiente: el código de estado, las cabeceras y el cuerpo. Puede responder con JSON, con texto, con un archivo o sin contenido. Para Twilio, la respuesta correcta es un 200 con el cuerpo <Response></Response> y la cabecera Content-Type: text/xml, que significa “recibido, no envíe nada al cliente”; para una petición que no viene de la cuenta de Trigo Lento, un 403 sin contenido.
TL Pedidos WhatsApp n8n v1
Con los nodos núcleo a la vista, Valeria rehace el flujo de entrada que construyó en Make el Día 23 (Disparadores, acciones y módulos en Make). Aquel escenario, TL Pedidos WhatsApp v1, tenía cinco módulos: el webhook personalizado, una lectura del almacén con las credenciales de Twilio, una segunda lectura con el filtro de la firma X-Twilio-Signature y la ficha del cliente, la fila nueva en la hoja Borradores y la respuesta vacía en XML. El objetivo de hoy es que el workflow de n8n deje en la hoja exactamente la misma fila que el escenario de Make con el mismo mensaje, para poder comparar las dos plataformas con un solo cambio.
El resultado tiene seis nodos:
- Mensaje de Twilio (Webhook): recibe el
POSTen la rutapedidos-whatsapp, exige autenticación básica y deja la respuesta para un nodo posterior (responseMode: responseNode). - ¿Es de Trigo Lento? (IF): comprueba que
AccountSides la cuenta de Twilio de la panadería y queToes su número de WhatsApp. - Preparar borrador (Edit Fields): construye los nueve campos de la fila con expresiones, incluida la clave seudónima del cliente con
hash('sha256'). - Guardar borrador (Google Sheets): añade la fila a la hoja
Borradores, con reintentos ante fallos pasajeros. - Responder a Twilio (Respond to Webhook):
200con<Response></Response>en XML. - Rechazar (Respond to Webhook):
403para lo que llega por la rama falsa del IF.
Este es el JSON del workflow, con identificadores de ejemplo y las credenciales reducidas a su nombre, como pide la regla de anonimización de la serie. Su estructura se verificó contra la documentación, revisado en octubre de 2026:
{
"name": "TL Pedidos WhatsApp n8n v1",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "pedidos-whatsapp",
"authentication": "basicAuth",
"responseMode": "responseNode",
"options": {}
},
"id": "0b1c0000-0000-4000-8000-000000000001",
"name": "Mensaje de Twilio",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "00000000-0000-4000-8000-0000000000aa",
"credentials": {"httpBasicAuth": {"id": "Cr3d00000000000A", "name": "Twilio hacia n8n (Basic)"}}
},
{
"parameters": {
"conditions": {
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [
{
"id": "c1000000-0000-4000-8000-000000000001",
"leftValue": "={{ $json.body.AccountSid }}",
"rightValue": "AC00000000000000000000000000000000",
"operator": {"type": "string", "operation": "equals"}
},
{
"id": "c1000000-0000-4000-8000-000000000002",
"leftValue": "={{ $json.body.To }}",
"rightValue": "whatsapp:+525500000001",
"operator": {"type": "string", "operation": "equals"}
}
],
"combinator": "and"
},
"options": {}
},
"id": "7f3a0000-0000-4000-8000-000000000002",
"name": "¿Es de Trigo Lento?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [220, 0]
},
{
"parameters": {
"assignments": {
"assignments": [
{"id": "a1000000-0000-4000-8000-000000000001", "name": "id_mensaje", "value": "={{ $json.body.MessageSid }}", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000002", "name": "canal", "value": "whatsapp", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000003", "name": "nombre", "value": "={{ $json.body.ProfileName }}", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000004", "name": "telefono", "value": "={{ $json.body.From.replace('whatsapp:', '') }}", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000005", "name": "clave_cliente", "value": "={{ $json.body.WaId.hash('sha256').slice(0, 12) }}", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000006", "name": "texto", "value": "={{ $json.body.Body }}", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000007", "name": "adjuntos", "value": "={{ Number($json.body.NumMedia) }}", "type": "number"},
{"id": "a1000000-0000-4000-8000-000000000008", "name": "estado", "value": "por revisar", "type": "string"},
{"id": "a1000000-0000-4000-8000-000000000009", "name": "recibido", "value": "={{ $now.toFormat('yyyy-MM-dd HH:mm') }}", "type": "string"}
]
},
"options": {}
},
"id": "2d4e0000-0000-4000-8000-000000000003",
"name": "Preparar borrador",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [440, -100]
},
{
"parameters": {
"operation": "append",
"documentId": {"__rl": true, "value": "ID_DE_EJEMPLO", "mode": "id"},
"sheetName": {"__rl": true, "value": "Borradores", "mode": "name"},
"columns": {"mappingMode": "autoMapInputData", "value": {}, "matchingColumns": [], "schema": []},
"options": {}
},
"id": "5a6b0000-0000-4000-8000-000000000004",
"name": "Guardar borrador",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [660, -100],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 1000,
"credentials": {"googleSheetsOAuth2Api": {"id": "Cr3d00000000000B", "name": "Hojas de Trigo Lento"}}
},
{
"parameters": {
"respondWith": "text",
"responseBody": "<Response></Response>",
"options": {
"responseCode": 200,
"responseHeaders": {"entries": [{"name": "Content-Type", "value": "text/xml"}]}
}
},
"id": "9c2d0000-0000-4000-8000-000000000005",
"name": "Responder a Twilio",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [880, -100]
},
{
"parameters": {
"respondWith": "noData",
"options": {"responseCode": 403}
},
"id": "9c2d0000-0000-4000-8000-000000000006",
"name": "Rechazar",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [440, 120]
}
],
"connections": {
"Mensaje de Twilio": {"main": [[{"node": "¿Es de Trigo Lento?", "type": "main", "index": 0}]]},
"¿Es de Trigo Lento?": {"main": [
[{"node": "Preparar borrador", "type": "main", "index": 0}],
[{"node": "Rechazar", "type": "main", "index": 0}]
]},
"Preparar borrador": {"main": [[{"node": "Guardar borrador", "type": "main", "index": 0}]]},
"Guardar borrador": {"main": [[{"node": "Responder a Twilio", "type": "main", "index": 0}]]}
},
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "America/Mexico_City",
"errorWorkflow": "Er7v0000000000C1",
"saveDataSuccessExecution": "all",
"saveManualExecutions": true
},
"versionId": "5e7f0000-0000-4000-8000-000000000036",
"pinData": {},
"meta": {"templateCredsSetupCompleted": true},
"tags": []
}
Lea el JSON de arriba abajo con tres preguntas en mente:
- ¿Dónde están los nodos? En la lista
nodes, cada uno conparameters(su configuración),id(un identificador único),name(el nombre visible, que también es la clave de las conexiones),type,typeVersionypositionen píxeles del lienzo. Los ajustes de reintento (retryOnFail,maxTries,waitBetweenTries) no van dentro deparameters, sino al lado, porque son ajustes del nodo y no de su operación. - ¿Cómo se conectan? En
connections, indexadas por el nombre del nodo de origen. Cada origen tiene una lista de salidas (main), y cada salida, una lista de destinos. El IF tiene dos salidas: la primera lleva aPreparar borradory la segunda aRechazar. Los nodos finales no aparecen como origen. - ¿Dónde están los secretos? En ningún lugar del archivo. Los nodos que los necesitan llevan un bloque
credentialscon el identificador y el nombre de una credencial guardada en n8n, cifrada con la clave que el Día 35 puso a salvo. ElAccountSidde la condición no es un secreto (identifica la cuenta, no la abre), y aun así se publica con ceros.
La traducción de Make a n8n no fue uno a uno, y las tres diferencias tienen motivo:
- La verificación de la firma cambió de lugar. En Make, el filtro calculaba la firma HMAC de Twilio con el token guardado en el almacén. En n8n, calcular esa firma exige un nodo Crypto con el token como parámetro (quedaría en el JSON exportado) o un nodo Code que lea el token de una variable de entorno, algo que n8n 2.x bloquea por omisión. La versión 1 usa en su lugar la autenticación básica del Webhook: Twilio admite usuario y contraseña en la URL del webhook, y n8n los comprueba contra una credencial cifrada antes de crear el item. El IF añade una segunda comprobación barata sobre la cuenta y el número de destino.
- La ficha del cliente se queda fuera. El escenario de Make buscaba el nombre del cliente en un almacén de datos; la versión 1 de n8n usa el
ProfileNameque manda WhatsApp y deja la búsqueda para cuando el agente consulte la base del negocio, de modo que la fila es la misma salvo para clientes con ficha. - Hay un sexto nodo. En Make, el filtro detenía en silencio lo que no pasaba y el webhook respondía por su cuenta; en n8n, con
responseMode: responseNode, cada camino debe terminar en una respuesta, y el nodoRechazares esa respuesta para la rama falsa.
Atención aquí: la autenticación básica protege la URL del webhook, pero no prueba que el cuerpo del mensaje no se haya alterado, que es lo que garantiza la firma X-Twilio-Signature. Mientras TL Pedidos WhatsApp n8n v1 reciba solo pruebas, la combinación de usuario y contraseña más el IF es suficiente; antes de que reciba mensajes reales de clientes, la firma debe verificarse otra vez, ya sea en n8n con la credencial cifrada o dejando que el escenario de Make, que ya la comprueba, siga siendo la puerta de entrada. No abra la lectura de variables de entorno en el nodo Code (N8N_BLOCK_ENV_ACCESS_IN_NODE=false) para resolverlo por la vía rápida: expone a cualquier workflow todos los secretos del servidor.
En los ajustes del workflow, al final del JSON, hay tres decisiones heredadas de días anteriores: executionOrder: v1 (el orden de ejecución actual, rama por rama), timezone: America/Mexico_City (para que $now dé la hora de la panadería) y errorWorkflow, el identificador del workflow que se ejecutará si este falla, que se verá en la sección de errores.
Leer el workflow con Python
Un workflow exportado es un documento JSON, y como cualquier documento se puede leer, revisar y dibujar con código. Desde el Día 22, la serie dibuja los escenarios de Make con dibujar_flujo a partir de un lector de formato, nodos_desde_blueprint. Hoy nace su pareja para n8n, nodos_desde_n8n, que convierte la lista nodes y el diccionario connections en los nodos y las aristas que dibujar_flujo sabe pintar. La función categoria asigna la forma de la paleta de la serie a partir del tipo: óvalo verde para los disparadores, rombo naranja para IF y Switch, rectángulo morado para los nodos de IA del paquete @n8n/n8n-nodes-langchain, rectángulo rojo para las respuestas de error y rectángulo azul para el resto. Las conexiones de IA (ai_languageModel, ai_memory, ai_tool), que aparecerán mañana, se dibujarán en línea punteada morada:
# Lectura del workflow exportado con nodos_desde_n8n y dibujo con la paleta de la serie en Python:
with open(carpeta / 'flujos' / 'n8n' / 'pedidos_whatsapp_n8n_v1.json', encoding = 'utf-8') as f:
workflow = 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'),
}
DISPARADORES = {'webhook', 'scheduleTrigger', 'manualTrigger', 'errorTrigger', 'executeWorkflowTrigger'}
def categoria(nodo):
corto = nodo['type'].split('.')[-1]
if corto in DISPARADORES:
return 'disparador'
if corto in ('if', 'switch'):
return 'decision'
if nodo['type'].startswith('@n8n/n8n-nodes-langchain'):
return 'ia'
codigo = nodo['parameters'].get('options', {}).get('responseCode', 200)
if corto == 'stopAndError' or (corto == 'respondToWebhook' and codigo >= 400):
return 'error'
return 'accion'
def nodos_desde_n8n(wf, escala_x = 2.5 / 220, escala_y = 1 / 100):
# n8n guarda la posición en píxeles con y hacia abajo; aquí y crece hacia arriba:
nodos = [{'id': n['name'], 'tipo': categoria(n), 'x': n['position'][0] * escala_x,
'y': -n['position'][1] * escala_y,
'texto': textwrap.fill(n['name'], 13) + '\n(' + n['type'].split('.')[-1] + ')'} for n in wf['nodes']]
aristas = []
for origen, tipos in wf['connections'].items():
for tipo, salidas in tipos.items():
for i, destinos in enumerate(salidas):
etiqueta = ['verdadero', 'falso'][i] if len(salidas) == 2 else ''
aristas += [(origen, d['node'], etiqueta, tipo) for d in destinos]
return nodos, aristas
def dibujar_flujo(nodos, aristas, titulo, ax = None, ancho = 2.05, alto = 1.0, recorte = 38):
if ax is None:
fig, ax = plt.subplots(figsize = (12, 4.4))
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.25), (x + ancho / 1.6, y), (x, y - alto / 1.25)])
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, tipo in aristas:
(x1, y1), (x2, y2) = posicion[origen], posicion[destino]
ia = tipo.startswith('ai_') # las conexiones de IA van en línea punteada morada
ax.add_patch(FancyArrowPatch((x1, y1), (x2, y2), arrowstyle = '-|>', mutation_scale = 12,
shrinkA = recorte, shrinkB = recorte + 2, linewidth = 1.3,
color = '#7b5ea7' if ia else '#52514e', linestyle = ':' if ia else '-'))
if etiqueta:
ax.text((x1 + x2) / 2 - 0.1, (y1 + y2) / 2 + (0.25 if y2 > y1 else -0.3), etiqueta, ha = 'right', fontsize = 8.5, color = '#52514e')
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.0)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(titulo, fontsize = 16)
return ax
nodos, aristas = nodos_desde_n8n(workflow)
resumen_nodos = pd.DataFrame([{'nodo': n['name'], 'tipo': n['type'].split('.')[-1], 'versión': n['typeVersion'],
'categoría': categoria(n),
'expresiones': json.dumps(n['parameters'], ensure_ascii = False).count('={{'),
'credencial': ', '.join(n.get('credentials', {})) or '-'} for n in workflow['nodes']])
print(resumen_nodos.to_string(index = False))
print('Aristas:', len(aristas), '| ajustes:', {k: workflow['settings'][k] for k in ('executionOrder', 'timezone')})
dibujar_flujo(nodos, aristas, workflow['name'] + ': dibujado desde su JSON')
plt.tight_layout()
plt.show()
nodo tipo versión categoría expresiones credencial
Mensaje de Twilio webhook 2.0 disparador 0 httpBasicAuth
¿Es de Trigo Lento? if 2.2 decision 2 -
Preparar borrador set 3.4 accion 7 -
Guardar borrador googleSheets 4.5 accion 0 googleSheetsOAuth2Api
Responder a Twilio respondToWebhook 1.1 accion 0 -
Rechazar respondToWebhook 1.1 error 0 -
Aristas: 5 | ajustes: {'executionOrder': 'v1', 'timezone': 'America/Mexico_City'}

La tabla de nodos confirma lo que se leyó en el JSON: 6 nodos, de los cuales uno es disparador, uno decisión, tres acciones y uno respuesta de error; 5 aristas; dos nodos con credencial; y 9 expresiones en total, 2 en el IF y 7 en Edit Fields. El dibujo tiene la forma esperada: el óvalo del webhook, el rombo del IF con sus dos ramas rotuladas, la fila de tres acciones de la rama verdadera y el rectángulo rojo de la falsa.
Fíjese en un detalle de la función: n8n guarda las posiciones con el eje vertical hacia abajo, como las pantallas, y el dibujo de matplotlib lo tiene hacia arriba; por eso nodos_desde_n8n cambia el signo de la coordenada y. Sin ese cambio, la rama verdadera, que en el editor está arriba, quedaría abajo en la figura.
Leer el JSON tiene una segunda utilidad, más importante que dibujarlo: revisarlo antes de importarlo. Cuando un workflow se edita a mano o con ayuda de un modelo de lenguaje (como el generador de borradores del Día 18), los errores más comunes no son de sintaxis, sino de conexiones. El siguiente validador revisa cinco cosas: nombres repetidos, conexiones que parten de un nodo que no existe o apuntan a uno que no existe, nodos que nadie alimenta, salidas de un IF sin destino y, como el webhook responde con un nodo, caminos que terminan sin responder a Twilio. También busca secretos y datos fijados en el archivo. Para probarlo, el bloque crea una copia con dos errores típicos de edición manual: renombrar un nodo en nodes sin cambiar connections, y borrar la rama falsa del IF:
# Validador de conexiones del workflow: nombres, huérfanos, salidas vacías y caminos sin respuesta en Python:
SALIDAS_ESPERADAS = {'if': 2}
def validar_workflow(wf):
hallazgos = []
nombres = [n['name'] for n in wf['nodes']]
por_nombre = {n['name']: n for n in wf['nodes']}
hallazgos += [f'nombre repetido: {x}' for x in sorted({x for x in nombres if nombres.count(x) > 1})]
sucesores = {x: [] for x in nombres}
con_entrada = set()
for origen, tipos in wf['connections'].items():
if origen not in por_nombre:
hallazgos.append(f'conexión desde un nodo inexistente: {origen}')
continue
for tipo, salidas in tipos.items():
esperadas = SALIDAS_ESPERADAS.get(por_nombre[origen]['type'].split('.')[-1], 1)
usadas = [i for i, destinos in enumerate(salidas) if destinos]
hallazgos += [f'{origen}: la salida {i} no tiene destino' for i in range(esperadas) if i not in usadas]
for destinos in salidas:
for d in destinos:
if d['node'] not in por_nombre:
hallazgos.append(f"{origen} apunta a un nodo inexistente: {d['node']}")
else:
sucesores[origen].append(d['node'])
con_entrada.add(d['node'])
disparadores = [x for x in nombres if por_nombre[x]['type'].split('.')[-1] in DISPARADORES]
hallazgos += [f'nodo huérfano (nadie lo alimenta): {x}' for x in nombres
if x not in con_entrada and x not in disparadores]
# Con responseMode = responseNode, todo camino debe terminar en un Respond to Webhook:
for t in disparadores:
if por_nombre[t]['parameters'].get('responseMode') != 'responseNode':
continue
pila = [t]
while pila:
actual = pila.pop()
siguientes = sucesores[actual]
if not siguientes and not por_nombre[actual]['type'].endswith('respondToWebhook'):
hallazgos.append(f'el camino termina en {actual} sin responder: Twilio esperaría 15 s')
pila += siguientes
texto = json.dumps(wf, ensure_ascii = False)
if re.search(r'sk-ant-|Bearer |"password"|"x-api-key"', texto, re.I):
hallazgos.append('posible secreto en el JSON')
if wf.get('pinData'):
hallazgos.append('el workflow exportado lleva datos fijados (pinData): revisar que no sean de clientes')
return hallazgos
print('Workflow original:', validar_workflow(workflow) or 'sin hallazgos')
# Una copia editada a mano con dos errores típicos: renombrar un nodo y borrar la rama falsa:
editado = copy.deepcopy(workflow)
editado['nodes'][2]['name'] = 'Preparar el borrador'
editado['connections']['¿Es de Trigo Lento?']['main'][1] = []
editado['pinData'] = {'Mensaje de Twilio': [{'json': {'body': {'From': 'whatsapp:+525512345678'}}}]}
for h in validar_workflow(editado):
print('-', h)
Workflow original: sin hallazgos
- ¿Es de Trigo Lento?: la salida 1 no tiene destino
- ¿Es de Trigo Lento? apunta a un nodo inexistente: Preparar borrador
- conexión desde un nodo inexistente: Preparar borrador
- nodo huérfano (nadie lo alimenta): Preparar el borrador
- nodo huérfano (nadie lo alimenta): Guardar borrador
- nodo huérfano (nadie lo alimenta): Rechazar
- el camino termina en ¿Es de Trigo Lento? sin responder: Twilio esperaría 15 s
- el workflow exportado lleva datos fijados (pinData): revisar que no sean de clientes
El workflow original pasa sin hallazgos. La copia editada acumula 8, y conviene leerlos como una cadena de causas:
- El cambio de nombre rompe las dos conexiones que mencionan
Preparar borrador: el IF apunta a un nodo inexistente y la conexión haciaGuardar borradorparte de un nodo inexistente. En n8n, el nombre es la clave: renombrar en el editor actualiza las conexiones solo; renombrar en el JSON, no. - Esas conexiones rotas dejan huérfanos a tres nodos:
Preparar el borrador,Guardar borradory, por la rama borrada,Rechazar. Un nodo huérfano nunca se ejecuta y no da ningún aviso. - La salida falsa del IF queda sin destino, y el recorrido de caminos detecta la consecuencia de negocio: un mensaje que no sea de la cuenta de Trigo Lento terminaría en el IF sin respuesta, y Twilio esperaría sus 15 segundos.
- El último hallazgo no es de conexiones: la copia lleva datos fijados con un número de teléfono. Un workflow que se exporta y se versiona no debe llevar nunca datos de clientes reales, como se verá en la sección siguiente.
Un validador de veinte líneas detiene en segundos el error que en el editor se descubre a las once de la mañana con un cliente esperando. El Día 42 lo convertirá en una revisión automática antes de cada cambio que se guarde en el repositorio de flujos.
Probar sin molestar a nadie
En Make, Valeria probaba los escenarios con “Run once” y mensajes que se mandaba a sí misma desde la cuenta de pruebas de Twilio. En n8n hay una herramienta más cómoda: los datos fijados (en el editor, pin data). Fijar los datos de un nodo significa guardar su salida para que, en las ejecuciones manuales, n8n no lo vuelva a ejecutar y use lo guardado. Para un Webhook, eso significa no tener que pedirle a nadie que escriba por WhatsApp cada vez que se cambia un nodo de más adelante: se fija una vez un mensaje de prueba y se prueba el resto del workflow contra él tantas veces como haga falta.
Los datos fijados se guardan en la clave pinData del workflow, por nombre de nodo, como una lista de items con la misma forma que produciría el nodo. Este es el mensaje de prueba que Valeria fija en Mensaje de Twilio, con un teléfono del rango de pruebas de la serie y una clienta inventada:
{
"pinData": {
"Mensaje de Twilio": [
{
"json": {
"headers": {"host": "n8n.trigolento.example", "content-type": "application/x-www-form-urlencoded", "x-twilio-signature": "FIRMA_DE_EJEMPLO="},
"params": {},
"query": {},
"body": {
"SmsMessageSid": "SM0000000000000000000000000000a036",
"NumMedia": "0",
"ProfileName": "Clienta de prueba",
"MessageType": "text",
"WaId": "525500000036",
"SmsStatus": "received",
"Body": "Hola, quiero 2 conchas de vainilla y 1 hogaza para el martes a las 9:00",
"To": "whatsapp:+525500000001",
"NumSegments": "1",
"MessageSid": "SM0000000000000000000000000000a036",
"AccountSid": "AC00000000000000000000000000000000",
"From": "whatsapp:+525500000036",
"ApiVersion": "2010-04-01"
},
"webhookUrl": "https://n8n.trigolento.example/webhook-test/pedidos-whatsapp",
"executionMode": "test"
}
}
]
}
}
Tres reglas convierten esta herramienta en una práctica segura:
- Solo afectan a las ejecuciones manuales. n8n usa los datos fijados mientras usted trabaja en el editor; las ejecuciones de producción, disparadas por un mensaje real, los ignoran. Por eso no hay riesgo de que un pedido real se confunda con el de prueba.
- Se escriben a mano o se generan, nunca se copian de una ejecución real. El historial de ejecuciones del Día 35 guarda los mensajes reales de los clientes; fijar la salida de una de esas ejecuciones metería el nombre y el teléfono de una clienta en el workflow, y con él en cada exportación, copia y repositorio.
- Se quitan antes de exportar. El workflow del Bloque 4 lleva
"pinData": {}; el validador del Bloque 6 avisa si no es así. El archivo de datos de prueba vive aparte, enpin_data_prueba.json, donde se revisa una sola vez.
Con el mensaje fijado, Valeria pulsa “Probar workflow” y recorre el resultado nodo por nodo en el panel de datos, que muestra los items de entrada y de salida de cada uno en forma de tabla, de JSON o de esquema. El IF manda el item a la rama verdadera (la cuenta y el número coinciden), Edit Fields produce los nueve campos (con +525500000036 como teléfono y la clave seudónima en lugar del número completo), y Google Sheets, conectado a una copia de prueba de la hoja, añade una fila. La ejecución queda en el historial de ejecuciones, donde se puede abrir días después para ver qué recibió y qué entregó cada nodo. Ese historial es el registro de auditoría del workflow, y también la razón por la que el Día 35 fijó una poda de 14 días: guarda conversaciones de clientes.
Cuando el workflow está probado en el editor, el paso siguiente es llevarlo al servidor de Trigo Lento de forma repetible. Importarlo a mano funciona, pero el Día 42 versionará los workflows en un repositorio y necesitará hacerlo con código. La API pública de n8n, que el Día 35 usó para listar los workflows activos después de la restauración, también crea workflows. El cuerpo de POST /api/v1/workflows admite name, nodes, connections y settings, y rechaza los campos de solo lectura que trae una exportación (id, active, versionId), así que el script envía solo esos cuatro. La clave de la API se lee del entorno. Este bloque llama al servidor real y no se ejecutó para escribir la entrada; su salida es un ejemplo comentado:
# Importar TL Pedidos WhatsApp n8n v1 con la API pública de n8n, sin activarlo, en Python:
import json
import os
from pathlib import Path
import requests
URL = os.environ['N8N_URL'] # https://n8n.trigolento.example (Día 34)
CABECERAS = {'accept': 'application/json', 'content-type': 'application/json',
'X-N8N-API-KEY': os.environ['N8N_API_KEY']} # la clave nunca va en el código
ruta = Path('trigo_lento') / 'dia_36' / 'flujos' / 'n8n' / 'pedidos_whatsapp_n8n_v1.json'
exportado = json.loads(ruta.read_text(encoding = 'utf-8'))
# La API rechaza los campos de solo lectura (id, active, versionId...): se envían solo estos cuatro:
cuerpo = {clave: exportado[clave] for clave in ('name', 'nodes', 'connections', 'settings')}
r = requests.post(f'{URL}/api/v1/workflows', headers = CABECERAS, json = cuerpo, timeout = 30)
r.raise_for_status()
creado = r.json()
print('Creado:', creado['id'], '|', creado['name'], '| nodos:', len(creado['nodes']))
print('Activo:', creado.get('active'), '| versión:', creado.get('versionId', '')[:8])
# Comprobación de ida y vuelta: los nombres y las conexiones deben ser los mismos que en el archivo:
r = requests.get(f"{URL}/api/v1/workflows/{creado['id']}", headers = CABECERAS, timeout = 30)
r.raise_for_status()
leido = r.json()
print('Mismos nodos:', sorted(n['name'] for n in leido['nodes']) == sorted(n['name'] for n in cuerpo['nodes']))
print('Mismas conexiones:', leido['connections'] == cuerpo['connections'])
# Salida de ejemplo (puede variar):
# Creado: Qm4tP8sLw2ZbNc7R | TL Pedidos WhatsApp n8n v1 | nodos: 6
# Activo: False | versión: 3f9a1c27
# Mismos nodos: True
# Mismas conexiones: True
El workflow se crea sin activar: en las versiones 2.x, activar un workflow se llama publicarlo y se hace con su propio punto de acceso (POST /api/v1/workflows/{id}/publish), de modo que importar y poner en producción son dos decisiones separadas. La comprobación de ida y vuelta lee el workflow recién creado y compara los nombres de los nodos y las conexiones con los del archivo; si n8n hubiera cambiado algo al importar (por ejemplo, una versión de nodo que no conoce), aparecería aquí y no un sábado a mediodía.
Make frente a n8n
Con el mismo flujo construido en las dos plataformas, la comparación ya no es de folleto. El siguiente bloque escribe el diccionario de conceptos y, al final, pone uno al lado del otro los cinco módulos del blueprint del Día 23 y los nodos que los sustituyen hoy:
# Equivalencias entre Make y n8n, con el caso de Trigo Lento en Python:
equivalencias = pd.DataFrame([
('Paquete (bundle)', 'Item', '{"json": {...}, "binary": {...}}; viajan en listas'),
('Módulo', 'Nodo', 'Se ejecuta una vez por item (salvo Code en modo "una vez")'),
('Escenario', 'Workflow', 'JSON con nodes y connections por nombre'),
('Router', 'Switch', 'Salidas numeradas con reglas y salida de respaldo'),
('Filtro', 'IF', 'Dos salidas: main[0] verdadero, main[1] falso'),
('Iterador', 'Split Out o ejecución por item', 'n8n no necesita iterador para recorrer items'),
('Agregador', 'Aggregate', 'Junta muchos items en uno'),
('{{1.Body}}', '{{ $json.body.Body }}', 'Mapeo por número de módulo frente a item actual'),
('Directiva Break', 'Retry On Fail', 'Reintentos configurados en el propio nodo'),
('Ruta de error (onerror)', 'Continue (using error output)', 'Una salida roja extra en el nodo'),
('Escenario de errores', 'Error Workflow', 'Workflow aparte que empieza con Error Trigger'),
('Crédito', 'Ejecución', 'Make cobra por operación; n8n Cloud por corrida completa'),
], columns = ['Make', 'n8n', 'diferencia práctica'])
print(equivalencias.to_string(index = False))
# El primer flujo de Trigo Lento, módulo a módulo (blueprint del Día 23 frente al JSON de hoy):
with open(Path('trigo_lento') / 'dia_23' / 'flujos' / 'make' / 'pedidos_whatsapp_v1.blueprint.json',
encoding = 'utf-8') as f:
blueprint = json.load(f)
EN_N8N = {1: 'Mensaje de Twilio (webhook)', 2: 'credencial httpBasicAuth (no es un nodo)',
3: '¿Es de Trigo Lento? (if) + Preparar borrador (set)', 4: 'Guardar borrador (googleSheets)',
5: 'Responder a Twilio (respondToWebhook)'}
for m in blueprint['flow']:
print(f"{m['id']}. {m['module']:<24} -> {EN_N8N[m['id']]}")
print(f"Make: {len(blueprint['flow'])} módulos, {CREDITOS_V1_MAKE} créditos por mensaje | "
f"n8n: {len(workflow['nodes'])} nodos, 1 ejecución por mensaje")
Make n8n diferencia práctica
Paquete (bundle) Item {"json": {...}, "binary": {...}}; viajan en listas
Módulo Nodo Se ejecuta una vez por item (salvo Code en modo "una vez")
Escenario Workflow JSON con nodes y connections por nombre
Router Switch Salidas numeradas con reglas y salida de respaldo
Filtro IF Dos salidas: main[0] verdadero, main[1] falso
Iterador Split Out o ejecución por item n8n no necesita iterador para recorrer items
Agregador Aggregate Junta muchos items en uno
{{1.Body}} {{ $json.body.Body }} Mapeo por número de módulo frente a item actual
Directiva Break Retry On Fail Reintentos configurados en el propio nodo
Ruta de error (onerror) Continue (using error output) Una salida roja extra en el nodo
Escenario de errores Error Workflow Workflow aparte que empieza con Error Trigger
Crédito Ejecución Make cobra por operación; n8n Cloud por corrida completa
1. gateway:CustomWebHook -> Mensaje de Twilio (webhook)
2. datastore:GetRecord -> credencial httpBasicAuth (no es un nodo)
3. datastore:GetRecord -> ¿Es de Trigo Lento? (if) + Preparar borrador (set)
4. google-sheets:addRow -> Guardar borrador (googleSheets)
5. gateway:WebhookRespond -> Responder a Twilio (respondToWebhook)
Make: 5 módulos, 5 créditos por mensaje | n8n: 6 nodos, 1 ejecución por mensaje
La Tabla 3 recoge esas equivalencias con el caso de Trigo Lento.
Tabla 3. Equivalencias entre Make y n8n, con su uso en Trigo Lento.
| Make | n8n | Diferencia práctica | Caso de Trigo Lento |
|---|---|---|---|
| Paquete | Item | Viajan en listas con json y binary | Un mensaje, un renglón o una fila |
| Módulo | Nodo | Se ejecuta una vez por item, salvo Code en modo “una vez” | Edit Fields prepara el borrador |
| Escenario | Workflow | JSON con nodes y connections por nombre | TL Pedidos WhatsApp n8n v1 |
| Router | Switch | Salidas numeradas y salida de respaldo | Versión 2: pedido, consulta, queja |
| Filtro | IF | La rama falsa entrega, no descarta | Cuenta y número de Trigo Lento |
| Iterador | Split Out o ejecución por item | n8n no necesita iterador | Renglones del pedido de la Cafetería Brújula |
| Agregador | Aggregate | Muchos items en uno | Total del pedido: 2 348 MXN |
{{1.Body}} | {{ $json.body.Body }} | Por número de módulo frente a item actual | El texto del mensaje |
| Directiva Break | Retry On Fail | Reintentos en el ajuste del nodo | Guardar borrador: 3 intentos |
| Ruta de error | Continue (using error output) | Una salida extra para los errores | Pendiente para el agente |
| Escenario de errores | Error Workflow | Empieza con Error Trigger | TL Errores, aviso a Valeria |
| Crédito | Ejecución | Por operación frente a por corrida | 5 créditos frente a 1 ejecución |
La Figura 3 dibuja el mismo diccionario como pares unidos por líneas, que es como Valeria lo pegó junto a la pantalla.
Figura 3. Diccionario Make y n8n: paquete e item, módulo y nodo, escenario y workflow, router y Switch, filtro e IF, iterador y ejecución por item, agregador y Aggregate, Break y Retry On Fail, escenario de errores y Error Workflow, crédito y ejecución: 5 créditos frente a 1 ejecución por mensaje.
La correspondencia de módulos de la salida del bloque muestra dónde está la diferencia de consumo. Los cinco módulos de Make cobran un crédito cada uno por mensaje: 5 créditos. Los seis nodos de n8n, uno más que en Make, cuentan 1 ejecución. La lectura del almacén con las credenciales de Twilio, que en Make era un módulo (y un crédito), en n8n ni siquiera es un nodo: es una credencial cifrada que el Webhook consulta.
Para el presupuesto, lo que importa es cómo crece cada unidad con el tráfico. Con $M$ mensajes al mes y $p_m$ paquetes por módulo y mensaje:
$$ C_{\text{Make}} = M \sum_{m=1}^{5} p_m = 5M, \qquad E_{\text{n8n}} = M, \qquad \frac{C_{\text{Make}}}{E_{\text{n8n}}} = \sum_m p_m $$
La lógica detrás de esta fórmula es múltiple:
- En
TL Pedidos WhatsApp v1cada módulo recibe un paquete por mensaje ($p_m = 1$), así que cada mensaje cuesta 5 créditos, la cifra que el Día 23 midió. - En n8n Cloud, cada mensaje es una corrida y cuesta una ejecución, se añadan los nodos que se añadan.
- El cociente es el número de operaciones por mensaje: cuanto más largo sea el flujo, más ventaja tiene contar por ejecuciones. El agente del Día 38, con diez o quince pasos por mensaje, ensanchará esa diferencia.
- En la instalación propia, la ejecución deja de ser una unidad de cobro: el límite lo pone el servidor, no un cupo.
El siguiente bloque aplica la fórmula entre 500 y 5 000 mensajes al mes y la compara con los cupos de Make Core (10 000 créditos) y de n8n Cloud Starter (2 500 ejecuciones):
# Consumo del mismo tráfico: créditos de Make frente a ejecuciones de n8n en Python:
mensajes = np.arange(500, 5_001, 50)
creditos = CREDITOS_V1_MAKE * mensajes
ejecuciones = mensajes.copy()
filas = []
for m in (500, 1_000, MENSAJES_MES, 2_000, 2_500, 5_000):
filas.append({'mensajes': m, 'créditos Make': CREDITOS_V1_MAKE * m,
'% de Make Core': round(100 * CREDITOS_V1_MAKE * m / CUPO_MAKE_CORE, 1),
'ejecuciones n8n': m, '% de n8n Starter': round(100 * m / CUPO_N8N_STARTER, 1),
'n8n propio': 'sin cupo'})
print(pd.DataFrame(filas).to_string(index = False))
print('Mensajes que caben en Make Core con este flujo:', CUPO_MAKE_CORE // CREDITOS_V1_MAKE,
'| en n8n Starter:', CUPO_N8N_STARTER)
print('Unidades por mensaje, Make / n8n:', CREDITOS_V1_MAKE / 1)
# Consumo mensual frente a los cupos de cada plan:
fig, ax = plt.subplots(figsize = (8, 4.6))
ax.plot(mensajes, creditos, color = '#2a78d6', linewidth = 2.2, label = 'Créditos de Make (5 por mensaje)')
ax.plot(mensajes, ejecuciones, color = '#3a9d5d', linewidth = 2.2, label = 'Ejecuciones de n8n (1 por mensaje)')
ax.axhline(CUPO_MAKE_CORE, color = '#2a78d6', linestyle = '--', linewidth = 1)
ax.axhline(CUPO_N8N_STARTER, color = '#3a9d5d', linestyle = '--', linewidth = 1)
ax.text(3_000, CUPO_MAKE_CORE - 1_300, 'Cupo de Make Core: 10 000 créditos', fontsize = 9, color = '#2a78d6')
ax.text(3_000, CUPO_N8N_STARTER - 1_300, 'Cupo de n8n Starter: 2 500 ejecuciones', fontsize = 9, color = '#3a9d5d')
ax.axvline(MENSAJES_MES, color = '#8a8986', linestyle = ':', linewidth = 1.2)
ax.text(MENSAJES_MES + 60, 14_000, 'Trigo Lento hoy:\n1 560 mensajes', fontsize = 9, color = '#52514e')
ax.set_xlabel('Mensajes de WhatsApp al mes')
ax.set_ylabel('Unidades consumidas al mes')
ax.set_ylim(0, 26_000)
ax.legend(loc = 'upper left', fontsize = 9)
ax.set_title('El mismo flujo, dos formas de contar', fontsize = 16)
plt.tight_layout()
plt.show()
mensajes créditos Make % de Make Core ejecuciones n8n % de n8n Starter n8n propio
500 2500 25.0 500 20.0 sin cupo
1000 5000 50.0 1000 40.0 sin cupo
1560 7800 78.0 1560 62.4 sin cupo
2000 10000 100.0 2000 80.0 sin cupo
2500 12500 125.0 2500 100.0 sin cupo
5000 25000 250.0 5000 200.0 sin cupo
Mensajes que caben en Make Core con este flujo: 2000 | en n8n Starter: 2500
Unidades por mensaje, Make / n8n: 5.0

La Tabla 4 resume los puntos clave de la salida.
Tabla 4. Consumo mensual del mismo flujo: créditos de Make frente a ejecuciones de n8n (cupos de la Tabla B, revisado en octubre de 2026).
| Mensajes al mes | Créditos de Make | % de Make Core | Ejecuciones de n8n | % de n8n Starter | n8n propio |
|---|---|---|---|---|---|
| 500 | 2 500 | 25.0 | 500 | 20.0 | Sin cupo |
| 1 000 | 5 000 | 50.0 | 1 000 | 40.0 | Sin cupo |
| 1 560 (hoy) | 7 800 | 78.0 | 1 560 | 62.4 | Sin cupo |
| 2 000 | 10 000 | 100.0 | 2 000 | 80.0 | Sin cupo |
| 2 500 | 12 500 | 125.0 | 2 500 | 100.0 | Sin cupo |
| 5 000 | 25 000 | 250.0 | 5 000 | 200.0 | Sin cupo |
Con el tráfico actual de Trigo Lento, 1 560 mensajes, el flujo de Make consume 7 800 créditos, el 78 % de Make Core para un solo escenario; el Día 12 lo había estimado en 7 644 con un promedio de 4.9 módulos por mensaje, y la medición del Día 23 lo subió a 5. El mismo tráfico son 1 560 ejecuciones en n8n, el 62.4 % de Starter. La misma lógica cuesta cinco veces más unidades en Make porque cobra por módulo. Make Core se llena a los 2 000 mensajes y Starter a los 2 500; el servidor propio del Día 34 no tiene cupo.
La gráfica muestra por qué la comparación no es solo de porcentajes: la línea azul de Make sube cinco veces más rápido que la verde de n8n y cruza su cupo antes. Don Ernesto mira la gráfica un rato y hace la pregunta que le corresponde: “Entonces, ¿apago el de los círculos?” Todavía no. El escenario de Make funciona, comprueba la firma de Twilio y es el respaldo que el Día 34 dejó configurado por si el servidor cae. Lo que dice la gráfica es dónde conviene construir lo nuevo: el agente, que multiplicará los pasos por mensaje, vivirá en n8n. La decisión de qué se migra y cuándo se tomará con su propio análisis en el Día 45.
Errores en n8n
El escenario de Make del Día 28 aprendió a fallar con elegancia: directivas en las rutas de error, reintentos y un escenario aparte para los avisos. n8n tiene piezas equivalentes, pero repartidas de otra manera: unas son ajustes de cada nodo y otra es un workflow aparte. La Tabla 5 las reúne con el caso de Trigo Lento.
Tabla 5. Opciones de manejo de errores en n8n y su uso en Trigo Lento.
| Opción | Dónde se configura | Qué hace | Caso de Trigo Lento |
|---|---|---|---|
| Retry On Fail | Ajustes del nodo (retryOnFail, maxTries, waitBetweenTries) | Repite el nodo de 2 a 5 veces, con hasta 5 s de espera entre intentos | Guardar borrador: 3 intentos, 1 s |
| On Error: Stop Workflow | Ajustes del nodo (onError, valor por omisión) | Detiene la ejecución y la marca como fallida | Todos los nodos de la versión 1 |
| On Error: Continue | onError: continueRegularOutput | Sigue con el error dentro del item, por la salida normal | No se usa: esconde fallos |
| On Error: Continue (using error output) | onError: continueErrorOutput | Añade una salida extra por la que viajan los items que fallaron | El agente del Día 38: pedir ayuda a Valeria |
| Error Workflow | Ajustes del workflow (errorWorkflow) | Ejecuta otro workflow, que empieza con Error Trigger, cuando este falla | TL Errores: aviso con el nombre del workflow y el enlace a la ejecución |
| Stop and Error | Nodo n8n-nodes-base.stopAndError | Provoca un fallo a propósito, con mensaje propio | Un pedido con fecha imposible |
Dos detalles de la tabla evitan sorpresas. El primero: el Error Trigger recibe el identificador y la URL de la ejecución fallida, el último nodo ejecutado, el mensaje de error y el nombre del workflow, pero si el fallo ocurre en el propio disparador, la ejecución todavía no existe y esos dos primeros campos faltan. El segundo: cuando un nodo tiene a la vez Retry On Fail y una de las opciones de continuar, la documentación y los informes de la comunidad indican que los reintentos pueden no aplicarse; en Trigo Lento, los nodos que reintentan dejan onError en su valor por omisión.
El nodo que más puede fallar en TL Pedidos WhatsApp n8n v1 es Guardar borrador. La API de Google Sheets responde con un 429 cuando se superan sus cuotas de escritura por minuto y, de vez en cuando, con errores 5xx pasajeros. Para un fallo pasajero, el reintento es la respuesta natural. Si cada intento falla con probabilidad $p$ de forma independiente y se permiten $k$ intentos:
$$ P_{\text{falla}} = p^{k}, \qquad E[\text{intentos}] = \sum_{i=0}^{k-1} p^{i} = \frac{1-p^{k}}{1-p} $$
La lógica detrás de esta fórmula es múltiple:
- El borrador se pierde solo si fallan todos los intentos; con fallos independientes, sus probabilidades se multiplican.
- El primer intento siempre se hace; el segundo, con probabilidad $p$; el tercero, con $p^2$; sumar esas probabilidades da el número esperado de intentos, que apenas sube cuando $p$ es pequeño: con $p = 0.05$ y $k = 3$, $1.0525$ intentos por mensaje.
- Con $p = 0.30$, un día malo, el mismo ajuste pide $1.39$ intentos de media y deja el fallo final en $0.3^3 = 0.027$.
- En conclusión, cada intento extra divide el fallo final entre $1/p$, pero cuesta tiempo, y el tiempo tiene un límite.
Ese límite es el reloj de Twilio. Como el Webhook espera a Responder a Twilio, que está después de Guardar borrador, cada reintento y cada espera retrasan la respuesta. En el peor caso:
$$ T_{\max} = k , \ell_{\max} + (k - 1) , w \le 15 \text{ s} $$
La lógica detrás de esta fórmula es múltiple:
- $\ell_{\max}$ es la duración máxima razonable de un intento contra la hoja (aquí, $1.5$ segundos) y $w$ la espera entre intentos (
waitBetweenTries). - Hay $k$ intentos, pero solo $k - 1$ esperas, porque después del último no se espera.
- Con 3 intentos y 1 segundo de espera, $T_{\max} = 6.5$ s; con 5 intentos y 5 segundos, $27.5$ s, casi el doble de lo que Twilio espera.
- En conclusión, más reintentos no siempre es más seguro: pasado el reloj del emisor, el reintento que salva la fila provoca que Twilio dé el mensaje por fallido.
El siguiente bloque simula $20,000$ mensajes por configuración en dos escenarios de juguete: un día normal, con un $5%$ de intentos fallidos, y un día de cuota agotada (otro workflow escribiendo en masa con la misma credencial, por ejemplo), con un $30%$. Cada intento exitoso tarda una duración lognormal con mediana de $0.9$ segundos; cada intento fallido, unos $0.3$ segundos, porque un 429 vuelve rápido. Las probabilidades son supuestos didácticos, no mediciones de la API de Google:
# Retry On Fail en "Guardar borrador": fallos de Google Sheets y el reloj de 15 s de Twilio en Python:
LIMITE_TWILIO = 15.0 # segundos que Twilio espera la respuesta (Día 23)
def simular(p, intentos, espera_ms, n, rng):
# Latencia de los nodos previos, intentos con fallo p (429 o 5xx) y espera entre intentos:
tiempo = rng.lognormal(np.log(0.08), 0.3, n)
pendiente = np.ones(n, dtype = bool)
for k in range(intentos):
falla = rng.random(n) < p
duracion = np.where(falla, rng.lognormal(np.log(0.3), 0.3, n), rng.lognormal(np.log(0.9), 0.45, n))
tiempo += np.where(pendiente, duracion, 0)
pendiente &= falla
if k < intentos - 1:
tiempo += np.where(pendiente, espera_ms / 1000, 0)
return tiempo, pendiente
CONFIGURACIONES = [('Sin reintento', 1, 0), ('3 intentos, 1 s', 3, 1000), ('5 intentos, 1 s', 5, 1000),
('3 intentos, 5 s', 3, 5000), ('5 intentos, 5 s', 5, 5000)]
N = 20_000
filas = []
for dia, p in (('normal', 0.05), ('cuota agotada', 0.30)):
for nombre, k, w in CONFIGURACIONES:
tiempo, perdido = simular(p, k, w, N, rng)
filas.append({'día': dia, 'p': p, 'configuración': nombre, 'falla final %': round(100 * perdido.mean(), 3),
'teórica %': round(100 * p ** k, 3), 'perdidos al mes': round(MENSAJES_MES * p ** k, 2),
'p95 s': round(np.percentile(tiempo, 95), 2),
'> 15 s %': round(100 * np.mean(tiempo > LIMITE_TWILIO), 2)})
reintentos = pd.DataFrame(filas)
print(reintentos.to_string(index = False))
for k, w in ((3, 1000), (5, 5000)):
print(f'Peor caso con {k} intentos y {w} ms: {k} x 1.5 s + {k - 1} x {w / 1000:.0f} s = '
f'{k * 1.5 + (k - 1) * w / 1000:.1f} s')
# Probabilidad de fallo final por número de intentos y mensajes que vencen el reloj de Twilio:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (11, 4.4))
k = np.arange(1, 6)
for p, color in ((0.05, '#3a9d5d'), (0.30, '#eb6834')):
ax1.semilogy(k, p ** k, marker = 'o', color = color, label = f'p = {p:.2f} por intento')
ax1.set_xticks(k)
ax1.set_xlabel('Intentos (maxTries)')
ax1.set_ylabel('Probabilidad de fallo final')
ax1.legend()
ax1.set_title('Fallo final: p elevado a k', fontsize = 14)
malo = reintentos[reintentos['día'] == 'cuota agotada']
ax2.barh(malo['configuración'], malo['> 15 s %'], color = '#c8423b')
for i, v in enumerate(malo['> 15 s %']):
ax2.text(v + 0.1, i, f'{v:.2f} %', va = 'center', fontsize = 9)
ax2.set_xlabel('Mensajes que vencen los 15 s de Twilio (%)')
ax2.set_xlim(0, max(malo['> 15 s %']) * 1.3 + 0.5)
ax2.set_title('Día de cuota agotada (p = 0.30)', fontsize = 14)
plt.suptitle('Reintentar sí, pero dentro del reloj de Twilio', fontsize = 16)
plt.tight_layout()
plt.show()
día p configuración falla final % teórica % perdidos al mes p95 s > 15 s %
normal 0.05 Sin reintento 5.215 5.000 78.00 1.95 0.00
normal 0.05 3 intentos, 1 s 0.020 0.013 0.20 2.29 0.00
normal 0.05 5 intentos, 1 s 0.000 0.000 0.00 2.29 0.00
normal 0.05 3 intentos, 5 s 0.020 0.013 0.20 5.42 0.00
normal 0.05 5 intentos, 5 s 0.000 0.000 0.00 3.48 0.01
cuota agotada 0.30 Sin reintento 29.915 30.000 468.00 1.83 0.00
cuota agotada 0.30 3 intentos, 1 s 2.605 2.700 42.12 3.47 0.00
cuota agotada 0.30 5 intentos, 1 s 0.310 0.243 3.79 3.88 0.00
cuota agotada 0.30 3 intentos, 5 s 2.670 2.700 42.12 11.37 0.00
cuota agotada 0.30 5 intentos, 5 s 0.215 0.243 3.79 11.78 2.63
Peor caso con 3 intentos y 1000 ms: 3 x 1.5 s + 2 x 1 s = 6.5 s
Peor caso con 5 intentos y 5000 ms: 5 x 1.5 s + 4 x 5 s = 27.5 s

La Tabla 6 resume la salida para las configuraciones que importan.
Tabla 6. Reintentos de Guardar borrador: fallo final, percentil 95 del tiempo de respuesta y mensajes que vencen los 15 s de Twilio (simulación de 20 000 mensajes por fila).
| Día | Configuración | Fallo final simulado (%) | Fallo teórico (%) | Borradores perdidos al mes | p95 (s) | Más de 15 s (%) |
|---|---|---|---|---|---|---|
| Normal ($p = 0.05$) | Sin reintento | 5.215 | 5.000 | 78.00 | 1.95 | 0.00 |
| Normal ($p = 0.05$) | 3 intentos, 1 s | 0.020 | 0.013 | 0.20 | 2.29 | 0.00 |
| Cuota agotada ($p = 0.30$) | Sin reintento | 29.915 | 30.000 | 468.00 | 1.83 | 0.00 |
| Cuota agotada ($p = 0.30$) | 3 intentos, 1 s | 2.605 | 2.700 | 42.12 | 3.47 | 0.00 |
| Cuota agotada ($p = 0.30$) | 5 intentos, 1 s | 0.310 | 0.243 | 3.79 | 3.88 | 0.00 |
| Cuota agotada ($p = 0.30$) | 5 intentos, 5 s | 0.215 | 0.243 | 3.79 | 11.78 | 2.63 |
Las cifras cuentan tres historias:
- En primer lugar, sin reintentos, un día normal pierde 78 borradores al mes, el $5%$ de los mensajes; con 3 intentos y 1 segundo, la pérdida teórica baja a 0.20 al mes y el percentil $95$ del tiempo de respuesta solo sube de $1.95$ a $2.29$ segundos. Es el ajuste de
TL Pedidos WhatsApp n8n v1. - En segundo lugar, en un día de cuota agotada, 3 intentos todavía pierden el $2.7%$ (unos $42$ borradores si el mes entero fuera así), y 5 intentos con 1 segundo de espera lo bajan a $0.243%$ sin pasar nunca de los 15 segundos.
- En tercer lugar, 5 intentos con 5 segundos de espera pierden lo mismo que con 1 segundo (la espera no cambia $p^k$), pero el $2.63%$ de los mensajes vence el reloj de Twilio: la configuración “más prudente” es la peor de la tabla.
La simulación coincide con la fórmula en el fallo final (las diferencias son ruido de muestreo de $20,000$ mensajes) y añade lo que la fórmula no da: el reparto del tiempo. Para Trigo Lento queda una regla sencilla: 3 intentos con 1 segundo por omisión, 5 con 1 segundo si la hoja empieza a dar 429, y nunca esperas que sumen más de lo que el emisor está dispuesto a esperar. Lo que no se resuelva con reintentos irá al Error Workflow TL Errores, que avisará a Valeria con el enlace a la ejecución fallida para que el borrador se recupere a mano desde el historial.
Consideraciones finales
El día empezó con un nodo arrastrado a un lienzo vacío y termina con un workflow de seis nodos que recibe el mismo mensaje que el escenario de Make, deja la misma fila en la hoja y responde a Twilio en $2.29$ segundos en el percentil $95$ de un día normal. Por el camino, Valeria aprendió que n8n piensa en listas de items y no en paquetes sueltos, que las expresiones son JavaScript entre llaves dobles con el item actual en $json, que el nombre de un nodo es la clave de sus conexiones y que reintentar tiene un techo que pone el emisor.
Antes de dar por terminado TL Pedidos WhatsApp n8n v1, conviene hacerse algunas preguntas:
- ¿Qué nodos de su workflow se ejecutan una vez por item y cuáles una vez por ejecución? Si no lo sabe, no sabe cuánto trabajo hará el servidor cuando un pedido traiga cincuenta renglones.
- ¿Qué expresión de su workflow lee un campo de algo que puede faltar? Esa es la que fallará primero.
- ¿Todos los caminos que salen del disparador terminan en una respuesta? Si el emisor espera una, la rama que no responde es una falla silenciosa.
- ¿Qué datos lleva su workflow exportado? Si la respuesta incluye un
pinDatacon una persona real, el problema no es técnico.
Las cifras de hoy tienen límites claros. El consumo de Make supone que cada mensaje pasa por los cinco módulos, cuando los mensajes con firma inválida se detienen antes; las probabilidades de fallo de Google Sheets son supuestos y los fallos reales llegan en rachas, no de forma independiente; y el workflow publicado tiene la estructura verificada contra la documentación, no la de una exportación real de la versión 2.43.7. Ninguno de esos límites cambia las conclusiones: la lista de items es la forma de los datos, la ejecución completa es la unidad de cobro, y el reloj del emisor manda sobre los reintentos.
Un gran libro que habla de esto es Enterprise Integration Patterns (Hohpe y Woolf, 2003), un catálogo de los patrones con que se conectan sistemas por mensajes: el splitter que parte un mensaje en varios (el nodo que separó los renglones), el aggregator que los vuelve a juntar, el content-based router que reparte según el contenido (el Switch) y el message filter (el IF). Leerlo después de esta entrada le ayudará a ver que Make y n8n no inventaron sus piezas, sino que les pusieron nombre de producto a patrones que la integración de sistemas usa desde hace décadas.
Una herramienta nueva se aprende rehaciendo un flujo conocido: así cada diferencia en el resultado es una lección sobre la herramienta y no un error de diseño. Con el mismo mensaje, la misma fila y la misma respuesta, Valeria ya puede comparar Make y n8n con números, y Don Ernesto tiene su respuesta: no se hizo dos veces el mismo pan, se probó el horno nuevo con la receta de siempre.
Conclusiones clave
Items
- En n8n todo lo que pasa entre nodos es una lista de items
{"json": ..., "binary": ...}; la mayoría de los nodos se ejecuta una vez por item, sin iterador. - En la ejecución de juguete, el pedido de la Cafetería Brújula se convirtió en 3 items,
Calcular subtotalse ejecutó 3 veces, el IF mandó 1 item a la rama verdadera y 2 a la falsa, y el Aggregate devolvió 1 item con 2 348 MXN. - La ejecución completa sumó 7 operaciones de nodo, unos 7 créditos en Make, y 1 ejecución en n8n: la longitud de la lista cambia el trabajo del servidor, no la factura.
Expresiones y nodos
- Una expresión es JavaScript entre
{{ }}; en el JSON, el parámetro empieza con=;$jsones el item actual y$('Nodo').item.jsonsigue el parentesco hasta un nodo anterior. $json.asobre un item sinaes vacío, pero$json.a.bes un error;$nowes un objeto de Luxon en la zona del workflow, yhash('sha256')seudonimiza sin código.- Los nodos núcleo son Webhook, Edit Fields (Set), IF, Switch, Code y Respond to Webhook;
TL Pedidos WhatsApp n8n v1usa seis nodos, 9 expresiones y dos credenciales, y responde a Twilio con un nodo propio en cada camino. - El validador detuvo la copia editada con 8 hallazgos: en n8n el nombre del nodo es la clave de sus conexiones, y cada salida de un IF necesita destino.
Make frente a n8n
- Los cinco módulos de
TL Pedidos WhatsApp v1cuestan 5 créditos por mensaje y 7 800 al mes (78 % de Make Core); el mismo tráfico son 1 560 ejecuciones de n8n (62.4 % de Starter) y no tiene cupo en el servidor propio. - Make Core se llena con 2 000 mensajes al mes y Starter con 2 500; cuanto más largo el flujo, más ventaja tiene contar por ejecuciones.
- Con $p = 0.05$, 3 intentos con 1 s de espera dejan la pérdida teórica en 0.20 borradores al mes y el p95 en 2.29 s; 5 intentos con 5 s de espera hacen que el 2.63 % de los mensajes venza los 15 s de Twilio en un día malo.
Mañana, en el Día 37: Nodos de IA de n8n: modelos, cadenas, memoria y agentes con LangChain. Con el flujo de entrada funcionando en n8n, Valeria descubrirá que en n8n la IA no es una petición HTTP más, sino un grupo de nodos que se enchufan a un nodo raíz, y construirá un clasificador con el modelo de Anthropic y un analizador de salida estructurada con el contrato del pedido.
Referencias
- n8n (2026). Data structure, Expressions y Error handling. n8n Docs. https://docs.n8n.io/data/data-structure/ y https://docs.n8n.io/flow-logic/error-handling/
- n8n (2026). n8n public REST API: Workflow. n8n Docs. https://docs.n8n.io/api/
- n8n (2026). Data pinning y Executions. n8n Docs. https://docs.n8n.io/data/data-pinning/
- n8n (2025). n8n v2.0 breaking changes. n8n Docs. https://docs.n8n.io/2-0-breaking-changes/
- Luxon (2026). Documentación de Luxon. https://moment.github.io/luxon/
- Twilio (2026). Webhooks security: HTTP authentication y validación de X-Twilio-Signature. Twilio Docs. https://www.twilio.com/docs/usage/webhooks/webhooks-security
- Google (2026). Usage limits de la API de Google Sheets. Google for Developers. https://developers.google.com/workspace/sheets/api/limits
- Hohpe, G. y Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley.

