waiaconnect

Guías

¿Cómo conecto WhatsApp a una hoja de Google Sheets con WAIA Connect?

Pegás un Apps Script en tu hoja, lo publicás como aplicación web y registrás esa URL como webhook en Connect. Cada mensaje que te escriben queda en una fila, y si querés, el script contesta solo. Abajo está el código completo y las tres cosas raras de Apps Script que conviene saber antes.

MedidoEl 29/09/2026, sobre los webhooks de nuestros clientes: 2 de 31 son un Apps Script. Las 497 entregas guardadas de esos dos contestaron 200 después del 302 de Google, y 28 (5,6 %) tardaron más de 10 segundos la primera vez y entraron por el reintento. Por eso el script ignora un evento que ya procesó.

Antes de empezar

  • Una cuenta de WAIA Connect con un número conectado.
  • Una clave de API (wc_live_…): en el panel, API → Crear API key. Se muestra una sola vez: copiala.
  • Una cuenta de Google y una hoja de cálculo nueva (o la que ya usás).

1. Pegá el script en tu hoja

  1. En la hoja: Extensiones → Apps Script.
  2. Borrá lo que haya en Código.gs y pegá el script de abajo, entero.
  3. Guardá (el ícono del disquete).
/**
 * WAIA Connect → Google Sheets (Apps Script web app)
 * Guide: https://waiaconnect.com/guias/google-sheets-webhook-apps-script
 *
 * What it does, for every event Connect sends to your webhook:
 *   1. checks the secret token in the URL (Apps Script cannot read HTTP headers, so the
 *      X-Connect-Signature header is not available here — see the guide);
 *   2. ignores an event it already handled (Connect retries if your script is slow);
 *   3. saves it as a row in the "Messages" sheet;
 *   4. optionally answers a text message through the Connect API.
 *
 * Script properties (Project Settings → Script properties):
 *   CONNECT_API_KEY        your wc_live_… key (Connect panel → API keys). Never paste it in the code.
 *   CONNECT_WEBHOOK_TOKEN  a long random secret. The SAME value goes at the end of the
 *                          webhook URL you register in Connect: …/exec?token=<value>
 *   AUTO_REPLY_TEXT        optional. If set, every incoming text message gets this answer.
 *                          Leave it empty to only save messages.
 *
 * ⚠ After ANY change to this code: Deploy → Manage deployments → edit → Version: "New version".
 *   Otherwise the /exec URL keeps running the old code.
 */

var CONNECT_API_URL = 'https://api.waiaconnect.com/v1/messages';
var MESSAGES_SHEET = 'Messages';
var ERRORS_SHEET = 'Errors';
var MESSAGE_COLUMNS = ['Received at', 'Event', 'From', 'Name', 'Text', 'Event id', 'Number (connection)'];
var SEEN_SECONDS = 21600; // 6 h: the longest CacheService keeps a value

function doPost(e) {
  try {
    var props = PropertiesService.getScriptProperties();

    // 1. The token. A request without it is not from Connect: answer and do nothing.
    var expected = props.getProperty('CONNECT_WEBHOOK_TOKEN');
    var given = e && e.parameter ? String(e.parameter.token || '') : '';
    if (!expected || !sameText_(given, expected)) {
      return answer_('ignored');
    }

    var evt = JSON.parse(e.postData.contents);
    if (!evt || !evt.id || !evt.type) {
      return answer_('ignored');
    }

    // 2. Once per event. Connect retries with the SAME event id if your script took
    //    longer than 10 seconds; the lock stops two copies running at the same time.
    var lock = LockService.getScriptLock();
    lock.waitLock(20000);
    try {
      var cache = CacheService.getScriptCache();
      if (cache.get('evt:' + evt.id)) {
        return answer_('duplicate');
      }
      cache.put('evt:' + evt.id, '1', SEEN_SECONDS);
    } finally {
      lock.releaseLock();
    }

    // 3. Save it.
    var data = evt.data || {};
    var msg = data.message || {};
    var contact = (data.contacts && data.contacts[0]) || {};
    var name = (contact.profile && contact.profile.name) || '';
    var text = msg.type === 'text' && msg.text ? String(msg.text.body || '') : '[' + (msg.type || evt.type) + ']';
    sheet_(MESSAGES_SHEET, MESSAGE_COLUMNS).appendRow([
      new Date(),
      evt.type,
      cell_(msg.from || ''),
      cell_(name),
      cell_(text),
      evt.id,
      (evt.connection && evt.connection.id) || ''
    ]);

    // 4. Answer — only a text a person sent you. Never an echo (message.echo is what YOU
    //    sent): answering it would make the bot talk to itself.
    if (evt.type === 'message.received' && msg.type === 'text' && msg.from) {
      var reply = buildReply(text, name);
      if (reply) {
        sendText_(evt.connection.id, msg.from, reply, evt.id);
      }
    }
    return answer_('ok');
  } catch (err) {
    logError_(err);
    return answer_('error');
  }
}

/**
 * The bot's answer. This is the ONE function to change (or to ask ChatGPT to change):
 * return the text to send, or null to send nothing.
 */
function buildReply(text, name) {
  var fixed = PropertiesService.getScriptProperties().getProperty('AUTO_REPLY_TEXT');
  return fixed ? fixed : null;
}

function sendText_(connectionId, to, body, eventId) {
  var key = PropertiesService.getScriptProperties().getProperty('CONNECT_API_KEY');
  if (!key) {
    throw new Error('CONNECT_API_KEY is not set in Script properties');
  }
  var res = UrlFetchApp.fetch(CONNECT_API_URL, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: 'Bearer ' + key,
      // Same event → same key: if this runs twice, Connect sends the answer only once.
      'Idempotency-Key': 'sheets-reply-' + eventId
    },
    payload: JSON.stringify({ connectionId: connectionId, to: to, type: 'text', text: { body: body } }),
    muteHttpExceptions: true
  });
  var code = res.getResponseCode();
  if (code !== 200 && code !== 202) {
    throw new Error('Connect API ' + code + ': ' + res.getContentText().slice(0, 300));
  }
}

// Apps Script always answers 200 (it cannot send another status), so what we return
// here is only for you to read in a test.
function answer_(text) {
  return ContentService.createTextOutput(text).setMimeType(ContentService.MimeType.TEXT);
}

function sheet_(name, columns) {
  var book = SpreadsheetApp.getActiveSpreadsheet();
  var sh = book.getSheetByName(name);
  if (!sh) {
    sh = book.insertSheet(name);
    sh.appendRow(columns);
  }
  return sh;
}

// A cell that starts with = + - @ would be run as a FORMULA by Sheets. Someone could
// send you "=IMPORTXML(...)" over WhatsApp: the leading apostrophe keeps it as text.
function cell_(value) {
  var s = String(value);
  return /^[=+\-@]/.test(s) ? "'" + s : s;
}

function sameText_(a, b) {
  if (a.length !== b.length) return false;
  var diff = 0;
  for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

function logError_(err) {
  try {
    sheet_(ERRORS_SHEET, ['When', 'Error']).appendRow([new Date(), cell_(String(err && err.stack ? err.stack : err))]);
  } catch (ignored) {
    // Even the error sheet failed: it still shows in Apps Script → Executions.
  }
  console.error(err);
}

Descargar el script (.gs)

El script crea solo dos pestañas: Messages (una fila por mensaje) y Errors (si algo falla, queda escrito ahí).

Si le pedís cambios a ChatGPT, pedile que toque sólo la función buildReply: es la que decide qué contestar. El resto (el token, los duplicados, la fórmula) está para protegerte.

2. Cargá tus datos en las propiedades del script (no en el código)

  1. En Apps Script: Configuración del proyecto (el engranaje) → Propiedades de la secuencia de comandos → Agregar.
  2. CONNECT_API_KEY = tu clave wc_live_….
  3. CONNECT_WEBHOOK_TOKEN = una contraseña larga inventada por vos (30 caracteres o más, letras y números). Es la que prueba que el mensaje viene de Connect.
  4. AUTO_REPLY_TEXT (opcional) = el texto que querés contestar a cada mensaje. Si lo dejás vacío, el script sólo guarda.

3. Publicalo como aplicación web

  1. Implementar → Nueva implementación. En el engranaje de «Tipo», elegí Aplicación web.
  2. Ejecutar como: Yo. Quién tiene acceso: Cualquier persona. ⚠ «Cualquier persona con una cuenta de Google» NO sirve: Connect no tiene cuenta de Google y recibiría la pantalla de inicio de sesión.
  3. Implementar, aceptá los permisos que pide Google (es tu propio script) y copiá la URL de la aplicación web, la que termina en /exec.

4. Registrá el webhook en Connect

  1. En el panel: Webhooks → Agregar endpoint.
  2. URL: la de /exec con tu token al final: https://script.google.com/macros/s/…/exec?token=TU_TOKEN (el mismo valor de CONNECT_WEBHOOK_TOKEN).
  3. Eventos: message.received. Sumá message.echo sólo si también querés guardar lo que mandás desde el teléfono (el script nunca contesta un eco).
  4. Apretá Probar: tiene que decir 200, y en Messages aparece una fila webhook.test. Después mandate un WhatsApp desde otro teléfono y mirá la pestaña Messages.

Lo raro de Apps Script (leelo aunque todo ande)

  • El 302. Google ejecuta tu doPost y recién después contesta con una redirección (302) a script.googleusercontent.com, donde deja lo que devolvió tu script. Connect sigue esa redirección con un GET y cuenta el 200 final como entregado. Si probás con curl, usá -L y no pongas -X POST.
  • Siempre contesta 200, aunque tu script falle. Apps Script no deja elegir el código de respuesta, así que un error adentro de doPost le llega a Connect como «entregado». Si el bot no contesta, Connect no lo va a ver: mirá la pestaña Errors y, en Apps Script, Ejecuciones (ahí aparece cada llamada con su error).
  • No puede leer encabezados. El evento de doPost no trae los encabezados HTTP, así que la firma X-Connect-Signature no se puede verificar en Apps Script. Por eso el token va en la URL. Quien vea tu panel de Connect ve esa URL: si se filtra, cambiá el token en los dos lados (propiedad del script y URL del webhook).
  • Cada cambio de código necesita una versión nueva. Guardar no alcanza: Implementar → Administrar implementaciones → editar (lápiz) → Versión: Nueva versión → Implementar. Si no, la URL /exec sigue corriendo el código viejo. Así la URL no cambia y no tenés que tocar Connect.
  • 10 segundos. Connect espera 10 s la respuesta. Si tu script tarda más (por ejemplo, porque le pregunta a ChatGPT), Connect reintenta el mismo evento: el script lo reconoce y no lo guarda dos veces, y el Idempotency-Key hace que Connect no mande la respuesta dos veces. Google corta cualquier ejecución a los 6 minutos.

Los límites de Google

  • Llamadas a otras URL (cada respuesta que manda el script es una): 20.000 por día con una cuenta gmail.com, 100.000 con Google Workspace.
  • 30 ejecuciones a la vez por usuario. Si te escriben muchos al mismo tiempo, las que sobran fallan y Connect las reintenta.
  • El recuerdo de «ya lo procesé» dura 6 horas (es el máximo de la caché de Apps Script). Un reintento más tardío sí se guardaría de nuevo, pero la respuesta no sale dos veces: la frena el Idempotency-Key.
  • La hoja no es una base de datos: con decenas de miles de filas se pone lenta. Archivá la pestaña Messages de vez en cuando.

Si algo no anda

  • Probar dice 200 pero no aparece ninguna fila: el token de la URL no coincide con CONNECT_WEBHOOK_TOKEN (el script ignora el pedido y igual contesta 200), o publicaste sin Nueva versión.
  • Aparece la fila pero no contesta: mirá Errors. Connect API 401 = la clave está mal; CONNECT_API_KEY is not set = falta la propiedad; sin error = AUTO_REPLY_TEXT vacío.
  • Probar no da 200: revisá que el acceso sea Cualquier persona y que la URL termine en /exec (no en /dev).
  • ¿Querés ver la forma exacta de cada evento? Está en Recibir el webhook.

Probarlo sin WhatsApp (opcional, desde una terminal)

Esto manda un mensaje inventado directo a tu script, como lo haría Connect. Tiene que aparecer una fila nueva en Messages.

curl -L -H 'Content-Type: application/json' \
  -d '{"id":"evt_prueba_1","type":"message.received","connection":{"id":"conn_…"},"data":{"message":{"from":"5493511234567","type":"text","text":{"body":"hola"}},"contacts":[{"profile":{"name":"Test"}}]}}' \
  'https://script.google.com/macros/s/<…>/exec?token=<CONNECT_WEBHOOK_TOKEN>'

Fuentes (consultadas el 29/09/2026)

Conectá tu primer número hoy

Verificado por Meta como proveedor de tecnología. Coexistence en un click.

Empezar