> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://help.icommkt.com/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# ¿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.
|| Presta especial atención a este punto ya que será relevante en próximos pasos.

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.

![](https://storage.crisp.chat/users/helpdesk/website/-/e/4/b/4/e4b41ef8ae96c800/01-configuracion-regional_9w8dpe.png =632xauto)
## 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**.

![](https://storage.crisp.chat/users/helpdesk/website/-/e/4/b/4/e4b41ef8ae96c800/02-abrir-appscript_1gx7aj8.png)

|| 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     = 'Hoja1';
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:
![](https://storage.crisp.chat/users/helpdesk/website/-/e/4/b/4/e4b41ef8ae96c800/03-codigo-pegado_ro2h14.png =984xauto)


###### 2.3. Configurar las constantes
En el bloque superior del script, ajustá:
| Constante | Qué es | Ejemplo |
| ---- | ---- | ---- |
| `HOJA` | Nombre exacto de la pestaña destino. VALIDA que ambos nombres coincidan. | `'Hoja1'`→ **reemplazar por el nombre que lleva la hoja del sheets** |
| `TOKEN` | Secreto que valida el request | `'MITOKEN'` → **cambiar por un valor propio** |
| `COL_FECHA` | Nombre de la columna de fecha/hora | `'fecha'`→ **puedes dejarlo como está** |
| `LOG_BODY` | Si registra el body completo en los logs | `false` en producción → **puedes dejarlo como está** |

||| El nombre de la hoja debe ser identico al de google sheets. Si por ejemplo se llama "Suscriptores Evento Septiembre", debes colocar ese mismo nombre en el código que pegaste.

||| El TOKEN es la única barrera del endpoint, que es público: conviene un valor largo y aleatorio, no una palabra simple.


###### 2.4. Desplegar como aplicación web

1. Haz click en el botón de **Implementar → Nueva implementación.**
2. Hacé clic en el engranaje junto a *Seleccionar tipo* y elegí **Aplicación web**.
3. 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. |

3. Hacé clic en **Implementar** y copiá la URL que termina en `/exec`.

![](https://storage.crisp.chat/users/helpdesk/website/-/e/4/b/4/e4b41ef8ae96c800/image_nkb9ff.png =743xauto)

###### 2.5. 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.6. Copia la URL generada
Luego de otorgar los permisos, hacé clic en **Implementar** y copiá la URL que termina en `/exec`.

![](https://storage.crisp.chat/users/helpdesk/website/-/e/4/b/4/e4b41ef8ae96c800/image_1qdk8uj.png =620xauto)


## 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:
   * **Nombre:** algo que te permita identificarlo, por ejemplo `Google Sheets - Suscriptores`.
   * **URL:** pegá la URL  que copiaste en el paso 2.5. Luego del texto `/exec`. Agrega  `?token=` y seguido escribe tu token.
   * **Ejemplo:**
     URL obtenida en el paso 2.5:
     `https://script.google.com/macros/s/ABC123xyz456/exec`
     Token:
     `miToken123`
     URL que tenés que ingresar:
     `https://script.google.com/macros/s/ABC123xyz456/exec?token=miToken123`
     La URL final debe comenzar con `https://` y terminar con tu token.


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
* [Guía General del Módulo Sitios](https://help.icommkt.com/es/article/guia-general-del-modulo-sitios-1soksbm/)
* [Pop-up de Suscripción](https://help.icommkt.com/es/article/como-crear-el-pop-up-de-suscripcion-desde-sitios-1e190y5/)
* [Pop-up de Carrito Abandonado](https://help.icommkt.com/es/article/como-crear-el-pop-up-de-carrito-abandonado-desde-sitios-1vmqe9q/)
* [Modales Embebido](https://help.icommkt.com/es/article/creacion-y-configuracion-de-modales-embebidos-en-icomm-cbifhs/)