¿Cómo vincular el Webhook de un Modal de Sitios con Google Sheets?

Los Modales de Sitios permiten recolectar información de los usuarios a través de distintos formularios (Pop-up de suscripción, Modal embebido, Pop-up de carrito abandonado, etc.) y almacenar esos datos en icomm. Además de guardarlos en icomm, es posible utilizar un Webhook para enviar automáticamente esa información a otro sistema apenas se completa el formulario.
Esta guía explica cómo armar esa conexión de punta a punta cuando el sistema externo elegido es Google Sheets: cómo preparar una planilla para que reciba los datos, y cómo activar el Webhook dentro del Modal para que apunte a ella.


¿En qué Modales puedo usar esta integración?

El Webhook puede activarse en cualquier Modal que cuente con un formulario de captura de datos, por ejemplo:

  • Pop-up de suscripción: para enviar los nuevos registros a la planilla apenas se suscriben.
  • Modal embebido: para conectar los formularios integrados dentro del sitio.
  • Pop-up de carrito abandonado: para enviar los datos de usuarios que dejaron productos en su carrito.
  • Cualquier otro Modal con formulario de captura de información.


¿Qué necesitas antes de empezar?
  • Una cuenta de Google con acceso a Google Sheets y Google Apps Script.
  • El Modal ya creado y configurado en icomm, con el formulario que se quiere conectar.
  • Unos minutos: la conexión no requiere programación avanzada, pero sí copiar y pegar un script y seguir el orden de los pasos.


Paso 1: Preparar la planilla de Google Sheets

  1. Crea una planilla nueva en Google Sheets.
  2. Anota el nombre exacto de la hoja donde se van a guardar los datos (por defecto Sheet1 u Hoja 1, según el idioma de la cuenta). Es sensible a mayúsculas y espacios.
  3. Deja la hoja completamente vacía, sin encabezados. El script los crea automáticamente a partir de los campos del formulario.
  4. Revisa la configuración regional en Archivo → Configuración → Configuración regional, ya que determina cómo Sheets interpreta las fechas que lleguen como texto.


Paso 2: Crear el receptor del Webhook con Apps Script

Google Sheets no expone un endpoint propio para recibir datos por Webhook. Por eso se usa Apps Script: un pequeño proyecto ligado a la planilla que se publica como aplicación web y expone una URL pública donde icomm va a enviar la información.


2.1. Abrir el editor de Apps Script

Desde la planilla: Extensiones → Apps Script.



El script tiene que crearse desde este menú (ligado a la planilla), no como un proyecto independiente en script.google.com. Para verificarlo, el nombre de la planilla debe aparecer arriba a la izquierda del editor.


2.2. Pegar el código


Borra el contenido de Código.gs (la función myFunction vacía) y pegá el siguiente script completo:


const HOJA     = 'Sheet1';
const TOKEN = 'MITOKEN';
const COL_FECHA = 'fecha';
const LOG_BODY = false;

function doPost(e) {
const t0 = Date.now();
const rid = Utilities.getUuid().slice(0, 8); // correlaciona líneas de una misma request

console.log('[%s] === doPost inicio ===', rid);

const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) {
console.warn('[%s] lock no obtenido tras 30s -> busy', rid);
return json({ok: false, error: 'busy'});
}
console.log('[%s] lock obtenido', rid);

try {
if (e.parameter.token !== TOKEN) {
console.warn('[%s] token invalido (recibido: %s)', rid, e.parameter.token || '(vacio)');
return json({ok: false, error: 'unauthorized'});
}

if (!e.postData || !e.postData.contents) {
console.error('[%s] request sin body', rid);
return json({ok: false, error: 'body vacio'});
}
console.log('[%s] contentType=%s bytes=%s', rid, e.postData.type, e.postData.contents.length);
if (LOG_BODY) console.log('[%s] body: %s', rid, e.postData.contents);

const body = JSON.parse(e.postData.contents);

const rawInputs = body.data?.inputs ?? body.inputs;
console.log('[%s] inputs origen=%s cantidad=%s', rid,
body.data?.inputs ? 'body.data.inputs' : (body.inputs ? 'body.inputs' : 'ninguno'),
Array.isArray(rawInputs) ? rawInputs.length : 'n/a');

const datos = normalizarInputs(rawInputs);
const claves = Object.keys(datos);
console.log('[%s] claves normalizadas: %s', rid, JSON.stringify(claves));

if (!claves.length) {
console.warn('[%s] inputs vacio -> aborta', rid);
return json({ok: false, error: 'inputs vacio'});
}

const fechaStr = body.timestamp != null ? String(body.timestamp) : '';
if (fechaStr === '') {
console.warn('[%s] timestamp ausente o null. Claves del body: %s',
rid, JSON.stringify(Object.keys(body)));
} else {
console.log('[%s] timestamp="%s"', rid, fechaStr);
}

const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HOJA);
if (!sheet) {
const existentes = SpreadsheetApp.getActiveSpreadsheet().getSheets().map(s => s.getName());
console.error('[%s] hoja "%s" no existe. Hojas: %s', rid, HOJA, JSON.stringify(existentes));
return json({ok: false, error: 'hoja no encontrada'});
}

// --- Encabezados actuales ---
const ultCol = sheet.getLastColumn();
let headers = ultCol > 0
? sheet.getRange(1, 1, 1, ultCol).getValues()[0].map(h => String(h).trim())
: [];
while (headers.length && headers[headers.length - 1] === '') headers.pop();
console.log('[%s] headers leidos (lastColumn=%s): %s', rid, ultCol, JSON.stringify(headers));

let cambio = false;

// --- Garantiza 'fecha' como primera columna ---
if (!headers.length) {
headers = [COL_FECHA];
cambio = true;
console.log('[%s] hoja vacia -> crea columna "%s"', rid, COL_FECHA);
} else if (headers[0] === '') {
headers[0] = COL_FECHA;
cambio = true;
console.log('[%s] A1 vacio -> repara con "%s"', rid, COL_FECHA);
} else if (headers.indexOf(COL_FECHA) === -1) {
sheet.insertColumnBefore(1);
headers.unshift(COL_FECHA);
cambio = true;
console.log('[%s] inserta columna "%s" al inicio (corre el resto)', rid, COL_FECHA);
}

// --- Columnas nuevas ---
const nuevas = claves.filter(k => headers.indexOf(k) === -1);
if (nuevas.length) {
headers = headers.concat(nuevas);
cambio = true;
console.log('[%s] columnas nuevas: %s', rid, JSON.stringify(nuevas));
}

if (cambio) {
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
console.log('[%s] headers reescritos: %s', rid, JSON.stringify(headers));
}

// --- Claves que llegaron pero no se van a escribir (no deberia pasar) ---
const huerfanas = claves.filter(k => headers.indexOf(k) === -1);
if (huerfanas.length) console.error('[%s] claves sin columna: %s', rid, JSON.stringify(huerfanas));

// --- Fila alineada ---
const idxFecha = headers.indexOf(COL_FECHA);
const fila = headers.map((h, i) => i === idxFecha ? fechaStr : aCelda(datos[h]));
sheet.appendRow(fila);

const nroFila = sheet.getLastRow();
console.log('[%s] fila %s escrita: %s', rid, nroFila, JSON.stringify(fila));
console.log('[%s] === OK en %sms ===', rid, Date.now() - t0);

return json({ok: true, fila: nroFila, columnasNuevas: nuevas});

} catch (err) {
console.error('[%s] EXCEPCION: %s\n%s', rid, err, err.stack || '(sin stack)');
return json({ok: false, error: String(err)});
} finally {
lock.releaseLock();
console.log('[%s] lock liberado', rid);
}
}

// Acepta [{name,value}], [{key,value}], [{campo:valor}] o un objeto plano
function normalizarInputs(inputs) {
const out = {};
if (!inputs) return out;

if (!Array.isArray(inputs)) {
if (typeof inputs === 'object') { Object.assign(out, inputs); return out; }
console.warn('normalizarInputs: tipo inesperado "%s"', typeof inputs);
return out;
}

inputs.forEach((item, i) => {
if (!item || typeof item !== 'object') {
console.warn('normalizarInputs: item[%s] no es objeto: %s', i, JSON.stringify(item));
return;
}

const nombre = item.name !== undefined ? item.name
: item.key !== undefined ? item.key
: null;

if (nombre !== null) {
const k = String(nombre).trim();
if (k) out[k] = item.value;
else console.warn('normalizarInputs: item[%s] con nombre vacio', i);
} else {
console.warn('normalizarInputs: item[%s] sin name/key, se usa como objeto plano: %s',
i, JSON.stringify(item));
Object.keys(item).forEach(k => out[String(k).trim()] = item[k]);
}
});
return out;
}

function aCelda(v) {
if (v === undefined || v === null) return '';
if (typeof v === 'object') return JSON.stringify(v);
const s = String(v);
return /^[=+\-@]/.test(s) ? "'" + s : s;
}

function doGet() { return json({ok: true, status: 'alive'}); }

function json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}


Guarda con Ctrl+S (o Cmd+S).


Ejemplo visual:



2.3. Configurar las constantes

En el bloque superior del script, ajustá:

Constante

Qué es

Ejemplo

HOJA

Nombre exacto de la pestaña destino

'Sheet1'

TOKEN

Secreto que valida el request

'MITOKEN'cambiar por un valor propio

COL_FECHA

Nombre de la columna de fecha/hora

'fecha'

LOG_BODY

Si registra el body completo en los logs

false en producción

El TOKEN es la única barrera del endpoint, que es público: conviene un valor largo y aleatorio, no una palabra simple. Podés generarlo ejecutando Utilities.getUuid() una vez desde el editor.


2.4. Probar antes de desplegar

Agregá esta función al final del script para validar la lógica sin necesidad de publicar nada todavía:

function testDoPost() {
const fake = {
parameter: { token: TOKEN },
postData: {
type: 'application/json',
contents: JSON.stringify({
timestamp: '2026-09-01 13:20:00',
data: { inputs: [
{name: 'email', value: 'test@gmail.com'},
{name: 'empresa', value: 'icomm'}
]}
})
}
};
const res = doPost(fake);
console.log('respuesta: %s', res.getContent());
}

Seleccioná esta función en el desplegable de la parte superior y presioná Ejecutar. Los console.log aparecen al instante en el panel inferior.




Verificá en la planilla que se haya creado la fila de encabezados (fecha, email, empresa) junto con la fila de datos.


2.5. Desplegar como aplicación web

Implementar → Nueva implementación.

  1. Hacé clic en el engranaje junto a Seleccionar tipo y elegí Aplicación web.
  2. Completá:

Campo

Valor

Por qué

Descripción

Libre (ej: webhook v1)

Solo referencia

Ejecutar como

Yo (tu cuenta)

Corre con tus permisos sobre la planilla

Quién tiene acceso

Cualquier persona

icomm no inicia sesión con tu cuenta de Google.

  1. Hacé clic en Implementar y copiá la URL que termina en /exec.



2.6. Autorizar el script

La primera vez va a aparecer "Google no verificó esta aplicación". Es esperable, porque el script es propio:

  1. Hacé clic en Configuración avanzada.
  2. Elegí Ir a <nombre del proyecto> (no seguro).
  3. Revisá los permisos solicitados y hacé clic en Permitir.


2.7. Probar el endpoint

Antes de conectarlo con icomm, conviene simular un envío real:

curl -L -X POST "https://script.google.com/macros/s/XXXX/exec?token=TU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"timestamp": "2026-09-01 13:20:00",
"data": {
"inputs": [
{"name": "email", "value": "test@gmail.com"},
{"name": "empresa", "value": "icomm"}
]
}
}'

El flag -L es imprescindible: sin él, curl se detiene en la redirección y parece que falló aunque la fila se haya escrito correctamente. Si preferís Postman, activá Automatically follow redirects en la configuración de la request.



Una respuesta como esta confirma que el endpoint funciona:

{"ok": true, "fila": 2, "columnasNuevas": ["email", "empresa"]}


Paso 3: Activar el Webhook en el Modal de icomm

Con el endpoint de Google ya probado, el último paso es indicarle al Modal que envíe los datos del formulario a esa URL.

  1. Ingresá a Personalización → Sitios → Modales.
  2. Seleccioná el Sitio donde se encuentra el Modal que querés configurar.
  3. Ingresá a la configuración del Modal.
  4. Dirigite a la sección Webhooks.
  5. Activá el interruptor de Webhooks.
  6. Completá los campos:
  7. Guardá la configuración del Modal.

A partir de ese momento, cada vez que un usuario complete el formulario, icomm va a enviar automáticamente los datos a la planilla, sin necesidad de exportaciones ni cargas manuales.


Podés repetir esta configuración en más de un Modal apuntando al mismo endpoint, o crear un endpoint (y una planilla) distinto por cada uno.


Formato de los datos que se reciben

icomm envía los datos del formulario con esta estructura:

{
"event": "form_submit",
"timestamp": "2026-09-01 13:20:00",
"data": {
"inputs": [
{"name": "email", "value": "test@gmail.com"},
{"name": "evento", "value": "open"}
],
"modalId": "...",
"siteName": "...",
"target": "..."
}
}
  • timestamp va a la primera columna (fecha) tal cual llega, sin reformatear.
  • data.inputs convierte cada {name, value} en una columna y su valor.
  • event, modalId, siteName y target no se registran por defecto. Si se necesitan en la planilla, hay que sumarlos al objeto de datos dentro del script.
  • Si la hoja está vacía, el script crea fecha y las columnas de los campos del formulario. Si llega un campo nuevo, agrega la columna al final. El orden de las columnas queda fijado por el primer envío.


Seguridad y buenas prácticas

  • El endpoint de Google es público: cualquiera que tenga la URL y el token puede escribir filas. Usá un token largo y aleatorio, no una palabra simple.
  • Las solicitudes que envía icomm incluyen los headers Webhook-Id, Webhook-Timestamp y Webhook-Signature (firma HMAC-SHA256) para que el sistema receptor valide el origen del envío. Apps Script no puede leer headers HTTP entrantes, por lo que este script no valida esa firma: su única protección es el TOKEN en la URL. Si se necesita validar la firma real de icomm, hace falta un intermediario (por ejemplo, un API Gateway con una función Lambda) delante de Apps Script.
  • Como buena práctica adicional, evitá activar LOG_BODY en producción: si se loguea el body completo, los datos personales de los usuarios quedan guardados en los registros de ejecución de Apps Script.
  • Este esquema es apto para un volumen de decenas o cientos de envíos diarios (Apps Script tiene una cuota de aproximadamente 20.000 ejecuciones por día en cuentas Workspace). Para volúmenes más altos, conviene una integración directa con un sistema propio.


Problemas frecuentes

Síntoma

Causa probable

Solución

Los cambios en el script no tienen efecto

No se creó una nueva versión de la implementación

Desde Implementar → Administrar implementaciones, editar la implementación activa y elegir Nueva versión

unauthorized

El token no coincide o falta en la URL

Revisar el query string de la URL pegada en el Modal

La fecha no se guarda

El campo timestamp llegó mal formado

Revisar el payload de origen

Columnas desalineadas

Encabezados con formato pero sin título

Limpiar el formato de la fila 1

El Modal marca error pero la fila se escribe igual

El sistema no sigue la redirección 302 que devuelve Apps Script

Confirmar que el emisor siga redirects (curl -L, o "Automatically follow redirects" en Postman)

Devuelve una pantalla de login en vez de guardar el dato

El acceso de la implementación quedó en "Cualquier persona con cuenta de Google", o se usó la URL /dev en vez de /exec

Volver a implementar con acceso Cualquier persona y usar la URL /exec

Cannot read properties of null

El script se creó como proyecto independiente, no ligado a la planilla

Recrearlo desde Extensiones → Apps Script dentro de la planilla


Artículos relacionados

Actualizado el: 07/09/2026

¿Este artículo te resultó útil?

Comparte tu opinión

Cancelar

¡Gracias!