Cómo enviar mensajes de WhatsApp desde Google Sheets con Apps Script (API oficial)
Tutorial con código para mandar avisos de WhatsApp desde una planilla de Google: Apps Script llama a la API de Waspy, usa una plantilla aprobada, evita duplicados con Idempotency-Key y anota el estado de cada mensaje en la hoja. Sin extensiones que automatizan WhatsApp Web.
Respuesta corta: sí, podés enviar WhatsApp desde Google Sheets sin extensiones raras. Un script de Apps Script recorre la planilla y, por cada fila, hace un POST https://api.waspytech.com/api/v2/messages con tu API key de Waspy. Como la mayoría de esos clientes no te escribió en las últimas 24 horas, el mensaje tiene que ser una plantilla aprobada por Meta (por ejemplo, un aviso de despacho). El script guarda el id que devuelve la API y después consulta GET /api/v2/messages/:id para anotar si quedó entregado o leído.
Es el caso típico de una tienda chica de Rosario o de Palermo que arma los envíos del día en una planilla: nombre, teléfono, número de pedido y código de seguimiento de Andreani o Correo Argentino. En vez de copiar y pegar 80 mensajes a mano, un botón en la hoja los manda por la API oficial.
Por qué no usar una extensión que manda desde WhatsApp Web
Hay extensiones y "macros" que abren WhatsApp Web y escriben por vos. Funcionan hasta que no: automatizar WhatsApp Web va contra las condiciones de WhatsApp y es una de las causas típicas de bloqueo de número (lo explicamos en por qué WhatsApp bloquea tu número). Con la API oficial:
- Los envíos son legítimos y no dependen de una pestaña abierta.
- Tenés estados reales:
queued,sent,delivered,readofailed. - Las respuestas de tus clientes caen en la bandeja compartida de Waspy, donde tu equipo las atiende.
Lo que necesitás
- Número conectado a Waspy con la API oficial de WhatsApp.
- Una plantilla aprobada. Para este ejemplo, una de categoría utilidad llamada
aviso_despacho, enes_AR, con este cuerpo: "Hola {{1}}, tu pedido {{2}} ya salió. Podés seguirlo con el código {{3}}." Si nunca armaste una, mirá cómo crear y aprobar plantillas. - Una API key creada en Configuración → Claves de API. Para este script alcanza con los scopes
channels:read,messages:writeymessages:read. La key se muestra una sola vez. - Una planilla de Google con una hoja llamada Envios.
La API REST de Waspy figura en los planes Scale y Enterprise; el detalle de cada plan está en Planes.
Paso 1: armá la hoja
La hoja Envios con estas columnas, en este orden y con encabezado en la fila 1:
| Columna | Contenido | Ejemplo |
|---|---|---|
| A | Teléfono | 5491155554444 |
| B | Nombre | Laura |
| C | Pedido | 1847 |
| D | Seguimiento | 360000012345678 |
| E | Estado | (lo completa el script) |
| F | Message ID | (lo completa el script) |
| G | Error | (lo completa el script) |
Paso 2: guardá la API key fuera de la planilla
Nunca pegues la key en una celda: cualquiera con acceso a la hoja la vería. En el editor de Apps Script (Extensiones → Apps Script), andá a Configuración del proyecto → Propiedades del script y agregá la propiedad WASPY_API_KEY con tu key wspy_....
Paso 3: el cliente HTTP
Pegá esto en Código.gs. Es un helper que agrega el header de autorización, reintenta si la API responde 429 respetando Retry-After y devuelve el código HTTP con el JSON:
const API = 'https://api.waspytech.com/api/v2';
function apiKey_() {
const key = PropertiesService.getScriptProperties().getProperty('WASPY_API_KEY');
if (!key) throw new Error('Falta WASPY_API_KEY en Propiedades del script');
return key;
}
function waspy_(method, path, body, extraHeaders) {
const options = {
method: method,
headers: Object.assign({ Authorization: 'Bearer ' + apiKey_() }, extraHeaders || {}),
muteHttpExceptions: true,
};
if (body) {
options.contentType = 'application/json';
options.payload = JSON.stringify(body);
}
for (let intento = 0; intento < 3; intento++) {
const res = UrlFetchApp.fetch(API + path, options);
const code = res.getResponseCode();
if (code === 429) {
const h = res.getHeaders();
const espera = Number(h['Retry-After'] || h['retry-after'] || 5);
Utilities.sleep(espera * 1000);
continue;
}
return { code: code, json: JSON.parse(res.getContentText() || '{}') };
}
return { code: 429, json: { error: { code: 'RATE_LIMITED', message: 'Sin cupo tras 3 intentos' } } };
}
Paso 4: buscá el id de tu canal
El envío necesita phoneNumberId, que es el id del canal en Waspy (un UUID). Ojo: GET /channels devuelve dos campos parecidos, id (el de Waspy, el que va en el envío) y phoneNumberId (el identificador de Meta). Corré esta función una vez y copiá el id del canal con estado active:
function listarCanales() {
const r = waspy_('get', '/channels');
if (r.code !== 200) throw new Error(JSON.stringify(r.json));
r.json.data.forEach(function (c) {
Logger.log(c.displayName + ' ' + c.phoneNumber + ' | id: ' + c.id + ' | estado: ' + c.status);
});
}
Paso 5: el envío
const CANAL_ID = 'PEGA_ACA_EL_ID_DEL_CANAL';
const PLANTILLA = 'aviso_despacho';
function enviarAvisos() {
const hoja = SpreadsheetApp.getActive().getSheetByName('Envios');
const filas = hoja.getDataRange().getValues();
for (let i = 1; i < filas.length; i++) {
const [telefono, nombre, pedido, seguimiento, estado] = filas[i];
if (!telefono || estado) continue;
const r = waspy_('post', '/messages', {
phoneNumberId: CANAL_ID,
to: String(telefono),
type: 'template',
template: {
name: PLANTILLA,
language: { code: 'es_AR' },
components: [{
type: 'body',
parameters: [
{ type: 'text', text: String(nombre) },
{ type: 'text', text: String(pedido) },
{ type: 'text', text: String(seguimiento) },
],
}],
},
clientRef: 'pedido-' + pedido,
}, { 'Idempotency-Key': 'sheet-despacho-' + pedido });
if (r.code === 201) {
hoja.getRange(i + 1, 5, 1, 3).setValues([[r.json.data.status, r.json.data.id, '']]);
} else {
const e = r.json.error || {};
hoja.getRange(i + 1, 5, 1, 3).setValues([['error', '', (e.code || r.code) + ': ' + (e.message || '')]]);
}
Utilities.sleep(600);
}
}
Qué hace cada parte:
type: 'template'concomponentsde tipobody: los parámetros se asignan en orden a{{1}},{{2}}y{{3}}.Idempotency-Key: si el script se corta y lo volvés a correr, la API no manda dos veces el mismo aviso. Repetir la key dentro de las 24 horas devuelve la respuesta original con el headerIdempotency-Replayed: true. Usá siempre un identificador tuyo (el pedido), nunca un valor al azar.clientRef: un texto tuyo que la API devuelve en el mensaje y en cada webhook de estado, para conciliar contra tu número de pedido.Utilities.sleep(600): unos 100 envíos por minuto, por debajo del límite de 120 requests por minuto por cuenta.
Una respuesta 201 trae el mensaje con status: "queued": quedó en cola, todavía no entregado.
Paso 6: anotá el estado real
function actualizarEstados() {
const hoja = SpreadsheetApp.getActive().getSheetByName('Envios');
const filas = hoja.getDataRange().getValues();
for (let i = 1; i < filas.length; i++) {
const estado = filas[i][4];
const id = filas[i][5];
if (!id || estado === 'read' || estado === 'failed') continue;
const r = waspy_('get', '/messages/' + id);
if (r.code !== 200) continue;
const m = r.json.data;
hoja.getRange(i + 1, 5).setValue(m.status);
if (m.status === 'failed') hoja.getRange(i + 1, 7).setValue((m.errorCode || '') + ' ' + (m.errorMessage || ''));
}
}
Los estados avanzan queued → sent → delivered → read, o terminan en failed con el error de Meta en errorCode y errorMessage (por ejemplo, si el nombre o el idioma de la plantilla no coinciden, o la cantidad de variables está mal).
Paso 7: botón en la planilla y actualización automática
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('WhatsApp')
.addItem('Enviar avisos pendientes', 'enviarAvisos')
.addItem('Actualizar estados', 'actualizarEstados')
.addToUi();
}
Para que los estados se actualicen solos, en el editor andá a Activadores y creá uno de tipo "Según tiempo" que corra actualizarEstados cada 15 minutos. Si preferís enterarte en el momento sin consultar, la alternativa es suscribirte al webhook message.status_changed desde tu propio servidor: Apps Script no deja leer los headers de un pedido entrante, así que no podrías verificar la firma X-Waspy-Signature del webhook.
Límites que conviene conocer
| Límite | Valor | Quién lo pone |
|---|---|---|
| Requests a la API | 120 por minuto por cuenta | Waspy |
| Mensajes por llamada en lote | Hasta 100 con POST /api/v2/messages/batch | Waspy |
| Llamadas de UrlFetchApp | 20.000 por día (gmail.com) o 100.000 (Workspace) | |
| Duración de una ejecución | 6 minutos | |
| Usuarios distintos contactados fuera de la ventana | Según el límite de mensajería de tu portfolio (arranca en 250 en 24 h) | Meta |
POST /api/v2/messages/batch, que acepta hasta 100 mensajes por llamada, responde 202 enseguida y por defecto saltea a quienes se dieron de baja (respectOptOut: true). Y si es una promoción a toda tu base, hacelo como campaña desde el panel: ahí tenés segmentación y métricas.
Errores comunes
| HTTP | Código | Qué pasó |
|---|---|---|
| 401 | UNAUTHORIZED | La key es inválida, venció o fue revocada |
| 403 | FORBIDDEN | A la key le falta el scope (por ejemplo, messages:write) |
| 403 | SERVICE_WINDOW_EXPIRED | Mandaste texto libre fuera de la ventana de 24 h: usá plantilla |
| 403 | CHANNEL_NOT_CONNECTED | El canal no está activo |
| 400 | INVALID_PHONE | El teléfono no tiene un formato válido |
| 400 | INVALID_CHANNEL | El phoneNumberId no es un canal de tu cuenta (¿pusiste el id de Meta?) |
| 409 | IDEMPOTENCY_CONFLICT | Otro pedido con la misma Idempotency-Key todavía se está procesando |
| 429 | RATE_LIMITED | Pasaste las 120 requests por minuto: esperá lo que diga Retry-After |
Cuánto cuesta
El script no cuesta nada. Meta cobra cada mensaje entregado según su categoría y el país del destinatario. Para Argentina, desde el 1 de octubre de 2026:
| Categoría | Por mensaje | 300 mensajes |
|---|---|---|
Utilidad (como aviso_despacho) | ~US$0,026 (ARS 37,68) | ~US$7,80 (ARS 11.303,94) |
| Marketing | ~US$0,0618 (ARS 89,56) | ~US$18,54 (ARS 26.868,60) |
¿Y si mañana lo paso a un sistema de verdad?
El mismo pedido funciona desde cualquier lenguaje: el JSON es idéntico. El paso natural es mover el envío a tu backend o a n8n, Make o Zapier, y sumar webhooks para recibir las respuestas. Si además querés que un asistente como Claude o ChatGPT trabaje sobre la bandeja (buscar, resumir, dejar notas), mirá cómo conectar WhatsApp con Claude, ChatGPT y Cursor por MCP. La referencia completa está en la documentación de la API y en la página de la API de Waspy.
¿Lo querés probar con tu número? Creá tu cuenta en Waspy con 10 días gratis y sin tarjeta.
