---
title: "Píxel + CAPI sin duplicar · deja de sub-acreditar tus anuncios"
description: "Si tu landing solo tiene el píxel de Meta en el navegador, una parte de tus conversiones nunca llega: bloqueadores, iOS, Safari y conexiones que se cortan antes de que el script dispare. Si solo mandas eventos desde el servidor (Conversions API), llegan todos pero Meta los ata peor a la campaña. La "
url: https://www.davidiriza.com/lab/pixel-capi-sin-duplicar
author: David Iriza
level: Intermedio
category: Marketing
tags: ["meta ads", "pixel", "conversions api", "atribución", "next.js", "ads cli", "fbclid"]
tools: ["Meta Pixel", "Conversions API", "Events Manager", "Next.js", "Vercel", "Meta Ads CLI", "capi-param-builder"]
published: 2026-08-28
---
# Píxel + CAPI sin duplicar: deja de sub-acreditar tus anuncios

Una landing de eventos presenciales recibía cientos de registros al día. La base tenía cada uno con su fbc único (clics reales de anuncios) y su utm_campaign correcto. Meta reportaba 25 de 937. Nadie lo notó durante días porque la API del servidor sí estaba mandando eventos, Events Manager mostraba miles de CompleteRegistration y el log de fallas estaba en cero. Lo que faltaba era el otro lado: el píxel del navegador nunca cargó porque la variable pública con el ID del píxel estaba vacía en producción. Sin par navegador+servidor, Meta recibe el evento pero no lo acredita a la campaña. Esta guía es el sistema que quedó después de ese día: los dos lados siempre, deduplicados por event_id, y un vigía que grita cuando uno se apaga.

**Ficha:** Necesitas acceso a tu píxel de Meta · Herramientas: Meta Pixel, Conversions API, Events Manager +4 · Te llevas: 16 comandos · Lectura: 29 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. Por qué solo píxel sub-acredita (y solo servidor también)
2. La mecánica de deduplicación
3. El código del navegador
4. La API route completa (Next.js, TypeScript)
5. Reconstruir fbc en el servidor cuando la cookie no llegó
6. Las variables de entorno y la trampa del build
7. Probar con test_event_code en Events Manager
8. El vigía: detectar que un lado murió
9. La CLI oficial de Meta: qué sí revisa del píxel y qué no
10. Diagnóstico en orden: síntoma, causa, prueba

## 01 · Por qué solo píxel sub-acredita (y solo servidor también)

_el problema_

El píxel de Meta es un script en el navegador. Todo lo que impida que ese script corra o termine te quita conversiones: bloqueadores de anuncios, la protección de rastreo de Safari (ITP), la opción de iOS de no ser rastreado, redes corporativas que filtran el dominio de Meta, y la cola de registros que cierran la pestaña antes de que el evento salga. En landings con tráfico móvil de Latinoamérica he visto brechas del 20 al 40% entre lo que la base registra y lo que el píxel reporta.

La respuesta obvia es mandar el evento desde tu servidor: la Conversions API (CAPI). El servidor no tiene bloqueadores. Pero un evento que solo viene del servidor llega con menos señales para atarlo a la persona y al clic (no hay cookie fresca, no hay contexto de navegación) y en la práctica Meta lo acredita peor a la campaña. Lo vi en números: con CAPI vivo y píxel muerto, Events Manager mostraba miles de eventos recibidos y la campaña mostraba casi cero resultados.

- **Solo píxel** — Pierdes lo que el navegador bloquea. Lo que llega se acredita bien. Sub-cuenta entre 20 y 40% según audiencia y dispositivo.
- **Solo CAPI** — Llega todo, pero sin la cookie del navegador y sin el par, la acreditación a campaña cae. Meta 've' el evento y no sabe a qué anuncio pertenece.
- **Los dos, deduplicados** — Meta recibe el mismo evento por dos caminos, detecta que es uno (mismo nombre + mismo id) y se queda con la mejor versión. Es el único modo que cuenta todo y acredita bien.

> Mandar los dos sin deduplicar es peor que cualquiera de los dos solos: cada registro cuenta doble, tu costo por resultado se ve a la mitad y optimizas la campaña con datos falsos.

## 02 · La mecánica de deduplicación

_cómo decide Meta_

Meta deduplica cuando dos eventos coinciden en dos campos: el nombre del evento (event en fbq, event_name en CAPI) y el identificador (eventID en fbq, event_id en CAPI). Si los dos coinciden y ambos llegan dentro de 48 horas, Meta se queda con uno. Si uno de los dos campos difiere, son dos eventos distintos y cuentan doble.

| Navegador (fbq) | Servidor (CAPI) | Tiene que coincidir |
| --- | --- | --- |
| 'Lead' (segundo argumento de track) | event_name: 'Lead' | Sí, letra por letra |
| { eventID: '...' } (cuarto argumento) | event_id: '...' | Sí, exactamente |
| Se manda al cargar la página o al enviar el form | Se manda desde tu API route | Dentro de 48 horas uno del otro |

Hay un segundo método documentado, deduplicar por fbp o external_id sin event_id, pero tiene una limitación que lo descarta para landings: solo funciona si el evento del navegador llega primero. Si el navegador falla (que es justo el caso que quieres cubrir), el evento del servidor no se descarta pero tampoco se empareja. Usa event_id. Siempre.

La regla práctica para el event_id: lo genera el navegador una sola vez (crypto.randomUUID) y viaja en el body del POST a tu API. Si tienes un identificador natural (número de orden, id del registro), úsalo: es idempotente y te deja reinyectar el evento después sin duplicarlo. Nunca generes el id en los dos lados por separado.

## 03 · El código del navegador

_lado 1_

Tres pasos: cargar el píxel con el ID que viene de una variable pública, generar el event_id al momento del envío, y disparar fbq con eventID en el cuarto argumento antes de llamar a tu API con el mismo id. El componente también lee las cookies _fbp y _fbc que el píxel escribe, porque el servidor las va a necesitar.

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

```bash
git clone https://github.com/davidiriza-lab/pixel-capi-nextjs.git
```

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

**components/MetaPixel.tsx — carga del píxel**

```tsx
"use client";

import Script from "next/script";

const PIXEL_ID = process.env.NEXT_PUBLIC_META_PIXEL_ID ?? "";

export default function MetaPixel() {
  if (!PIXEL_ID) return null;
  return (
    <>
      <Script id="meta-pixel" strategy="afterInteractive">
        {`!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${PIXEL_ID}');
fbq('track', 'PageView');`}
      </Script>
      <noscript>
        <img
          height="1"
          width="1"
          style={{ display: "none" }}
          alt=""
          src={`https://www.facebook.com/tr?id=${PIXEL_ID}&ev=PageView&noscript=1`}
        />
      </noscript>
    </>
  );
}
```

**lib/meta-browser.ts — helpers para fbq, cookies y event_id**

```typescript
type FbqFn = (
  action: "track" | "trackCustom" | "init",
  name: string,
  params?: Record<string, string | number>,
  options?: { eventID: string },
) => void;

type FbqWindow = Window & { fbq?: FbqFn };

export function getCookie(name: string): string | undefined {
  const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
  return match ? decodeURIComponent(match[1]) : undefined;
}

export function newEventId(): string {
  return typeof crypto !== "undefined" && "randomUUID" in crypto
    ? crypto.randomUUID()
    : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}

/** Dispara el evento en el navegador. Devuelve false si el pixel no cargó. */
export function trackBrowser(
  eventName: string,
  eventId: string,
  params: Record<string, string | number> = {},
): boolean {
  const fbq = (window as FbqWindow).fbq;
  if (typeof fbq !== "function") return false;
  fbq("track", eventName, params, { eventID: eventId });
  return true;
}
```

**El envío del formulario: mismo id a los dos lados**

```typescript
import { getCookie, newEventId, trackBrowser } from "@/lib/meta-browser";

async function enviarRegistro(form: { nombre: string; email: string; telefono: string }) {
  const eventId = newEventId();

  // 1. Navegador: fbq con eventID. Si el pixel no cargó, no pasa nada: el servidor cubre.
  trackBrowser("Lead", eventId, { content_name: "evento-ciudad", content_category: "registro" });

  // 2. Servidor: el mismo event_id + las cookies que el pixel escribió.
  const res = await fetch("/api/registro", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      ...form,
      event_id: eventId,
      event_source_url: window.location.href,
      fbp: getCookie("_fbp"),
      fbc: getCookie("_fbc"),
    }),
  });
  if (!res.ok) throw new Error("registro falló");
}
```

> Fíjate en el orden: fbq primero, fetch después. Si el usuario cierra la pestaña a medio camino, al menos uno de los dos salió. Y trackBrowser devuelve false cuando fbq no existe: guárdalo en tu propio tracker si tienes uno, porque ese booleano es la primera pista de que el píxel está muerto (ver el vigía).

## 04 · La API route completa (Next.js, TypeScript)

_lado 2_

Esta es la ruta que recibe el registro, lo guarda, y manda el evento a Meta. Las reglas que importan y que salen de la documentación de parámetros: em, ph, fn y ln van normalizados y hasheados con SHA-256; client_ip_address, client_user_agent, fbp y fbc van en claro y nunca se hashean; action_source es 'website' para una landing; event_source_url es obligatorio para eventos web y debe ser del dominio verificado; event_time va en segundos Unix.

**lib/meta-capi.ts — construcción y envío del evento**

```typescript
import { createHash } from "crypto";

const PIXEL_ID = process.env.META_PIXEL_ID ?? "";
const CAPI_TOKEN = process.env.META_CAPI_TOKEN ?? "";
const TEST_EVENT_CODE = process.env.META_TEST_EVENT_CODE; // solo en desarrollo
const API_VERSION = "v21.0";

function sha256(value: string): string {
  return createHash("sha256").update(value).digest("hex");
}

/** Email: sin espacios, minúsculas. */
export function normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}

/** Teléfono: solo dígitos, con lada de país, sin ceros iniciales ni '+'. */
export function normalizePhone(phone: string, defaultCountryCode = "52"): string {
  let digits = phone.replace(/\D/g, "").replace(/^0+/, "");
  if (digits.length === 10) digits = defaultCountryCode + digits;
  return digits;
}

export interface CapiInput {
  eventName: "Lead" | "CompleteRegistration" | "Purchase" | "Schedule";
  eventId: string;
  eventSourceUrl: string;
  eventTime?: number; // segundos Unix; por defecto ahora
  email?: string;
  phone?: string;
  firstName?: string;
  lastName?: string;
  externalId?: string;
  clientIp?: string;
  userAgent?: string;
  fbp?: string;
  fbc?: string;
  customData?: Record<string, string | number>;
}

interface CapiUserData {
  em?: string[];
  ph?: string[];
  fn?: string[];
  ln?: string[];
  external_id?: string[];
  client_ip_address?: string;
  client_user_agent?: string;
  fbp?: string;
  fbc?: string;
}

interface CapiEvent {
  event_name: string;
  event_time: number;
  event_id: string;
  event_source_url: string;
  action_source: "website";
  user_data: CapiUserData;
  custom_data?: Record<string, string | number>;
}

interface CapiResponse {
  events_received?: number;
  fbtrace_id?: string;
  error?: { message: string; code: number; error_subcode?: number };
}

export function buildEvent(input: CapiInput): CapiEvent {
  const userData: CapiUserData = {};
  if (input.email) userData.em = [sha256(normalizeEmail(input.email))];
  if (input.phone) userData.ph = [sha256(normalizePhone(input.phone))];
  if (input.firstName) userData.fn = [sha256(input.firstName.trim().toLowerCase())];
  if (input.lastName) userData.ln = [sha256(input.lastName.trim().toLowerCase())];
  if (input.externalId) userData.external_id = [sha256(input.externalId)];
  if (input.clientIp) userData.client_ip_address = input.clientIp;   // NUNCA hashear
  if (input.userAgent) userData.client_user_agent = input.userAgent; // NUNCA hashear
  if (input.fbp) userData.fbp = input.fbp;                            // NUNCA hashear
  if (input.fbc) userData.fbc = input.fbc;                            // NUNCA hashear

  return {
    event_name: input.eventName,
    event_time: input.eventTime ?? Math.floor(Date.now() / 1000),
    event_id: input.eventId,
    event_source_url: input.eventSourceUrl,
    action_source: "website",
    user_data: userData,
    custom_data: input.customData,
  };
}

/** Manda 1..1000 eventos. Lanza si Meta responde error o si faltan credenciales. */
export async function sendToMeta(events: CapiEvent[]): Promise<CapiResponse> {
  if (!PIXEL_ID || !CAPI_TOKEN) {
    throw new Error("META_PIXEL_ID o META_CAPI_TOKEN vacíos: CAPI apagada");
  }
  const body: Record<string, unknown> = { data: events, access_token: CAPI_TOKEN };
  if (TEST_EVENT_CODE) body.test_event_code = TEST_EVENT_CODE;

  const res = await fetch(`https://graph.facebook.com/${API_VERSION}/${PIXEL_ID}/events`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(8000),
  });
  const json = (await res.json()) as CapiResponse;
  if (!res.ok || json.error) {
    throw new Error(`CAPI ${res.status}: ${json.error?.message ?? "sin detalle"}`);
  }
  return json;
}
```

**app/api/registro/route.ts — la ruta que recibe el formulario**

```typescript
import { NextRequest, NextResponse } from "next/server";
import { buildEvent, sendToMeta } from "@/lib/meta-capi";
import { paramsMetaDesdeRequest } from "@/lib/meta-fbc";
import { guardarRegistro, registrarFalla } from "@/lib/db"; // tu capa de datos, server-side

interface RegistroBody {
  nombre: string;
  email: string;
  telefono: string;
  event_id?: string;
  event_source_url?: string;
  fbp?: string;
  fbc?: string;
}

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

  // Si el navegador no mandó event_id (pixel muerto, JS a medias), lo generamos aquí.
  // Ya no habrá par que deduplicar, pero el evento llega.
  const eventId = body.event_id ?? crypto.randomUUID();

  // fbp/fbc: lo que mandó el cliente, luego la cookie del request, y al final
  // se reconstruyen desde fbclid con la librería oficial (ver lib/meta-fbc.ts).
  const reconstruido = paramsMetaDesdeRequest(req, body.event_source_url);
  const fbp = body.fbp || req.cookies.get("_fbp")?.value || reconstruido.fbp;
  const fbc = body.fbc || req.cookies.get("_fbc")?.value || reconstruido.fbc;

  const clientIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
  const userAgent = req.headers.get("user-agent") ?? undefined;

  const [firstName, ...rest] = body.nombre.trim().split(/\s+/);

  // 1. Lo que sí esperamos: la escritura a la base.
  const registro = await guardarRegistro({
    nombre: body.nombre,
    email: body.email,
    telefono: body.telefono,
    event_id: eventId,
    fbp: fbp ?? null,
    fbc: fbc ?? null,
  });

  // 2. Meta: fire-and-forget. Nunca hace fallar el registro, pero SIEMPRE deja rastro si falla.
  const event = buildEvent({
    eventName: "Lead",
    eventId,
    eventSourceUrl: body.event_source_url ?? req.headers.get("referer") ?? "",
    email: body.email,
    phone: body.telefono,
    firstName,
    lastName: rest.join(" ") || undefined,
    externalId: registro.id,
    clientIp,
    userAgent,
    fbp,
    fbc,
    customData: { content_name: "evento-ciudad", content_category: "registro" },
  });

  sendToMeta([event]).catch((err: unknown) => {
    const message = err instanceof Error ? err.message : String(err);
    console.error("[META CAPI]", message);
    void registrarFalla({ kind: "meta_capi", ref: registro.id, message });
  });

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

Dos decisiones que vienen del incidente. La primera: sendToMeta lanza si las variables están vacías, en vez de saltarse el envío en silencio. La versión anterior hacía 'if (TOKEN) { ... }' y por eso el log de fallas estaba en cero mientras nada se mandaba. Cero fallas no es igual a sistema sano. La segunda: el envío a Meta es fire-and-forget (no bloquea la respuesta al usuario), pero cada error queda en una tabla de fallas con el id del registro, para poder reinyectarlo.

> La reinyección existe: event_time acepta hasta 7 días hacia atrás y Meta reprocesa la atribución. Tras el incidente reinyecté 1,783 registros con su fecha original, sus hashes, fbp y fbc, y un event_id determinista ('backfill-' + id del registro). Los de más de 7 días se perdieron. Los que ya estaban acreditados (unos 70) corrían riesgo de contarse doble; fue un 4% aceptable frente a recuperar el 96%.

## 05 · Reconstruir fbc en el servidor cuando la cookie no llegó

_el clic que se pierde_

De todos los parámetros de user_data, fbc es el que ata el evento al clic en el anuncio. El píxel escribe la cookie _fbc cuando ve fbclid en la URL. Pero si el píxel no cargó (el incidente de esta guía), si Safari acortó la vida de la cookie o si el registro llega en el primer request, la cookie no existe y el evento del servidor sale sin clic. Lo que sí sigue ahí es el fbclid en la URL de la página. Meta documenta el formato de fbc (fb.1.<timestamp en milisegundos>.<fbclid>) y publica una librería propia, capi-param-builder, que arma fbc y fbp a partir del request y te dice qué cookies escribir. Hay versión para Node, Python, PHP, Java, Ruby y una para el navegador.

**Instalar la librería oficial en tu proyecto Next.js (terminal)**

```bash
npm install capi-param-builder-nodejs
```

> Es la de Meta (repo facebook/capi-param-builder en GitHub). No confundir con 'capi-param-builder' a secas en npm, que es un paquete vacío de retención de nombre.

**lib/meta-fbc.ts — fbc y fbp desde el request, con la librería de Meta**

```typescript
import { ParamBuilder } from "capi-param-builder-nodejs";
import type { NextRequest } from "next/server";

// Dominio(s) donde vive la landing; la librería lo usa para decidir el dominio de las cookies.
const DOMINIOS = [process.env.SITE_DOMAIN ?? "tu-landing.com"];

export interface CookieMeta { name: string; value: string; maxAge: number; domain: string }
export interface ParamsMeta { fbc?: string; fbp?: string; cookies: CookieMeta[] }

/**
 * Lee fbclid, _fbc y _fbp del request (y de la URL de la página, si la mandas)
 * y devuelve fbc/fbp listos para CAPI, más las cookies que conviene escribir.
 * Si hay fbclid y no hay _fbc, construye el fbc. Si no hay _fbp, genera uno.
 */
export function paramsMetaDesdeRequest(req: NextRequest, eventSourceUrl?: string): ParamsMeta {
  const pb = new ParamBuilder(DOMINIOS);
  const url = eventSourceUrl ? new URL(eventSourceUrl) : req.nextUrl;

  const queries: Record<string, string> = {};
  url.searchParams.forEach((v, k) => { queries[k] = v; });

  const cookies: Record<string, string> = {};
  for (const c of req.cookies.getAll()) cookies[c.name] = c.value;

  const aEscribir = pb.processRequest(
    url.host,
    queries,
    cookies,
    req.headers.get("referer"),
    req.headers.get("x-forwarded-for"),
  );

  return {
    fbc: pb.getFbc() ?? undefined,
    fbp: pb.getFbp() ?? undefined,
    cookies: aEscribir.map((c) => ({ name: c.name, value: c.value, maxAge: c.maxAge, domain: c.domain })),
  };
}
```

**En la API route: tercer fallback para fbp y fbc**

```typescript
import { paramsMetaDesdeRequest } from "@/lib/meta-fbc";

// Antes: body → cookie del request. Ahora, si tampoco hay cookie, se reconstruye desde fbclid.
const reconstruido = paramsMetaDesdeRequest(req, body.event_source_url);
const fbp = body.fbp || req.cookies.get("_fbp")?.value || reconstruido.fbp;
const fbc = body.fbc || req.cookies.get("_fbc")?.value || reconstruido.fbc;
```

> Lo probé en Node con un request que traía fbclid=IwAR0abc123XYZ y ninguna cookie: devolvió fbc con forma fb.1.<timestamp>.IwAR0abc123XYZ.<sufijo>, un fbp nuevo, y las dos cookies con 90 días de vida. El sufijo corto al final es una marca de versión que la librería agrega; Meta lo acepta. Si además escribes esas cookies en la respuesta, el siguiente evento de esa persona ya sale con las mismas.

- El timestamp de fbc es el momento en que viste el fbclid por primera vez. Si guardas el fbclid 30 días en tu propio storage (ver la guía de UTMs) y reconstruyes el fbc después, guarda también esa marca de tiempo y no uses la de hoy.
- La misma librería trae getNormalizedAndHashedPII(valor, 'phone' | 'email' | ...). Comparé sus hashes contra los de normalizeEmail y normalizePhone de esta guía: idénticos para correo y para teléfono con lada. La diferencia: a un teléfono de 10 dígitos sin lada la librería no le agrega el 52; el nuestro sí. En México, sigue usando el tuyo para el teléfono.
- fbc sin fbclid no existe. Si el visitante llegó por tráfico orgánico o por un enlace sin fbclid, no hay clic que reconstruir y está bien que el parámetro vaya vacío.

## 06 · Las variables de entorno y la trampa del build

_donde se rompe_

El sistema necesita tres variables: el ID del píxel para el navegador (con prefijo NEXT_PUBLIC_, porque se hornea en el build), el mismo ID para el servidor, y el token de la Conversions API (sin prefijo público, jamás). El token se genera en Events Manager: tu píxel, pestaña Settings, sección Conversions API, 'Generate access token'. Solo lo ve quien tenga permisos de desarrollador en el Business.

**.env.local**

```bash
NEXT_PUBLIC_META_PIXEL_ID=1234567890
META_PIXEL_ID=1234567890
META_CAPI_TOKEN=EAAB...tu-token-de-conversions-api
# Solo mientras pruebas. Bórrala antes de producción.
META_TEST_EVENT_CODE=TEST12345
```

- En Vercel es común que las tres variables existan pero estén vacías (""), creadas como placeholder. Vacía no es lo mismo que ausente: el código con '?? ""' las acepta y todo se apaga sin error.
- Una variable NEXT_PUBLIC_ que agregas o corriges no aplica hasta el siguiente deploy. El píxel vive en el HTML del build, no en el runtime. Commit vacío + push, o redeploy.
- Una variable agregada después de que un deploy arrancó no entra en ese deploy. Revisa que el redeploy sea posterior a la variable.
- El CLI de Vercel puede marcar la variable como 'sensitive' por defecto en producción; después 'vercel env pull' la muestra vacía aunque en el servidor tenga valor. No lo confundas con la trampa anterior: verifica en el HTML servido, no en el pull.
- Un salto de línea pegado al final del valor (pasa al copiar desde un editor) hace que la URL a Meta sea inválida. El ID del píxel es solo dígitos: valídalo.

**next.config.ts — revienta el build de producción si el píxel no está limpio**

```typescript
import type { NextConfig } from "next";

const pixel = process.env.NEXT_PUBLIC_META_PIXEL_ID ?? "";
if (process.env.VERCEL_ENV === "production" && !/^\d{10,20}$/.test(pixel)) {
  throw new Error(
    `NEXT_PUBLIC_META_PIXEL_ID inválido en producción: "${pixel}". El pixel del navegador no cargaría.`,
  );
}

const nextConfig: NextConfig = {};
export default nextConfig;
```

**Verificar en el HTML servido que el píxel está horneado (terminal)**

```bash
curl -s https://tu-landing.com/ | grep -o 'facebook.com/tr?id=[0-9]*' | head -1
```

> Si no imprime nada, el píxel no está en el build. No importa lo que diga el dashboard de variables.

## 07 · Probar con test_event_code en Events Manager

_antes de confiar_

Events Manager tiene una pestaña 'Test Events' dentro de tu píxel. Ahí aparece un código con forma TEST12345. Si mandas ese código en el campo test_event_code del payload (al mismo nivel que data), los eventos del servidor aparecen en esa pestaña en tiempo real, con sus parámetros, y puedes ver si Meta los emparejó con el del navegador. La documentación es clara en dos cosas: los eventos con test_event_code no se descartan (sí cuentan), y el campo hay que quitarlo en producción.

1. Abre Events Manager, tu píxel, pestaña Test Events. Copia el código TEST que aparece.
2. Ponlo en META_TEST_EVENT_CODE de tu .env.local (el código de arriba lo agrega al payload solo si existe).
3. En la misma pestaña, en la parte de navegador, pega la URL de tu landing y abre la página desde ahí: así también ves los eventos de fbq.
4. Envía el formulario una vez. Deberías ver dos entradas 'Lead': una de Browser y una de Server, con el mismo Event ID, y la de servidor marcada como deduplicada o 'processed' con el par.
5. Revisa en la entrada del servidor que lleguen em, ph, fbp, fbc, client_ip_address y client_user_agent. Cada parámetro que falte baja el Event Match Quality.
6. Borra META_TEST_EVENT_CODE. Un test_event_code en producción manda todo tu tráfico a la vista de pruebas.

**Probar el servidor a mano, sin la landing (terminal)**

```bash
curl -s -X POST "https://graph.facebook.com/v21.0/$META_PIXEL_ID/events" -H 'Content-Type: application/json' -d "{\"data\":[{\"event_name\":\"Lead\",\"event_time\":$(date +%s),\"event_id\":\"prueba-1\",\"action_source\":\"website\",\"event_source_url\":\"https://tu-landing.com/\",\"user_data\":{\"em\":[\"$(printf 'prueba@ejemplo.com' | shasum -a 256 | cut -d' ' -f1)\"],\"client_user_agent\":\"curl\"}}],\"test_event_code\":\"TEST12345\",\"access_token\":\"$META_CAPI_TOKEN\"}"
```

> Respuesta esperada: {"events_received":1,"messages":[],"fbtrace_id":"..."}. Si devuelve error 190, el token está mal o sin permiso sobre ese píxel; si 100, hay un parámetro mal formado y Meta te dice cuál.

> Un evento con events_received:1 solo prueba que Meta lo aceptó. No prueba que se acredite a una campaña. Eso solo lo ves después, comparando registros reales contra resultados en el administrador de anuncios.

## 08 · El vigía: detectar que un lado murió

_para que no vuelva a pasar_

El incidente duró días porque cada señal individual parecía sana: la base llena, la CAPI respondiendo, cero fallas. Lo único que lo delataba era una comparación entre dos fuentes. Ese es el vigía: un cron diario que junta tres conteos y alerta cuando se separan.

- **Conteo 1: tu base** — Registros de ayer agrupados por utm_campaign. Es la verdad. Todo lo demás se compara contra esto.
- **Conteo 2: Meta acreditado** — Insights de la cuenta publicitaria a nivel campaña, date_preset=yesterday, sumando las acciones offsite_conversion.fb_pixel_lead (o complete_registration según tu evento). Requiere un token con permiso ads_read, que no es el mismo permiso que el token de CAPI.
- **Conteo 3: navegador vs servidor** — En Events Manager, la vista general de cada evento muestra cuántos llegaron por Browser y cuántos por Server. Si los dos lados están vivos, se parecen. Si uno cae a cero, el otro sigue igual y nadie se entera sin mirar.

La regla que quedó después de calibrarla contra 37 campañas reales: alertar cuando Meta acredita menos del 50% de lo que la base tiene, con al menos 15 registros en el día, y solo si la campaña aparece en Meta con gasto. Sin esa última condición el vigía daba falsos positivos diarios por campañas renombradas por el equipo o tráfico de otras plataformas que llegaba con UTMs parecidas. La primera corrida dio 9 alertas falsas; tres iteraciones después, cero.

**app/api/cron/vigia-pixel/route.ts — el detector de brecha de acreditación**

```typescript
import { NextRequest, NextResponse } from "next/server";
import { registrosDeAyerPorCampana } from "@/lib/db";

const UMBRAL_RATIO = 0.5;   // alerta si Meta acredita menos del 50% de lo real
const MIN_REGISTROS = 15;   // por debajo de esto, el ruido gana
const ACCION_META = "offsite_conversion.fb_pixel_lead";
const AD_ACCOUNTS = (process.env.VIGIA_AD_ACCOUNTS ?? "").split(",").filter(Boolean);
const META_TOKEN = process.env.VIGIA_META_TOKEN ?? ""; // token con ads_read, distinto al de CAPI

interface InsightAction { action_type: string; value: string }
interface InsightRow { campaign_name: string; spend?: string; actions?: InsightAction[] }
interface InsightsResponse { data?: InsightRow[]; error?: { message: string } }

export async function GET(req: NextRequest) {
  if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  const real = await registrosDeAyerPorCampana(); // Map<utm_campaign, number>
  const meta = new Map<string, { acreditados: number; gasto: number }>();
  const cuentasCiegas: string[] = [];

  for (const acct of AD_ACCOUNTS) {
    const url =
      `https://graph.facebook.com/v21.0/act_${acct}/insights?level=campaign&date_preset=yesterday` +
      `&fields=campaign_name,actions,spend&limit=100&access_token=${META_TOKEN}`;
    const json = (await fetch(url).then((r) => r.json())) as InsightsResponse;
    if (json.error || !json.data) { cuentasCiegas.push(acct); continue; }
    for (const c of json.data) {
      const acreditados = Number(c.actions?.find((a) => a.action_type === ACCION_META)?.value ?? 0);
      const prev = meta.get(c.campaign_name) ?? { acreditados: 0, gasto: 0 };
      meta.set(c.campaign_name, {
        acreditados: prev.acreditados + acreditados,
        gasto: prev.gasto + Number(c.spend ?? 0),
      });
    }
  }

  const alertas: string[] = [];

  // Un vigía que no puede ver debe decirlo, no reportar "sin alertas".
  if (cuentasCiegas.length > 0) {
    alertas.push(`Sin acceso a insights de ${cuentasCiegas.length} cuenta(s): token vencido o sin ads_read`);
  }

  for (const [campana, registros] of real) {
    const m = meta.get(campana);
    if (registros < MIN_REGISTROS) continue;
    if (!m || m.gasto === 0) continue; // no está gastando en Meta: no es un problema de pixel
    const ratio = m.acreditados / registros;
    if (ratio < UMBRAL_RATIO) {
      alertas.push(
        `${campana}: ${registros} registros reales, Meta acredita ${m.acreditados} (${Math.round(ratio * 100)}%)`,
      );
    }
  }

  // Aquí mandas las alertas a donde las leas de verdad (Telegram, Slack, correo).
  return NextResponse.json({ ok: true, alertas, revisadas: real.size });
}
```

**vercel.json — el cron diario**

```json
{
  "crons": [
    { "path": "/api/cron/vigia-pixel", "schedule": "30 15 * * *" }
  ]
}
```

El detalle que más me costó: el vigía tiene que avisar cuando se queda ciego. Si el token de insights vence, la respuesta es un error, el mapa queda vacío, no se genera ninguna alerta y el cron responde ok. Indistinguible de un día sano. Por eso las cuentas que fallan se acumulan y se reportan como alerta propia.

- Complementa el cron con el booleano de trackBrowser del lado navegador: si guardas 'pixel_cargado' en tu propio tracker por sesión, un día con 0% de píxel cargado se ve antes de que Meta lo refleje.
- Cuando una campaña 'no convierte' hay tres fallas distintas que se confunden: página emisora restringida, píxel o atribución rotos, y URL de destino rota (un typo de una letra en el dominio de 9 anuncios costó varios miles de pesos a una página de error). Se diagnostican en ese orden.
- La comparación sana es: base ≥ Meta acreditado. Lo grave es Meta cerca de cero con la base llena. Si Meta reporta más que la base, tienes duplicados.

## 09 · La CLI oficial de Meta: qué sí revisa del píxel y qué no

_desde la terminal_

En 2026 Meta publicó una CLI oficial para su Marketing API, pensada para terminal y para agentes. La instalé y recorrí su ayuda completa para esta guía: es el paquete meta-ads en PyPI (versión 1.1.0 al escribir esto), el binario se llama meta, y solo trae ruedas para Python 3.12 y 3.13. Con Python 3.14 pip responde 'no matching distribution' y parece que el paquete no existe; no es eso, es la versión de Python. La forma limpia es instalarla aislada con uv.

**Instalar la CLI oficial aislada, sin tocar tu Python global (terminal)**

```bash
uv python install 3.12 && uv tool install --python 3.12 meta-ads && meta --version
```

> Autenticación: variable de entorno ACCESS_TOKEN con un token de system user (scopes ads_read y ads_management como mínimo para lo de aquí) y AD_ACCOUNT_ID con tu cuenta (act_...). 'meta auth status' te dice si la tomó.

| Comando | Para qué te sirve en este sistema |
| --- | --- |
| meta ads dataset list | Meta ahora llama 'dataset' al píxel. Lista los de la cuenta (o de todo el negocio con --business-id). Sirve para confirmar que el ID que horneaste en NEXT_PUBLIC_META_PIXEL_ID es un píxel real y de esa cuenta. |
| meta ads dataset get <PIXEL_ID> | Detalle de ese píxel. Con --output json lo lees desde un script o un agente. |
| meta ads dataset connect <PIXEL_ID> --ad-account-id act_... | Conecta el píxel a una cuenta publicitaria. Es escritura: solo si dataset list te mostró que no estaba conectado. |
| meta ads insights get --date-preset yesterday --fields campaign_name,actions,spend --output json | La versión manual del conteo 2 del vigía. Ojo: en su ayuda no hay bandera --level; para ver por campaña usa --campaign-id o la llamada directa a insights que ya usa el cron. |
| meta ads guidance list | Recomendaciones de Meta a nivel cuenta. No tiene que ver con el píxel, pero está ahí. |

- Lo que NO hace (revisado en su ayuda, subcomando por subcomando): no muestra eventos recibidos, no separa Browser de Server, no expone Event Match Quality, no dispara test events y no manda eventos a la Conversions API. Todo eso sigue viviendo en Events Manager y en el curl de la sección anterior.
- Hay un paquete de terceros llamado meta-ads-cli (en npm y en PyPI) que no es de Meta. El oficial es meta-ads, y su binario es meta.
- Meta anunció también un servidor MCP con las mismas capacidades para usarlo desde el chat de Claude; no lo probé para esta guía.
- Honestidad sobre el alcance: instalé la CLI y verifiqué cada comando de arriba contra su --help, pero no la corrí contra una cuenta real al escribir esto, así que no describo sus formatos de salida.

Si un agente (Claude Code, por ejemplo) va a operar esta CLI por ti, ponle reglas antes del primer comando: solo lectura sobre el píxel (list, get, insights); connect, disconnect y create únicamente con tu OK explícito en ese momento; un comando a la vez; el token en variable de entorno y nunca dentro del prompt ni del repo; salida con --output json para que la lea sin adivinar. Las cuentas publicitarias se bloquean por patrones de automatización agresiva, no por leer.

## 10 · Diagnóstico en orden: síntoma, causa, prueba

_cuando 'no convierte'_

'La campaña no convierte' esconde fallas distintas que se confunden porque el síntoma en el administrador de anuncios es el mismo. Esta tabla es el orden en que las reviso. Cada fila tiene una prueba concreta, no una corazonada.

| Síntoma | Causa probable | Cómo lo compruebas |
| --- | --- | --- |
| Events Manager recibe miles de eventos; la campaña muestra casi cero resultados | El píxel del navegador está muerto (variable vacía, build viejo) y solo llega el servidor | curl al HTML servido buscando facebook.com/tr?id=; en Events Manager la columna Browser del evento en cero |
| El costo por resultado bajó a la mitad de golpe; Meta reporta más que tu base | Duplicados: event_id o event_name distintos entre navegador y servidor | Test Events: dos entradas del evento sin marca de deduplicado; compara el Event ID de cada una |
| Los dos lados llegan pero la acreditación sigue baja | Event Match Quality baja: faltan em, ph, fbp, fbc, IP o user agent, o el teléfono se hasheó con '+' o sin lada | Abre el evento de servidor en Test Events y cuenta los parámetros que llegan; revisa el EMQ del evento en Events Manager |
| Todo bien en Chrome; se cae en iOS y Safari | fbc no llega porque la cookie _fbc no sobrevivió o nunca se escribió | Reconstruir fbc desde fbclid en el servidor (sección de arriba) y volver a medir por dispositivo |
| events_received:1 pero nada aparece en Test Events | test_event_code viejo o el ID de píxel no es el que crees | meta ads dataset list para confirmar el ID; regenera el código TEST en la pestaña |
| El vigía dice ok pero la campaña no tiene datos | El token de insights venció y el vigía quedó ciego | La alerta de cuentas sin acceso del cron; meta auth status con ese token |
| Nada de lo anterior falla y aun así cero | La página emisora está restringida o la URL de destino está rota | Revisa la calidad de la página en Business Suite; abre cada URL de anuncio a mano (un typo en el dominio de 9 anuncios costó varios miles de pesos) |

## Preguntas frecuentes

**¿Qué pasa si el navegador nunca manda su evento? ¿El del servidor se descarta?**

No. Con el método de event_id, Meta solo descarta cuando encuentra el par. Si solo llega el del servidor, cuenta como un evento normal. Ese es el punto: el servidor cubre lo que el navegador pierde. Lo que sí pasa es que ese evento se acredita peor a la campaña, por eso los dos lados tienen que estar vivos la mayor parte del tiempo.

**¿Puedo usar el mismo token de Meta que uso para leer campañas?**

Puedes, pero conviene separar. El token que genera Events Manager para la Conversions API tiene permiso de escribir eventos en ese píxel y no trae ads_read; el que usas para insights necesita ads_read y no tiene por qué escribir eventos. Dos tokens, dos variables, y si uno se filtra el otro sigue vivo.

**¿Cuánto retroactivo acepta la Conversions API?**

event_time puede ser hasta 7 días anterior al momento en que lo mandas, y Meta reprocesa la atribución. Más de 7 días se rechaza. Si tuviste el píxel muerto una semana, reinyecta con la fecha original y un event_id determinista (por ejemplo 'backfill-' + id del registro) para poder repetir el script sin duplicar.

**¿El teléfono lleva el '+' o el código de país?**

Solo dígitos, con código de país, sin '+', sin espacios y sin ceros a la izquierda. Un número mexicano de 10 dígitos se manda como 52 seguido de los 10 dígitos, y luego se hashea con SHA-256. Si hasheas '+52 33 1234 5678' el hash no coincide con nada y ese parámetro no suma al Event Match Quality.

**¿Un solo píxel para varias landings o uno por landing?**

Un solo píxel por negocio, distinguiendo cada embudo con content_name y content_category en custom_data (el mismo par en navegador y servidor). Audiencias y conversiones personalizadas se filtran por esos parámetros. Ojo: la optimización de un conjunto de anuncios usa el evento completo del píxel salvo que crees una conversión personalizada filtrada y la elijas como objetivo.

**¿Por qué la landing responde ok aunque Meta falle?**

Porque el registro del usuario no depende de Meta. La escritura a la base se espera; el envío a Meta es fire-and-forget con su error registrado en una tabla de fallas. Lo que nunca debe hacer es saltarse el envío en silencio cuando faltan credenciales: eso fue lo que escondió el incidente.

**¿Meta ahora le dice 'dataset' al píxel? ¿Tengo que cambiar algo?**

Es el mismo objeto con otro nombre. El ID no cambia, el endpoint sigue siendo /{pixel_id}/events, y fbq('init', ...) recibe el mismo número. Donde vas a ver la palabra dataset es en la CLI oficial (meta ads dataset ...) y en partes de Events Manager. Si buscas 'píxel' en la interfaz y no lo encuentras, busca dataset.

**¿La CLI oficial de Meta me dice si el píxel está disparando?**

No. Te dice que el píxel existe, a qué cuenta está conectado y qué acreditan las campañas (insights). Si está disparando o no, y si el par navegador+servidor se está deduplicando, solo lo ves en Events Manager (Test Events y la vista Browser/Server de cada evento) o con el curl de esta guía. La CLI es para confirmar identidad y conexión del píxel, y para sacar el conteo de acreditados sin abrir el navegador.

**¿Puedo dejar que un agente revise o mande los eventos por mí?**

Revisar, sí: dale la CLI con comandos de lectura y acceso a Events Manager, y que te traiga la tabla de diagnóstico llena. Mandar los eventos, no: eso lo hace tu API route, determinista, con el event_id que generó el navegador. Un agente que 'reinyecta' eventos a mano sin event_id determinista te llena la cuenta de duplicados. Si un día hay que reinyectar, es un script con 'backfill-' + id del registro, y lo corres tú.

**¿Qué es el Event Match Quality y cómo lo subo?**

Es la calificación que Events Manager le pone a cada evento del servidor según cuántos parámetros de user_data llegaron y qué tan bien los pudo emparejar con personas. Sube con cada parámetro correcto: em y ph normalizados y hasheados, fn y ln, fbp, fbc, client_ip_address y client_user_agent. Baja con parámetros mal formados (teléfono con '+' hasheado, correo con mayúsculas) porque el hash no coincide con nada. La prueba práctica es la misma de la sección de Test Events: abre el evento y cuenta qué llegó.

## Cierre de la guía

Píxel y CAPI no compiten: son el mismo evento por dos caminos, y Meta ya sabe juntarlos si le das el mismo nombre y el mismo id. Lo que no hace Meta es avisarte cuando uno de los caminos se cierra. Instala los dos lados, prueba el par en Test Events, y programa el cron que compara tu base contra lo que Meta acredita. El día que la brecha aparezca, la vas a ver esa mañana y no tres semanas después en un reporte de costo por lead que no cuadra. Esta guía vive en el Lab de David Iriza.

## Fuentes oficiales

- [Deduplicate Pixel and Server Events (Meta for Developers)](https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events): Los dos métodos de deduplicación, los campos que deben coincidir, la ventana de 48 horas y la sintaxis de eventID en fbq.
- [Server Event Parameters (Meta for Developers)](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event): Valores permitidos de action_source, el límite de 7 días de event_time, y la obligatoriedad de event_source_url en eventos web.
- [Customer Information Parameters (Meta for Developers)](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters): Normalización y hash SHA-256 de em, ph, fn, ln; qué parámetros nunca se hashean; formato de fbp y fbc.
- [Using the Conversions API (Meta for Developers)](https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api): El endpoint /{pixel_id}/events, el tope de 1,000 eventos por request, y la regla de usar test_event_code solo en pruebas.
- [Conversions API Parameter Builder (GitHub de Meta)](https://github.com/facebook/capi-param-builder): La librería oficial que construye fbc y fbp desde el request y normaliza y hashea PII. Paquete npm capi-param-builder-nodejs; también Python, PHP, Java, Ruby y navegador.
- [Ads CLI: referencia de comandos (Meta for Developers)](https://developers.facebook.com/documentation/ads-commerce/ads-ai-connectors/ads-cli/command-reference): Todos los grupos de comandos de la CLI oficial, incluidos dataset (píxel) e insights. Requisitos: Python 3.12+, token de system user en ACCESS_TOKEN.
- [meta-ads en PyPI](https://pypi.org/project/meta-ads/): El paquete oficial de la CLI (binario meta). Solo publica ruedas para Python 3.12 y 3.13.

## Repositorios

- [davidiriza-lab/pixel-capi-nextjs](https://github.com/davidiriza-lab/pixel-capi-nextjs): Meta Pixel + Conversions API en Next.js deduplicados por event_id, con hash SHA-256, test_event_code y cron vigía de acreditación. (MIT)

## Guías que se conectan con esta

- [utms-que-sobreviven-30-dias](https://www.davidiriza.com/lab/utms-que-sobreviven-30-dias): El fbc y las UTMs que el servidor manda a Meta y guarda en la base salen de la misma captura persistente de atribución.
- [escrituras-seguras-desde-una-landing](https://www.davidiriza.com/lab/escrituras-seguras-desde-una-landing): La API route de esta guía sigue el mismo patrón: la base se espera, los servicios externos son fire-and-forget con registro de fallas.
- [analytics-propio-en-tu-landing](https://www.davidiriza.com/lab/analytics-propio-en-tu-landing): El tracker propio es donde guardas si el píxel cargó en cada sesión, la señal más temprana para el vigía.
- [anuncios-con-video-desde-un-agente](https://www.davidiriza.com/lab/anuncios-con-video-desde-un-agente): Si un agente ya publica anuncios en Meta por ti, las mismas reglas (dry-run, un 'sí' antes de escribir) aplican cuando le das la CLI oficial para revisar el píxel.

---

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 oficial de la Conversions API de Meta: https://developers.facebook.com/docs/marketing-api/conversions-api

---

Versión markdown de https://www.davidiriza.com/lab/pixel-capi-sin-duplicar — el sitio negocia por `Accept: text/markdown` y por sufijo `.md`. Índice para agentes: https://www.davidiriza.com/llms.txt