Emtiaz
ServicesCase StudiesTestimonialsAboutFAQBlogs
Advanced Tracking20 min read

Meta CAPI Tracking Using Own Server (Coding Method)

Emtiaz Hossain

Author Name

Emtiaz Hossain

Last Edit : Jul 20, 2026, 04:39:00 PM
Meta CAPI Tracking Using Own Server (Coding Method)

TABLE OF CONTENTS▼
Meta CAPI Tracking Using Own Server (Coding Method)Step 1: Meta Pixel via GTM (Already in Place)Step 2: Set Up Conversions API in Events ManagerStep 3: Server-Side API Route in Next.jsAPI Route: src/app/api/facebook-capi/route.jssrc/lib/utils.jssrc/lib/facebookCapi.jssrc/utils/getFbCookies.jssrc/lib/hash.jsStep 4: Frontend Triggerssrc/components/Tracking/PageViewTracker.jssrc/components/contact/ContactForm.jsxStep 5: Environment VariablesStep 6: Verifying It WorkedKey TakeawaysFrequently Asked QuestionsDo I still need the Meta Pixel if I'm sending events server-side via CAPI?Why is my Event Match Quality score low even though events are firing?What happens if I leave test_event_code in my production code?How do I know if an event was deduplicated correctly instead of double-counted?

Contents

0%
Meta CAPI Tracking Using Own Server (Coding Method)Step 1: Meta Pixel via GTM (Already in Place)Step 2: Set Up Conversions API in Events ManagerStep 3: Server-Side API Route in Next.jsAPI Route: src/app/api/facebook-capi/route.jssrc/lib/utils.jssrc/lib/facebookCapi.jssrc/utils/getFbCookies.jssrc/lib/hash.jsStep 4: Frontend Triggerssrc/components/Tracking/PageViewTracker.jssrc/components/contact/ContactForm.jsxStep 5: Environment VariablesStep 6: Verifying It WorkedKey TakeawaysFrequently Asked QuestionsDo I still need the Meta Pixel if I'm sending events server-side via CAPI?Why is my Event Match Quality score low even though events are firing?What happens if I leave test_event_code in my production code?How do I know if an event was deduplicated correctly instead of double-counted?

Meta CAPI Tracking Using Own Server (Coding Method)

iOS 14.5+ and browser tracking prevention quietly cut off a meaningful share of the Meta Pixel events a site used to see. The fix most people reach for is a paid server-side tagging tool. This is the alternative: implementing Meta's Conversions API directly from a custom Next.js server, no Stape, no third-party middleware, full control over what gets sent and when.

This walks through exactly how it's built, file by file.

Step 1: Meta Pixel via GTM (Already in Place)

Browser-side Meta Pixel was already running through GTM, firing standard events like PageView and Lead. That part isn't the focus here, it's the baseline CAPI needs to deduplicate against.

Step 2: Set Up Conversions API in Events Manager

  1. Open Meta Events Manager and select the Pixel
  2. Add Events → Using the Conversions API → Set up manually
  3. Walk through: Getting Started → Explore Integration → Generate an Access Token → Set Up Events → Finish Implementation

At the end of this, keep two values safe: the Pixel ID and the Access Token. Both are needed in the next step and neither should ever end up in client-side code.

Step 3: Server-Side API Route in Next.js

File structure for a custom Next.js 13+ App Router setup:

javascript
src/
├── app/
│   └── api/
│       └── facebook-capi/
│           └── route.js
├── lib/
│   ├── facebookCapi.js
│   ├── hash.js
│   └── utils.js
├── utils/
│   └── getFbCookies.js
└── components/
    ├── Tracking/PageViewTracker.js
    └── contact/ContactForm.jsx

API Route: src/app/api/facebook-capi/route.js

javascript
import { sendFacebookEvent } from "@/lib/facebookCapi";
import { getClientIp, getUserAgent, prepareUserData } from "@/lib/utils";

export async function POST(req) {
  let eventName = "pageview";
  try {
    const body = await req.json();

    const {
      eventName: receivedEventName,
      eventSourceUrl,
      eventId,
      eventTime,
      ...restOfBody
    } = body;

    if (!receivedEventName || !eventSourceUrl || !eventId || !eventTime) {
      return new Response(
        JSON.stringify({
          error: "Missing required event parameters.",
          received: body,
        }),
        { status: 400 },
      );
    }

    eventName = receivedEventName;

    const userAgent = getUserAgent(req);
    const clientIp = getClientIp(req);
    const userData = prepareUserData(restOfBody, clientIp, userAgent);

    const result = await sendFacebookEvent({
      eventName,
      eventId,
      eventTime,
      eventSourceUrl,
      userData,
    });

    return new Response(JSON.stringify(result), { status: 200 });
  } catch (err) {
    console.error(`Facebook CAPI API Error - ${eventName}`, err);

    let errorMessage = "An unknown error occurred.";
    if (err instanceof SyntaxError && err.message.includes("JSON")) {
      errorMessage = "Invalid JSON in request body.";
    } else if (err instanceof Error) {
      errorMessage = err.message;
    }

    return new Response(JSON.stringify({ error: errorMessage }), { status: 500 });
  }
}

src/lib/utils.js

javascript
import { hash } from "./hash";

export function getClientIp(req) {
  return (
    req.headers.get("x-forwarded-for") ||
    req.headers.get("x-real-ip") ||
    "0.0.0.0"
  );
}

export function getUserAgent(req) {
  return req.headers.get("user-agent");
}

export function prepareUserData(body, clientIp, userAgent) {
  const { city, state, zip, country, externalId, fbc, fbp, firstName, lastName, email, phone } = body;

  const userData = {
    client_ip_address: clientIp,
    client_user_agent: userAgent,
    fbc,
    fbp,
  };

  if (email) userData.em = [hash(email)];
  if (firstName) userData.fn = hash(firstName);
  if (lastName) userData.ln = hash(lastName);
  if (phone) userData.ph = [hash(phone)];
  if (city) userData.ct = hash(city);
  if (state) userData.st = hash(state);
  if (zip) userData.zp = hash(zip);
  if (country) userData.country = hash(country);
  if (externalId) userData.external_id = hash(externalId?.toString());

  return userData;
}

src/lib/facebookCapi.js

This sends the payload to {{https://graph.facebook.com/v18.0/{pixelId}}}/events.

javascript
export const sendFacebookEvent = async ({
  eventName,
  eventTime,
  eventId,
  eventSourceUrl,
  userData = {},
}) => {
  const pixelId = process.env.FB_PIXEL_ID;
  const accessToken = process.env.FB_ACCESS_TOKEN;

  if (!pixelId || !accessToken) {
    throw new Error("Facebook Pixel ID or Access Token is missing from environment variables.");
  }

  const fbUrl = `{{https://graph.facebook.com/v18.0/${pixelId}}}/events?access_token=${accessToken}`;

  const payload = {
    data: [
      {
        event_name: eventName,
        event_time: eventTime,
        event_id: eventId,
        event_source_url: eventSourceUrl,
        action_source: "website",
        user_data: userData,
      },
    ],
  };

  try {
    const fbRes = await fetch(fbUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });

    const result = await fbRes.json();

    if (!fbRes.ok || result.error) {
      console.error(`${eventName} API returned an error:`, result);
      throw new Error(`Error: ${result.error?.message || "Unknown error"}`);
    }

    return result;
  } catch (error) {
    console.error(`Error - ${eventName}`, error);
    throw error;
  }
};
The test_event_code trap. Meta's setup docs show test_event_code in every example because it's how you verify events in the Test Events tool before going live. If that parameter stays in the URL after testing, every event sent from this endpoint gets tagged as a test event permanently, it will show up in Meta's Test Events tab but will never count as a real conversion in Ads Manager, never feed optimization, and never show up in reporting. Remove it entirely once verification is done, don't leave it commented out and don't gate it behind an environment flag you might forget to unset.

src/utils/getFbCookies.js

Reads Meta's first-party cookies (_fbc, _fbp) client-side so they can be passed to the server for matching.

javascript
export const getFbCookies = () => {
  if (typeof document === "undefined") return { fbc: "", fbp: "" };

  const fbc =
    document.cookie
      .split("; ")
      .find((row) => row.startsWith("_fbc="))
      ?.split("=")[1] || "";

  const fbp =
    document.cookie
      .split("; ")
      .find((row) => row.startsWith("_fbp="))
      ?.split("=")[1] || "";

  return { fbc, fbp };
};

src/lib/hash.js

Meta requires PII (email, phone, name, address fields) to be SHA-256 hashed before it's sent, never send these raw.

javascript
import crypto from "crypto";

export const hash = (val) => {
  if (!val) return undefined;
  return crypto
    .createHash("sha256")
    .update(val.trim().toLowerCase())
    .digest("hex");
};

Step 4: Frontend Triggers

Two components call the API route when events actually happen.

src/components/Tracking/PageViewTracker.js

Fires a server-side PageView on mount.

javascript
"use client";

import { getEventId, getEventTime, getExternalId } from "@/lib/customID";
import { getGeoFromLocal } from "@/lib/getGeoFromLocal";
import { getFbCookies } from "@/utils/getFbCookies";
import { useEffect } from "react";

export default function PageViewTracker() {
  useEffect(() => {
    const { fbc, fbp } = getFbCookies();
    const geo = getGeoFromLocal();

    const payload = {
      eventName: "PageView",
      eventSourceUrl: window.location.href,
      eventId: getEventId("pageview"),
      eventTime: getEventTime(),
      externalId: getExternalId(),
      fbc,
      fbp,
      city: geo?.city || "",
      state: geo?.region || "",
      zip: geo?.postal || "",
      country: geo?.country || "",
    };

    fetch("/api/facebook-capi", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    }).catch((error) => {
      console.error("Facebook CAPI PageView error:", error);
    });
  }, []);

  return null;
}

src/components/contact/ContactForm.jsx

Fires a Lead event on form submission, using the same eventId the browser Pixel uses so Meta can deduplicate the browser and server events instead of double-counting.

javascript
const contactPayload = {
  eventName: "Lead",
  eventSourceUrl: window.location.href,
  eventId: getEventId("lead"),
  eventTime: getEventTime(),
  externalId: getExternalId(),
  firstName,
  lastName,
  email,
  phone,
  city: geo?.city || "Unknown",
  state: geo?.region || "Unknown",
  zip: geo?.postal || "Unknown",
  country: geo?.country || "Unknown",
  fbc,
  fbp,
};

fetch("/api/facebook-capi", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(contactPayload),
});

The same pattern extends to any conversion point, a Calendly booking form uses an identical payload shape with eventName: "Schedule".

Step 5: Environment Variables

javascript
FB_PIXEL_ID=your_pixel_id
FB_ACCESS_TOKEN=your_long_lived_token

Keep the access token server-side only. It should never appear in a client bundle.

Step 6: Verifying It Worked

  • ▸Event Match Quality (EMQ) score reached 9+ after implementation
  • ▸PageView and Lead events fire from the server, bypassing iOS 14.5+ ITP restrictions on client-side tracking
  • ▸Both hashed user data and first-party cookies (fbc/fbp) are sent, giving Meta multiple signals to match against

[Screenshot: Meta Events Manager showing server events matched with EMQ score]

Key Takeaways

  • ▸Server-side CAPI isn't a replacement for the browser Pixel, it's a second signal that survives what the browser blocks
  • ▸Use the same eventId on both the Pixel and CAPI calls for the same user action, or Meta will count it twice
  • ▸Always hash PII before it leaves your server, never send raw email or phone to the Graph API
  • ▸Strip test_event_code before going live, it silently prevents every event from counting as a real conversion

Frequently Asked Questions

Do I still need the Meta Pixel if I'm sending events server-side via CAPI?

Yes. CAPI is meant to run alongside the browser Pixel, not replace it. Meta deduplicates matching events between the two using a shared event_id, so you get the browser signal when it's available and the server-side signal to cover what ad blockers and Safari's ITP strip out. Sending CAPI only, with no Pixel, means losing real-time browser signals like scroll and click behavior that Meta also uses for optimization.

Why is my Event Match Quality score low even though events are firing?

EMQ measures how much reliable customer data (email, phone, external ID) is attached to each event, not whether the event fired. A tag that fires perfectly but sends only an IP address and user agent will still score low. Hash the available PII fields (SHA-256) and pass as many matching parameters as you legitimately have, that's what moves the score.

What happens if I leave test_event_code in my production code?

Every event carrying that code gets routed to Meta's Test Events tool and excluded from campaign optimization and reporting, permanently, until removed. This is one of the most common CAPI mistakes: a developer sets it during setup, ships it, and Meta quietly stops learning from real conversions while the dashboard shows nothing obviously wrong.

How do I know if an event was deduplicated correctly instead of double-counted?

Check Meta's Events Manager under the Test Events or Diagnostics view for that event name. Correctly deduplicated events show a single combined entry with both browser and server sources listed. If you see two separate event counts for the same action, the event_id isn't matching between your Pixel and CAPI calls.

#Facebook CAPI#Stape#web analytics#full stack web analytics#tracking#event deduplication#facebook pixel#tracking setup#conversion tracking#CustomTracking#AdvancedTracking

Discussed this on LinkedIn

Found this helpful?

Drop a comment on the LinkedIn post – I read every reply and love connecting with tracking folks.

View on LinkedIn

Not Sure If Your Tracking Is Costing You?

Book a free 30-minute call. I will review your current tracking setup, show you where conversions are leaking, and tell you exactly what I would fix.

More Blog Posts

View all
The GA4 & GTM Audit That Found a Shopify Store's Google Ads Data Was 2x Inflated
Audit and Reporting

The GA4 & GTM Audit That Found a Shopify Store's Google Ads Data Was 2x Inflated

A full GA4/GTM/Google Ads audit on a UK Shopify store spending £18,000+/month found duplicate conversion tags inflating reported purchases 2x, a dropped GCLID at checkout, and misconfigured variables silently degrading Enhanced Conversions. Here's the audit, the fixes, and the migration off a legacy GTM setup before Shopify's deprecation deadline.

Complete LinkedIn Conversion Tracking Setup
Advanced Tracking

Complete LinkedIn Conversion Tracking Setup

How a UK marketing agency's LinkedIn Insight Tag upgrade turned into a full conversion audit, catching duplicate tags and stale triggers across eight lead-gen conversions.

Fixing Double Form Submission Tracking in GTM
Audit and Reporting

Fixing Double Form Submission Tracking in GTM

Duplicate form conversions in GA4 are usually a reload firing the same tag twice, not a real second submission. Here's the Custom JavaScript variable that catches it, plus other common causes to rule out.

Emtiaz
EmailLinkedInPrivacyTerms

© 2026 Emtiaz Hossain