---
title: "UTMs que sobreviven 30 días · atribuye la venta aunque compren semanas después"
description: "Alguien ve tu anuncio, toca el link, llega a la landing con utm_campaign y fbclid en la URL, lee, cierra. Nueve días después escribe tu dominio en la barra y se registra. En tu CRM ese lead aparece como 'directo' y en Meta como si la campaña no hubiera hecho nada. Cuando lo medí en landings de event"
url: https://www.davidiriza.com/lab/utms-que-sobreviven-30-dias
author: David Iriza
level: Principiante
category: Marketing
tags: ["utm", "atribución", "meta ads", "fbclid", "landing", "crm"]
tools: ["Next.js", "TypeScript", "Meta Ads", "Conversions API"]
published: 2026-08-28
---
# UTMs que sobreviven 30 días: atribuye la venta aunque compren semanas después

Las UTMs funcionan perfecto en el escenario que nadie vive: la persona hace clic y se registra en la misma visita. En la vida real llega desde Instagram, se distrae, vuelve tres días después por un recordatorio de WhatsApp o escribiendo la URL, y ahí ya no hay parámetros. Si tu landing lee window.location.search en el momento del submit, ese lead se atribuye a nada. La solución no es complicada, pero tiene cuatro trampas que cuestan semanas de datos sucios: Safari borra cookies de script a los 7 días, Meta a veces manda {{campaign.name}} sin resolver, el _fbc tiene un formato exacto con timestamp en milisegundos, y una función de limpieza que existe pero que nadie llama no limpia nada. Aquí va todo junto, con el código que uso en producción.

**Ficha:** Necesitas una web propia · Herramientas: Next.js, TypeScript, Meta Ads +1 · Te llevas: 7 comandos · Lectura: 18 min · Verificado 28-ago-2026

> Para agentes: esta guía describe pasos ejecutables. Sigue los bloques de comando y código en orden; pregunta al usuario solo lo que no puedas inferir (rutas, nombres de proyecto). Repositorio y contexto del sitio: https://www.davidiriza.com/llms.txt

## De un vistazo

1. Lo que pierdes cuando lees la URL al momento del submit
2. Qué capturar y por qué cada cosa
3. Dónde guardarlo para que dure 30 días
4. El módulo completo: capturar, persistir, leer
5. Los {{campaign.name}} que llegan sin resolver
6. Construir _fbc desde fbclid (y el error de los segundos)
7. Adjuntarlo al formulario y al CRM
8. Cómo verificar que llega

## 01 · Lo que pierdes cuando lees la URL al momento del submit

_el problema medido_

La primera versión de mis landings guardaba las UTMs en un useRef al montar la página y las mandaba con el formulario. Funcionaba en las pruebas porque yo hacía clic y llenaba el formulario de inmediato. En producción, en una landing de eventos presenciales con cientos de registros diarios, alrededor de uno de cada cuatro registros llegaba con utm_source vacío. No eran orgánicos: el volumen subía y bajaba con el gasto en anuncios. Eran personas que habían llegado por un anuncio en una visita y se registraban en otra.

- **Visita de retorno** — La persona vuelve por la URL directa, por un link compartido sin parámetros o por un mensaje de recordatorio. La URL ya no trae nada y la memoria del componente se fue con la pestaña.
- **Embudo de varias páginas** — Landing, página de oferta, checkout, gracias. Si las UTMs viven en memoria de la primera página, en la tercera ya no existen. El evento de compra sale sin campaña.
- **Pixel bloqueado o lento** — La cookie _fbc la escribe el pixel de Meta cuando ve fbclid en la URL. Con un bloqueador, o si el submit ocurre antes de que cargue, no hay cookie. El fbclid estuvo en la URL y nadie lo guardó.
- **Safari y su ITP** — Las cookies escritas desde JavaScript en Safari duran máximo 7 días, y 24 horas si la navegación llegó con parámetros de tracking. Tu cookie de 30 días no dura 30 días ahí.

> La cifra que te importa no es cuántos leads tienen UTM, sino cuántos NO la tienen mientras estás pagando anuncios. Si ese porcentaje se mueve con el presupuesto, es atribución perdida, no tráfico orgánico.

## 02 · Qué capturar y por qué cada cosa

_la lista_

Captura más de lo que crees que vas a usar. Guardar un campo extra cuesta cero; reconstruirlo después es imposible. Esta es la lista mínima que persisto en toda landing con formulario:

| Campo | De dónde sale | Para qué sirve |
| --- | --- | --- |
| utm_source, utm_medium, utm_campaign, utm_content, utm_term | Query de la URL | Reportes por campaña y por anuncio en tu CRM y tu dashboard. utm_content = el anuncio concreto. |
| utm_id | Query de la URL | ID numérico de campaña si lo pones en el link; sobrevive a los renombres que hace el equipo. |
| fbclid | Query de la URL (lo agrega Meta) | Reconstruir _fbc para la Conversions API cuando el pixel no escribió la cookie. |
| gclid, ttclid | Query de la URL (Google, TikTok) | Lo mismo para conversiones offline de Google Ads y Events API de TikTok. |
| referrer | document.referrer | Distinguir un directo real de un clic desde Instagram cuya app no pasó parámetros. |
| landing_path | pathname + search de la primera visita | Saber en qué página aterrizó, no en cuál se registró. |
| first_seen_at | Date.now() en la primera captura | Calcular días entre clic y conversión, y expirar el registro a los 30 días. |

### First-touch o last-touch

First-touch guarda la primera fuente y no la pisa. Last-touch sobrescribe con cada visita que traiga parámetros. Para un embudo de registro a evento o de venta de un solo producto, first-touch es el correcto: quieres saber qué anuncio trajo a la persona, no cuál remarketing la cerró. Mi regla en el módulo de abajo es un punto medio que en la práctica se comporta como first-touch: un valor nuevo solo pisa al anterior si viene lleno, y nunca un vacío borra un lleno. Si quieres last-touch puro, guarda dos bloques: el original congelado y el último, y decide en el reporte.

## 03 · Dónde guardarlo para que dure 30 días

_la decisión_

Hay dos lugares en el navegador y ninguno es suficiente solo. La cookie first-party viaja al servidor en cada request (útil si tu API route quiere leerla sin que el cliente la mande) y es lo que Meta espera para _fbc. localStorage no viaja al servidor, pero Safari no lo expira a los 7 días como hace con las cookies de script, y aguanta valores más largos. Guardo en los dos y leo primero la cookie viva.

|  | Cookie first-party | localStorage |
| --- | --- | --- |
| Vida en Chrome/Edge | Lo que pongas en max-age (30 días) | Hasta que el usuario borre datos |
| Vida en Safari (ITP) | 7 días máximo; 24 h si llegó con parámetros de tracking | 7 días sin interacción con el sitio (también aplica ITP), pero sin el recorte de 24 h |
| Viaja al servidor | Sí, en cada request | No; el cliente lo manda en el body |
| Tamaño cómodo | Unos 4 KB por cookie | Varios MB |
| Quién más lo lee | Pixel de Meta lee _fbc y _fbp | Solo tu código |

Atributos de la cookie que importan: max-age=2592000 (30 días en segundos; tiene prioridad sobre expires), path=/ para que se lea en todo el embudo, SameSite=Lax para que sobreviva a la navegación desde la app de Instagram, y Secure porque estás en HTTPS. No pongas domain a menos que tengas subdominios que compartan el embudo.

> Regla que evita el 80% de los datos sucios: nunca pises un valor lleno con uno vacío. Al fusionar lo nuevo con lo guardado, una llave ausente en la URL no debe borrar la que ya tenías. Es un if de una línea y cambia todo el reporte.

## 04 · El módulo completo: capturar, persistir, leer

_para copiar_

Un archivo, sin dependencias, seguro en SSR (todo hace early-return si no hay window). Tres funciones públicas: captureAttribution() se llama al montar cualquier página del embudo; getAttribution() se llama en el submit; limpiarUTM() la exportas para que ningún otro archivo lea utm_ de la URL por su cuenta. Más adelante vas a ver por qué esa última es la importante.

**Atajo: clonar el repo de esta guía (terminal)**

```bash
git clone https://github.com/davidiriza-lab/utms-30-dias.git
```

> Repo público con licencia MIT: github.com/davidiriza-lab/utms-30-dias. Trae los archivos de abajo listos para copiar a tu proyecto.

**lib/attribution.ts**

```typescript
/* Atribución: captura y persiste utm_* + fbclid/gclid + referrer 30 días.
   Cookie first-party + localStorage como respaldo. Cliente-only, seguro en SSR. */

const KEY = "attr";
const MAX_AGE_S = 30 * 24 * 60 * 60;        // 30 días para la cookie
const MAX_AGE_MS = MAX_AGE_S * 1000;         // 30 días para localStorage

export interface Attribution {
  utm_source?:   string;
  utm_medium?:   string;
  utm_campaign?: string;
  utm_content?:  string;
  utm_term?:     string;
  utm_id?:       string;
  fbclid?:       string;
  gclid?:        string;
  ttclid?:       string;
  referrer?:     string;
  landing_path?: string;
  first_seen_at?: number;
  fbp?:          string;
  fbc?:          string;
}

const URL_KEYS = [
  "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
  "utm_id", "fbclid", "gclid", "ttclid",
] as const;

/** Descarta placeholders sin resolver de las plataformas de ads:
    {{campaign.name}}, {{site_source_name}}, {campaign}. Solo si la cadena
    empieza Y termina con llaves; "{promo}-2026" o "NR-Norte-Sep" no se tocan. */
export function limpiarUTM(v: string | null | undefined): string | undefined {
  const s = String(v ?? "").trim();
  if (!s) return undefined;
  if (/^\{+.*\}+$/.test(s)) return undefined;
  return s;
}

function readCookie(name: string): string | undefined {
  if (typeof document === "undefined") return undefined;
  const safe = name.replace(/([.$?*|{}()[\]\\/+^])/g, "\\$1");
  const m = document.cookie.match(new RegExp("(?:^|; )" + safe + "=([^;]*)"));
  return m ? decodeURIComponent(m[1] ?? "") : undefined;
}

function writeCookie(name: string, value: string): void {
  if (typeof document === "undefined") return;
  document.cookie =
    name + "=" + encodeURIComponent(value) +
    "; max-age=" + MAX_AGE_S + "; path=/; SameSite=Lax; Secure";
}

function readStore(): Attribution {
  if (typeof window === "undefined") return {};
  // 1. cookie (viaja al servidor)   2. localStorage (aguanta mejor en Safari)
  const raw = readCookie(KEY) ?? (() => {
    try { return localStorage.getItem(KEY) ?? undefined; } catch { return undefined; }
  })();
  if (!raw) return {};
  try {
    const o = JSON.parse(raw) as Attribution;
    if (typeof o.first_seen_at === "number" && Date.now() - o.first_seen_at > MAX_AGE_MS) return {};
    return o;
  } catch { return {}; }
}

function writeStore(a: Attribution): void {
  const raw = JSON.stringify(a);
  writeCookie(KEY, raw);
  try { localStorage.setItem(KEY, raw); } catch { /* modo privado o cuota */ }
}

/** Llamar al montar CUALQUIER página del embudo (landing, oferta, checkout, gracias). */
export function captureAttribution(): Attribution {
  if (typeof window === "undefined") return {};
  const p = new URLSearchParams(window.location.search);
  const stored = readStore();
  const merged: Attribution = { ...stored };

  // Un valor nuevo solo pisa si viene lleno; un vacío nunca borra un lleno.
  for (const k of URL_KEYS) {
    const v = limpiarUTM(p.get(k));
    if (v) merged[k] = v;
  }
  if (!merged.referrer && document.referrer) merged.referrer = document.referrer;
  if (!merged.landing_path) merged.landing_path = window.location.pathname + window.location.search;
  if (!merged.first_seen_at) merged.first_seen_at = Date.now();

  // fbp/fbc: la cookie viva del pixel gana; si no hay _fbc, se construye del fbclid.
  const fbp = readCookie("_fbp");
  if (fbp) merged.fbp = fbp;
  const cookieFbc = readCookie("_fbc");
  if (cookieFbc) merged.fbc = cookieFbc;
  else if (!merged.fbc && merged.fbclid) merged.fbc = "fb.1." + Date.now() + "." + merged.fbclid;

  writeStore(merged);
  return merged;
}

/** Leer en el submit: lo persistido + cookies vivas (que ganan para fbc/fbp). */
export function getAttribution(): Attribution {
  const stored = readStore();
  const fbp = readCookie("_fbp");
  const fbc = readCookie("_fbc");
  return { ...stored, ...(fbp ? { fbp } : {}), ...(fbc ? { fbc } : {}) };
}
```

Dos detalles de diseño: la cookie guarda el JSON completo en una sola cookie (unos 300 a 600 bytes en la práctica) en lugar de una cookie por campo, para que el max-age se renueve completo en cada captura. Y first_seen_at cumple doble función: expira el registro a los 30 días y te deja calcular cuántos días pasaron entre el clic y la conversión, que es un número que te va a sorprender.

## 05 · Los {{campaign.name}} que llegan sin resolver

_la trampa silenciosa_

Meta permite poner parámetros dinámicos en la URL del anuncio: {{campaign.name}}, {{adset.name}}, {{ad.name}}, {{placement}}, {{site_source_name}}. Cuando el anuncio se sirve, Meta los sustituye por el valor real y la URL llega con utm_campaign=Verano-Norte. Cuando NO los sustituye (previsualizaciones, links copiados desde el administrador, algunos placements, tests del equipo), la URL llega con utm_campaign={{campaign.name}}, literal, con llaves. Si guardas eso, tu reporte tiene una 'campaña' llamada {{campaign.name}} que acumula leads que no son de ninguna.

La corrección es una regex. Lo que quiero contarte es lo que pasó con ella. La función limpiarUTM existió durante meses en el módulo de atribución y no limpió nada, porque las cinco plantillas de landing leían las UTMs de la URL por su cuenta, sin pasar por el módulo, y mandaban su propio objeto al API. Al fusionar objetos, la llave ausente del módulo (que había descartado el placeholder) no pisaba la llave cruda de la plantilla, y el {{campaign.name}} sobrevivía hasta la base de datos, el CRM y la Conversions API. Se descubrió cuando alguien preguntó por qué la campaña con más leads del mes tenía llaves en el nombre.

- Exporta limpiarUTM y prohíbe en el repo cualquier p.get('utm_ que no pase por ella. Un grep en el pre-commit basta.
- Repite el saneado del lado del servidor, en el API route que guarda. El cliente se puede saltar; el servidor no.
- Sé conservador: descarta solo lo que empieza y termina con llaves. {promo}-2026 es un nombre válido que alguien puso a propósito.
- Guarda vacío, no el valor sucio ni un 'desconocido'. Los filtros de tu dashboard ignoran vacíos; un 'desconocido' se convierte en categoría.

**Del lado del servidor, en el API route (misma regla, distinta firma)**

```typescript
function sanitizeUtm(value: unknown): string {
  const s = String(value ?? "").trim();
  return /^\{+.*\}+$/.test(s) ? "" : s;
}
```

## 06 · Construir _fbc desde fbclid (y el error de los segundos)

_el formato exacto_

La Conversions API acepta el parámetro fbc dentro de user_data y con él Meta ata tu evento server-side al clic del anuncio. Normalmente lo lees de la cookie _fbc que escribe el pixel. Cuando la cookie no existe pero tienes el fbclid guardado, la documentación de Meta dice que lo construyas tú con este formato:

**Formato documentado de _fbc**

```text
fb.<subdomainIndex>.<creationTime>.<fbclid>

fb               versión, siempre "fb"
subdomainIndex   1 para tudominio.com, 2 para www.tudominio.com (0 = TLD)
creationTime     UNIX time en MILISEGUNDOS del momento en que recibiste el fbclid
fbclid           el valor tal cual llegó en la URL; es case-sensitive, no lo toques

Ejemplo:  fb.1.1756368000000.IwAR2xkQ...
```

El error que cometí: mi primera versión hacía Math.floor(Date.now() / 1000) porque asumí segundos, como casi todo lo que es 'UNIX time'. Meta lo especifica en milisegundos y su propio ejemplo de _fbp (fb.1.1596403881668.1116446470) lo confirma: 13 dígitos. Un timestamp en segundos no rompe el envío (Meta lo acepta) pero el clic parece de 1970 y el matching pierde calidad. En el módulo de arriba está corregido: Date.now() sin dividir.

- Cookie viva primero. Si el pixel escribió _fbc, ese valor gana siempre sobre tu reconstrucción: es el que Meta ya conoce.
- Meta recomienda una expiración de 90 días para _fbc; tu registro de atribución de 30 días es más corto y está bien, porque el clic que te importa está dentro de tu ventana de conversión.
- En un embudo de varias páginas, reconstruye el fbc una vez (en la landing) y arrástralo persistido. Si lo reconstruyes en cada página, cada evento sale con un creationTime distinto para el mismo clic.
- Manda también fbp (la cookie _fbp) y un external_id estable (un visitor_id en localStorage) con el mismo evento. fbc solo te da el clic; los tres juntos dan la persona.

## 07 · Adjuntarlo al formulario y al CRM

_el viaje completo_

El cliente llama captureAttribution() al montar y getAttribution() al enviar, y manda el objeto junto con nombre, correo y teléfono a un API route. El servidor sanea otra vez, guarda en tu base con await (la respuesta al usuario depende de eso) y dispara CRM y Conversions API sin esperar. Las escrituras siempre desde el servidor: la llave del CRM no vive en el navegador.

**Cliente: al montar y al enviar**

```typescript
"use client";
import { useEffect, type FormEvent } from "react";
import { captureAttribution, getAttribution } from "@/lib/attribution";

export function FormularioRegistro() {
  useEffect(() => { captureAttribution(); }, []);

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    const attr = getAttribution();
    const event_id = crypto.randomUUID();     // el mismo para pixel y CAPI

    const res = await fetch("/api/registro", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        name:  String(form.get("name") ?? ""),
        email: String(form.get("email") ?? ""),
        phone: String(form.get("phone") ?? ""),
        event_id,
        ...attr,                                // utm_*, fbclid, gclid, referrer, fbc, fbp, first_seen_at
      }),
    });
    if (!res.ok) { /* mostrar error */ return; }
    // aquí disparas el pixel con { eventID: event_id } y rediriges a /gracias
  }

  return <form onSubmit={onSubmit}>{/* campos */}</form>;
}
```

**app/api/registro/route.ts (esquema; rellena tus clientes)**

```typescript
import { NextResponse, type NextRequest } from "next/server";
import { guardarRegistro, upsertContactoCRM, enviarCAPI } from "@/lib/integraciones";

interface RegistroBody {
  name: string; email: string; phone: string; event_id: string;
  utm_source?: string; utm_medium?: string; utm_campaign?: string;
  utm_content?: string; utm_term?: string; utm_id?: string;
  fbclid?: string; gclid?: string; referrer?: string; landing_path?: string;
  first_seen_at?: number; fbc?: string; fbp?: string;
}

function sanitizeUtm(value: unknown): string {
  const s = String(value ?? "").trim();
  return /^\{+.*\}+$/.test(s) ? "" : s;
}

export async function POST(req: NextRequest) {
  const body = (await req.json()) as RegistroBody;
  if (!body.email || !body.name) {
    return NextResponse.json({ error: "faltan campos" }, { status: 400 });
  }

  const attr = {
    utm_source:   sanitizeUtm(body.utm_source),
    utm_medium:   sanitizeUtm(body.utm_medium),
    utm_campaign: sanitizeUtm(body.utm_campaign),
    utm_content:  sanitizeUtm(body.utm_content),
    utm_term:     sanitizeUtm(body.utm_term),
    utm_id:       sanitizeUtm(body.utm_id),
    fbclid:       String(body.fbclid ?? "").slice(0, 256),
    gclid:        String(body.gclid ?? "").slice(0, 256),
    referrer:     String(body.referrer ?? "").slice(0, 512),
    landing_path: String(body.landing_path ?? "").slice(0, 512),
    dias_clic_a_registro: body.first_seen_at
      ? Math.round((Date.now() - body.first_seen_at) / 86_400_000)
      : null,
  };

  // 1. Tu base de datos: AWAIT (service role, nunca desde el cliente)
  const saved = await guardarRegistro({ ...body, ...attr });
  if (!saved.ok) return NextResponse.json({ error: saved.error }, { status: 500 });

  // 2 y 3. CRM y Conversions API: fire-and-forget con log de fallas
  upsertContactoCRM({ email: body.email, name: body.name, phone: body.phone, attr })
    .catch((e: unknown) => console.error("[crm]", e));
  enviarCAPI({ event_id: body.event_id, email: body.email, phone: body.phone,
               fbc: body.fbc ?? "", fbp: body.fbp ?? "", attr })
    .catch((e: unknown) => console.error("[capi]", e));

  return NextResponse.json({ ok: true });
}
```

En el CRM, las UTMs van a campos personalizados (uno por utm_*, más fbclid y la página de aterrizaje) y el contacto se hace con upsert por correo, no create: si la persona ya existe, no quieres un duplicado ni quieres pisar la fuente original con la de hoy. La mayoría de los CRM dejan configurar 'solo escribir si está vacío' por campo; si el tuyo no, hazlo en el código: lee el contacto, y manda solo las UTMs que él no tenga.

> Si además de la base propia usas un CRM con su propia atribución automática, no confíes en ella para el retorno a 30 días: casi todas leen la URL de la visita en que se envió el formulario. Tus campos personalizados son la fuente de verdad; la atribución del CRM es un extra.

## 08 · Cómo verificar que llega

_antes de darlo por bueno_

1. Abre la landing en ventana privada con ?utm_source=prueba&utm_campaign=verificacion&utm_content=anuncio-1&fbclid=TEST123. En DevTools, Application, revisa la cookie attr y la llave attr en localStorage: deben tener los cinco valores y first_seen_at.
2. Cierra la pestaña. Abre la landing de nuevo SIN parámetros, en la misma ventana privada. Vuelve a revisar: los valores siguen ahí y no se pisaron con vacíos.
3. Ahora abre con ?utm_campaign={{campaign.name}}. Revisa que utm_campaign NO cambió a la cadena con llaves: el valor anterior se conservó.
4. Registra un contacto de prueba desde la visita sin parámetros. En tu base y en el CRM, el contacto debe traer utm_campaign=verificacion y fbc=fb.1.<13 dígitos>.TEST123.
5. Repite lo mismo en Safari de iPhone. Es el navegador donde se pierde más; si ahí funciona, funciona en todos.
6. En producción, una vez por semana: cuenta registros sin utm_source en los días con gasto de anuncios. Si baja de 25% a menos de 10%, el sistema está haciendo su trabajo. Lo que queda es orgánico real.

**Consulta de salud semanal (SQL, ajusta la tabla)**

```bash
SELECT date_trunc('day', created_at) AS dia, count(*) AS total, count(*) FILTER (WHERE utm_source = '' OR utm_source IS NULL) AS sin_fuente, count(*) FILTER (WHERE utm_campaign LIKE '{{%') AS con_llaves, count(DISTINCT fbc) AS fbc_unicos FROM registros WHERE created_at > now() - interval '7 days' GROUP BY 1 ORDER BY 1;
```

> con_llaves debe ser 0 siempre. fbc_unicos cerca de total = clics reales; un mismo fbc repetido en decenas de registros = alguien compartió un link con fbclid pegado (típico en grupos de WhatsApp).

Una última señal que te ahorra un diagnóstico largo: si la base tiene cientos de registros diarios con utm_campaign correcto y fbc únicos, pero el administrador de anuncios reporta casi cero conversiones para esa campaña, la atribución de tu lado está bien. El problema es del pixel o de la Conversions API, y eso es otra guía.

## Preguntas frecuentes

**¿Por qué 30 días y no 7 o 90?**

Porque es la ventana de atribución más larga que Meta ofrece para clics (7 días clic es la default; 28 días existía antes) y cubre el ciclo real de decisión de un evento o un producto de ticket medio. Más largo acumula visitas viejas que ya no explican la compra; más corto pierde a los que tardan dos semanas. Si vendes algo que se decide en horas, baja a 7. Si es un ciclo B2B de meses, sube y guarda first y last por separado.

**¿No basta con la cookie _fbc que ya escribe el pixel de Meta?**

No por tres razones: el pixel puede estar bloqueado, puede no haber cargado cuando la persona envía el formulario, y en Safari la cookie muere a los 7 días (o 24 horas si la visita llegó con fbclid). Tu copia en localStorage con el fbclid crudo te permite reconstruirla. Y la cookie del pixel no guarda utm_*, que es lo que tu CRM necesita.

**¿Qué pasa con el consentimiento de cookies?**

Una cookie first-party con tus propios parámetros de campaña es de las que la mayoría de marcos legales tratan como analítica propia, pero eso depende de tu jurisdicción y de tu aviso de privacidad. Lo práctico: si tienes banner de consentimiento, llama a captureAttribution() después del consentimiento y guarda el fbclid en memoria mientras tanto para no perderlo.

**¿Cómo manejo un embudo con landing en un dominio y checkout en otro?**

Ni la cookie ni localStorage cruzan dominios. Pasa la atribución en la URL al saltar: getAttribution() serializado como parámetros del link al checkout, y captureAttribution() del otro lado la vuelve a persistir. Es la misma razón por la que el módulo se llama al montar cualquier página y no solo la primera.

**¿Sirve el mismo módulo para Google Ads y TikTok?**

Sí, ya captura gclid y ttclid. Para Google, el gclid se manda con la conversión offline (importación de conversiones) junto con la fecha; el first_seen_at te da esa fecha. Para TikTok, la Events API acepta ttclid en el contexto del evento. El formato reconstruido fb.1.<ms>.<fbclid> es solo de Meta.

## Cierre de la guía

Todo esto cabe en un archivo de 100 líneas y una regla: nunca leas utm_ de la URL fuera del módulo. Lo que cambia no es el código sino el reporte: la columna 'directo' se encoge a lo que de verdad es directo, y las campañas recuperan los leads que siempre fueron suyos. Instálalo, corre la prueba de Safari y mide la semana siguiente cuántos registros llegan sin fuente. Esta guía vive en el Lab de David Iriza.

## Fuentes oficiales

- [fbp y fbc: parámetros de clic y cookies (Conversions API, Meta for Developers)](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc): El formato exacto de _fbc y _fbp (versión, índice de subdominio, creationTime en milisegundos, fbclid), cómo construirlo desde fbclid cuando no hay cookie y la expiración recomendada de 90 días.
- [Especificaciones de los parámetros de URL dinámicos (Centro de ayuda de Meta para empresas)](https://www.facebook.com/business/help/2360940870872492): Los marcadores {{campaign.name}}, {{adset.name}}, {{ad.name}}, {{placement}} y {{site_source_name}} que Meta sustituye al servir el anuncio, y que a veces llegan sin resolver.
- [Document.cookie (MDN Web Docs)](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie): Semántica de max-age, expires, path, SameSite y Secure, y por qué max-age tiene prioridad sobre expires.
- [Intelligent Tracking Prevention 2.3 (blog de WebKit)](https://webkit.org/blog/9521/intelligent-tracking-prevention-2-3/): Por qué en Safari la cookie que escribes dura 24 horas si la visita llegó con parámetros en la URL, y por qué localStorage se borra tras 7 días sin interacción con el sitio.

## Repositorios

- [davidiriza-lab/utms-30-dias](https://github.com/davidiriza-lab/utms-30-dias): Módulo TS sin dependencias que persiste utm_*, fbclid y gclid 30 días (cookie + localStorage) y los manda a base, CRM y CAPI. (MIT)

## Guías que se conectan con esta

- [pixel-capi-sin-duplicar](https://www.davidiriza.com/lab/pixel-capi-sin-duplicar): El fbc que construyes aquí es el que viaja en user_data de la Conversions API; esa guía cubre el doble disparo pixel + servidor con el mismo event_id.
- [analytics-propio-en-tu-landing](https://www.davidiriza.com/lab/analytics-propio-en-tu-landing): El tracker propio guarda estas mismas UTMs por sesión; esta guía las hace sobrevivir entre sesiones para el registro.
- [escrituras-seguras-desde-una-landing](https://www.davidiriza.com/lab/escrituras-seguras-desde-una-landing): El API route que recibe la atribución y escribe en base, CRM y CAPI sigue las reglas de esa guía: llaves solo en el servidor.

---

Guía escrita con la información oficial disponible al 28 de agosto de 2026. Esta página no está afiliada a Meta. Ante la duda, revisa la documentación de la Conversions API de Meta: https://developers.facebook.com/docs/marketing-api/conversions-api

---

Versión markdown de https://www.davidiriza.com/lab/utms-que-sobreviven-30-dias — el sitio negocia por `Accept: text/markdown` y por sufijo `.md`. Índice para agentes: https://www.davidiriza.com/llms.txt