¿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.
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
- En la hoja: Extensiones → Apps Script.
- Borrá lo que haya en
Código.gsy pegá el script de abajo, entero. - 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);
}
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)
- En Apps Script: Configuración del proyecto (el engranaje) → Propiedades de la secuencia de comandos → Agregar.
CONNECT_API_KEY= tu clavewc_live_….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.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
- Implementar → Nueva implementación. En el engranaje de «Tipo», elegí Aplicación web.
- 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.
- 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
- En el panel: Webhooks → Agregar endpoint.
- URL: la de
/execcon tu token al final:https://script.google.com/macros/s/…/exec?token=TU_TOKEN(el mismo valor deCONNECT_WEBHOOK_TOKEN). - 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).
- 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
doPosty recién después contesta con una redirección (302) ascript.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 concurl, usá-Ly 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
doPostle 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
doPostno trae los encabezados HTTP, así que la firmaX-Connect-Signatureno 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
/execsigue 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-Keyhace 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_TEXTvací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