La etiqueta widget.js que ya añadiste para la edición en vivo cuenta en silencio cada visita a una página, averigua de dónde viene el visitante y qué anuncio lo trajo. Añade una línea de JavaScript (o una petición desde tu servidor) y verás también quién añadió algo al carrito, quién se registró y quién pagó de verdad, con el dinero incluido. Sin un segundo script de analítica, sin gestor de etiquetas y sin reescribir el banner de cookies.
Esto es lo que hay en la carta:
- Visitas a páginas: automáticas, cero código. Canales, etiquetas UTM, ids de clic publicitario, geografía, dispositivos.
- Eventos propios desde el navegador:
window.crmTrack('add_to_cart', …)para señales de UX. - Eventos de servidor:
POST /marketing/eventcon una clave secreta, para compras y todo lo que deba contarse exactamente una vez.
Todo acaba en el CRM, en Marketing: Tráfico para las visitas, Eventos para tus propios eventos y Canales publicitarios para los enlaces etiquetados.
Lo que funciona desde el primer momento: visitas a páginas
Si widget.js está en la página, las visitas ya se están contando. La etiqueta es la misma de Edición en vivo:
<script src="https://widget.sitecog.com/widget.js" defer></script>En cada carga de página el widget envía una visita con navigator.sendBeacon: una petición diminuta de tipo “dispara y olvídate” que no ralentiza la página y sobrevive aunque el visitante cierre la pestaña. El servidor responde 204 No Content; no hay nada que leer de vuelta.
Qué se recoge
Desde el navegador:
- URL de la página, título y referrer;
- etiquetas UTM:
utm_source,utm_medium,utm_campaign,utm_term,utm_content; - ids de clic publicitario:
gclid,gbraid,wbraid(Google),yclid(Yandex),fbclid(Meta),msclkid(Microsoft); - el código de enlace publicitario
?dl=de los enlaces de Canales publicitarios; - algunas cookies publicitarias, si tu web ya las tiene:
_ga,_gcl_*,_ym_uid,_fbp,_fbc(con el modo de consentimiento, no se leen hasta que el visitante acepta, y con Global Privacy Control no se leen nunca); - tamaño de pantalla y de viewport, densidad de píxeles, idioma del navegador y zona horaria.
Lo que añadimos nosotros:
- ubicación, a partir de la dirección IP;
- tipo de dispositivo, navegador y sistema operativo;
- canal de tráfico: de pago, social, orgánico, referido, email o directo;
- visitante nuevo o recurrente.
Antes de guardar nada, recortamos la IP y limpiamos las URLs de fragmentos #… y de parámetros delicados como token o email. Los detalles, en Qué guardamos y durante cuánto.
Los bots no se tiran: se marcan y se filtran de los informes. Así, los números que miras hablan de personas, y los datos en bruto siguen ahí por si algún día te preguntas cuánto de tu tráfico son crawlers (spoiler: más de lo que te gustaría).
Los informes viven en Marketing → Tráfico: visitantes, sesiones, canales, páginas, geografía y dispositivos.
Días y horas en los informes
“Ayer” en un informe es ayer en la zona horaria de tu sitio, no en UTC ni en la zona de quien esté mirando el gráfico. Una tienda en Madrid ve su pico de la tarde a las 20:00, no a las 18:00, y una dueña en Madrid y un gestor en Nueva York miran los mismos días.
- La zona se configura en CRM → Ajustes → Zona horaria. Mientras nadie elija una, el CRM toma la zona horaria del navegador del propietario.
- Los días en Tráfico, Canales publicitarios y Eventos, y las horas de los gráficos, la siguen.
- Cambiar la zona recalcula también los informes pasados. Los datos no cambian: solo cambia dónde cae la medianoche.
Ids de visitante y de sesión
El widget reconoce a un visitante que vuelve sin usar cookies. Guarda tres claves en localStorage:
| Clave | Qué es | Cuánto dura |
|---|---|---|
crm_vid | Id del visitante | Hasta que el visitante borre los datos del sitio |
crm_sid | Id de sesión | Empieza una nueva tras 30 minutos de inactividad |
crm_sat | Momento de la última actividad: sirve para decidir cuándo termina una sesión | Se actualiza mientras el visitante navega |
Si el almacenamiento está bloqueado (algunos modos de privacidad lo hacen), los ids viven en memoria mientras la pestaña siga abierta. Quédate con crm_vid y crm_sid: los necesitarás para vincular eventos de servidor al visitante.
Con el modo de consentimiento activado, estas claves solo aparecen después de que el visitante acepte, y crmConsent.deny() las borra.
Aplicaciones de una sola página
En una SPA (React Router, navegación de cliente de Next.js, Vue Router) la página no se recarga al cambiar de ruta, pero el widget se entera igualmente, sin que tengas que tocar nada. Escucha history.pushState, history.replaceState y el evento popstate y, tras una pausa de 300 ms (para que una ráfaga de redirecciones cuente como una sola visita), envía una visita si ha cambiado la ruta o la query string. El referrer de esa visita “virtual” es la página anterior de tu web, así que los recorridos se leen igual que en una web clásica.
El comportamiento se ajusta con el atributo data-spa de la etiqueta:
data-spa | Qué cuenta como una visita nueva |
|---|---|
| sin atributo (por defecto) | Un cambio de ruta o de query string vía pushState / replaceState / popstate. |
"hash" | Lo mismo, y además los cambios de hash: para routers del estilo #/route. |
"off" | El seguimiento de SPA se apaga: una visita por cada carga del script, como antes. |
<script src="https://widget.sitecog.com/widget.js" data-spa="hash" defer></script>¿Un paso que no cambia la URL, como un modal o un asistente de varios pasos? Eso no es una visita: envíalo como evento propio.
Privacidad y consentimiento
El widget no pone cookies, y si tu web necesita consentimiento antes de la analítica, se enchufa a tu banner en vez de pelearse con él. Sin código de carga diferida ni trucos: un atributo en la etiqueta y dos botones.
Modo de consentimiento
Añade data-consent="required" a la etiqueta del widget:
<script src="https://widget.sitecog.com/widget.js" data-consent="required" defer></script>Hasta que el visitante acepte:
- no se envían visitas ni eventos. Se descartan, no se guardan en cola: si el visitante acepta más tarde, no se envía nada con efecto retroactivo;
- no se lee
document.cookie, así que las cookies publicitarias (_ga,_gcl_*,_ym_uid,_fbp,_fbc) quedan intactas; - no se crean ni se guardan
crm_vid,crm_sidnicrm_sat.
Los formularios de leads y el chat de soporte siguen funcionando antes del consentimiento, solo que sin vid/sid, es decir, sin atribución: ese lead no queda vinculado a ningún canal, etiqueta UTM ni enlace publicitario.
Sin el atributo no cambia nada: la cuenta empieza en cuanto carga la página, como siempre.
window.crmConsent
Tu banner habla con el widget a través de window.crmConsent:
crmConsent.grant()functionopcionalcrmConsent.deny()functionopcionalcrm_vid, crm_sid y crm_sat. Se respeta incluso sin data-consent: mira más abajo.crmConsent.status()() => 'granted' | 'denied' | 'pending'opcionalpending: el visitante aún no ha decidido.crmConsent.requiredbooleanopcionaltrue si la etiqueta lleva data-consent="required".crmConsent.gpcbooleanopcionaltrue si el navegador envía la señal Global Privacy Control.La elección se guarda en localStorage con la clave crm_consent, así que en las siguientes visitas el widget ya la conoce y tu banner no tiene que volver a preguntar.
¿Y si el banner responde antes de que cargue widget.js? Lo mismo que con crmq: una cola. push sigue funcionando después de la carga, así que tu banner puede usar siempre push y olvidarse del orden de carga.
window.crmConsent = window.crmConsent || [];
crmConsent.push('grant'); // o 'deny'Cada vez que cambia la elección, el widget lanza el evento crm:consent en window. En el manejador, lee el estado con window.crmConsent.status().
<div id="cookie-banner" hidden>
<p>Usamos analítica propia para saber qué funciona en la web. ¿Te parece bien?</p>
<button type="button" data-choice="grant">Aceptar</button>
<button type="button" data-choice="deny">Rechazar</button>
</div>
<script>
window.crmConsent = window.crmConsent || [];
const banner = document.getElementById('cookie-banner');
banner.addEventListener('click', (e) => {
const choice = e.target.closest('[data-choice]')?.dataset.choice;
if (!choice) return;
crmConsent.push(choice); // 'grant' o 'deny': vale antes y después de cargar widget.js
banner.hidden = true;
});
// Enseña el banner solo a quien todavía no ha elegido
function syncBanner() {
banner.hidden = window.crmConsent.status?.() !== 'pending';
}
window.addEventListener('load', syncBanner);
window.addEventListener('crm:consent', syncBanner);
</script>
<script src="https://widget.sitecog.com/widget.js" data-consent="required" defer></script>'use client';
import { useEffect, useState } from 'react';
type ConsentStatus = 'granted' | 'denied' | 'pending';
type CrmConsent = {
grant(): void;
deny(): void;
status(): ConsentStatus;
required: boolean;
gpc: boolean;
push(choice: 'grant' | 'deny'): void;
};
declare global {
interface Window {
crmConsent?: CrmConsent | Array<'grant' | 'deny'>;
}
}
function choose(choice: 'grant' | 'deny') {
window.crmConsent = window.crmConsent || [];
window.crmConsent.push(choice); // la cola sirve antes y después de cargar widget.js
}
export function CookieBanner() {
// null = el widget aún no ha cargado: mejor no enseñar nada todavía
const [status, setStatus] = useState<ConsentStatus | null>(null);
useEffect(() => {
const read = () => {
const api = window.crmConsent;
if (api && !Array.isArray(api)) setStatus(api.status());
};
read(); // el widget ya estaba cargado
window.addEventListener('load', read); // o carga un poco más tarde
window.addEventListener('crm:consent', read); // la elección ha cambiado
return () => {
window.removeEventListener('load', read);
window.removeEventListener('crm:consent', read);
};
}, []);
if (status !== 'pending') return null;
return (
<div className="cookie-banner">
<p>Usamos analítica propia para saber qué funciona en la web.</p>
<button onClick={() => choose('grant')}>Aceptar</button>
<button onClick={() => choose('deny')}>Rechazar</button>
</div>
);
}Global Privacy Control y “No me rastrees”
Si el navegador envía la señal Global Privacy Control (navigator.globalPrivacyControl), el widget no lee nunca las cookies publicitarias, en ningún modo: con data-consent o sin él. Puedes comprobarlo en crmConsent.gpc.
Además, crmConsent.deny() se respeta en cualquier web, también sin el atributo data-consent: borra los ids del visitante y la renuncia queda recordada. Así, un simple enlace en el pie de página basta para ofrecer una forma de darse de baja:
<a href="#" id="dont-track">No quiero que me rastreen</a>
<script>
document.getElementById('dont-track').addEventListener('click', (e) => {
e.preventDefault();
window.crmConsent = window.crmConsent || [];
crmConsent.push('deny'); // borra crm_vid / crm_sid / crm_sat, con o sin data-consent
e.currentTarget.textContent = 'Hecho: ya no te rastreamos';
});
</script>Qué guardamos y durante cuánto
Direcciones IP recortadas. En IPv4 el último octeto pasa a cero (203.0.113.57 → 203.0.113.0); en IPv6 la dirección se corta a /48. El país y la ciudad se calculan con la IP completa antes del recorte, así que los informes de geografía siguen funcionando.
URLs limpias. De las URLs de página, los referrers, la URL de entrada y las URLs de los eventos quitamos el fragmento (#…) y estos parámetros de query:
token *_token access_token id_token refresh_token auth_token auth password pass passwd pwd email e-mail mail key api_key apikey secret client_secret otp session sessionid jwt
*_token significa cualquier nombre que acabe en _token. En cambio, code se queda (los códigos promocionales son útiles en los informes), igual que las etiquetas UTM, los ids de clic publicitario y dl.
13 meses y fuera. Las visitas y los eventos se guardan 13 meses (395 días) y después se borran; quien opera la instalación puede cambiar ese plazo. Los leads no entran en esta limpieza: no se borran.
Un visitante puede pedir que lo olvidemos antes. El propietario del sitio borra su perfil, sus visitas y sus eventos por id de visitante directamente desde el CRM; los leads, solo si se elige aparte. Más detalles en Leads → Eliminar los datos de una persona.
Enlaces publicitarios: sabe qué anuncio trajo al visitante
En Marketing → Canales publicitarios creas enlaces etiquetados para cada sitio donde te anuncias: una publicación en una red social, una newsletter, un banner en la web de otro. Cada enlace apunta a tu web y lleva etiquetas UTM más un código corto:
https://shop.example/?utm_source=instagram&utm_medium=social&utm_campaign=autumn_sale&dl=k3Zp9QaW1xdl es un código de 10 caracteres que identifica el enlace. No tienes que hacer nada en la web: el widget lo recoge con la primera visita, y cada visita, evento y lead de esa sesión se atribuye al enlace. Cada enlace tiene sus propias estadísticas en el CRM.
Eventos propios
Las visitas te dicen adónde fue la gente. Los eventos te dicen qué hizo: añadir al carrito, registrarse, abrir la calculadora de precios, pagar. Describes un evento una vez en el CRM y luego lo envías desde el navegador o desde tu servidor.
Paso cero: declara el evento en el CRM
Diil solo acepta los eventos que conoce. Y es una ventaja: una errata en tu código no puede crear en silencio un tipo de evento nuevo y partirte los informes en dos.
Abre Marketing → Eventos y haz clic en “Crear evento”
Necesitas tener un sitio (dominio) seleccionado arriba.Ponle exactamente el nombre que usa el código
add_to_cart,signup_completed,purchase. Letras latinas, dígitos y guiones bajos, empezando por una letra, hasta 64 caracteres. Añade un título legible y una descripción: tu yo del futuro te lo agradecerá.Describe los parámetros
Hasta 20 por evento, cada uno con un tipo: Texto, Número o Sí/no (booleano). Lo que no se describa aquí no llegará a la base de datos.¿Hay dinero? Marca “Este evento genera ingresos”
Y pon el nombre de la moneda (EUR,USD,USDT, incluso tus propios puntos de fidelidad). Sin esta casilla, elvaluedel evento no se guarda.Copia la llamada lista
El CRM te muestra la llamada exacta acrmTracky la petición de servidor para este evento. Pegar y listo.
Un sitio puede tener hasta 100 tipos de evento activos. En cuanto un evento tiene datos, su nombre queda bloqueado (ya está en tu código) y el evento se puede archivar en lugar de eliminarse, para que los informes nunca acaben con filas sin nombre.
Envía eventos desde el navegador: crmTrack
window.crmTrack(
name: string,
params?: Record<string, string | number | boolean>,
options?: { id?: string; value?: number; currency?: string },
): voidEs de tipo “dispara y olvídate”: no devuelve nada, nunca te lanza excepciones y envía con sendBeacon, así que el evento sobrevive aunque el clic te saque de la página. Los ids de visitante y de sesión se adjuntan solos: tú solo describes qué ha pasado.
Argumentos
namestringobligatorioparamsobjectopcionalPor defecto: {}{ sku: 'air3-graphite', price: 149 }. Las claves siguen las mismas reglas que los nombres, hasta 40 caracteres. Solo se conservan los parámetros descritos en el CRM; los valores se convierten al tipo declarado: mira Parámetros y tipos.options.idstringopcionalPor defecto: ningunoid dos veces y el evento se guarda una sola. Para compras, usa el id de tu pedido.options.valueintegeropcionalPor defecto: ninguno149900 significa 1499,00. De 0 a 1012. Solo se guarda si el evento tiene activado “Este evento genera ingresos”.options.currencystringopcionalPor defecto: la moneda del eventoEUR, USD, UAH. Si la omites, se usa la moneda configurada en el evento en el CRM.Ejemplos
Los tres eventos que necesita casi cualquier tienda, esté hecha tu web con lo que esté hecha:
<button id="buy" data-sku="air3-graphite" data-price="149">Añadir al carrito</button>
<form id="signup">…</form>
<script>
// ?. para que un bloqueador de anuncios que se haya comido widget.js no rompa tu botón
document.getElementById('buy').addEventListener('click', (e) => {
const { sku, price } = e.currentTarget.dataset;
window.crmTrack?.('add_to_cart', { sku, price: Number(price) });
});
// Llámala cuando la cuenta se haya creado de verdad, no en el primer clic
function onSignupSuccess() {
window.crmTrack?.('signup_completed', { method: 'email', newsletter: true });
}
// En la página de "gracias". Esto se ejecuta antes que el widget.js diferido,
// así que pasa por la cola (ver más abajo); el id hace que recargar no haga daño
window.crmq = window.crmq || [];
crmq.push([
'purchase',
{ order_id: 'A-1024', items: 2 },
{ id: 'A-1024', value: 29800, currency: 'EUR' }, // 298,00 EUR
]);
</script>// diil.d.ts: enséñale a TypeScript lo que es el widget, una sola vez
export {};
type CrmParams = Record<string, string | number | boolean>;
type CrmOptions = { id?: string; value?: number; currency?: string };
declare global {
interface Window {
crmTrack?: (name: string, params?: CrmParams, options?: CrmOptions) => void;
crmq?: unknown[];
}
}
// AddToCartButton.tsx
type Product = { sku: string; price: number };
export function AddToCartButton({ product, onAdd }: { product: Product; onAdd: () => void }) {
const handleClick = () => {
onAdd();
window.crmTrack?.('add_to_cart', { sku: product.sku, price: product.price });
};
return <button onClick={handleClick}>Añadir al carrito</button>;
}// app/checkout/success/PurchaseTracker.tsx
'use client';
import { useEffect } from 'react';
type Props = { orderId: string; items: number; totalCents: number; currency: string };
export function PurchaseTracker({ orderId, items, totalCents, currency }: Props) {
useEffect(() => {
// El efecto puede ejecutarse antes de que cargue widget.js: la cola lo espera
window.crmq = window.crmq || [];
window.crmq.push([
'purchase',
{ order_id: orderId, items },
{ id: orderId, value: totalCents, currency },
]);
}, [orderId, items, totalCents, currency]);
return null;
}
// app/checkout/success/page.tsx (un Server Component)
// <PurchaseTracker orderId="A-1024" items={2} totalCents={29800} currency="EUR" />Llamar antes de que cargue el script: la cola crmq
widget.js se carga con defer, así que durante un instante window.crmTrack todavía no existe. Los eventos lanzados demasiado pronto (al cargar la página, en un efecto, desde un script inline en el <head>) se perderían. La cola lo soluciona:
window.crmq = window.crmq || [];
crmq.push(['purchase', { order_id: 'A-1024', items: 2 }, { id: 'A-1024', value: 29800, currency: 'EUR' }]);Cada elemento es un array con los mismos tres argumentos que crmTrack: nombre, parámetros y opciones. Cuando widget.js se carga, reproduce todo lo que hay en la cola. A partir de ahí, crmq.push envía al instante, así que puedes usar siempre la cola y olvidarte del orden de carga.
type CrmParams = Record<string, string | number | boolean>;
type CrmOptions = { id?: string; value?: number; currency?: string };
export function track(name: string, params?: CrmParams, options?: CrmOptions) {
if (typeof window === 'undefined') return; // render en servidor: nada que hacer
window.crmq = window.crmq || [];
window.crmq.push([name, params || {}, options || {}]);
}Parámetros y tipos
Los parámetros se comprueban contra la descripción del CRM y se normalizan al tipo declarado según estas reglas:
| Tipo declarado | Acepta | Conviene saber |
|---|---|---|
| Texto | Cualquier valor | Se recorta a 500 caracteres. Los objetos se convierten en cadenas JSON, aunque los valores planos quedan mucho mejor en los informes. |
| Número | Números finitos de hasta 1012 en valor absoluto | Se redondea a 4 decimales. |
| Sí/no | true, false, "true", "false", 1, 0 | Muy práctico cuando el valor sale de un atributo data-*. |
Claves de parámetro: letras latinas, dígitos y guiones bajos, empezando por una letra, hasta 40 caracteres. Hasta 20 parámetros por evento.
Ingresos e idempotencia
value y currency
El dinero se envía como un entero en unidades menores: céntimos, kopeks, satoshis, lo que sea la unidad más pequeña de tu moneda. 29800 con EUR son 298,00 €. El dinero con decimales en una base de datos acaba tarde o temprano en descuadres de céntimos, así que directamente no lo permitimos.
value: un entero de 0 a 1012.currency: de 1 a 10 letras latinas o dígitos (EUR,USD,UAH,USDT). Si la omites, se usa la moneda configurada en el evento.- Los dos se guardan solo si el evento tiene marcado Este evento genera ingresos; si no,
valueacaba siendonull.
const total = 298.0; // lo que muestra tu carrito
const value = Math.round(total * 100); // 29800: lo que quiere Diilid: cuéntalo una vez
Las páginas de “gracias” se recargan, se abren desde el historial, se comparten a un segundo dispositivo. Pasa un id (en el navegador) o un event_id (desde el servidor) y Diil guarda el evento una sola vez, llegue las veces que llegue. Hasta 128 caracteres, único por sitio. Tu número de pedido es el candidato perfecto.
Eventos de servidor: POST /marketing/event
Para todo lo que tenga que ver con dinero, el navegador no es el sitio donde contarlo. Los bloqueadores de anuncios pueden bloquear la petición, la gente cierra la pestaña antes de que cargue la página de “gracias” y cualquiera puede llamar a crmTrack('purchase') desde la consola. Tu servidor, en cambio, sabe exactamente cuándo se confirma un pago. Envía el evento desde ahí:
POST https://back.sitecog.com/marketing/event
content-type: application/json
x-event-key: sk_…Claves secretas
Los eventos de servidor se firman con una clave secreta: Marketing → Eventos → Claves secretas → Emitir una clave. Tiene la forma sk_ + 48 caracteres hexadecimales. Puedes tener hasta 5 claves activas por sitio (una por servidor o integración) y revocar cualquiera de ellas en el CRM. Los leads enviados desde un servidor usan las mismas claves.
Petición
x-event-keyheaderstringobligatoriosk_….namestringobligatorion.event_idstringopcionalduplicate: true y no se vuelve a guardar. Alias: id.visitor_idstringopcionalcrm_vid del visitante, sacado del navegador. Alias: vid.session_idstringopcionalcrm_sid del visitante. Con él, el evento hereda el canal, las etiquetas UTM y el enlace publicitario de esa sesión. Alias: sid.paramsobjectopcionalp.valueintegeropcionalval.currencystringopcionalcur.urlstringopcionalu.El cuerpo entero tiene que caber en 8 KB.
// Node 18+: fetch viene de serie
export async function sendPurchase(order) {
const res = await fetch('https://back.sitecog.com/marketing/event', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-event-key': process.env.DIIL_EVENT_KEY,
},
body: JSON.stringify({
name: 'purchase',
event_id: order.id, // "A-1024": los reintentos son seguros
visitor_id: order.crmVid, // guardado al pagar, puede ser null
session_id: order.crmSid,
params: { order_id: order.id, items: order.items.length },
value: order.totalCents, // 29800 = 298,00
currency: 'EUR',
url: 'https://shop.example/checkout',
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
console.error('Diil event failed:', res.status, await res.text());
}
return res.ok;
}<?php
function send_purchase(array $order): bool
{
$payload = [
'name' => 'purchase',
'event_id' => $order['id'], // "A-1024": los reintentos son seguros
'visitor_id' => $order['crm_vid'], // guardado al pagar, puede ser null
'session_id' => $order['crm_sid'],
'params' => ['order_id' => $order['id'], 'items' => count($order['items'])],
'value' => $order['total_cents'], // 29800 = 298,00
'currency' => 'EUR',
'url' => 'https://shop.example/checkout',
];
$ch = curl_init('https://back.sitecog.com/marketing/event');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-event-key: ' . getenv('DIIL_EVENT_KEY'),
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
error_log("Diil event failed: $status $body");
return false;
}
return true;
}curl -X POST https://back.sitecog.com/marketing/event \
-H "content-type: application/json" \
-H "x-event-key: $DIIL_EVENT_KEY" \
-d '{
"name": "purchase",
"event_id": "A-1024",
"visitor_id": "<crm_vid>",
"session_id": "<crm_sid>",
"params": { "order_id": "A-1024", "items": 2 },
"value": 29800,
"currency": "EUR",
"url": "https://shop.example/checkout"
}'Respuesta
{ "ok": true, "duplicate": false }{ "ok": true, "duplicate": true }okbooleanduplicatebooleanevent_id. No se ha guardado nada nuevo, y eso está bien: no es un error.Errores
A diferencia del navegador, la vía del servidor te dice exactamente qué ha fallado:
| Estado | Cuerpo | Qué ha pasado |
|---|---|---|
| 400 | {"message":"invalid_body"} | El cuerpo no es un JSON válido o no es el objeto esperado. |
| 400 | {"message":"invalid_event_name"} | El nombre no cumple el formato: letras latinas, dígitos y guiones bajos, empieza por una letra, hasta 64 caracteres. |
| 400 | {"message":"unknown_event","name":"purchse"} | No existe ese evento en Marketing → Eventos. Una errata, o aún no está declarado. El cuerpo te devuelve el nombre que enviaste. |
| 401 | {"message":"invalid_key"} | x-event-key falta, está mal formada o revocada. |
| 413 | — | El cuerpo pesa más de 8 KB. |
| 429 | {"message":"rate_limit_exceeded"} | Demasiadas peticiones en este minuto. Mira Límites. |
Vincula los eventos de servidor al visitante
Un evento de servidor por sí solo no sabe nada de anuncios: tu servidor no tiene ni idea de que el comprador llegó desde una publicación de Instagram hace tres días. El navegador sí. Así que el truco es llevar los ids del widget desde el navegador hasta tu backend junto con el pedido:
- al pagar, lee
crm_vidycrm_siddelocalStorage; - envíalos a tu backend con el pedido y guárdalos junto a él;
- cuando se confirme el pago, pásalos como
visitor_idysession_id.
Así el evento hereda el canal, las etiquetas UTM y el enlace publicitario de la primera visita de la sesión, y Marketing → Eventos muestra qué canal y qué enlace publicitario trajeron el dinero, no solo los clics.
// checkout.js: cuando el cliente pulsa "Pagar"
function diilIds() {
try {
return {
crm_vid: localStorage.getItem('crm_vid'),
crm_sid: localStorage.getItem('crm_sid'),
};
} catch {
return { crm_vid: null, crm_sid: null }; // almacenamiento bloqueado: el pedido sigue adelante
}
}
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cart, ...diilIds() }),
});// server.js (Express)
app.post('/api/orders', async (req, res) => {
const { cart, crm_vid, crm_sid } = req.body;
// Guarda los ids con el pedido: el pago suele confirmarse más tarde, por webhook
const order = await db.orders.create({ cart, crmVid: crm_vid, crmSid: crm_sid });
res.json({ id: order.id, payUrl: await createPayment(order) });
});
// Tu proveedor de pagos llama aquí cuando el dinero ha llegado de verdad
app.post('/webhooks/payment', async (req, res) => {
const order = await db.orders.markPaid(req.body.orderId);
// 3 · Diil: que la analítica nunca rompa el checkout
try {
await sendPurchase(order); // la función de "Eventos de servidor" de arriba
} catch (err) {
console.error('Diil is unreachable, will retry later', err);
}
res.sendStatus(200);
});Límites
Los límites son comunes a todas las rutas de marketing (visitas, eventos y leads juntos) y se cuentan en ventanas fijas de un minuto.
| Qué | Límite |
|---|---|
| Peticiones por IP | 120 por minuto |
| Peticiones por sitio | 6000 por minuto |
| Cuerpo de la petición | 8 KB (más → 413) |
| Tipos de evento activos por sitio | 100 |
| Parámetros por evento | 20 |
| Nombre de evento / clave de parámetro | 64 / 40 caracteres |
| Valor de un parámetro de texto | 500 caracteres |
id / event_id | 128 caracteres |
| Claves secretas activas por sitio | 5 |
Solución de problemas
El evento no aparece
- No está declarado. El endpoint del navegador siempre responde
204y descarta en silencio los nombres de evento desconocidos: sin pistas, por diseño. El endpoint del servidor es sincero:400 unknown_eventcon el nombre que enviaste. Ante la duda, envía el mismo evento una vez con curl y lee la respuesta. - El nombre no coincide.
addToCarten el código yadd_to_carten el CRM son dos cosas distintas. Copia la llamada de la tarjeta del evento. - El modo de consentimiento está activado y aún no has aceptado. Si
crmConsent.status()devuelve'pending', las visitas y los eventos se descartan (no se guardan para después). Acepta en tu propio banner y vuelve a probar: mira Modo de consentimiento. - Estás probando en el modo Live. Dentro del iframe del CRM no se registra nada. Usa una pestaña normal.
- El script no se ha cargado o se llamó a
crmTrackdemasiado pronto. Usa la cola crmq. - El dominio no es un sitio del CRM. El sitio se reconoce por el
Originde la página (elwww.se ignora); uno desconocido recibe400 unknown_domain: búscalo en la pestaña Network de DevTools. - Lo bloquea tu CSP. Permite
https://widget.sitecog.comenscript-srcyhttps://back.sitecog.comenconnect-src.
El evento está, pero faltan parámetros
Abre el evento en Marketing → Eventos. Si ves Llega pero no está descrito, el parámetro nos llegó pero no está en la descripción del evento: añádelo (o corrige la errata en el código). Comprueba también que los valores encajan con los tipos declarados de Parámetros y tipos.
La compra no tiene importe
Marca Este evento genera ingresos en el evento y envía value como un entero en unidades menores: 29800, no 298.00 y tampoco "298 €".
Los números no cuadran con el sistema de pagos
Algunos visitantes usan bloqueadores de anuncios o extensiones de privacidad que bloquean las peticiones de analítica, y otros cierran la pestaña antes de la página de “gracias”. Los eventos del navegador siempre se quedarán un poco cortos. Para señales de UX no pasa nada; para el dinero, sí: envía las compras desde el servidor. No se puede bloquear, no se puede falsear desde la consola y, con event_id, nunca se cuenta dos veces.
Nombrar eventos: unos cuantos hábitos que compensan
- snake_case, en inglés y con aire de verbo:
add_to_cart,signup_completed,purchase,calculator_opened. Nada declick1niButtonPressed. - Nombra el resultado, no el botón.
signup_completedsobrevive a un rediseño;green_button_click, no. - Un evento, muchos parámetros.
add_to_cartcon{ sku, price }es mejor queadd_to_cart_air3,add_to_cart_air4… y te mantiene lejos del límite de 100 tipos. - Pasa siempre un
iden todo lo que pueda dispararse dos veces: compras, confirmaciones, registros únicos. - El navegador para el comportamiento, el servidor para el dinero. Clics y pasos con
crmTrack; pagos, devoluciones y suscripciones desde tu backend. - Escribe la descripción en el CRM. “Se dispara cuando el proveedor de pagos confirma el cobro” te ahorra una reunión dentro de seis meses.