Cargando
Miniatura del artículo

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

Índice de contenido

Resumen:
Seis semanas después del Día 7, la Clínica Dental Sonrisa Norte pone en marcha el proceso que su matriz de priorización colocó en primer lugar: el recordatorio de cita por WhatsApp. En esta entrada, Valeria transfiere a un negocio que trata datos de salud todo lo que Trigo Lento aprendió en el módulo 5: una plantilla de utilidad con dos botones que no revela ningún dato clínico, un consentimiento expreso y por finalidad, reglas de envío con horario, días hábiles y cierres de Semana Santa, la verificación de la firma de cada respuesta y una puerta siempre abierta hacia la recepcionista. Al terminar sabrá calcular el coste mensual de los recordatorios (de $10.68$ a $36.23$ USD según la arquitectura), estimar con una simulación las inasistencias que se evitan y construir los dos workflows de n8n sin que un solo mensaje de paciente pase por un modelo de IA.
¿Cómo citar el presente artículo?
Romero, J. (23 de marzo de 2026). Arquitecto de automatización con IA: Día 51. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_51/.
Arquitecto de automatización con IA: Día 51 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_51/.

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. Se contrastaron en octubre de 2026 los límites de las plantillas de WhatsApp (cuerpo de 1 024 caracteres, pie de 60 y botones de respuesta rápida de 25), la firma X-Hub-Signature-256 de los webhooks de Meta y la versión vigente de la Graph API (v26.0). Los dos workflows de n8n se muestran con la estructura de exportación verificada contra la documentación, revisado en octubre de 2026. La clínica, sus personas, sus pacientes y todas sus cifras son ficticios.

Día 51. Caso de otro negocio IV: recordatorios de citas por WhatsApp en la clínica dental

Introducción


Ayer, en el Día 50 (Slack como centro de operaciones: webhooks, Block Kit y aprobaciones), Valeria sacó al equipo de la panadería del WhatsApp de los clientes, ordenó los avisos internos en canales por función, compuso mensajes con botones y verificó la firma de cada aprobación, y Trigo Lento ganó TL Slack operaciones v1. Hoy la serie vuelve a salir de la panadería: el método del módulo 5 se pone a prueba en un negocio donde un mensaje mal escrito no cuesta un pastel, sino la intimidad de una persona.

Es lunes 23 de marzo, seis semanas después de aquella tarde del Día 7 (Caso de otro negocio I: la clínica dental mide y prioriza sus citas) en la que Valeria midió la recepción de la Clínica Dental Sonrisa Norte con el cuaderno de la panadería. La Dra. Lucía Fernández llama a media mañana. La matriz de priorización de aquel día colocó el recordatorio de cita en primer lugar por valor, y nada ha cambiado desde entonces: Irma Salgado, la recepcionista, sigue dedicando las tardes a llamar paciente por paciente para recordarles la cita del día siguiente, y uno de cada siete sigue sin llegar. “Valeria, ya tenemos el número de WhatsApp verificado y la clínica paga el servidor. ¿Me ayuda a ponerlo en marcha esta semana? Quiero que Irma deje de llamar y que los sillones dejen de quedarse vacíos.”

Valeria lo comenta en la cocina de Trigo Lento, y Don Ernesto deja la pala del horno apoyada en la pared para contestar: “Si a alguien le llega ‘su cita de endodoncia’ y lo lee la suegra en la mesa, ¿quién da la cara? ¿Usted, la doctora o el aparato?” La pregunta no es retórica. En la panadería, un aviso de más se arregla con una disculpa; en una clínica, un recordatorio que nombra el tratamiento revela un dato de salud a quien tenga el teléfono en la mano, y la ley mexicana trata ese dato como sensible. La respuesta, por tanto, no es “lo haremos con cuidado”, sino un diseño en el que el mensaje no puede decir lo que no debe, porque no tiene dónde decirlo.

En la presente entrada, descubrirá cómo transferir lo que Trigo Lento construyó en el módulo 5 (plantillas de utilidad, ventana de atención de 24 horas, consentimiento por finalidad, la Cloud API de Meta directa desde n8n y la verificación de firmas) a un negocio que trata datos personales sensibles. Diseñará la plantilla recordatorio_cita con dos botones de respuesta rápida y la revisará con código, que comprueba su categoría, sus límites, la línea de baja, la ausencia de datos de salud y su legibilidad. Escribirá las reglas que deciden si un recordatorio puede salir, construirá el calendario de envíos de abril con el cierre de Semana Santa, verificará la firma de cada respuesta y la enrutará sin pasar por ningún modelo de IA. Después calculará el coste mensual con cuatro combinaciones de proveedor y plataforma, simulará $1,000$ meses de citas para saber cuántas inasistencias se evitan y con qué incertidumbre, y terminará con los dos workflows de SN Recordatorios de citas v1 leídos, revisados y dibujados desde su JSON.

Objetivos de aprendizaje


Al terminar esta entrada, usted podrá:

  • Transferir lo construido para Trigo Lento en el módulo 5 (plantillas, ventana de 24 horas, consentimiento, Cloud API desde n8n) a un negocio que trata datos personales sensibles.
  • Diseñar una plantilla de utilidad para recordatorios de cita con botones de respuesta rápida (Confirmar y Reprogramar) que no revele ningún dato de salud.
  • Calcular el coste mensual de los recordatorios con las tarifas de la Tabla C y estimar con una simulación la reducción de inasistencias y las horas de recepción recuperadas.
  • Construir en n8n los workflows de envío y de respuesta, con consentimiento verificado, horario permitido y derivación a una persona.

Del diagnóstico del Día 7 al diseño


El Día 7 dejó la clínica medida. Sonrisa Norte es una clínica ficticia de la colonia Lindavista con dos dentistas, la Dra. Lucía Fernández y el Dr. Andrés Ríos, y una sola recepcionista, Irma Salgado, que atiende teléfono, WhatsApp y mostrador de lunes a viernes de 9:00 a 19:00. Agenda $18$ citas al día, $360$ al mes con $20$ días hábiles; el ingreso medio por cita es de $850$ MXN y la inasistencia, la fracción de citas a las que el paciente no llega ni avisa, es del $15%$. Todas son cifras inventadas y se declaran como supuestos didácticos, igual que los $70$ MXN de la hora de Irma y los $2.0$ minutos que dura cada llamada de recordatorio. La Tabla 1 reúne la ficha y la pone frente a Trigo Lento.

Tabla 1. Ficha de la Clínica Dental Sonrisa Norte retomada del Día 7 (todo inventado) y lo que cambia frente a Trigo Lento.

ConceptoSonrisa NorteTrigo LentoQué cambia al trasladar el diseño
PersonasDos dentistas y una recepcionistaSeis personas, Valeria en administraciónIrma es la única puerta humana; el flujo no puede saturarla
HorarioLunes a viernes, 9:00 a 19:00Lunes a sábadoSin envíos en fin de semana; cierres por Semana Santa
Volumen360 citas al mes (18 al día)468 encargos al mesUn recordatorio por cita y un segundo aviso a quien no responde
Ingreso por unidad850 MXN por cita380 MXN por encargoEl coste invisible es el sillón vacío
Inasistencia15 % (54 citas al mes)No aplica45 900 MXN al mes no percibidos
Llamadas de recordatorio2.0 minutos por cita, 12 horas al mesMensajes ya automatizados840 MXN de horas de Irma
Datos que manejaIdentificativos, contacto, agenda y saludNombre, teléfono, dirección y pedidoDatos sensibles: consentimiento expreso y por escrito
Texto libre del clientePuede contener datos de saludPuede ir a Claude seudonimizadoEn la clínica no va a ningún modelo

Las dos columnas se parecen más de lo que parece. Los dos negocios tienen un aviso de utilidad sobre algo que el cliente pidió (una recogida, una cita), los dos necesitan consentimiento por finalidad y una baja inmediata, y los dos eligen la Cloud API de Meta directa por la razón que dio el Día 46 (El reto de la mensajería: el mapa de canales y el coste real de WhatsApp): sin recargo del proveedor por mensaje. Lo que cambia es la última fila. En Trigo Lento, un mensaje ambiguo de un cliente podía llegar a Claude seudonimizado para entender qué pedía; en la clínica, el texto libre de un paciente puede decir “me sangra la encía desde la extracción”, y ese texto no sale de la recepción.

El primer bloque fija las importaciones, la semilla global de la serie, la zona horaria de la Ciudad de México y la carpeta del caso. Como el Día 7, los archivos de la clínica viven aparte de los de la panadería, en sonrisa_norte_dia51/, porque los datos de dos negocios no se mezclan aunque los lleve la misma asesora:

python
# Importaciones base, semilla de la serie y carpeta del caso de la clínica en Python:
import warnings
warnings.filterwarnings('ignore')
import json
import re
import math
import hmac
import hashlib
from datetime import datetime, date, timedelta
from zoneinfo import ZoneInfo
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)
clinica = Path('sonrisa_norte_dia51')
clinica.mkdir(exist_ok = True)
ZONA = ZoneInfo('America/Mexico_City')
MXN_POR_USD, USD_POR_EUR = 18.50, 1.08           # tipos didácticos fijos de la serie
print('numpy', np.__version__, '| pandas', pd.__version__)
print('Carpeta del caso:', clinica.name, '| zona:', ZONA.key,
      '| hoy:', datetime(2026, 3, 23, 9, 0, tzinfo = ZONA).isoformat())
numpy 2.4.6 | pandas 3.0.3
Carpeta del caso: sonrisa_norte_dia51 | zona: America/Mexico_City | hoy: 2026-03-23T09:00:00-06:00

La salida confirma las versiones con las que se calcularon las cifras de hoy y la zona America/Mexico_City, que desde 2022 no tiene horario de verano: el lunes 23 de marzo a las 9:00 es -06:00, igual que en diciembre. Esa constancia simplifica el calendario, pero no exime de usar zoneinfo: el servidor de n8n suele vivir en UTC y la base de datos guarda instantes, no horas de pared.

El siguiente código recalcula las dos cifras del Día 7 que justifican el proyecto y deja por escrito, en una tabla, qué se reutiliza de Trigo Lento y qué cambia:

python
# Ficha de Sonrisa Norte retomada del Día 7 y lo que cambia frente a Trigo Lento en Python:
ficha = {'citas_dia': 18, 'dias_habiles': 20, 'minutos_llamada': 2.0, 'inasistencia': 0.15,
         'ingreso_cita_mxn': 850, 'hora_recepcion_mxn': 70}
CITAS_MES = ficha['citas_dia'] * ficha['dias_habiles']
horas_llamadas = CITAS_MES * ficha['minutos_llamada'] / 60
perdidas = CITAS_MES * ficha['inasistencia']
print(f'Citas al mes: {CITAS_MES} | llamadas de recordatorio: {horas_llamadas:.1f} h = '
      f'{horas_llamadas * ficha["hora_recepcion_mxn"]:,.0f} MXN')
print(f'Inasistencias esperadas: {perdidas:.0f} citas = {perdidas * ficha["ingreso_cita_mxn"]:,.0f} MXN al mes')

comparacion = pd.DataFrame([
    ('Unidad de valor', 'encargo (380 MXN)', 'cita (850 MXN)', 'el coste invisible es el sillón vacío'),
    ('Mensajes salientes', 'recordatorio de recogida', 'recordatorio de cita', 'misma categoría: utilidad'),
    ('Datos del mensaje', 'nombre, pedido, hora', 'nombre de pila, fecha, hora', 'nunca tratamiento ni dentista'),
    ('Consentimiento', 'por finalidad', 'expreso, por finalidad y escrito', 'datos de salud en la agenda'),
    ('Texto libre del cliente', 'chatbot y Claude si es ambiguo', 'solo Irma, sin IA', 'puede contener datos de salud'),
    ('Horario de envío', 'lunes a sábado, 9:00 a 19:00', 'lunes a viernes, 9:00 a 19:00', 'cierres de Semana Santa'),
    ('Plataforma', 'n8n propio y Cloud API', 'la que pague la clínica', 'Valeria solo asesora'),
], columns = ['concepto', 'Trigo Lento', 'Sonrisa Norte', 'qué cambia'])
print(comparacion.to_string(index = False))
(clinica / 'ficha_dia51.json').write_text(json.dumps(ficha, indent = 2), encoding = 'utf-8')
Citas al mes: 360 | llamadas de recordatorio: 12.0 h = 840 MXN
Inasistencias esperadas: 54 citas = 45,900 MXN al mes
               concepto                    Trigo Lento                    Sonrisa Norte                            qué cambia
        Unidad de valor              encargo (380 MXN)                   cita (850 MXN) el coste invisible es el sillón vacío
     Mensajes salientes       recordatorio de recogida             recordatorio de cita             misma categoría: utilidad
      Datos del mensaje           nombre, pedido, hora      nombre de pila, fecha, hora         nunca tratamiento ni dentista
         Consentimiento                  por finalidad expreso, por finalidad y escrito           datos de salud en la agenda
Texto libre del cliente chatbot y Claude si es ambiguo                solo Irma, sin IA         puede contener datos de salud
       Horario de envío   lunes a sábado, 9:00 a 19:00    lunes a viernes, 9:00 a 19:00               cierres de Semana Santa
             Plataforma         n8n propio y Cloud API          la que pague la clínica                  Valeria solo asesora

Las llamadas de recordatorio ocupan $12$ horas al mes de Irma, $840$ MXN a su valor por hora, y las inasistencias cuestan $45,900$ MXN al mes, $54$ citas perdidas. La proporción entre las dos cifras, más de $50$ veces, ordena las prioridades del día: liberar las tardes de Irma es bueno, pero el proyecto se justifica por los sillones. La fórmula del Día 7 lo resume:

$$ I = n \cdot p \cdot v $$

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

  • $n = 360$ es el número de citas al mes, $p$ la tasa de inasistencia y $v = 850$ MXN el ingreso medio por cita.
  • Con $p = 0.15$, $I = 360 \cdot 0.15 \cdot 850 = 45,900$ MXN; cada punto porcentual que baje la inasistencia vale $360 \cdot 0.01 \cdot 850 = 3,060$ MXN al mes.
  • El recordatorio actúa sobre $p$, no sobre $n$ ni sobre $v$: no agenda más citas ni las cobra más caras; evita que se pierdan las que ya existen.
  • En conclusión, el beneficio del recordatorio se mide en puntos de inasistencia, y por eso la sección de beneficios no promete una cifra, sino una distribución.

La tabla de comparación tiene una fila que conviene subrayar: la plataforma la paga la clínica y Valeria solo asesora. No tiene acceso a la agenda real ni a los teléfonos de los pacientes; trabaja con datos inventados, entrega los workflows y la clínica los importa en su propio servidor con sus propias credenciales. Es la primera regla del código de honor del Día 6 aplicada a un tercero: quien no necesita ver un dato, no lo ve.

Consentimiento y datos sensibles


La Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP), publicada en el Diario Oficial de la Federación el 20 de marzo de 2025, define en su artículo 2, fracción VI, los datos personales sensibles como los que afectan a la esfera más íntima de la persona o cuyo uso indebido puede dar origen a discriminación, y menciona el estado de salud presente o futuro. Su artículo 8 exige, para tratarlos, el consentimiento expreso y por escrito de la persona titular, con firma autógrafa, firma electrónica o cualquier mecanismo de autenticación. El Día 7 lo citó así y la revisión de octubre de 2026 no encontró reformas posteriores que lo cambien.

Un recordatorio de cita no tiene por qué tratar datos de salud. Que una persona tiene cita en una clínica dental el martes a las 10:30 ya dice algo, pero mucho menos que “su endodoncia del martes con la Dra. Fernández”. De ahí la primera decisión de diseño: el mensaje lleva solo el nombre de pila, la fecha, la hora y la dirección de la clínica. Ni tratamiento, ni dentista, ni motivo de consulta. El nombre del dentista parece inofensivo, pero en una clínica pequeña identifica la especialidad: si el Dr. Ríos es el ortodoncista, “su cita con el Dr. Ríos” revela un tratamiento.

La segunda decisión es el consentimiento por finalidad. La ficha de alta de cada paciente pide, en una casilla propia y con fecha, el consentimiento para “recibir recordatorios de cita por WhatsApp al número que usted indica”. No es la misma casilla que la de los recordatorios de revisión semestral (“hace seis meses de su última limpieza”), que el Día 7 separó porque no responden a una cita pedida y se parecen demasiado a la publicidad. Tampoco vale un consentimiento dado para SMS. El registro guarda cuatro campos, como consentimientos.json en Trigo Lento: quién, cuándo, para qué y por qué canal, más la fecha de baja si existe.

La tercera decisión es nombrar a los encargados. Cuando la clínica envía un recordatorio por la Cloud API, Meta recibe el teléfono del paciente, su nombre de pila y la fecha y hora de la cita; cuando n8n lo procesa, el proveedor del servidor también los aloja. El aviso de privacidad de la clínica debe decir que esos dos terceros tratan los datos por su cuenta y con qué finalidad, como el Día 46 pidió para Trigo Lento.

La cuarta es el plazo de conservación. El registro de envíos guarda identificadores y estados (la cita, el identificador del mensaje, el intento, el estado de entrega) y se conserva $12$ meses, lo bastante para medir la inasistencia de un año y compararla; los textos libres de los pacientes viven en la bandeja de recepción solo hasta que Irma los atiende y, si contienen información clínica relevante, la pasa al expediente, que se rige por su propia norma (la NOM-004-SSA3-2012 que el Día 7 describió). Son propuestas de diseño que la clínica debe validar con su asesoría legal, no plazos que imponga la ley.

La Figura 1 resume la primera decisión con la burbuja del mensaje: lo que dice dentro, y lo que se queda fuera, tachado.

Lo que dice el mensaje y lo que calla: dentro de la burbuja, nombre de pila, fecha, hora y dirección de la clínica; fuera, tachados, el tratamiento, el dentista y el motivo de consulta, que nunca salen de la clínica.

Figura 1. Lo que dice el mensaje y lo que calla: dentro de la burbuja, nombre de pila, fecha, hora y dirección de la clínica; fuera, tachados, el tratamiento, el dentista y el motivo de consulta, que nunca salen de la clínica.

Atención aquí: esta entrada explica principios de diseño, no da asesoría legal. Una clínica real debe revisar su aviso de privacidad, sus formatos de consentimiento y las obligaciones de la LFPDPPP de 2025 y de la NOM-004-SSA3-2012 con un especialista antes de enviar el primer recordatorio, y comprobar si hay reformas posteriores. La clínica es la responsable del tratamiento; Valeria, como asesora, no accede a la agenda real ni a los teléfonos de los pacientes. Y ningún flujo de esta entrada toma decisiones clínicas: si un paciente escribe que le duele, la respuesta la da una persona, nunca una regla ni un modelo.

La plantilla de utilidad con botones


El Día 48 (WhatsApp Business API: plantillas, ventana de 24 horas, secuencias y chatbots) registró las plantillas de Trigo Lento y explicó por qué una plantilla de utilidad solo puede llevar información de una transacción que el cliente pidió o espera. Un recordatorio de cita encaja en esa definición sin forzarla: la cita ya existe, la pidió el paciente y el mensaje solo la recuerda. Las guías de categoría que publican los proveedores, revisadas en octubre de 2026, ponen precisamente “Recordatorio: su cita está programada para…” como ejemplo de utilidad, y advierten que una plantilla aprobada puede volver a revisarse y cambiar de categoría si su contenido deja de encajar.

La plantilla recordatorio_cita se registra en es_MX con tres componentes: un cuerpo con tres variables numeradas ({{1}} el nombre de pila, {{2}} la fecha escrita y {{3}} la hora), un pie con la instrucción de baja y dos botones de respuesta rápida, Confirmar y Reprogramar. La dirección de la clínica va como texto fijo, no como variable, porque nunca cambia y así Meta la revisa una sola vez. Así queda la petición de creación que se envía a la API de plantillas de Meta, con el ejemplo de valores que Meta exige para revisarla:

json
{
  "name": "recordatorio_cita",
  "language": "es_MX",
  "category": "UTILITY",
  "parameter_format": "positional",
  "components": [
    {
      "type": "BODY",
      "text": "Hola, {{1}}. Le recordamos su cita en la Clínica Dental Sonrisa Norte el {{2}} a las {{3}}. Estamos en Calle Ejemplo 123, Lindavista. Use un botón para confirmar o pedir otro horario.",
      "example": {"body_text": [["Ana", "martes 24 de marzo", "10:30"]]}
    },
    {"type": "FOOTER", "text": "Responda BAJA para dejar de recibir recordatorios"},
    {
      "type": "BUTTONS",
      "buttons": [{"type": "QUICK_REPLY", "text": "Confirmar"}, {"type": "QUICK_REPLY", "text": "Reprogramar"}]
    }
  ]
}

Observe tres detalles. La categoría es UTILITY y el cuerpo no contiene ninguna palabra que invite a comprar. El pie, “Responda BAJA para dejar de recibir recordatorios”, ocupa $49$ de los $60$ caracteres que admite un pie y no lleva variables, porque los pies no las admiten. Y los botones no llevan aquí su carga útil: la carga útil (el texto oculto que vuelve con la respuesta, CONFIRMAR:C0412) se fija al enviar cada mensaje, como verá en el workflow y en el bloque de la Cloud API. Así cada respuesta llega con el identificador de su cita y el flujo no tiene que adivinar a qué recordatorio contesta el paciente.

La Tabla 2 pone esta versión al lado de la que Irma habría escrito por costumbre, la que dice el tratamiento y el nombre de la doctora y, ya puestos, aprovecha para ofrecer un descuento.

Tabla 2. Plantilla recordatorio_cita: componentes, variables con ejemplo inventado y botones, frente a la versión rechazada.

ComponenteVersión aceptada (recordatorio_cita)Versión rechazada (recordatorio_cita_v0)
CategoríaUtilidadUtilidad declarada; Meta la trataría como marketing
Cuerpo“Hola, {{1}}. Le recordamos su cita en la Clínica Dental Sonrisa Norte el {{2}} a las {{3}}. Estamos en Calle Ejemplo 123, Lindavista. Use un botón para confirmar o pedir otro horario.”“Hola {{1}}, te recordamos tu cita de {{2}} con {{3}} el {{4}} a las {{5}}. Aprovecha 10 % de descuento en tu limpieza dental si confirmas hoy.”
Variables con ejemplo{{1}} Ana, {{2}} martes 24 de marzo, {{3}} 10:30{{1}} Ana, {{2}} endodoncia, {{3}} la Dra. Lucía Fernández, {{4}} martes 24 de marzo, {{5}} 10:30
Pie“Responda BAJA para dejar de recibir recordatorios”Sin pie
BotonesConfirmar y Reprogramar (respuesta rápida)Confirmar y Reprogramar
Datos de saludNingunoTratamiento y dentista
Resultado de la revisiónAceptada, Szigriszt-Pazos de 75.27Rechazada por cinco motivos

Revisar una plantilla a ojo funciona hasta el día en que alguien tiene prisa. Por eso el Día 7 escribió revisar_mensaje, que rechazaba un recordatorio con campos de salud, palabras clínicas o sin forma de darse de baja. El siguiente código lo amplía con lo que el módulo 5 añadió: la categoría, los botones, los límites de longitud de Meta, la detección de lenguaje promocional y el índice de legibilidad de Szigriszt-Pazos del Día 49 (Accesibilidad y multilingüismo de los mensajes), con el umbral de $65$ puntos que separa lo “normal” de lo “bastante fácil” en la escala INFLESZ. El contador de sílabas se copia del Día 49 para que el bloque se entienda solo:

python
# Revisión de la plantilla: categoría, botones, baja, datos de salud, promoción y legibilidad en Python:
FUERTES = set('aeoáéóíú')
VOCALES = FUERTES | set('iuü')

def contar_silabas(palabra):
    # Núcleos vocálicos (Día 49): dos vocales seguidas son una sílaba salvo que ambas sean fuertes:
    p = palabra.lower()
    if p.isdigit() or ':' in p:
        return 2
    if p == 'y':
        return 1
    p, silabas, previa = re.sub(r'y$', 'i', p), 0, ''
    for letra in p:
        if letra in VOCALES and (previa not in VOCALES or (previa in FUERTES and letra in FUERTES)):
            silabas += 1
        previa = letra
    return max(silabas, 1)

def szigriszt(texto):
    palabras = re.findall(r'\d+(?::\d+)?|[a-záéíóúüñ]+', texto.lower())
    frases = [f for f in re.split(r'[.!?]+(?:\s|$)', texto) if re.search(r'\w', f)]
    S, W, F = sum(contar_silabas(w) for w in palabras), len(palabras), len(frases)
    return round(206.835 - 62.3 * S / W - W / F, 2)

CAMPOS_PERMITIDOS = {'nombre_pila', 'fecha_cita', 'hora_cita'}
SALUD = r'\b(endodoncia|extracci[oó]n|implante|ortodoncia|caries|dolor|limpieza|resina|tratamiento|dra?\.)'
PROMO = r'(\d+\s*%|descuento|aprovech\w*|promoci[oó]n|oferta|gratis|2x1)'

def revisar_mensaje(plantilla, campos):
    comp = {c['type']: c for c in plantilla['components']}
    cuerpo = comp['BODY']['text']
    ejemplo = cuerpo
    for i, valor in enumerate(comp['BODY']['example']['body_text'][0], 1):
        ejemplo = ejemplo.replace('{{' + str(i) + '}}', valor)
    botones = [b['text'] for b in comp.get('BUTTONS', {}).get('buttons', [])]
    pie = comp.get('FOOTER', {}).get('text', '')
    P = szigriszt(ejemplo)
    revision = {
        'categoría': plantilla['category'],
        'campos de salud': ', '.join(c for c in campos if c not in CAMPOS_PERMITIDOS) or 'ninguno',
        'palabras clínicas': ', '.join(re.findall(SALUD, ejemplo, flags = re.I)) or 'ninguna',
        'promoción': ', '.join(m if isinstance(m, str) else m[0] for m in re.findall(PROMO, ejemplo, flags = re.I)) or 'ninguna',
        'botones': ' / '.join(botones),
        'baja en el pie': 'BAJA' in pie,
        'límites (1024/60/25)': len(cuerpo) <= 1024 and len(pie) <= 60 and all(len(b) <= 25 for b in botones),
        'Szigriszt-Pazos': P,
    }
    motivos = [k for k, v in revision.items() if (k in ('campos de salud', 'palabras clínicas', 'promoción') and
               v not in ('ninguno', 'ninguna')) or v is False]
    if P < 65:
        motivos.append('legibilidad < 65')
    if motivos[:1] and revision['promoción'] != 'ninguna':
        motivos.append('Meta la reclasificaría como marketing')
    return revision, ('RECHAZADA: ' + '; '.join(motivos) if motivos else 'ACEPTADA')

aceptada = json.loads((clinica / 'plantillas' / 'recordatorio_cita.json').read_text(encoding = 'utf-8'))
rechazada = {'name': 'recordatorio_cita_v0', 'language': 'es_MX', 'category': 'UTILITY', 'components': [
    {'type': 'BODY', 'text': ('Hola {{1}}, te recordamos tu cita de {{2}} con {{3}} el {{4}} a las {{5}}. '
                              'Aprovecha 10 % de descuento en tu limpieza dental si confirmas hoy.'),
     'example': {'body_text': [['Ana', 'endodoncia', 'la Dra. Lucía Fernández', 'martes 24 de marzo', '10:30']]}},
    {'type': 'BUTTONS', 'buttons': [{'type': 'QUICK_REPLY', 'text': 'Confirmar'},
                                    {'type': 'QUICK_REPLY', 'text': 'Reprogramar'}]}]}
rev_0, veredicto_0 = revisar_mensaje(rechazada, ['nombre_pila', 'tratamiento', 'dentista', 'fecha_cita', 'hora_cita'])
rev_1, veredicto_1 = revisar_mensaje(aceptada, ['nombre_pila', 'fecha_cita', 'hora_cita'])
print(pd.DataFrame({'rechazada (v0)': rev_0, 'aceptada': rev_1}).to_string())
print('v0:', veredicto_0)
print('v1:', veredicto_1)
cuerpo = aceptada['components'][0]['text']
print(f"Caracteres: cuerpo {len(cuerpo)} de 1024 | pie {len(aceptada['components'][1]['text'])} de 60")
                                  rechazada (v0)                 aceptada
categoría                                UTILITY                  UTILITY
campos de salud            tratamiento, dentista                  ninguno
palabras clínicas     endodoncia, Dra., limpieza                  ninguna
promoción             Aprovecha, 10 %, descuento                  ninguna
botones                  Confirmar / Reprogramar  Confirmar / Reprogramar
baja en el pie                             False                     True
límites (1024/60/25)                        True                     True
Szigriszt-Pazos                            79.36                    75.27
v0: RECHAZADA: campos de salud; palabras clínicas; promoción; baja en el pie; Meta la reclasificaría como marketing
v1: ACEPTADA
Caracteres: cuerpo 183 de 1024 | pie 49 de 60

La versión rechazada cae por cinco motivos: usa dos campos que no están en la lista permitida (tratamiento y dentista), nombra tres palabras clínicas (“endodoncia”, “Dra.” y “limpieza”), contiene tres marcas de promoción (“Aprovecha”, “10 %” y “descuento”), no tiene la palabra BAJA en el pie y, por la promoción, Meta la reclasificaría como marketing. La aceptada pasa todas las comprobaciones: su cuerpo ocupa $183$ de $1,024$ caracteres, su pie $49$ de $60$, sus botones caben en los $25$ caracteres y su índice es $P = 75.27$, “bastante fácil”.

Hay un resultado que merece una pausa: la versión rechazada es más legible ($79.36$) que la aceptada. Tutea, usa frases cortas y palabras de uso diario, y por eso puntúa mejor. La legibilidad es una condición necesaria, no suficiente; un mensaje fácil de leer puede ser exactamente el que no se debe enviar. El índice mide la carga de lectura, y el resto de las comprobaciones mide lo que el mensaje revela y a qué invita. Para recordar la fórmula del Día 49:

$$ P = 206.835 - 62.3 \cdot \frac{S}{W} - \frac{W}{F} $$

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

  • $S$, $W$ y $F$ son las sílabas, las palabras y las frases del cuerpo ya relleno con los valores de ejemplo; se mide el mensaje que leerá el paciente, no la plantilla con sus llaves.
  • La aceptada tiene una frase más (la dirección) y palabras algo más largas (“recordamos”, “Lindavista”), y por eso pierde unos cuatro puntos frente a la v0.
  • Los $75.27$ puntos quedan por encima del umbral de $65$ que la serie fija para los mensajes a clientes desde el Día 49, y por debajo de $80$, el límite de “muy fácil”.
  • En conclusión, la legibilidad se mide sobre el mensaje relleno y se combina con las demás reglas, nunca sola.

La revisión tiene los límites del Día 7: una lista de palabras no detecta “su tratamiento de conducto” ni una sigla médica, y por eso cada plantilla nueva la aprueba además una persona de la clínica antes de enviarla a Meta. El código evita que se escape lo obvio; la firma de la Dra. Fernández en la plantilla evita lo demás.

Cuándo se envía


Un recordatorio que llega a las once de la noche no recuerda nada: molesta. Don Ernesto lo dijo el Día 26 (Laboratorio guiado en Make: el escenario de recordatorios de recogida, paso a paso) con su frase “a las once de la noche no le escribe usted a nadie”, y la clínica la hace suya con cuatro reglas:

  1. El primer aviso sale el día hábil anterior a la cita, a las 10:00. Las citas del martes se recuerdan el lunes; las del lunes, el viernes anterior. Así el paciente tiene tiempo de reorganizar su día y la clínica, de reocupar el hueco.
  2. El segundo aviso sale ese mismo día a las 17:00, y solo a quien no respondió. Un segundo mensaje a quien ya confirmó no aporta nada y cuesta lo mismo.
  3. Nunca fuera de 9:00 a 19:00 ni en fin de semana, aunque el sistema se haya caído y esté reintentando. Si un reintento cae fuera del horario, se descarta y se registra el motivo.
  4. Los cierres los gestiona una persona. La clínica cierra el jueves 2 y el viernes 3 de abril por Semana Santa. Las citas que había en esos días las reprograma Irma por teléfono antes del cierre, y ningún recordatorio sale en ellos.

La regla de cada envío se convierte en una función, puede_recordar(cita, consentimientos, ahora), que devuelve si se puede enviar y, si no, el primer motivo que lo impide. El orden de las comprobaciones importa: primero el consentimiento (sin él, nada más importa), después la baja, después la cita y, al final, el reloj. El siguiente código la define junto con dia_habil_anterior, que retrocede día a día saltando fines de semana y cierres, y la prueba con doce casos:

python
# Reglas de envío: consentimiento por finalidad, baja, día hábil, horario y cierres en Python:
CIERRES = {date(2026, 4, 2), date(2026, 4, 3)}          # Jueves y Viernes Santo
HORARIO = (9, 19)                                        # de 9:00 a 19:00, lunes a viernes

def es_habil(d):
    return d.weekday() < 5 and d not in CIERRES

def dia_habil_anterior(d):
    d -= timedelta(days = 1)
    while not es_habil(d):
        d -= timedelta(days = 1)
    return d

def puede_recordar(cita, consentimientos, ahora):
    # Devuelve (se puede enviar, motivo); el primer motivo que falla es el que se registra:
    vigentes = [c for c in consentimientos.get(cita['paciente'], [])
                if c['finalidad'] == 'recordatorios_cita' and c['canal'] == 'whatsapp' and c['fecha'] <= ahora]
    if not vigentes:
        return False, 'sin consentimiento para esta finalidad y canal'
    if any(c['baja'] is not None and c['baja'] <= ahora for c in vigentes):
        return False, 'el paciente se dio de baja'
    dia_cita = cita['inicio'].date()
    if not es_habil(dia_cita):
        return False, 'cita en día inhábil o de cierre: la revisa una persona'
    if not es_habil(ahora.date()):
        return False, 'hoy no es día hábil de la clínica'
    if not HORARIO[0] <= ahora.hour < HORARIO[1]:
        return False, 'fuera del horario de 9:00 a 19:00'
    if ahora.date() != dia_habil_anterior(dia_cita):
        return False, f'no toca: se recuerda el {dia_habil_anterior(dia_cita):%d/%m}'
    return True, 'enviar'

t = lambda m, d, h, mi = 0: datetime(2026, m, d, h, mi, tzinfo = ZONA)
DIAS = ['lun', 'mar', 'mié', 'jue', 'vie', 'sáb', 'dom']
corto = lambda x: f'{DIAS[x.weekday()]} {x:%d/%m %H:%M}'
alta = t(1, 15, 12)
consentimientos = {
    'P01': [{'finalidad': 'recordatorios_cita', 'canal': 'whatsapp', 'fecha': alta, 'baja': None}],
    'P02': [{'finalidad': 'revisiones_semestrales', 'canal': 'whatsapp', 'fecha': alta, 'baja': None}],
    'P03': [{'finalidad': 'recordatorios_cita', 'canal': 'whatsapp', 'fecha': alta, 'baja': t(3, 20, 18)}],
    'P04': [{'finalidad': 'recordatorios_cita', 'canal': 'sms', 'fecha': alta, 'baja': None}],
}
casos = [
    ('Caso normal', 'P01', t(3, 24, 10, 30), t(3, 23, 10)),
    ('Solo consintió revisiones', 'P02', t(3, 24, 10, 30), t(3, 23, 10)),
    ('Se dio de baja el viernes', 'P03', t(3, 24, 10, 30), t(3, 23, 10)),
    ('Consintió por SMS', 'P04', t(3, 24, 10, 30), t(3, 23, 10)),
    ('Reintento a las 20:15', 'P01', t(3, 24, 10, 30), t(3, 23, 20, 15)),
    ('Cita del lunes, enviada el viernes', 'P01', t(3, 30, 9), t(3, 27, 10)),
    ('Cita del lunes, enviada el domingo', 'P01', t(3, 30, 9), t(3, 29, 10)),
    ('Cita el sábado 28', 'P01', t(3, 28, 11), t(3, 27, 10)),
    ('Cita el Jueves Santo', 'P01', t(4, 2, 12), t(4, 1, 10)),
    ('Cita del lunes 6 de abril, el 1', 'P01', t(4, 6, 9, 30), t(4, 1, 10)),
    ('Cita del lunes 6 de abril, el 3', 'P01', t(4, 6, 9, 30), t(4, 3, 10)),
    ('Cita del miércoles, enviada el lunes', 'P01', t(3, 25, 16), t(3, 23, 10)),
]
filas = []
for nombre, paciente, inicio, ahora in casos:
    ok, motivo = puede_recordar({'paciente': paciente, 'inicio': inicio}, consentimientos, ahora)
    filas.append((nombre, corto(inicio), corto(ahora), 'sí' if ok else 'no', motivo))
pruebas = pd.DataFrame(filas, columns = ['caso', 'cita', 'ahora', 'envía', 'motivo'])
print(pruebas.to_string(index = False))
print('Envíos permitidos:', (pruebas['envía'] == 'sí').sum(), 'de', len(pruebas))
                                caso            cita           ahora envía                                                 motivo
                         Caso normal mar 24/03 10:30 lun 23/03 10:00    sí                                                 enviar
           Solo consintió revisiones mar 24/03 10:30 lun 23/03 10:00    no         sin consentimiento para esta finalidad y canal
           Se dio de baja el viernes mar 24/03 10:30 lun 23/03 10:00    no                             el paciente se dio de baja
                   Consintió por SMS mar 24/03 10:30 lun 23/03 10:00    no         sin consentimiento para esta finalidad y canal
               Reintento a las 20:15 mar 24/03 10:30 lun 23/03 20:15    no                      fuera del horario de 9:00 a 19:00
  Cita del lunes, enviada el viernes lun 30/03 09:00 vie 27/03 10:00    sí                                                 enviar
  Cita del lunes, enviada el domingo lun 30/03 09:00 dom 29/03 10:00    no                      hoy no es día hábil de la clínica
                   Cita el sábado 28 sáb 28/03 11:00 vie 27/03 10:00    no cita en día inhábil o de cierre: la revisa una persona
                Cita el Jueves Santo jue 02/04 12:00 mié 01/04 10:00    no cita en día inhábil o de cierre: la revisa una persona
     Cita del lunes 6 de abril, el 1 lun 06/04 09:30 mié 01/04 10:00    sí                                                 enviar
     Cita del lunes 6 de abril, el 3 lun 06/04 09:30 vie 03/04 10:00    no                      hoy no es día hábil de la clínica
Cita del miércoles, enviada el lunes mié 25/03 16:00 lun 23/03 10:00    no                          no toca: se recuerda el 24/03
Envíos permitidos: 3 de 12

Solo $3$ de los $12$ casos se envían, y cada uno de los nueve rechazos tiene un motivo distinto que se puede registrar. El paciente que solo consintió las revisiones semestrales y el que consintió por SMS quedan fuera por la misma razón: el consentimiento es por finalidad y por canal. El que se dio de baja el viernes 20 queda fuera aunque su consentimiento original siga en el registro, porque la baja manda. El reintento de las 20:15 se descarta por horario, y la cita del lunes 30 se recuerda el viernes 27 pero no el domingo 29.

Dos casos merecen comentario. La cita del sábado 28 es un error de agenda (la clínica no abre los sábados), y la del Jueves Santo es una cita que debió reprogramarse: las dos caen en “cita en día inhábil o de cierre: la revisa una persona”. El flujo no intenta arreglarlas, porque una regla que no sabe por qué existe una cita no debe decidir qué hacer con ella. Y la cita del lunes 6 de abril se recuerda el miércoles 1, no el viernes 3, porque el viernes la clínica está cerrada: el día hábil anterior al lunes 6 es el miércoles 1. Esa antelación es larga, y el calendario del mes la medirá.

Con las reglas escritas, el siguiente código construye el calendario completo de abril: $18$ citas en cada día hábil, repartidas al azar entre los $20$ huecos de media hora de la jornada, su primer aviso según dia_habil_anterior y un segundo aviso a las 17:00 para quien no respondió (un $40%$ en promedio, el supuesto de la ficha). Después comprueba que ningún envío cae fuera del horario y dibuja los mensajes por día y por hora:

python
# Calendario de recordatorios de abril de 2026 con Semana Santa en Python:
habiles = [d for d in pd.date_range('2026-04-01', '2026-04-30').date if es_habil(d)]
huecos = [(h, m) for h in range(9, 19) for m in (0, 30)]          # 20 huecos de media hora
citas = []
for d in habiles:
    for k in rng.choice(len(huecos), size = ficha['citas_dia'], replace = False):
        h, m = huecos[k]
        citas.append({'id_cita': f'C{len(citas) + 1:04d}', 'inicio': datetime(d.year, d.month, d.day, h, m, tzinfo = ZONA)})
citas = pd.DataFrame(citas)
citas['envio_1'] = [datetime.combine(dia_habil_anterior(i.date()), datetime.min.time(), ZONA) + timedelta(hours = 10)
                    for i in citas['inicio']]
responde_1 = rng.random(len(citas)) < 0.60
citas['envio_2'] = [e1 + timedelta(hours = 7) if not r else pd.NaT for e1, r in zip(citas['envio_1'], responde_1)]
envios = pd.concat([citas['envio_1'].rename('momento').to_frame().assign(aviso = 'primero'),
                    citas['envio_2'].dropna().rename('momento').to_frame().assign(aviso = 'segundo')])
envios['dia'] = [m.date() for m in envios['momento']]
fuera = [m for m in envios['momento'] if not (es_habil(m.date()) and HORARIO[0] <= m.hour < HORARIO[1])]
antelacion = (citas['inicio'] - citas['envio_1']).dt.total_seconds() / 3600
print(f'Días hábiles de abril: {len(habiles)} | citas: {len(citas)} | primeros avisos: {(envios.aviso == "primero").sum()}'
      f' | segundos avisos: {(envios.aviso == "segundo").sum()}')
print('Envíos fuera del horario o en día inhábil:', len(fuera))
print(f'Antelación del primer aviso: mínima {antelacion.min():.1f} h, mediana {antelacion.median():.1f} h, '
      f'máxima {antelacion.max():.1f} h')
print('Citas recordadas el 1 de abril:', int((citas['envio_1'].dt.date == date(2026, 4, 1)).sum()),
      '| de ellas, del lunes 6:', int(((citas['envio_1'].dt.date == date(2026, 4, 1)) &
                                      (citas['inicio'].dt.date == date(2026, 4, 6))).sum()))

por_dia = envios.groupby(['dia', 'aviso']).size().unstack(fill_value = 0)
dias = pd.date_range('2026-03-31', '2026-04-30').date
por_dia = por_dia.reindex(dias, fill_value = 0)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize = (11, 6.4), gridspec_kw = {'height_ratios': [3, 1.3]})
x = np.arange(len(dias))
for i, d in enumerate(dias):
    if d.weekday() >= 5:
        ax1.axvspan(i - 0.5, i + 0.5, color = '#ecebe7')
    if d in CIERRES:
        ax1.axvspan(i - 0.5, i + 0.5, color = '#f6d2c4')
ax1.bar(x, por_dia['primero'], color = '#2a78d6', label = 'Primer aviso, 10:00')
ax1.bar(x, por_dia['segundo'], bottom = por_dia['primero'], color = '#eb6834', label = 'Segundo aviso, 17:00')
ax1.text(2.5, 31, 'Cierre de\nSemana Santa', ha = 'center', fontsize = 9, color = '#a33f17')
ax1.set_xticks(x[::2])
ax1.set_xticklabels([f'{d:%d/%m}' for d in dias[::2]], rotation = 45)
ax1.set_ylabel('Mensajes enviados')
ax1.set_ylim(0, 37)
ax1.legend(loc = 'upper right', fontsize = 9)
ax1.set_title('Recordatorios de las citas de abril de 2026 por día de envío', fontsize = 14)
horas = [m.hour + m.minute / 60 for m in envios['momento']]
ax2.axvspan(9, 19, color = '#d9ead3', label = 'Horario permitido (9:00 a 19:00)')
ax2.hist(horas, bins = np.arange(0, 24.5, 0.5), color = '#52514e')
ax2.set_xlim(0, 24)
ax2.set_xticks(range(0, 25, 3))
ax2.set_xlabel('Hora del envío')
ax2.set_ylabel('Mensajes')
ax2.legend(loc = 'upper right', fontsize = 9)
plt.tight_layout()
plt.show()
Días hábiles de abril: 20 | citas: 360 | primeros avisos: 360 | segundos avisos: 151
Envíos fuera del horario o en día inhábil: 0
Antelación del primer aviso: mínima 23.0 h, mediana 29.0 h, máxima 128.5 h
Citas recordadas el 1 de abril: 18 | de ellas, del lunes 6: 18

Gráfica: Cuándo se envía. Calendario de recordatorios de abril de 2026 con Semana Santa

Abril de 2026 tiene $20$ días hábiles en la clínica: $22$ días de lunes a viernes menos el Jueves y el Viernes Santo. Las $360$ citas producen $360$ primeros avisos y, en este mes simulado, $151$ segundos avisos, algo más de los $144$ que da el supuesto del $40%$ por puro azar. Ningún envío cae fuera del horario o en día inhábil, que es lo que se buscaba comprobar.

La gráfica superior muestra el ritmo del mes: de $23$ a $31$ mensajes cada día hábil, ningún mensaje en fines de semana ni en los dos días de cierre, sombreados en naranja. La inferior confirma que los envíos se concentran en dos horas, las 10:00 y las 17:00, dentro de la franja verde de 9:00 a 19:00. La antelación del primer aviso tiene una mediana de $29$ horas, pero su máximo es de $128.5$ horas: las $18$ citas del lunes 6 de abril se recuerdan el miércoles 1, más de cinco días antes. Un recordatorio con tanta antelación pierde parte de su efecto. La regla no se cambia por un caso que ocurre dos veces al año; se añade una decisión humana: Irma revisa el martes 31 la lista del lunes 6 y, si lo cree necesario, llama a los pacientes que no hayan confirmado el viernes 3 por la mañana, cuando la clínica está cerrada pero ella no. Ese tipo de excepción es la que conviene dejar fuera del flujo.

Qué pasa con cada respuesta


Un recordatorio con botones abre una conversación, y cada respuesta tiene que llegar a su sitio sin que nadie lea más de lo necesario. Cuando el paciente pulsa un botón, Meta envía al webhook de la clínica un evento con un mensaje de tipo button, que trae la carga útil del botón (CONFIRMAR:C0412); cuando escribe, el mensaje es de tipo text. Antes de mirar el contenido, el flujo comprueba que el evento viene de Meta. Cada petición lleva la cabecera X-Hub-Signature-256, que según la documentación de Meta revisada en octubre de 2026 es un código de autenticación de mensajes calculado sobre el cuerpo de la petición con el secreto de la aplicación como clave:

$$ \text{firma} = \texttt{sha256=} ,|, \mathrm{HMAC}_{\mathrm{SHA256}}(K, \text{cuerpo}) $$

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

  • $K$ es el secreto de la aplicación de Meta, que solo conocen Meta y el servidor de la clínica; $|$ indica concatenación y el resultado se escribe en hexadecimal.
  • Se calcula sobre los bytes exactos que llegaron, no sobre el JSON vuelto a serializar: Meta escapa los caracteres no ASCII, y reescribir el JSON cambia los bytes y rompe la firma. Por eso el webhook de n8n se configura con la opción de cuerpo en bruto.
  • La comparación se hace en tiempo constante (hmac.compare_digest en Python, crypto.timingSafeEqual en n8n), para que el tiempo de respuesta no revele cuántos caracteres coinciden.
  • En conclusión, una petición que no trae la firma correcta no existe para el flujo: no se responde, no se registra su contenido y no cambia ninguna cita.

Verificada la firma, la Tabla 3 dice qué hace el flujo con cada respuesta, quién se ocupa y qué queda registrado.

Tabla 3. Respuestas posibles a un recordatorio: acción automática, persona responsable, mensaje de vuelta y registro.

RespuestaAcción automáticaResponsableMensaje de vuelta (dentro de la ventana)Registro
Botón ConfirmarMarca la cita como confirmadaAutomático“Gracias, su cita quedó confirmada. Lo esperamos.”Cita, identificador del mensaje y estado
Botón ReprogramarDeja la cita pendiente en la bandeja de recepción y avisa a IrmaIrma“Gracias. Irma le escribirá hoy con horarios disponibles.”Cita y estado “por reprogramar”
“BAJA” o “ya no”Retira el consentimiento al instanteAutomático“Listo. Ya no recibirá recordatorios por WhatsApp.”Fecha de baja en el consentimiento
Cualquier otro textoLo guarda en la bandeja de recepción, sin pasar por ningún modelo de IA, y avisa a IrmaIrma“Recibimos su mensaje; Irma le responde en horario de atención.”Teléfono y tipo; el texto vive solo en la bandeja
Firma inválidaDescarta el eventoNadieNinguno“evento descartado”

La regla más importante de la tabla está en la cuarta fila. Trigo Lento usó a Claude para entender mensajes ambiguos desde el Día 30, siempre seudonimizados; la clínica no lo hace, y no por desconfianza hacia el modelo. Un texto libre de un paciente puede contener datos de salud (“me duele mucho la muela”, “estoy tomando anticoagulantes”), y una tarea que no necesita un modelo no le envía datos a un modelo. Para recordar una cita no hace falta entender nada: basta con dos botones y una persona para todo lo demás. Es el principio de minimización del Día 7 aplicado a la arquitectura.

El siguiente código lo pone a prueba con $20$ respuestas inventadas en el formato del webhook de Meta: doce confirmaciones, tres peticiones de reprogramar, dos bajas (“BAJA” y “ya no, gracias”), dos textos libres y una petición cuyo cuerpo se alteró después de firmarse (alguien cambió la cita C1020 por C9999). El secreto de la aplicación es un valor de ejemplo; en n8n vive en una variable de entorno:

python
# Respuestas a un recordatorio: firma X-Hub-Signature-256, enrutamiento y derivación a Irma en Python:
SECRETO_APP = 'EJEMPLO-NO-REAL-secreto-de-la-app-sonrisa-norte'   # en n8n vive en una variable de entorno

def firmar(cuerpo, secreto):
    return 'sha256=' + hmac.new(secreto.encode(), cuerpo, hashlib.sha256).hexdigest()

def evento(i, telefono, boton = None, texto = None):
    m = {'from': telefono, 'id': f'wamid.EJEMPLO-NO-REAL-{i:03d}', 'timestamp': str(1774290000 + 60 * i)}
    m.update({'type': 'button', 'button': boton} if boton else {'type': 'text', 'text': {'body': texto}})
    valor = {'messaging_product': 'whatsapp', 'metadata': {'phone_number_id': 'EJEMPLO-NO-REAL-PNID'}, 'messages': [m]}
    cuerpo = json.dumps({'object': 'whatsapp_business_account',
                         'entry': [{'changes': [{'field': 'messages', 'value': valor}]}]}).encode()
    return cuerpo, firmar(cuerpo, SECRETO_APP)

ACCIONES = {
    'confirmar': ('marcar confirmada', 'automático', 'Gracias, su cita quedó confirmada.'),
    'reprogramar': ('pendiente para Irma', 'Irma', 'Irma le escribirá hoy con horarios.'),
    'baja': ('retirar consentimiento', 'automático', 'Ya no recibirá recordatorios.'),
    'texto_libre': ('bandeja de Irma, sin IA', 'Irma', 'Recibimos su mensaje; Irma le responde.'),
}

def atender(cuerpo, firma):
    if not hmac.compare_digest(firmar(cuerpo, SECRETO_APP), firma or ''):
        return {'tipo': 'firma inválida', 'accion': 'descartar sin responder', 'responsable': '-', 'acuse': '-',
                'registro': 'evento descartado'}
    m = json.loads(cuerpo)['entry'][0]['changes'][0]['value']['messages'][0]
    if m['type'] == 'button':
        tipo, id_cita = m['button']['payload'].split(':')
        tipo = tipo.lower()
    else:
        tipo = 'baja' if re.match(r'\s*(baja|ya no)\b', m['text']['body'], flags = re.I) else 'texto_libre'
        id_cita = None
    accion, responsable, acuse = ACCIONES[tipo]
    # El registro guarda identificadores y estados, nunca el texto del paciente:
    return {'tipo': tipo, 'accion': accion, 'responsable': responsable, 'acuse': acuse,
            'registro': f"{m['id'][-3:]} {id_cita or '-'} {tipo}"}

tel = lambda n: f'+52 55 0000 00{n:02d}'
eventos = ([evento(i, tel(i), boton = {'payload': f'CONFIRMAR:C{1000 + i}', 'text': 'Confirmar'}) for i in range(1, 13)] +
           [evento(i, tel(i), boton = {'payload': f'REPROGRAMAR:C{1000 + i}', 'text': 'Reprogramar'}) for i in range(13, 16)] +
           [evento(16, tel(16), texto = 'BAJA'), evento(17, tel(17), texto = 'ya no, gracias'),
            evento(18, tel(18), texto = 'me duele mucho la muela, ¿puedo ir antes?'),
            evento(19, tel(19), texto = '¿cuánto cuesta la limpieza?')])
cuerpo_alterado, firma_original = evento(20, tel(20), boton = {'payload': 'CONFIRMAR:C1020', 'text': 'Confirmar'})
eventos.append((cuerpo_alterado.replace(b'C1020', b'C9999'), firma_original))
atendidos = pd.DataFrame([atender(c, f) for c, f in eventos])
print(atendidos.groupby(['tipo', 'accion', 'responsable', 'acuse'], sort = False).size().rename('n').reset_index()
      .to_string(index = False))
print('Ejemplos de registro:', list(atendidos['registro'].iloc[[0, 12, 17, 19]]))
print('¿Algún registro contiene el texto de un paciente?',
      any('muela' in r or 'limpieza' in r for r in atendidos['registro']))
print('Mensajes que llegan a un modelo de IA en este flujo:', 0)
          tipo                  accion responsable                                   acuse  n
     confirmar       marcar confirmada  automático      Gracias, su cita quedó confirmada. 12
   reprogramar     pendiente para Irma        Irma     Irma le escribirá hoy con horarios.  3
          baja  retirar consentimiento  automático           Ya no recibirá recordatorios.  2
   texto_libre bandeja de Irma, sin IA        Irma Recibimos su mensaje; Irma le responde.  2
firma inválida descartar sin responder           -                                       -  1
Ejemplos de registro: ['001 C1001 confirmar', '013 C1013 reprogramar', '018 - texto_libre', 'evento descartado']
¿Algún registro contiene el texto de un paciente? False
Mensajes que llegan a un modelo de IA en este flujo: 0

Las $20$ respuestas se reparten como se esperaba: $12$ confirmaciones automáticas, $3$ reprogramaciones y $2$ textos libres que van a Irma, $2$ bajas aplicadas al instante y $1$ evento descartado por firma inválida. El cuerpo alterado se detectó porque la firma se calculó sobre los bytes originales: cambiar cuatro caracteres cambia el código entero. Si el flujo no verificara la firma, cualquiera que conociera la URL del webhook podría confirmar o cancelar citas ajenas.

Los ejemplos de registro muestran qué se guarda: el final del identificador del mensaje, la cita y el tipo de respuesta, como “013 C1013 reprogramar”. El texto “me duele mucho la muela” no aparece en ningún registro (la comprobación da False), y el número de mensajes que llegan a un modelo de IA es cero, por diseño y no por casualidad.

Los acuses de la tercera columna de la Tabla 3 son mensajes de servicio, libres y sin plantilla, porque el paciente acaba de escribir y la ventana de atención de $24$ horas está abierta. Si Irma tarda más de un día en escribir a quien pidió reprogramar, la ventana se cierra y necesitará una plantilla aprobada; por eso el acuse promete “hoy”, y por eso el aviso a Irma sale en el momento. La Figura 2 resume la idea: dos botones que el flujo atiende solo y una puerta que siempre lleva a Irma.

Dos botones y una puerta: Confirmar marca la cita y Reprogramar avisa a Irma; BAJA retira el consentimiento al instante, y todo lo que no sea un botón cruza una puerta siempre abierta hacia Irma, sin pasar por ningún modelo de IA.

Figura 2. Dos botones y una puerta: Confirmar marca la cita y Reprogramar avisa a Irma; BAJA retira el consentimiento al instante, y todo lo que no sea un botón cruza una puerta siempre abierta hacia Irma, sin pasar por ningún modelo de IA.

Cuánto cuesta


El coste de los recordatorios depende de tres volúmenes y de dos decisiones. Los volúmenes salen de la ficha del plan: el primer aviso va a las $360$ citas; si el $60%$ responde, el segundo aviso va a $144$ personas y responde la mitad. Las plantillas enviadas y las respuestas recibidas son:

$$ N_p = n + n(1 - p_1), \qquad R = n,p_1 + n(1 - p_1),p_2 $$

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

  • Con $n = 360$, $p_1 = 0.60$ y $p_2 = 0.50$, $N_p = 360 + 144 = 504$ plantillas de utilidad y $R = 216 + 72 = 288$ respuestas al mes.
  • Cada respuesta recibe un acuse dentro de la ventana, así que hay $288$ acuses; de las respuestas, un $16%$ (reprogramar y texto libre) genera un aviso a Irma, unos $46$ al mes, que el plan no contaba.
  • Meta cobra cada plantilla de utilidad entregada a $0.0085$ USD (Tabla C). Los acuses son mensajes de servicio: hasta el 30 de septiembre de 2026 no tenían tarifa de Meta, y desde el 1 de octubre se cobran a la tarifa de utilidad solo a partir del número $1,001$ del mes, como explicó el Día 46. Con $288$ acuses, la clínica no paga ninguno en ninguno de los dos regímenes.
  • En conclusión, lo que cuesta son las plantillas, no las conversaciones: el coste crece con el segundo aviso y no con las respuestas.

La primera decisión es la puerta de WhatsApp: la Cloud API directa no cobra recargo, y Twilio cobra $0.005$ USD por cada mensaje enviado o recibido. La segunda es la plataforma: n8n Cloud Starter cuesta $24$ EUR al mes, $25.92$ USD al tipo didáctico de $1.08$, con $2,500$ ejecuciones incluidas; un servidor propio pequeño, el supuesto de la serie, cuesta $6$ USD. El coste mensual total es:

$$ C = (N_p + A),t_u + \max(0,, R - 1,000),t_s + r,(N_p + A + 2R) + F $$

donde $A$ son los avisos a Irma, $t_u$ y $t_s$ las tarifas de utilidad y de servicio, $r$ el recargo del proveedor ($0$ con la Cloud API, $0.005$ con Twilio) y $F$ la cuota de la plataforma. Con n8n hay un último detalle que cuenta: cada webhook que llega es una ejecución, y Meta envía un aviso de estado por cada mensaje saliente cuando se envía, cuando se entrega y cuando se lee. El siguiente código calcula todo con las Tablas B y C y compara las cuatro combinaciones:

python
# Coste mensual de los recordatorios por arquitectura y plataforma (Tablas B y C) en Python:
TARIFA = {'utilidad': 0.0085, 'servicio': 0.0085, 'twilio': 0.005}   # USD, revisado en octubre de 2026
PLATAFORMA = {'n8n Cloud Starter': 24 * USD_POR_EUR, 'servidor propio': 6.00}
LIMITE_STARTER = 2500

def volumen(citas = CITAS_MES, p1 = 0.60, p2 = 0.50, p_irma = 0.16):
    plantillas = citas + citas * (1 - p1)                  # primer aviso a todos, segundo a quien no respondió
    respuestas = citas * p1 + citas * (1 - p1) * p2
    return {'plantillas': plantillas, 'respuestas': respuestas, 'acuses': respuestas,
            'avisos_irma': round(respuestas * p_irma), 'corridas': 2 * ficha['dias_habiles']}

def coste_recordatorios(arquitectura, plataforma, v, regimen = 'octubre'):
    acuses_cobrados = max(0, v['acuses'] - 1000) if regimen == 'octubre' else 0
    meta = (v['plantillas'] + v['avisos_irma']) * TARIFA['utilidad'] + acuses_cobrados * TARIFA['servicio']
    recargo = 0.0
    if arquitectura == 'Twilio':
        recargo = (v['plantillas'] + v['avisos_irma'] + v['respuestas'] + v['acuses']) * TARIFA['twilio']
    salientes = v['plantillas'] + v['acuses'] + v['avisos_irma']
    ejecuciones = v['corridas'] + v['respuestas'] + 3 * salientes      # enviado, entregado y leído
    total = meta + recargo + PLATAFORMA[plataforma]
    return {'arquitectura': arquitectura, 'plataforma': plataforma, 'meta': round(meta, 2), 'recargo': round(recargo, 2),
            'plataforma_usd': round(PLATAFORMA[plataforma], 2), 'total_usd': round(total, 2),
            'total_mxn': round(total * MXN_POR_USD, 2), 'ejecuciones': int(ejecuciones),
            'cabe_en_starter': ejecuciones <= LIMITE_STARTER if plataforma == 'n8n Cloud Starter' else '-'}

v = volumen()
print({k: round(x, 1) for k, x in v.items()})
print(f"Solo recordatorios por Cloud API: {v['plantillas'] * TARIFA['utilidad']:.2f} USD | por Twilio: "
      f"{v['plantillas'] * (TARIFA['utilidad'] + TARIFA['twilio']) + 2 * v['respuestas'] * TARIFA['twilio']:.2f} USD")
plan = 40 + v['respuestas'] + 3 * v['plantillas']
print(f'Ejecuciones contadas en el plan: {plan:.0f} | con estados de acuses y avisos: '
      f"{v['corridas'] + v['respuestas'] + 3 * (v['plantillas'] + v['acuses'] + v['avisos_irma']):.0f} | límite Starter: {LIMITE_STARTER}")
tabla_4 = pd.DataFrame([coste_recordatorios(a, p, v) for a in ('Cloud API directa', 'Twilio') for p in PLATAFORMA])
print(tabla_4.to_string(index = False))
anterior = coste_recordatorios('Cloud API directa', 'servidor propio', v, regimen = 'septiembre')['total_usd']
print('Régimen anterior al 1 de octubre, Cloud API y servidor propio:', anterior, 'USD (los acuses no llegan a 1 000)')

fig, ax = plt.subplots(figsize = (9, 4.8))
etiquetas = [f"{a}\n{p}" for a, p in zip(tabla_4.arquitectura, tabla_4.plataforma)]
ax.bar(etiquetas, tabla_4.meta, color = '#2a78d6', label = 'Tarifa de Meta')
ax.bar(etiquetas, tabla_4.recargo, bottom = tabla_4.meta, color = '#eb6834', label = 'Recargo del proveedor')
ax.bar(etiquetas, tabla_4.plataforma_usd, bottom = tabla_4.meta + tabla_4.recargo, color = '#8a8986', label = 'Plataforma n8n')
for i, total in enumerate(tabla_4.total_usd):
    ax.text(i, total + 0.6, f'{total:.2f} USD', ha = 'center', fontsize = 10)
ax.set_ylabel('USD al mes')
ax.set_ylim(0, 42)
ax.legend(loc = 'upper left', fontsize = 9)
ax.set_title('Coste mensual de los recordatorios de Sonrisa Norte', fontsize = 14)
plt.tight_layout()
plt.show()
{'plantillas': 504.0, 'respuestas': 288.0, 'acuses': 288.0, 'avisos_irma': 46, 'corridas': 40}
Solo recordatorios por Cloud API: 4.28 USD | por Twilio: 9.68 USD
Ejecuciones contadas en el plan: 1840 | con estados de acuses y avisos: 2842 | límite Starter: 2500
     arquitectura        plataforma  meta  recargo  plataforma_usd  total_usd  total_mxn  ejecuciones cabe_en_starter
Cloud API directa n8n Cloud Starter  4.68     0.00           25.92      30.60     566.01         2842           False
Cloud API directa   servidor propio  4.68     0.00            6.00      10.68     197.49         2842               -
           Twilio n8n Cloud Starter  4.68     5.63           25.92      36.23     670.16         2842           False
           Twilio   servidor propio  4.68     5.63            6.00      16.30     301.64         2842               -
Régimen anterior al 1 de octubre, Cloud API y servidor propio: 10.68 USD (los acuses no llegan a 1 000)

Gráfica: Cuánto cuesta. Coste mensual de los recordatorios por arquitectura y plataforma (Tablas B y C)

Las dos primeras líneas reproducen el ejemplo de la ficha: solo los recordatorios cuestan $4.28$ USD al mes con la Cloud API y $9.68$ USD con Twilio, que cobra también las $288$ respuestas y los $288$ acuses. Con los $46$ avisos a Irma, la parte de Meta sube a $4.68$ USD. La Tabla 4 reúne las cuatro combinaciones.

Tabla 4. Coste mensual de los recordatorios de Sonrisa Norte por arquitectura y plataforma (USD y MXN al tipo didáctico de 18.50; revisado en octubre de 2026).

ArquitecturaPlataformaMeta (USD)Recargo (USD)Plataforma (USD)Total (USD)Total (MXN)Ejecuciones al mes¿Cabe en Starter?
Cloud API directan8n Cloud Starter4.680.0025.9230.60566.012 842No
Cloud API directaServidor propio4.680.006.0010.68197.492 842No aplica
Twilion8n Cloud Starter4.685.6325.9236.23670.162 842No
TwilioServidor propio4.685.636.0016.30301.642 842No aplica

La lectura de la tabla tiene tres capas. La primera: la plataforma pesa más que los mensajes. Con n8n Cloud, la cuota fija es el $85%$ del total; los mensajes de una clínica pequeña cuestan menos que una comida. La segunda: Twilio añade $5.63$ USD al mes, un $53%$ más que con la Cloud API y servidor propio, por cobrar cada mensaje en las dos direcciones. La tercera es la que el plan no vio: la ficha contaba $1,840$ ejecuciones (los $40$ disparos programados, las $288$ respuestas y los avisos de estado de las $504$ plantillas), que caben en las $2,500$ de Starter. Pero los acuses y los avisos a Irma también generan avisos de estado, y con ellos el total es de $2,842$ ejecuciones, un $14%$ por encima del límite. Con n8n Cloud, la clínica tendría que pasar a un plan superior o filtrar los avisos de estado antes de que lleguen a n8n; con un servidor propio el problema no existe.

La gráfica lo muestra con colores: el gris de la plataforma domina las dos barras de n8n Cloud, el naranja de Twilio es visible pero menor, y el azul de Meta es idéntico en las cuatro. La recomendación para la clínica sale sola: Cloud API directa con un servidor propio, $10.68$ USD al mes ($197.49$ MXN), el mismo diseño que eligió Trigo Lento el Día 46. El cambio de régimen del 1 de octubre no la mueve: con $288$ acuses al mes, la franquicia de $1,000$ mensajes de servicio sobra, y el total es idéntico antes y después. El servidor propio exige alguien que lo opere; la clínica lo contrata con el mismo proveedor que le aloja la agenda, y esa persona, no Valeria, tiene las credenciales.

Cuánto se espera ganar


El Día 7 supuso que los recordatorios bajarían la inasistencia del $15%$ al $9%$ y advirtió que el metaanálisis de Guy y colaboradores (2012) respalda la dirección de esa mejora, pero no la cifra: su efecto resumido de $1.48$ sobre la asistencia, aplicado a los momios de Sonrisa Norte, llevaría la inasistencia al $10.7%$, no al $9%$. Hoy el supuesto se sustituye por un modelo más explícito, que separa a los pacientes según su respuesta y da a cada grupo su propia probabilidad de no llegar. La inasistencia esperada con recordatorios es:

$$ q = p_r \sum_{g} \pi_g, q_g + (1 - p_r), q_{\text{nr}} $$

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

  • $p_r = 0.80$ es la probabilidad de responder a alguno de los dos avisos ($0.60 + 0.40 \cdot 0.50$); $\pi_g$ es el reparto de las respuestas: el $82%$ confirma, el $12%$ reprograma, el $2%$ se da de baja y el $4%$ escribe un texto libre.
  • $q_g$ es la probabilidad de inasistencia de cada grupo: $5%$ de quien confirma, $15%$ de quien se da de baja (vuelve a la tasa de siempre), $10%$ de quien escribe y $25%$ de quien no responde ($q_{\text{nr}}$). Quien reprograma no cuenta como inasistencia: avisó y su hueco queda libre con tiempo.
  • Con esos supuestos, $q = 0.80 \cdot (0.82 \cdot 0.05 + 0.02 \cdot 0.15 + 0.04 \cdot 0.10) + 0.20 \cdot 0.25 = 0.0884$, un $8.8%$.
  • En conclusión, el beneficio depende sobre todo de cuántos responden y de cuánto faltan los que no responden; son las dos cifras que la clínica debe medir en sus primeras semanas.

Las probabilidades de cada grupo son supuestos didácticos declarados, no mediciones. El siguiente código simula $1,000$ meses de $360$ citas sin recordatorios (inasistencia binomial del $15%$) y con recordatorios (respuesta, grupo e inasistencia como variables de Bernoulli), cuenta los huecos avisados que Irma logra reocupar (un $55%$, otro supuesto) y descuenta de las $12$ horas de llamadas el tiempo que Irma dedica a reprogramar ($4$ minutos) y a contestar textos libres ($3$ minutos). Al final, una tabla de sensibilidad recorre las tasas posibles:

python
# Simulación de 1 000 meses de citas con y sin recordatorios en Python:
MESES = 1000
P_RESPUESTA = (0.60, 0.50)                                  # primer aviso y segundo aviso
REPARTO = {'confirma': 0.82, 'reprograma': 0.12, 'baja': 0.02, 'texto_libre': 0.04}
P_FALTA = {'confirma': 0.05, 'baja': 0.15, 'texto_libre': 0.10, 'no_responde': 0.25}
P_REOCUPAR = 0.55                                           # hueco avisado que Irma vuelve a llenar
MIN_IRMA = {'reprograma': 4.0, 'texto_libre': 3.0}          # minutos de Irma por respuesta derivada

sin = rng.binomial(CITAS_MES, ficha['inasistencia'], size = MESES)
resultado = []
for _ in range(MESES):
    responde = (rng.random(CITAS_MES) < P_RESPUESTA[0]) | (rng.random(CITAS_MES) < P_RESPUESTA[1])
    grupo = np.where(responde, rng.choice(list(REPARTO), size = CITAS_MES, p = list(REPARTO.values())), 'no_responde')
    falta = np.array([rng.random() < P_FALTA.get(g, 0.0) for g in grupo])
    n_rep, n_lib = (grupo == 'reprograma').sum(), (grupo == 'texto_libre').sum()
    resultado.append({'faltas': falta.sum(), 'reprogramadas': n_rep, 'reocupadas': rng.binomial(n_rep, P_REOCUPAR),
                      'respuestas': responde.sum(),
                      'horas_irma': CITAS_MES * 2 / 60 - (n_rep * MIN_IRMA['reprograma'] + n_lib * MIN_IRMA['texto_libre']) / 60})
con = pd.DataFrame(resultado)
recuperadas = sin - con['faltas']
ingreso = recuperadas * ficha['ingreso_cita_mxn']
p = lambda s: f'{s.mean():.1f} [{np.percentile(s, 2.5):.1f}, {np.percentile(s, 97.5):.1f}]'
print('Media [intervalo del 95 %] de 1 000 meses simulados:')
print('  Inasistencias sin recordatorios:', p(pd.Series(sin)), f'| tasa {sin.mean() / CITAS_MES:.1%}')
print('  Inasistencias con recordatorios:', p(con['faltas']), f"| tasa {con['faltas'].mean() / CITAS_MES:.1%}")
print('  Respuestas al recordatorio:', p(con['respuestas']), '| reprogramadas:', p(con['reprogramadas']),
      '| huecos reocupados:', p(con['reocupadas']))
print('  Citas recuperadas:', p(recuperadas), '| ingreso recuperado (MXN):', p(ingreso))
print('  Horas de llamadas que Irma recupera, netas de reprogramar y leer textos:', p(con['horas_irma']))
print(f"  Meses con inasistencia por encima del 10.7 %: {(con['faltas'] / CITAS_MES > 0.107).mean():.1%}")
coste_mxn = tabla_4.query("arquitectura == 'Cloud API directa' and plataforma == 'servidor propio'")['total_mxn'].item()
sens = pd.DataFrame({'inasistencia_con_recordatorios': [0.12, 0.107, 0.09, round(con['faltas'].mean() / CITAS_MES, 3)]})
sens['citas_recuperadas'] = (CITAS_MES * (ficha['inasistencia'] - sens.iloc[:, 0])).round(1)
sens['ingreso_mxn'] = (sens['citas_recuperadas'] * ficha['ingreso_cita_mxn']).round(0)
sens['neto_mxn'] = (sens['ingreso_mxn'] + con['horas_irma'].mean() * ficha['hora_recepcion_mxn'] - coste_mxn).round(0)
print(sens.to_string(index = False))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (11, 4.6))
ax1.boxplot([sin, con['faltas']], widths = 0.5)
ax1.set_xticks([1, 2])
ax1.set_xticklabels(['Sin recordatorios', 'Con recordatorios'])
ax1.axhline(CITAS_MES * 0.09, color = '#eb6834', linestyle = '--', linewidth = 1, label = 'Supuesto del Día 7 (9 %)')
ax1.set_ylabel('Inasistencias al mes')
ax1.legend(fontsize = 9)
ax1.set_title('Inasistencias en 1 000 meses simulados', fontsize = 13)
ax2.hist(ingreso / 1000, bins = 30, color = '#1f9d55')
ax2.axvline(ingreso.mean() / 1000, color = '#52514e', linestyle = '--', label = f'Media: {ingreso.mean():,.0f} MXN')
ax2.set_xlabel('Ingreso recuperado (miles de MXN al mes)')
ax2.set_ylabel('Meses')
ax2.legend(fontsize = 9)
ax2.set_title('Ingreso recuperado por mes', fontsize = 13)
plt.tight_layout()
plt.show()
Media [intervalo del 95 %] de 1 000 meses simulados:
  Inasistencias sin recordatorios: 54.1 [41.0, 67.0] | tasa 15.0%
  Inasistencias con recordatorios: 32.1 [22.0, 43.0] | tasa 8.9%
  Respuestas al recordatorio: 287.7 [271.0, 302.0] | reprogramadas: 34.4 [24.0, 45.0] | huecos reocupados: 19.0 [11.0, 28.0]
  Citas recuperadas: 21.9 [5.0, 38.0] | ingreso recuperado (MXN): 18643.9 [4250.0, 32300.0]
  Horas de llamadas que Irma recupera, netas de reprogramar y leer textos: 9.1 [8.4, 9.9]
  Meses con inasistencia por encima del 10.7 %: 12.2%
 inasistencia_con_recordatorios  citas_recuperadas  ingreso_mxn  neto_mxn
                          0.120               10.8       9180.0    9622.0
                          0.107               15.5      13175.0   13617.0
                          0.090               21.6      18360.0   18802.0
                          0.089               22.0      18700.0   19142.0

Gráfica: Cuánto se espera ganar. Simulación de 1 000 meses de citas con y sin recordatorios

Sin recordatorios, la clínica pierde de media $54.1$ citas al mes, con un intervalo del $95%$ de $41$ a $67$: el azar mueve la cifra más de diez citas en cualquier dirección. Con recordatorios, la media baja a $32.1$ citas, un $8.9%$, con un intervalo de $22$ a $43$. La diferencia media es de $21.9$ citas recuperadas, unos $18,644$ MXN al mes, pero el intervalo va de $4,250$ a $32,300$ MXN. Un mes malo con recordatorios puede parecerse a un mes bueno sin ellos; en el $12.2%$ de los meses simulados la inasistencia con recordatorios supera el $10.7%$ que sugiere la literatura. Un solo mes no demuestra nada; tres meses empiezan a hablar.

Las otras cifras completan el cuadro. Responden de media $287.7$ pacientes, prácticamente las $288$ del cálculo de costes; $34.4$ reprograman, e Irma reocupa $19$ de esos huecos con pacientes de la lista de espera, un ingreso adicional que no se ha sumado al beneficio por prudencia. Las horas que Irma recupera no son $12$, sino $9.1$: el flujo le quita las llamadas, pero le devuelve unas tres horas al mes de reprogramaciones y mensajes que solo ella puede atender. Es una buena noticia disfrazada, porque esas tres horas son trabajo con criterio, no marcar números.

La Tabla 5 recoge la sensibilidad al supuesto, que es la forma honesta de presentar el beneficio a la Dra. Fernández.

Tabla 5. Beneficio mensual según la inasistencia que se alcance con recordatorios (neto de las horas de Irma recuperadas y del coste de 197.49 MXN de la Cloud API con servidor propio).

Inasistencia con recordatoriosOrigen del supuestoCitas recuperadasIngreso recuperado (MXN)Beneficio neto (MXN)
12.0 %Escenario prudente10.89 1809 622
10.7 %Efecto medio de Guy et al. (2012)15.513 17513 617
9.0 %Supuesto del Día 721.618 36018 802
8.9 %Simulación de hoy22.018 70019 142

Incluso en el escenario prudente, el beneficio neto es de $9,622$ MXN al mes, unas $49$ veces el coste de la solución. La decisión no depende del supuesto; lo que depende del supuesto es lo que se le promete a la clínica, y por eso la recomendación de Valeria es prometer la fila del $12%$ y medir. La Figura 3 pone la idea en la agenda de un día: antes, huecos de inasistencia sin aviso; después, menos huecos y algunos reocupados gracias a las reprogramaciones avisadas a tiempo.

El sillón recuperado: un día de 18 citas antes y después de los recordatorios; los huecos sin aviso bajan y parte de los huecos avisados a tiempo se reocupan con pacientes de la lista de espera.

Figura 3. El sillón recuperado: un día de 18 citas antes y después de los recordatorios; los huecos sin aviso bajan y parte de los huecos avisados a tiempo se reocupan con pacientes de la lista de espera.

Atención aquí: la simulación no mide la clínica: describe lo que pasaría si los supuestos fueran ciertos. Las tasas de respuesta, el reparto de las respuestas y la inasistencia de cada grupo deben medirse durante las primeras ocho semanas con el registro de envíos y la agenda, y la tabla se recalcula con los valores reales. Además, n8n solo permite require('crypto') en el nodo Code si el servidor se arranca con NODE_FUNCTION_ALLOW_BUILTIN=crypto, y solo lee $env si no se bloquea el acceso a las variables de entorno (N8N_BLOCK_ENV_ACCESS_IN_NODE=false); sin esas dos opciones, la verificación de la firma falla y el workflow descarta todas las respuestas. Pruébelo primero con el número de prueba de Meta.

SN Recordatorios de citas v1 en n8n


Todo lo anterior se monta en dos workflows que la clínica importa en su servidor. El de envío, SN Recordatorios de citas v1, tiene un disparador programado de lunes a viernes a las 10:00 y a las 17:00 en la zona America/Mexico_City, una consulta a la agenda que devuelve las citas del siguiente día hábil que todavía no tienen respuesta (y, en la corrida de las 17:00, solo las que ya recibieron el primer aviso), una decisión que exige consentimiento vigente y horario permitido, el envío de la plantilla por la Cloud API y dos registros: el del envío, con el identificador que devuelve Meta, y el del descarte, con su motivo. Una nota documenta que los cierres los reprograma Irma.

El de respuestas, SN Respuestas de citas v1, recibe los eventos de Meta en un webhook configurado con el cuerpo en bruto; un nodo de código verifica la firma X-Hub-Signature-256 y normaliza el evento; un switch lo envía por una de cinco salidas (confirmar, reprogramar, baja, texto libre o aviso de estado), y cada salida actualiza la base de datos de la agenda y, si toca, envía el acuse dentro de la ventana o el aviso a Irma. El aviso a Irma es una plantilla de utilidad, aviso_recepcion, que se envía al teléfono de trabajo de la recepción con su consentimiento como trabajadora y que no lleva el contenido del mensaje del paciente: solo dice que hay algo pendiente en la bandeja. Irma lee el texto en la bandeja de la clínica, no en su teléfono.

La consulta de verificación con la que Meta comprueba el webhook al registrarlo (una petición GET con un token de verificación y un desafío que hay que devolver) se resuelve en la consola de Meta con un workflow auxiliar de dos nodos y no se muestra aquí. Los dos workflows se exportan juntos con la CLI de n8n, que produce una lista de objetos, con identificadores de ejemplo y las credenciales referidas solo por nombre:

json
[
  {
    "name": "SN Recordatorios de citas v1",
    "nodes": [
      {
        "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 10,17 * * 1-5"}]}},
        "id": "5a510001-0000-4000-8000-000000000001",
        "name": "Lunes a viernes 10:00 y 17:00",
        "type": "n8n-nodes-base.scheduleTrigger",
        "typeVersion": 1.2,
        "position": [0, 0]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "SELECT c.id_cita, p.nombre_pila, p.telefono, sn_fecha_texto(c.inicio) AS fecha_texto, to_char(c.inicio AT TIME ZONE 'America/Mexico_City', 'HH24:MI') AS hora_texto, (cs.id_consentimiento IS NOT NULL) AS consentimiento_vigente, sn_dentro_de_horario(now()) AS dentro_de_horario FROM citas c JOIN pacientes p USING (id_paciente) LEFT JOIN consentimientos cs ON cs.id_paciente = p.id_paciente AND cs.finalidad = 'recordatorios_cita' AND cs.canal = 'whatsapp' AND cs.baja IS NULL WHERE c.estado = 'agendada' AND (c.inicio AT TIME ZONE 'America/Mexico_City')::date = sn_siguiente_dia_habil(current_date) AND NOT EXISTS (SELECT 1 FROM respuestas r WHERE r.id_cita = c.id_cita) AND (SELECT count(*) FROM envios e WHERE e.id_cita = c.id_cita) = CASE WHEN extract(hour FROM now() AT TIME ZONE 'America/Mexico_City') < 12 THEN 0 ELSE 1 END;",
          "options": {}
        },
        "id": "5a510001-0000-4000-8000-000000000002",
        "name": "Citas del siguiente día hábil",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [220, 0],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "conditions": {
            "options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
            "conditions": [
              {
                "id": "c0000000-0000-4000-8000-000000000001",
                "leftValue": "={{ $json.consentimiento_vigente }}",
                "operator": {"type": "boolean", "operation": "true", "singleValue": true},
                "rightValue": ""
              },
              {
                "id": "c0000000-0000-4000-8000-000000000002",
                "leftValue": "={{ $json.dentro_de_horario }}",
                "operator": {"type": "boolean", "operation": "true", "singleValue": true},
                "rightValue": ""
              }
            ],
            "combinator": "and"
          },
          "options": {}
        },
        "id": "5a510001-0000-4000-8000-000000000003",
        "name": "¿Consentimiento y horario?",
        "type": "n8n-nodes-base.if",
        "typeVersion": 2.2,
        "position": [440, 0]
      },
      {
        "parameters": {
          "method": "POST",
          "url": "=https://graph.facebook.com/v26.0/{{ $vars.sn_phone_number_id }}/messages",
          "authentication": "genericCredentialType",
          "genericAuthType": "httpHeaderAuth",
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={{ JSON.stringify({messaging_product: 'whatsapp', to: $json.telefono, type: 'template', template: {name: 'recordatorio_cita', language: {code: 'es_MX'}, components: [{type: 'body', parameters: [{type: 'text', text: $json.nombre_pila}, {type: 'text', text: $json.fecha_texto}, {type: 'text', text: $json.hora_texto}]}, {type: 'button', sub_type: 'quick_reply', index: '0', parameters: [{type: 'payload', payload: 'CONFIRMAR:' + $json.id_cita}]}, {type: 'button', sub_type: 'quick_reply', index: '1', parameters: [{type: 'payload', payload: 'REPROGRAMAR:' + $json.id_cita}]}]}}) }}",
          "options": {"timeout": 30000}
        },
        "id": "5a510001-0000-4000-8000-000000000004",
        "name": "Enviar recordatorio_cita",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [660, -100],
        "retryOnFail": true,
        "maxTries": 3,
        "waitBetweenTries": 5000,
        "credentials": {"httpHeaderAuth": {"id": "0000000000000051", "name": "Cloud API Sonrisa Norte (token de sistema)"}}
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO envios (id_cita, wamid, intento, enviado_en) VALUES ('{{ $('Citas del siguiente día hábil').item.json.id_cita }}', '{{ $json.messages[0].id }}', (SELECT count(*) + 1 FROM envios WHERE id_cita = '{{ $('Citas del siguiente día hábil').item.json.id_cita }}'), now());",
          "options": {}
        },
        "id": "5a510001-0000-4000-8000-000000000005",
        "name": "Registrar envío",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [880, -100],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO descartes (id_cita, motivo, registrado_en) VALUES ('{{ $json.id_cita }}', CASE WHEN {{ $json.consentimiento_vigente }} THEN 'fuera de horario' ELSE 'sin consentimiento vigente' END, now());",
          "options": {}
        },
        "id": "5a510001-0000-4000-8000-000000000006",
        "name": "Registrar descarte",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [660, 100],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "content": "## Cierres y reprogramaciones\nLas citas del 2 y 3 de abril (Semana Santa) las reprograma Irma por teléfono antes del cierre. Este workflow no reprograma nada.",
          "height": 160,
          "width": 300
        },
        "id": "5a510001-0000-4000-8000-000000000007",
        "name": "Irma reprograma los cierres",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [880, 100]
      }
    ],
    "connections": {
      "Lunes a viernes 10:00 y 17:00": {"main": [[{"node": "Citas del siguiente día hábil", "type": "main", "index": 0}]]},
      "Citas del siguiente día hábil": {"main": [[{"node": "¿Consentimiento y horario?", "type": "main", "index": 0}]]},
      "¿Consentimiento y horario?": {
        "main": [
          [{"node": "Enviar recordatorio_cita", "type": "main", "index": 0}],
          [{"node": "Registrar descarte", "type": "main", "index": 0}]
        ]
      },
      "Enviar recordatorio_cita": {"main": [[{"node": "Registrar envío", "type": "main", "index": 0}]]}
    },
    "settings": {
      "executionOrder": "v1",
      "timezone": "America/Mexico_City",
      "saveDataSuccessExecution": "none",
      "saveManualExecutions": false
    },
    "pinData": {},
    "meta": {"templateCredsSetupCompleted": true}
  },
  {
    "name": "SN Respuestas de citas v1",
    "nodes": [
      {
        "parameters": {
          "httpMethod": "POST",
          "path": "sn-whatsapp",
          "responseMode": "onReceived",
          "options": {"rawBody": true}
        },
        "id": "5a510002-0000-4000-8000-000000000001",
        "name": "Webhook de Meta",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [0, 0],
        "webhookId": "5a510002-0000-4000-8000-5a51abcdef01"
      },
      {
        "parameters": {
          "jsCode": "const crypto = require('crypto');\nconst firma = $input.first().json.headers['x-hub-signature-256'] ?? '';\nconst cuerpo = Buffer.from($input.first().binary.data.data, 'base64');\nconst esperada = 'sha256=' + crypto.createHmac('sha256', $env.SN_APP_SECRET).update(cuerpo).digest('hex');\nconst valida = firma.length === esperada.length && crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperada));\nif (!valida) { throw new Error('Firma X-Hub-Signature-256 inválida: se descarta el evento'); }\nconst v = JSON.parse(cuerpo.toString('utf8')).entry[0].changes[0].value;\nif (v.statuses) { return v.statuses.map(s => ({json: {tipo: 'estado', wamid: s.id, estado: s.status}})); }\nreturn (v.messages ?? []).map(m => ({json: {telefono: m.from, wamid: m.id,\n  tipo: m.type === 'button' ? m.button.payload.split(':')[0].toLowerCase() : (/^\\s*(baja|ya no)\\b/i.test(m.text?.body ?? '') ? 'baja' : 'texto_libre'),\n  id_cita: m.type === 'button' ? m.button.payload.split(':')[1] : null, texto: m.text?.body ?? null}}));"
        },
        "id": "5a510002-0000-4000-8000-000000000002",
        "name": "Verificar firma y normalizar",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [220, 0]
      },
      {
        "parameters": {
          "rules": {
            "values": [
              {
                "conditions": {
                  "options": {"caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2},
                  "conditions": [
                    {
                      "id": "c0000000-0000-4000-8000-000000000001",
                      "leftValue": "={{ $json.tipo }}",
                      "operator": {"type": "string", "operation": "equals"},
                      "rightValue": "confirmar"
                    }
                  ],
                  "combinator": "and"
                },
                "renameOutput": true,
                "outputKey": "confirmar"
              },
              {
                "conditions": {
                  "options": {"caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2},
                  "conditions": [
                    {
                      "id": "c0000000-0000-4000-8000-000000000002",
                      "leftValue": "={{ $json.tipo }}",
                      "operator": {"type": "string", "operation": "equals"},
                      "rightValue": "reprogramar"
                    }
                  ],
                  "combinator": "and"
                },
                "renameOutput": true,
                "outputKey": "reprogramar"
              },
              {
                "conditions": {
                  "options": {"caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2},
                  "conditions": [
                    {
                      "id": "c0000000-0000-4000-8000-000000000003",
                      "leftValue": "={{ $json.tipo }}",
                      "operator": {"type": "string", "operation": "equals"},
                      "rightValue": "baja"
                    }
                  ],
                  "combinator": "and"
                },
                "renameOutput": true,
                "outputKey": "baja"
              },
              {
                "conditions": {
                  "options": {"caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2},
                  "conditions": [
                    {
                      "id": "c0000000-0000-4000-8000-000000000004",
                      "leftValue": "={{ $json.tipo }}",
                      "operator": {"type": "string", "operation": "equals"},
                      "rightValue": "texto_libre"
                    }
                  ],
                  "combinator": "and"
                },
                "renameOutput": true,
                "outputKey": "texto_libre"
              },
              {
                "conditions": {
                  "options": {"caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2},
                  "conditions": [
                    {
                      "id": "c0000000-0000-4000-8000-000000000005",
                      "leftValue": "={{ $json.tipo }}",
                      "operator": {"type": "string", "operation": "equals"},
                      "rightValue": "estado"
                    }
                  ],
                  "combinator": "and"
                },
                "renameOutput": true,
                "outputKey": "estado"
              }
            ]
          },
          "options": {}
        },
        "id": "5a510002-0000-4000-8000-000000000003",
        "name": "¿Qué respondió?",
        "type": "n8n-nodes-base.switch",
        "typeVersion": 3.2,
        "position": [440, 0]
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "UPDATE citas SET estado = 'confirmada' WHERE id_cita = '{{ $json.id_cita }}' RETURNING id_cita, (SELECT telefono FROM pacientes p WHERE p.id_paciente = citas.id_paciente) AS telefono;",
          "options": {}
        },
        "id": "5a510002-0000-4000-8000-000000000004",
        "name": "Marcar confirmada",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [660, -300],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "method": "POST",
          "url": "=https://graph.facebook.com/v26.0/{{ $vars.sn_phone_number_id }}/messages",
          "authentication": "genericCredentialType",
          "genericAuthType": "httpHeaderAuth",
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={{ JSON.stringify({messaging_product: 'whatsapp', to: $json.telefono, type: 'text', text: {body: 'Gracias, su cita quedó confirmada. Lo esperamos.'}}) }}",
          "options": {"timeout": 30000}
        },
        "id": "5a510002-0000-4000-8000-000000000005",
        "name": "Acuse de confirmación",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [880, -300],
        "retryOnFail": true,
        "maxTries": 3,
        "waitBetweenTries": 5000,
        "credentials": {"httpHeaderAuth": {"id": "0000000000000051", "name": "Cloud API Sonrisa Norte (token de sistema)"}}
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO bandeja_recepcion (id_cita, telefono, motivo, recibido_en) VALUES ('{{ $json.id_cita }}', '{{ $json.telefono }}', 'reprogramar', now()) RETURNING telefono;",
          "options": {}
        },
        "id": "5a510002-0000-4000-8000-000000000006",
        "name": "Pendiente para Irma",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [660, -150],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "method": "POST",
          "url": "=https://graph.facebook.com/v26.0/{{ $vars.sn_phone_number_id }}/messages",
          "authentication": "genericCredentialType",
          "genericAuthType": "httpHeaderAuth",
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={{ JSON.stringify({messaging_product: 'whatsapp', to: $json.telefono, type: 'text', text: {body: 'Gracias. Irma le escribirá hoy con horarios disponibles.'}}) }}",
          "options": {"timeout": 30000}
        },
        "id": "5a510002-0000-4000-8000-000000000007",
        "name": "Acuse de reprogramación",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [880, -150],
        "retryOnFail": true,
        "maxTries": 3,
        "waitBetweenTries": 5000,
        "credentials": {"httpHeaderAuth": {"id": "0000000000000051", "name": "Cloud API Sonrisa Norte (token de sistema)"}}
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "UPDATE consentimientos SET baja = now() WHERE finalidad = 'recordatorios_cita' AND id_paciente = (SELECT id_paciente FROM pacientes WHERE telefono = '{{ $json.telefono }}') RETURNING '{{ $json.telefono }}' AS telefono;",
          "options": {}
        },
        "id": "5a510002-0000-4000-8000-000000000008",
        "name": "Retirar consentimiento",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [660, 0],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "method": "POST",
          "url": "=https://graph.facebook.com/v26.0/{{ $vars.sn_phone_number_id }}/messages",
          "authentication": "genericCredentialType",
          "genericAuthType": "httpHeaderAuth",
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={{ JSON.stringify({messaging_product: 'whatsapp', to: $json.telefono, type: 'text', text: {body: 'Listo. Ya no recibirá recordatorios por WhatsApp.'}}) }}",
          "options": {"timeout": 30000}
        },
        "id": "5a510002-0000-4000-8000-000000000009",
        "name": "Acuse de baja",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [880, 0],
        "retryOnFail": true,
        "maxTries": 3,
        "waitBetweenTries": 5000,
        "credentials": {"httpHeaderAuth": {"id": "0000000000000051", "name": "Cloud API Sonrisa Norte (token de sistema)"}}
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "INSERT INTO bandeja_recepcion (telefono, motivo, texto, recibido_en) VALUES ('{{ $json.telefono }}', 'texto_libre', $1, now()) RETURNING telefono;",
          "options": {"queryReplacement": "={{ $json.texto }}"}
        },
        "id": "5a510002-0000-4000-8000-000000000010",
        "name": "Texto libre a la bandeja",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [660, 150],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "method": "POST",
          "url": "=https://graph.facebook.com/v26.0/{{ $vars.sn_phone_number_id }}/messages",
          "authentication": "genericCredentialType",
          "genericAuthType": "httpHeaderAuth",
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={{ JSON.stringify({messaging_product: 'whatsapp', to: $vars.sn_telefono_recepcion, type: 'template', template: {name: 'aviso_recepcion', language: {code: 'es_MX'}, components: [{type: 'body', parameters: [{type: 'text', text: 'pendiente en la bandeja'}]}]}}) }}",
          "options": {"timeout": 30000}
        },
        "id": "5a510002-0000-4000-8000-000000000011",
        "name": "Aviso a Irma (sin contenido)",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [880, 150],
        "retryOnFail": true,
        "maxTries": 3,
        "waitBetweenTries": 5000,
        "credentials": {"httpHeaderAuth": {"id": "0000000000000051", "name": "Cloud API Sonrisa Norte (token de sistema)"}}
      },
      {
        "parameters": {
          "operation": "executeQuery",
          "query": "UPDATE envios SET estado = '{{ $json.estado }}' WHERE wamid = '{{ $json.wamid }}';",
          "options": {}
        },
        "id": "5a510002-0000-4000-8000-000000000012",
        "name": "Actualizar estado",
        "type": "n8n-nodes-base.postgres",
        "typeVersion": 2.5,
        "position": [660, 300],
        "credentials": {"postgres": {"id": "0000000000000052", "name": "Agenda Sonrisa Norte (rol n8n_recordatorios)"}}
      },
      {
        "parameters": {
          "content": "## Irma al mando\nIrma lee la bandeja, escribe a cada paciente con huecos reales y decide cualquier cambio de cita. Ningún mensaje de paciente pasa por un modelo de IA.",
          "height": 180,
          "width": 300
        },
        "id": "5a510002-0000-4000-8000-000000000013",
        "name": "Irma responde y reprograma",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [1100, -80]
      }
    ],
    "connections": {
      "Webhook de Meta": {"main": [[{"node": "Verificar firma y normalizar", "type": "main", "index": 0}]]},
      "Verificar firma y normalizar": {"main": [[{"node": "¿Qué respondió?", "type": "main", "index": 0}]]},
      "¿Qué respondió?": {
        "main": [
          [{"node": "Marcar confirmada", "type": "main", "index": 0}],
          [{"node": "Pendiente para Irma", "type": "main", "index": 0}],
          [{"node": "Retirar consentimiento", "type": "main", "index": 0}],
          [{"node": "Texto libre a la bandeja", "type": "main", "index": 0}],
          [{"node": "Actualizar estado", "type": "main", "index": 0}]
        ]
      },
      "Marcar confirmada": {"main": [[{"node": "Acuse de confirmación", "type": "main", "index": 0}]]},
      "Pendiente para Irma": {
        "main": [
          [
            {"node": "Acuse de reprogramación", "type": "main", "index": 0},
            {"node": "Aviso a Irma (sin contenido)", "type": "main", "index": 0}
          ]
        ]
      },
      "Retirar consentimiento": {"main": [[{"node": "Acuse de baja", "type": "main", "index": 0}]]},
      "Texto libre a la bandeja": {"main": [[{"node": "Aviso a Irma (sin contenido)", "type": "main", "index": 0}]]}
    },
    "settings": {
      "executionOrder": "v1",
      "timezone": "America/Mexico_City",
      "saveDataSuccessExecution": "none",
      "saveManualExecutions": false
    },
    "pinData": {},
    "meta": {"templateCredsSetupCompleted": true}
  }
]

Cuatro detalles del JSON merecen una mirada. Ningún nodo lleva un token: las llamadas a Meta usan la credencial Cloud API Sonrisa Norte (token de sistema) y las consultas, Agenda Sonrisa Norte (rol n8n_recordatorios), un rol de base de datos que solo puede leer citas y escribir envíos, respuestas y la bandeja, el mínimo privilegio del Día 17. El texto libre del paciente entra en la consulta como parámetro ($1 con queryReplacement) y no pegado en el SQL, lo que evita que un mensaje malicioso se convierta en una orden a la base de datos. El secreto de la aplicación se lee de $env.SN_APP_SECRET, nunca del JSON. Y la configuración no guarda los datos de las ejecuciones correctas (saveDataSuccessExecution: none), para que el historial de n8n no se convierta en una copia de los mensajes de los pacientes.

El último bloque offline lee la exportación, revisa cada workflow con las reglas de hoy (ningún nodo de IA, ningún token pegado, Meta siempre con la credencial guardada, el aviso a Irma sin el texto del paciente, ningún campo de salud, la firma verificada justo después del webhook y el consentimiento comprobado antes de enviar la plantilla), prueba la revisión con una copia alterada y dibuja los dos flujos con las formas de la serie: disparadores en óvalo verde, acciones en azul, decisiones en rombo naranja y el paso humano en hexágono gris:

python
# Lectura, revisión y dibujo de los workflows SN Recordatorios de citas v1 y SN Respuestas de citas v1 en Python:
import textwrap
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch, Polygon, RegularPolygon

flujos = json.loads((clinica / 'flujos' / 'n8n' / 'recordatorios_citas_v1.json').read_text(encoding = 'utf-8'))

def nodos_desde_n8n(wf):
    nodos = [{'id': n['name'], 'tipo': n['type'].split('.')[-1],
              'x': n['position'][0] / 100, 'y': -n['position'][1] / 100} for n in wf['nodes']]
    aristas = [(o, d['node']) for o, s in wf['connections'].items()
               for lista in s.values() for destinos in lista for d in destinos]
    return nodos, aristas

def revisar_clinica(wf):
    hallazgos, nombres = [], {n['name'] for n in wf['nodes']}
    for n in wf['nodes']:
        texto = json.dumps(n['parameters'], ensure_ascii = False)
        if 'anthropic' in texto or 'langchain' in n['type']:
            hallazgos.append(f"{n['name']}: un mensaje de paciente llegaría a un modelo de IA")
        if re.search(r'Bearer\s+[A-Za-z0-9]|EAA[A-Za-z0-9]{20}', texto):
            hallazgos.append(f"{n['name']}: token pegado en los parámetros")
        if 'graph.facebook.com' in texto and 'httpHeaderAuth' not in n.get('credentials', {}):
            hallazgos.append(f"{n['name']}: llama a Meta sin la credencial guardada")
        if 'Aviso a Irma' in n['name'] and '$json.texto' in texto:
            hallazgos.append(f"{n['name']}: el aviso copia el texto del paciente")
        if re.search(r'tratamiento|motivo_consulta|diagnostico', texto):
            hallazgos.append(f"{n['name']}: usa un campo de salud")
    if any(n['type'].endswith('webhook') for n in wf['nodes']):
        primero = wf['connections'][next(n['name'] for n in wf['nodes'] if n['type'].endswith('webhook'))]
        destino = primero['main'][0][0]['node']
        if 'X-Hub-Signature-256' not in json.dumps(next(n for n in wf['nodes'] if n['name'] == destino)['parameters']):
            hallazgos.append('el primer paso tras el webhook no verifica la firma')
    if any('graph.facebook.com' in json.dumps(n['parameters']) and 'template' in json.dumps(n['parameters'])
           and 'recordatorio_cita' in json.dumps(n['parameters']) for n in wf['nodes']):
        if not any(n['type'].endswith('.if') and 'consentimiento' in json.dumps(n['parameters']) for n in wf['nodes']):
            hallazgos.append('envía la plantilla sin comprobar el consentimiento')
    for o, d in nodos_desde_n8n(wf)[1]:
        if o not in nombres or d not in nombres:
            hallazgos.append(f'conexión rota: {o} -> {d}')
    return hallazgos

for wf in flujos:
    nodos, aristas = nodos_desde_n8n(wf)
    tipos = {k: int(x) for k, x in pd.Series([n['tipo'] for n in nodos]).value_counts().items()}
    print(f"{wf['name']}: {len(nodos)} nodos | {len(aristas)} conexiones | tipos: {tipos}")
    print('  Hallazgos:', revisar_clinica(wf) or 'ninguno')
alterado = json.loads(json.dumps(flujos[1]))
alterado['nodes'][1]['parameters']['jsCode'] = 'return $input.all();'
alterado['nodes'].append({'parameters': {'model': 'claude-haiku-5-5'}, 'name': 'Resumir texto libre',
                          'type': '@n8n/n8n-nodes-langchain.lmChatAnthropic', 'position': [880, 300]})
print('Hallazgos en una copia sin verificación de firma y con un modelo:', revisar_clinica(alterado))

COLORES = {'disparador': '#1f9d55', 'accion': '#2a78d6', 'decision': '#eb6834', 'humano': '#8a8986'}
def forma_de(n):
    if n['tipo'] in ('scheduleTrigger', 'webhook'):
        return 'disparador'
    if n['tipo'] in ('if', 'switch'):
        return 'decision'
    return 'humano' if n['tipo'] == 'stickyNote' else 'accion'

fig, ejes = plt.subplots(2, 1, figsize = (13, 9.4), gridspec_kw = {'height_ratios': [1, 2.1]})
for ax, wf in zip(ejes, flujos):
    nodos, aristas = nodos_desde_n8n(wf)
    pos = {n['id']: (n['x'] * 1.05, n['y'] * 1.1) for n in nodos}
    for n in nodos:
        forma, (x, y) = forma_de(n), pos[n['id']]
        color = COLORES[forma]
        if forma == 'decision':
            ax.add_patch(Polygon([(x - 0.95, y), (x, y + 0.72), (x + 0.95, y), (x, y - 0.72)], facecolor = color, edgecolor = 'white'))
        elif forma == 'humano':
            ax.add_patch(RegularPolygon((x, y), 6, radius = 0.95, orientation = math.pi / 6, facecolor = color, edgecolor = 'white'))
        else:
            estilo = 'round,pad=0.05,rounding_size=0.35' if forma == 'disparador' else 'round,pad=0.05'
            ax.add_patch(FancyBboxPatch((x - 0.95, y - 0.4), 1.9, 0.8, boxstyle = estilo, facecolor = color,
                                        edgecolor = 'white', linewidth = 2))
        nombre = textwrap.fill(n['id'], 15, break_long_words = False, break_on_hyphens = False)
        ax.text(x, y + 0.08, nombre, ha = 'center', va = 'center', color = 'white', fontsize = 7)
        ax.text(x, y - 0.3, n['tipo'], ha = 'center', va = 'center', color = 'white', fontsize = 6.5)
    for o, d in aristas:
        (x1, y1), (x2, y2) = pos[o], pos[d]
        # Las flechas salen del borde derecho de una forma y llegan al borde izquierdo de la siguiente:
        ax.add_patch(FancyArrowPatch((x1 + 0.95, y1), (x2 - 0.95, y2), arrowstyle = '-|>', mutation_scale = 11,
                                     shrinkA = 1, shrinkB = 1, color = '#52514e', linewidth = 1.1))
    if 'Irma responde y reprograma' in pos:
        for origen in ('Aviso a Irma (sin contenido)', 'Acuse de reprogramación'):
            (x1, y1), (x2, y2) = pos[origen], pos['Irma responde y reprograma']
            ax.add_patch(FancyArrowPatch((x1 + 0.95, y1), (x2 - 0.9, y2), arrowstyle = '-|>', mutation_scale = 11,
                                         shrinkA = 1, shrinkB = 1, color = '#8a8986', linewidth = 1.4, linestyle = '--'))
    ax.set_xlim(-1.1, max(p[0] for p in pos.values()) + 1.1)
    ax.set_ylim(min(p[1] for p in pos.values()) - 1.0, max(p[1] for p in pos.values()) + 1.0)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_title(wf['name'], fontsize = 13)
plt.suptitle('Sonrisa Norte: el flujo envía y clasifica; las decisiones son de Irma', fontsize = 14)
plt.tight_layout()
plt.show()
SN Recordatorios de citas v1: 7 nodos | 5 conexiones | tipos: {'postgres': 3, 'scheduleTrigger': 1, 'if': 1, 'httpRequest': 1, 'stickyNote': 1}
  Hallazgos: ninguno
SN Respuestas de citas v1: 13 nodos | 12 conexiones | tipos: {'postgres': 5, 'httpRequest': 4, 'webhook': 1, 'code': 1, 'switch': 1, 'stickyNote': 1}
  Hallazgos: ninguno
Hallazgos en una copia sin verificación de firma y con un modelo: ['Resumir texto libre: un mensaje de paciente llegaría a un modelo de IA', 'el primer paso tras el webhook no verifica la firma']

Gráfica: SN Recordatorios de citas v1 en n8n. Lectura, revisión y dibujo de los workflows SN Recordatorios de citas v1 y SN Respuestas de citas v1

El workflow de envío tiene $7$ nodos y $5$ conexiones, y el de respuestas $13$ nodos y $12$ conexiones; la revisión no encuentra ningún hallazgo en ninguno. La copia alterada demuestra para qué sirve: al quitar la verificación de la firma y añadir un nodo de Claude para “resumir el texto libre”, la revisión devuelve dos hallazgos en el acto, “un mensaje de paciente llegaría a un modelo de IA” y “el primer paso tras el webhook no verifica la firma”. Es la barrera que convierte las reglas de la clínica en algo que se comprueba antes de importar cada versión, como hizo el Día 42 con el control de versiones de los flujos.

El dibujo enseña la forma de cada flujo. Arriba, una línea corta con una sola bifurcación: o hay consentimiento y horario, o hay un descarte con motivo. Abajo, un abanico de cinco salidas que nace del rombo del switch, con el hexágono gris de Irma a la derecha unido con flechas discontinuas, porque esa conexión no existe en el JSON: es una persona que lee la bandeja. El flujo envía y clasifica; las decisiones son de Irma.

Para cerrar, el bloque que envía la plantilla de verdad. Usa requests contra el endpoint de mensajes de la Graph API, en la versión v26.0 que el registro de cambios de Meta da como vigente en octubre de 2026, con el token leído del entorno. Debe probarse primero con el número de prueba que Meta da a cada cuenta de desarrollo y con datos inventados; la plantilla tiene que estar aprobada antes, o Meta responde con un error:

python
# Envío de la plantilla recordatorio_cita al número de prueba de Meta con la Cloud API en Python (bloque API, no se ejecuta aquí):
import os
import requests

VERSION = 'v26.0'                                   # versión de la Graph API vigente en octubre de 2026
ID_NUMERO = os.environ['SN_PHONE_NUMBER_ID']        # identificador del número de prueba de Meta
# Token de sistema con el permiso whatsapp_business_messaging; vive en el entorno, nunca en el código:
cabeceras = {'Authorization': f"Bearer {os.environ['SN_WHATSAPP_TOKEN']}", 'Content-Type': 'application/json'}

def enviar_recordatorio(telefono, nombre_pila, fecha_texto, hora_texto, id_cita):
    # Los botones llevan la cita en su carga útil: así la respuesta llega con su identificador:
    cuerpo = {'messaging_product': 'whatsapp', 'to': telefono, 'type': 'template',
              'template': {'name': 'recordatorio_cita', 'language': {'code': 'es_MX'}, 'components': [
                  {'type': 'body', 'parameters': [{'type': 'text', 'text': nombre_pila},
                                                  {'type': 'text', 'text': fecha_texto},
                                                  {'type': 'text', 'text': hora_texto}]},
                  {'type': 'button', 'sub_type': 'quick_reply', 'index': '0',
                   'parameters': [{'type': 'payload', 'payload': f'CONFIRMAR:{id_cita}'}]},
                  {'type': 'button', 'sub_type': 'quick_reply', 'index': '1',
                   'parameters': [{'type': 'payload', 'payload': f'REPROGRAMAR:{id_cita}'}]}]}}
    r = requests.post(f'https://graph.facebook.com/{VERSION}/{ID_NUMERO}/messages',
                      headers = cabeceras, json = cuerpo, timeout = 30)
    if r.status_code != 200:
        # Error de Meta: se registra el código, nunca el teléfono ni el nombre del paciente:
        return {'ok': False, 'codigo': r.status_code, 'error': r.json().get('error', {}).get('code')}
    return {'ok': True, 'wamid': r.json()['messages'][0]['id']}

# Solo al número de prueba de la cuenta de desarrollo, con datos inventados:
print(enviar_recordatorio('+52 55 0000 0001', 'Ana', 'martes 24 de marzo', '10:30', 'C0001'))

# Salida de ejemplo (puede variar):
# {'ok': True, 'wamid': 'wamid.EJEMPLO-NO-REAL-HBgNNTIxNTUwMDAwMDAwMRUCABEYEjk0'}
# Si la plantilla todavía no está aprobada, Meta responde 400 y la función devuelve:
# {'ok': False, 'codigo': 400, 'error': 132001}

La función devuelve el identificador del mensaje (wamid), que el workflow guarda en el registro de envíos para casar después los avisos de estado. Fíjese en el manejo del error: si Meta rechaza el envío, la función devuelve el código de estado y el código de error, pero nunca el teléfono ni el nombre del paciente, por la misma regla de registros sin datos personales que rige todo el día. El código $132001$ de la salida de ejemplo es el que Meta devuelve cuando la plantilla no existe en ese idioma, el error más común de la primera prueba.

Consideraciones finales


El día empezó con una pregunta de Don Ernesto y termina con un diseño que la responde sin promesas: si a alguien le llega un recordatorio de Sonrisa Norte y lo lee la suegra en la mesa, lo que lee es “Hola, Ana. Le recordamos su cita en la Clínica Dental Sonrisa Norte el martes 24 de marzo a las 10:30”. No hay endodoncia que leer, porque la plantilla no tiene una variable donde ponerla y la revisión la rechazaría si alguien la añadiera. Nadie tiene que dar la cara por un dato que nunca salió.

Las cifras de hoy tienen límites que conviene decir. La clínica es inventada y todos sus números son supuestos: las $360$ citas, los $850$ MXN, el $15%$ de inasistencia, las tasas de respuesta y la inasistencia de cada grupo. Las tarifas de Meta y de Twilio se revisaron en octubre de 2026 y cambian por trimestre; la cuenta de ejecuciones supone tres avisos de estado por mensaje saliente, y en la práctica algunos pacientes no tienen activadas las confirmaciones de lectura. Los workflows se muestran con la estructura de exportación de la documentación de n8n, y los nombres de sus parámetros deben contrastarse con la versión instalada antes de importarlos. Nada de eso cambia la decisión, que resiste el escenario prudente con un margen amplio, pero todo justifica medir desde la primera semana.

Antes de automatizar los recordatorios de cualquier negocio de citas, conviene hacerse algunas preguntas:

  • ¿Qué revela el mensaje a quien tenga el teléfono en la mano, además del destinatario?
  • ¿El consentimiento que tiene es para esta finalidad y para este canal, y la baja se aplica en el mismo instante en que se pide?
  • ¿Qué pasa con los mensajes que no son un botón, y quién los lee?
  • ¿Cuánto cuesta la plataforma frente a los mensajes, y cuántas ejecuciones consumen los avisos de estado que nadie mira?
  • ¿Qué tasa de inasistencia le promete al cliente, y cómo la va a medir?

Un gran libro que habla de esto es Un pequeño empujón (o Nudge: Improving Decisions About Health, Wealth, and Happiness), de Richard H. Thaler y Cass R. Sunstein, en el que se explica cómo pequeños cambios en el entorno de una decisión, un recordatorio a tiempo o una opción por defecto, cambian lo que hace la gente sin quitarle libertad. Leerlo desde la recepción de una clínica ayuda a entender por qué un mensaje la víspera funciona mejor que una llamada que no se contesta, y también por qué el empujón tiene que ser transparente y fácil de rechazar: un botón para confirmar, otro para cambiar y una palabra para no recibir más.

En un negocio que trata datos de salud, el mejor mensaje automático es el que no puede decir más de lo necesario, porque no tiene dónde decirlo. Sonrisa Norte tiene hoy su plantilla aprobable, sus reglas de envío probadas, sus respuestas firmadas y enrutadas, un coste de $10.68$ USD al mes, un beneficio que resiste el escenario prudente y una recepcionista que recupera nueve horas al mes para el trabajo que solo ella puede hacer.

Conclusiones clave


Datos sensibles y consentimiento

  • Los datos de salud son datos personales sensibles (LFPDPPP de 2025, art. 2, fr. VI) y exigen consentimiento expreso y por escrito (art. 8): la ficha de alta pide uno por finalidad y canal, separado del de revisiones semestrales.
  • El recordatorio lleva solo nombre de pila, fecha, hora y dirección; el nombre del dentista también se calla, porque en una clínica pequeña revela la especialidad.
  • Meta y el proveedor del servidor aparecen como encargados en el aviso de privacidad, el registro guarda identificadores y estados durante $12$ meses, y Valeria asesora sin acceso a la agenda real.

Plantilla y respuestas

  • La plantilla recordatorio_cita es de utilidad, ocupa $183$ de $1,024$ caracteres, su pie $49$ de $60$ y obtiene $75.27$ en Szigriszt-Pazos; la versión con tratamiento, dentista y descuento se rechaza por cinco motivos, aunque sea más legible ($79.36$).
  • Solo $3$ de $12$ casos de prueba se envían: el consentimiento es por finalidad y canal, la baja manda, el horario es de 9:00 a 19:00 de lunes a viernes y las citas en días de cierre las revisa una persona.
  • La firma X-Hub-Signature-256 se verifica sobre los bytes originales y descarta el cuerpo alterado; el texto libre va a la bandeja de Irma y ningún mensaje de paciente llega a un modelo de IA.

Coste y beneficio

  • Solo los recordatorios cuestan $4.28$ USD al mes con la Cloud API y $9.68$ con Twilio; con plataforma y avisos a Irma, de $10.68$ USD (Cloud API y servidor propio) a $36.23$ (Twilio y n8n Cloud Starter).
  • Las ejecuciones reales son $2,842$ y no $1,840$: los avisos de estado de los acuses rompen las $2,500$ de Starter, y el servidor propio evita el problema.
  • La simulación de $1,000$ meses baja la inasistencia del $15.0%$ al $8.9%$, recupera $21.9$ citas y $18,644$ MXN al mes con un intervalo de $4,250$ a $32,300$, e Irma recupera $9.1$ horas netas; aun con un $12%$, el beneficio neto es de $9,622$ MXN.

Mañana, en el Día 52: Orquestación multicanal sin volverse loco: enrutamiento, deduplicación, horarios y SLA. De vuelta en Trigo Lento, un mismo cliente escribe por WhatsApp, por Instagram y por correo para el mismo pastel, y Valeria construirá un orquestador que reconoce que es una sola conversación, la enruta a una sola persona y respeta los horarios y los tiempos de respuesta que el negocio puede cumplir.

Referencias


  1. Guy, R., Hocking, J., Wand, H., Stott, S., Ali, H. y Kaldor, J. (2012). How Effective Are Short Message Service Reminders at Increasing Clinic Attendance? A Meta-Analysis and Systematic Review. Health Services Research, 47(2), 614-632. https://doi.org/10.1111/j.1475-6773.2011.01342.x
  2. Meta (2026). Plantillas de mensajes, categorías y botones de respuesta rápida. WhatsApp Business Platform. https://developers.facebook.com/documentation/business-messaging/whatsapp (revisado en octubre de 2026).
  3. Meta (2026). Pricing on the WhatsApp Business Platform. https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing (revisado en octubre de 2026).
  4. Meta (2026). Create a webhook endpoint: validating payloads con X-Hub-Signature-256. https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/create-webhook-endpoint (revisado en octubre de 2026).
  5. Meta (2026). Graph API changelog: Version 26.0. https://developers.facebook.com/docs/graph-api/changelog/version26.0 (revisado en octubre de 2026).
  6. Cámara de Diputados del H. Congreso de la Unión (2025). Ley Federal de Protección de Datos Personales en Posesión de los Particulares. Diario Oficial de la Federación, 20 de marzo de 2025. https://www.ordenjuridico.gob.mx/Documentos/Federal/html/wo125102.html
  7. Secretaría de Salud (2012). Norma Oficial Mexicana NOM-004-SSA3-2012, Del expediente clínico. Diario Oficial de la Federación, 15 de octubre de 2012. https://www.dof.gob.mx/nota_detalle_popup.php?codigo=5272787
  8. Twilio (2026). WhatsApp pricing. https://www.twilio.com/en-us/whatsapp/pricing (revisado en octubre de 2026).
  9. n8n (2026). Pricing y Webhook node. Documentación de n8n. https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/ (revisado en octubre de 2026).
  10. Barrio-Cantalejo, I. M., Simón-Lorda, P., Melguizo, M., Escalona, I., Marijuán, M. I. y Hernando, P. (2008). Validación de la Escala INFLESZ para evaluar la legibilidad de los textos dirigidos a pacientes. Anales del Sistema Sanitario de Navarra, 31(2), 135-152.
  11. Krawczyk, H., Bellare, M. y Canetti, R. (1997). HMAC: Keyed-Hashing for Message Authentication (RFC 2104). IETF. https://www.rfc-editor.org/rfc/rfc2104
  12. Thaler, R. H. y Sunstein, C. R. (2008). Nudge: Improving Decisions About Health, Wealth, and Happiness. Yale University Press.
comments powered by Disqus