/messagesscope: messages:send · templates:sendSi tu lista de clientes ya vive en una hoja de cálculo, no necesitas un servidor para mandarles WhatsApp: Google Apps Script corre dentro de tu cuenta de Google y puede llamar a la API de FlujosChat. Esta página es una plantilla lista para copiar: lee cada fila, la envía y escribe el resultado al lado. Si la vuelves a ejecutar, no repite lo que ya salió, y el único caso que no puede decidir sola te lo deja marcado para revisar.
Requisitos
- Un plan con acceso a la API (PRO o ENTERPRISE) y una API key creada en el panel con el scope
messages:send. Si vas a usar la plantilla de respaldo, la key necesita ademástemplates:send. - Una hoja de Google Sheets con una pestaña llamada
Envios. La fila 1 son los encabezados; los datos empiezan en la fila 2.
Columnas de la pestaña Envios
| Campo | Tipo | Descripción |
|---|---|---|
Areq. | texto | Teléfono en formato internacional con +: +51987654321. Se admiten espacios y guiones; cualquier otra cosa (puntos, paréntesis, letras) se marca ERROR: TELEFONO_INVALIDO y no se envía. Dale a la columna el formato Texto sin formato: si la hoja guarda el número como número, el + se pierde, y en notación científica (+5.1987654321E+10) la API lo leería como otro teléfono. Por eso el script exige el formato exacto antes de enviar. |
Breq. | texto | El mensaje. Hasta 4096 caracteres. |
C | estado | Lo escribe el script: ENVIANDO mientras llama a la API, y después ENVIADO, ENVIADO (plantilla), ERROR: <código> o REVISAR: pudo salir el <fecha>. No la borres: es lo que evita reenviar una fila al día siguiente. |
D | id / hora | Lo escribe el script: la hora del intento mientras C dice ENVIANDO, y el id del mensaje cuando sale. |
La API key va en Configuración del proyecto → Propiedades del script (Script properties), nunca escrita en el código: el script viaja con la hoja cuando la compartes o haces una copia.
Propiedades del script
| Campo | Tipo | Descripción |
|---|---|---|
FLUJOSCHAT_API_KEYreq. | string | Tu key, fjc_live_… |
FLUJOSCHAT_TEMPLATE_ID | string | Opcional. El id de una plantilla aprobada (sale de GET /templates). Si está, el script la usa cuando la ventana de 24 h está cerrada. |
Quién puede ver la key
Enviar desde una hoja
En la hoja, abre Extensiones → Apps Script, pega los dos bloques de esta página en el archivo de código, guarda y ejecuta la función enviarDesdeHoja. La primera vez Google te pide autorizar el acceso a la hoja y a servicios externos.
/**
* FlujosChat — enviar WhatsApp desde una hoja de Google Sheets.
*
* Hoja "Envios" (la fila 1 son los encabezados):
* A = teléfono en E.164 con + (+51987654321); se admiten espacios y guiones
* B = texto del mensaje
* C = estado (lo escribe el script)
* D = id del mensaje, o la hora del intento mientras C dice ENVIANDO (lo escribe el script)
*
* Configuración del proyecto → Propiedades del script:
* FLUJOSCHAT_API_KEY = fjc_live_... (obligatoria)
* FLUJOSCHAT_TEMPLATE_ID = id de GET /templates (opcional: plantilla fuera de 24 h)
*/
var URL_BASE = 'https://www.flujoschat.foo/api/public/v1';
var NOMBRE_HOJA = 'Envios';
var PRIMERA_FILA = 2;
var PAUSA_MS = 1100; // ~54 envíos por minuto: por debajo de 60/min del plan PRO
var TIEMPO_MAX_MS = 5 * 60000; // Apps Script corta cada ejecución a los 6 minutos
var REINTENTO_MAX_MS = 23 * 3600000; // la Idempotency-Key dura 24 h: se deja 1 h de margen
var TELEFONO_E164 = /^\+[1-9][0-9]{7,14}$/;
function enviarDesdeHoja() {
// Una sola ejecución a la vez: si un activador y una ejecución manual leyeran la
// misma fila, la segunda podría pisar el ENVIADO de la primera.
var lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) {
Logger.log('Ya hay otra ejecución en curso');
return;
}
try {
recorrerHoja();
} finally {
lock.releaseLock();
}
}
function recorrerHoja() {
var libro = SpreadsheetApp.getActiveSpreadsheet();
var hoja = libro.getSheetByName(NOMBRE_HOJA);
if (!hoja) throw new Error('No existe la hoja "' + NOMBRE_HOJA + '"');
leerApiKey(); // antes de marcar ninguna fila como ENVIANDO
var ultima = hoja.getLastRow();
if (ultima < PRIMERA_FILA) return;
// Solo sirve para saltar rápido lo ya resuelto: lo que se envía se relee fila a fila.
var estados = hoja.getRange(PRIMERA_FILA, 3, ultima - PRIMERA_FILA + 1, 1).getValues();
var plantillaId = PropertiesService.getScriptProperties().getProperty('FLUJOSCHAT_TEMPLATE_ID');
var inicio = Date.now();
for (var i = 0; i < estados.length; i++) {
if (resuelta(estados[i][0])) continue;
var fila = PRIMERA_FILA + i;
if (Date.now() - inicio > TIEMPO_MAX_MS) {
Logger.log('Se acabó el tiempo en la fila ' + fila + ': vuelve a ejecutar y sigue desde ahí.');
return;
}
var resultado = procesarFila(libro, hoja, fila, plantillaId);
if (resultado === 'parar') return;
if (resultado === 'llamada') Utilities.sleep(PAUSA_MS);
}
}
// Devuelve 'saltada' (no llamó a la API), 'llamada' o 'parar'.
function procesarFila(libro, hoja, fila, plantillaId) {
// Se relee justo antes de enviar: alguien pudo editar la hoja mientras corría el script.
var v = hoja.getRange(fila, 1, 1, 4).getValues()[0];
var telefono = String(v[0]).replace(/[\s-]/g, '');
var texto = String(v[1]).trim();
var estado = String(v[2]);
if (resuelta(estado)) return 'saltada';
if (telefono === '' && texto === '') return 'saltada';
// ENVIANDO = una ejecución anterior se cortó con la llamada en curso: pudo salir.
var reintento = estado.indexOf('ENVIANDO') === 0;
if (reintento) {
// La hora vuelve como texto ISO o, si la hoja la convirtió, como fecha. Lo que no
// se entienda va a revisión.
var desde = v[3] instanceof Date ? v[3].getTime() : Date.parse(String(v[3]));
if (isNaN(desde)) {
escribirEstado(hoja, fila, 'REVISAR: pudo salir (no hay hora del intento)');
return 'saltada';
}
if (Date.now() - desde >= REINTENTO_MAX_MS) {
// Con la clave a punto de caducar, la API volvería a enviar: decide una persona.
escribirEstado(hoja, fila, 'REVISAR: pudo salir el ' +
Utilities.formatDate(new Date(desde), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'));
return 'saltada';
}
}
// E.164 estricto. "+5.1987654321E+10" empieza por + pero NO es un teléfono: la API
// le quitaría el punto y la E, y el mensaje iría a otro número.
if (!TELEFONO_E164.test(telefono)) return fallo(hoja, fila, reintento, 'TELEFONO_INVALIDO');
if (texto === '') return fallo(hoja, fila, reintento, 'TEXTO_VACIO');
// Estable por fila: la misma fila manda siempre la misma clave.
var clave = 'hoja-' + libro.getId() + '-' + hoja.getSheetId() + '-fila-' + fila;
// Constancia ANTES de llamar, y guardada ya: si la ejecución se corta con el mensaje
// enviado, la próxima sabe que pudo salir y desde cuándo. Un reintento conserva la
// hora del primer intento, que es cuando empezó a contar la clave.
if (!reintento) {
hoja.getRange(fila, 4).setNumberFormat('@'); // formato texto: que la hora no se convierta en fecha
hoja.getRange(fila, 3, 1, 2).setValues([['ENVIANDO', new Date().toISOString()]]);
SpreadsheetApp.flush();
}
var r = llamarApi({ to: telefono, type: 'text', text: texto }, clave);
// Fuera de la ventana de 24 h el texto no sirve: se cae a la plantilla, con OTRA
// clave, porque la primera ya quedó asociada a la respuesta 422.
if (!r.ok && r.code === 'WHATSAPP_24H_WINDOW_EXPIRED' && plantillaId) {
Utilities.sleep(PAUSA_MS);
r = enviarPlantilla(telefono, plantillaId, {}, clave + '-plantilla');
if (r.ok) r.estado = 'ENVIADO (plantilla)';
}
if (r.ok) {
hoja.getRange(fila, 3, 1, 2).setValues([[r.estado || 'ENVIADO', r.id]]);
} else if (r.code === 'IDEMPOTENCY_IN_PROGRESS') {
// Otra petición con esta clave la está resolviendo: la fila no se toca.
} else if (reintento || r.incierto) {
// Pudo salir: sigue en ENVIANDO y D conserva la hora del primer intento.
escribirEstado(hoja, fila, 'ENVIANDO (último intento: ' + r.code + ')');
} else {
hoja.getRange(fila, 3, 1, 2).setValues([['ERROR: ' + r.code, '']]);
}
// Key inválida, sin permiso, suscripción bloqueada, límite alcanzado o respuesta
// ilegible: las filas siguientes fallarían igual, así que se para aquí.
if (r.incierto || r.status === 401 || r.status === 402 || r.status === 403 || r.status === 429) {
return 'parar';
}
return 'llamada';
}
function llamarApi(cuerpo, clave) {
var res = UrlFetchApp.fetch(URL_BASE + '/messages', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + leerApiKey(), 'Idempotency-Key': clave },
payload: JSON.stringify(cuerpo),
muteHttpExceptions: true
});
var status = res.getResponseCode();
var body = null;
try {
body = JSON.parse(res.getContentText());
} catch (e) {
// La respuesta no era JSON (por ejemplo, un error de un proxy intermedio)
}
if (status >= 200 && status < 300 && body && body.success) {
return { ok: true, status: status, id: body.data.message.id };
}
if (body && body.error && body.error.code) {
return { ok: false, status: status, code: body.error.code };
}
// Sin error.code no se sabe qué pasó al otro lado: pudo enviarse.
return { ok: false, status: status, code: 'HTTP_' + status, incierto: true };
}
function leerApiKey() {
var apiKey = PropertiesService.getScriptProperties().getProperty('FLUJOSCHAT_API_KEY');
if (!apiKey) throw new Error('Falta FLUJOSCHAT_API_KEY en las propiedades del script');
return apiKey;
}
function resuelta(estado) {
estado = String(estado);
return estado.indexOf('ENVIADO') === 0 || estado.indexOf('REVISAR') === 0;
}
function escribirEstado(hoja, fila, estado) {
hoja.getRange(fila, 3).setValue(estado);
}
function fallo(hoja, fila, reintento, codigo) {
escribirEstado(hoja, fila, reintento ? 'ENVIANDO (último intento: ' + codigo + ')' : 'ERROR: ' + codigo);
return 'saltada';
}Código.gs — bloque 1 de 2
Qué evita los envíos duplicados, y qué no
Cada fila manda siempre la misma Idempotency-Key: hoja-<id del libro>-<id de la pestaña>-fila-<n>. Va el id del libro además del de la pestaña porque el id de la pestaña se repite entre libros distintos (la primera pestaña suele tener el 0), y dos hojas de la misma empresa no pueden compartir clave. La API recuerda cada clave 24 horas.
- Si la fila no cambió, la API devuelve la respuesta guardada (con el header
Idempotency-Replayed: true) y no vuelve a mandar el WhatsApp. - Si una fila que todavía no dice ENVIADO cambió de texto o de teléfono dentro de esas 24 h, la API responde
409 IDEMPOTENCY_KEY_REUSEDy no envía: compara el cuerpo exacto de la petición. Añadir espacios al principio o al final del texto no cuenta como cambio, porque el script los quita. Una fila enENVIADOni siquiera se vuelve a leer. - La clave va por número de fila. No ordenes, insertes ni borres filas mientras el script se ejecuta, ni entre dos ejecuciones del mismo día. Cada fila se vuelve a leer justo antes de enviarla, así que un cambio hecho a mitad de ejecución sale con los datos nuevos, pero una fila que se mueve se lleva la clave de su nueva posición. Para mandar un mensaje nuevo, usa una fila nueva.
- Los errores también se recuerdan. Una fila que recibió
422 WHATSAPP_24H_WINDOW_EXPIREDrecibe el mismo 422 durante esas 24 h, aunque el cliente te haya escrito después. No se guardan los5xxni los rechazos previos al envío (key inválida, sin scope, suscripción bloqueada, límites): esas filas se reintentan de verdad. Pasadas las 24 h, re-ejecutar vuelve a intentar las filas enERROR.
Si la ejecución se corta a mitad de un envío
Apps Script detiene cualquier ejecución a los 6 minutos, y una llamada puede quedar a medias por la red. Si eso pasa con el mensaje ya enviado, no se llega a escribir ENVIADO, y pasadas 24 h la API volvería a mandarlo porque la clave ya caducó. Para que eso no ocurra sin que nadie se entere, el script escribe ENVIANDO y la hora en la hoja antes de cada llamada, y lo guarda al momento. En la siguiente ejecución, una fila en ENVIANDO:
- Con menos de 23 horas desde esa hora, se reintenta con la misma clave: la API devuelve lo que ya pasó, o
409si la petición anterior sigue en curso, pero no manda un segundo mensaje. Mientras tanto, D conserva la hora del primer intento y C muestra el último error, por ejemploENVIANDO (último intento: RATE_LIMITED). - Con 23 horas o más, no se envía: queda
REVISAR: pudo salir el <fecha>. La hora de margen es para no llegar al límite de las 24 h en mitad de un reintento. Revisa la conversación en el panel: si el mensaje llegó, escribeENVIADOen C; si no, borra C y D para que se envíe en la siguiente ejecución.
Solo corre una ejecución a la vez: si un activador y una ejecución manual coinciden, la segunda termina sin tocar nada. Y si la API responde 409 IDEMPOTENCY_IN_PROGRESS, porque otra petición con esa clave sigue en curso, el script tampoco toca la fila.
Qué escribe en la columna C
En un error, el script copia el error.code de la respuesta, que la API incluye siempre (la lista completa está en Errores y límites). Dos códigos los pone el propio script, no la API: TELEFONO_INVALIDO y TEXTO_VACIO. Si la respuesta no trae un error.code (por ejemplo, la página de error de un proxy), no hay forma de saber si el mensaje salió: la fila sigue en ENVIANDO (último intento: HTTP_<status>) y el script se detiene. También se detiene con un 401, 402, 403 o 429, porque las filas siguientes fallarían igual.
Plantillas fuera de 24 h
El texto libre solo funciona si el cliente te escribió en las últimas 24 horas; fuera de esa ventana la API responde 422 WHATSAPP_24H_WINDOW_EXPIRED y hay que usar una plantilla aprobada por Meta. La regla completa está en la ventana de 24 horas.
Pega este segundo bloque debajo del primero. Si definiste FLUJOSCHAT_TEMPLATE_ID, el script llama a enviarPlantilla cuando recibe ese 422 y marca la fila como ENVIADO (plantilla).
/**
* Envía una plantilla aprobada por Meta: la única forma de escribir primero
* o de retomar una conversación fuera de la ventana de 24 h.
*
* templateId → el "id" que devuelve GET /templates
* variables → valores por posición, por ejemplo { "1": "4512" }
* clave → Idempotency-Key; distinta de la que usó el envío de texto
*/
function enviarPlantilla(telefono, templateId, variables, clave) {
return llamarApi({
to: telefono,
type: 'template',
templateId: templateId,
variables: variables || {}
}, clave);
}Código.gs — bloque 2 de 2
- La plantilla se identifica por su
templateId, no por su nombre: la API todavía no acepta el nombre. Consíguelo conGET /templates. - La plantilla de respaldo usa
variables: {}, así que sirve tal cual para una plantilla sin variables, o para una cuyas variables resuelve suvariableMapping. Si necesita datos de la fila, pásalos por posición:{ "1": "4512" }. - Usa otra
Idempotency-Key(la de la fila más-plantilla): la primera ya quedó asociada al 422 del texto, y reusarla con otro cuerpo daría409 IDEMPOTENCY_KEY_REUSED.
Límites
Tu script está sujeto a dos juegos de límites a la vez: los de Google para Apps Script y los de tu plan en FlujosChat.
Los números que conviene tener a mano
UrlFetchApp por día en cuentas de consumo y 100 000 en cuentas de Google Workspace; 6 minutos por ejecución; y 90 minutos (consumo) o 6 horas (Workspace) de tiempo total de activadores por día. Google avisa que puede cambiarlas en cualquier momento y sin aviso.API de FlujosChat, por plan: PRO 10 000 requests al mes y 60 por minuto por key; ENTERPRISE 100 000 al mes y 120 por minuto; FREE y BASIC no incluyen API (detalle en Cuotas y rate limits). Cada llamada consume cuota mensual, incluida la que devuelve una respuesta ya guardada por su
Idempotency-Key.- La pausa de 1,1 s entre envíos deja el script por debajo de los 60 por minuto del plan PRO. Si otra integración usa la misma key a la vez, súbela.
- A los 5 minutos el script deja de empezar filas nuevas, para terminar antes que Apps Script. Una llamada que ya empezó no se puede acortar: si aun así la ejecución se corta, la fila queda en
ENVIANDOy se resuelve como se explica arriba. - Para enviar a una hora aproximada, crea un activador basado en tiempo para
enviarDesdeHoja(Activadores en el editor). No esperes un minuto exacto: según Google, un activador diario a las 9 AM se ejecuta a una hora entre las 9 y las 10, que luego se mantiene de un día a otro. Y un activador instalable corre siempre con la cuenta de quien lo creó, no con la de quien abre la hoja.
Preguntas frecuentes
¿Puedo enviar WhatsApp desde Google Sheets sin montar un servidor?
Sí. Google Apps Script corre dentro de tu cuenta de Google y puede llamar a la API de FlujosChat con UrlFetchApp. Copias el script en Extensiones → Apps Script, guardas tu API key en las propiedades del script y lo ejecutas. Necesitas un plan con acceso a la API (PRO o ENTERPRISE).
¿Qué pasa si ejecuto el script dos veces?
Las filas en ENVIADO o REVISAR se saltan, y cada fila manda siempre la misma Idempotency-Key, que la API recuerda 24 horas: repetir dentro de ese plazo una fila que no cambió devuelve la respuesta guardada sin volver a enviar. El caso que el script no puede resolver solo es una ejecución que se corta con el mensaje ya enviado: la fila queda en ENVIANDO con la hora del intento, se reintenta con la misma clave durante 23 horas y, pasado ese plazo, se marca REVISAR para que una persona compruebe si llegó, porque la API ya podría enviarla de nuevo.
¿Dónde guardo la API key en Apps Script?
En Configuración del proyecto → Propiedades del script, con el nombre FLUJOSCHAT_API_KEY. Nunca en el código: el script viaja con la hoja cuando se comparte o se copia. Ten en cuenta que Google describe las propiedades del script como compartidas por todos los usuarios del script, así que da acceso de edición solo a quien podría tener esa key.
¿Cuántos mensajes envía el script por ejecución?
Hace una pausa de 1,1 segundos entre envíos para quedar por debajo de los 60 requests por minuto del plan PRO, y deja de empezar filas nuevas a los 5 minutos porque Apps Script corta cada ejecución a los 6. Si quedan filas, vuelve a ejecutarlo: sigue desde la primera que no está resuelta. Si ya hay otra ejecución en curso, la nueva termina sin tocar nada.
¿Puedo escribirle desde la hoja a un cliente que nunca me escribió?
Solo con una plantilla aprobada por Meta. El texto libre funciona únicamente dentro de la ventana de 24 horas; fuera de ella la API responde 422 WHATSAPP_24H_WINDOW_EXPIRED. Si defines FLUJOSCHAT_TEMPLATE_ID, el script cae a esa plantilla automáticamente.