Volver al blog
Desarrolladores

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.

· 11 min
Cómo enviar mensajes de WhatsApp desde Google Sheets con Apps Script (API oficial)

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, read o failed.
  • Las respuestas de tus clientes caen en la bandeja compartida de Waspy, donde tu equipo las atiende.

Lo que necesitás

  1. Número conectado a Waspy con la API oficial de WhatsApp.
  2. Una plantilla aprobada. Para este ejemplo, una de categoría utilidad llamada aviso_despacho, en es_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.
  3. Una API key creada en Configuración → Claves de API. Para este script alcanza con los scopes channels:read, messages:write y messages:read. La key se muestra una sola vez.
  4. 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:

ColumnaContenidoEjemplo
ATeléfono5491155554444
BNombreLaura
CPedido1847
DSeguimiento360000012345678
EEstado(lo completa el script)
FMessage ID(lo completa el script)
GError(lo completa el script)
El teléfono de un celular argentino va como 549 + código de área + número, sin el 0 ni el 15 (10 dígitos después del 549). La API acepta el número con o sin el signo +.

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' con components de tipo body: 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 header Idempotency-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ímiteValorQuién lo pone
Requests a la API120 por minuto por cuentaWaspy
Mensajes por llamada en loteHasta 100 con POST /api/v2/messages/batchWaspy
Llamadas de UrlFetchApp20.000 por día (gmail.com) o 100.000 (Workspace)Google
Duración de una ejecución6 minutosGoogle
Usuarios distintos contactados fuera de la ventanaSegún el límite de mensajería de tu portfolio (arranca en 250 en 24 h)Meta
Con 6 minutos por ejecución, una pausa de 600 ms y lo que tarda cada llamada, una corrida manda unos 400 mensajes. Para listas más grandes, usá 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

HTTPCódigoQué pasó
401UNAUTHORIZEDLa key es inválida, venció o fue revocada
403FORBIDDENA la key le falta el scope (por ejemplo, messages:write)
403SERVICE_WINDOW_EXPIREDMandaste texto libre fuera de la ventana de 24 h: usá plantilla
403CHANNEL_NOT_CONNECTEDEl canal no está activo
400INVALID_PHONEEl teléfono no tiene un formato válido
400INVALID_CHANNELEl phoneNumberId no es un canal de tu cuenta (¿pusiste el id de Meta?)
409IDEMPOTENCY_CONFLICTOtro pedido con la misma Idempotency-Key todavía se está procesando
429RATE_LIMITEDPasaste 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íaPor mensaje300 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)
Las tarifas salen de la rate card oficial de Meta y pueden cambiar; verificalas en tu WhatsApp Manager. Una plantilla de despacho tiene que ser informativa: si le agregás un cupón, Meta la puede recategorizar como marketing y cuesta más del doble (cómo funcionan las categorías). Waspy no agrega markup: lo de Meta lo pagás directo a Meta. Más ejemplos en precios de la WhatsApp Business API.

¿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.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.