Meta CAPI + Meta Ads Setup Guide

Most accounts I audit are losing 20-30% of their conversion data because they’re only running pixel tracking. iOS 17.4+ blocks the Meta pixel for about 35% of users, and that percentage keeps climbing. I’ve seen ROAS improve by 15-25% just from getting server-side conversion data flowing properly through CAPI.

The tricky part isn’t the concept — it’s getting the infrastructure right so your server-side events match your client-side events, and Meta’s deduplication actually works instead of double-counting everything.

What You’ll Have Working By The End

Prerequisites

Step 1: Generate CAPI Access Token and Dataset ID

In Meta Business Manager, go to Events Manager → your pixel → Settings → Conversions API.

Click “Generate Access Token” and copy it. You’ll also need your Dataset ID (pixel ID) from the same page.

Store these securely — the access token has write access to your conversion data. I usually store them as environment variables in whatever cloud platform I’m using.

The access token format looks like: EAAxxxxxxxxx|xxxxxxxxxxxxxxxxxxxxxxxxxx Your Dataset ID is the numeric pixel ID, like: 1234567890123456

Step 2: Deploy CAPI Container Infrastructure

I use Google Cloud Run for most CAPI deployments because it’s simple and scales automatically. Here’s the Docker setup I use:

Create a Dockerfile:

FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 8080
CMD ["node", "server.js"]

And the core server.js:

const express = require('express');
const axios = require('axios');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const CAPI_ENDPOINT = 'https://graph.facebook.com/v19.0/{DATASET_ID}/events';
const ACCESS_TOKEN = process.env.META_ACCESS_TOKEN;
const DATASET_ID = process.env.META_DATASET_ID;

app.post('/capi', async (req, res) => {
  try {
    const { events } = req.body;
    
    const payload = {
      data: events.map(event => ({
        event_name: event.event_name,
        event_time: Math.floor(Date.now() / 1000),
        event_id: event.event_id,
        action_source: 'website',
        event_source_url: event.event_source_url,
        user_data: {
          em: event.user_data?.email ? hashData(event.user_data.email) : undefined,
          ph: event.user_data?.phone ? hashData(event.user_data.phone) : undefined,
          client_ip_address: req.ip,
          client_user_agent: req.headers['user-agent'],
          fbc: event.user_data?.fbc,
          fbp: event.user_data?.fbp
        },
        custom_data: event.custom_data || {}
      })),
      access_token: ACCESS_TOKEN
    };

    const response = await axios.post(
      CAPI_ENDPOINT.replace('{DATASET_ID}', DATASET_ID),
      payload
    );

    res.json({ success: true, meta_response: response.data });
  } catch (error) {
    console.error('CAPI Error:', error.response?.data || error.message);
    res.status(500).json({ error: error.message });
  }
});

function hashData(data) {
  return crypto.createHash('sha256').update(data.toLowerCase().trim()).digest('hex');
}

app.listen(8080, () => {
  console.log('CAPI server running on port 8080');
});

Deploy to Cloud Run:

gcloud run deploy meta-capi \
  --source . \
  --platform managed \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars META_ACCESS_TOKEN=your_token,META_DATASET_ID=your_pixel_id

Your container URL will be something like: https://meta-capi-xxx-uc.a.run.app

Step 3: Configure Client-Side Event Collection

You need to modify your existing pixel implementation to send events to both Meta directly AND your CAPI container.

Here’s the JavaScript I use to capture pixel events and forward them server-side:

// Enhanced pixel setup with CAPI forwarding
!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', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');

// CAPI forwarding function
const CAPI_ENDPOINT = 'https://your-container-url.run.app/capi';

function sendToCAPI(eventName, eventData = {}) {
  const eventId = crypto.randomUUID();
  
  // Send to pixel with event_id for deduplication
  fbq('track', eventName, eventData, { event_id: eventId });
  
  // Send to CAPI container
  const capiEvent = {
    events: [{
      event_name: eventName,
      event_id: eventId,
      event_source_url: window.location.href,
      user_data: {
        email: eventData.email,
        phone: eventData.phone,
        fbc: getCookie('_fbc'),
        fbp: getCookie('_fbp')
      },
      custom_data: eventData
    }]
  };
  
  fetch(CAPI_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(capiEvent)
  }).catch(err => console.error('CAPI send failed:', err));
}

function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
}

// Usage for purchases
sendToCAPI('Purchase', {
  value: 99.99,
  currency: 'USD',
  content_ids: ['product-123'],
  content_type: 'product'
});

The key is using the same event_id for both the pixel event and the CAPI event. That’s how Meta deduplicates them.

Step 4: Configure GTM Container (If Using GTM)

If you’re using Google Tag Manager, create a Custom HTML tag for CAPI forwarding:

Tag Type: Custom HTML Trigger: Same triggers as your Meta pixel events

<script>
function sendEventToCAPI(eventName, eventData) {
  const eventId = 'evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
  
  // Update pixel event to include event_id
  if (typeof fbq !== 'undefined') {
    fbq('track', eventName, eventData, { event_id: eventId });
  }
  
  // Send to CAPI
  fetch('https://your-container-url.run.app/capi', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      events: [{
        event_name: eventName,
        event_id: eventId,
        event_source_url: {{Page URL}},
        user_data: {
          em: {{Email Hash}}, // Create variables for these
          ph: {{Phone Hash}},
          fbc: {{Click ID}},
          fbp: {{Browser ID}}
        },
        custom_data: eventData
      }]
    })
  });
}

// Trigger based on your event
sendEventToCAPI('{{Event Name}}', {
  value: {{Purchase Value}},
  currency: 'USD'
});
</script>

Create variables for the hashed email, phone, etc. The email hash variable should use this custom JavaScript:

function() {
  const email = {{Form Email}}; // Your email variable
  if (!email) return undefined;
  
  return CryptoJS.SHA256(email.toLowerCase().trim()).toString();
}

Step 5: Set Up Event Matching and Data Quality

Meta’s event matching quality determines how well they can attribute your server-side events. You want quality scores above 7.0.

Required fields for good matching:

High-value optional fields:

The email and phone must be SHA256 hashed and lowercase. Don’t send plain text — Meta will reject the events.

Here’s how I handle the hashing server-side:

function processUserData(userData) {
  const processed = {};
  
  if (userData.email) {
    processed.em = crypto.createHash('sha256')
      .update(userData.email.toLowerCase().trim())
      .digest('hex');
  }
  
  if (userData.phone) {
    // Remove non-digits, add country code if missing
    let phone = userData.phone.replace(/\D/g, '');
    if (phone.length === 10) phone = '1' + phone; // Assume US
    
    processed.ph = crypto.createHash('sha256')
      .update(phone)
      .digest('hex');
  }
  
  return processed;
}

Step 6: Testing and Verification

Test in CAPI Gateway: Go to Events Manager → Test Events → your pixel. You should see server events flowing in with your test event IDs.

Check Event Matching Quality: Events Manager → Data Sources → your pixel → Diagnostics. Look for “Event Matching” scores. Anything below 6.0 needs improvement.

Verify Deduplication: Send the same event via pixel and CAPI with identical event_ids. In the Test Events tool, you should see one event with source “Browser and Server” — that means deduplication worked.

Attribution Testing: Run a small test campaign and compare attributed conversions in Ads Manager against your source-of-truth conversion count. I expect 5-15% variance due to attribution windows and view-through conversions.

Server Health: Monitor your container’s logs and response times. I typically see 50-100ms response times for CAPI requests. Anything over 500ms indicates infrastructure issues.

Troubleshooting

Problem: Events showing in Test Events but not attributed to campaigns Your event_time might be off. CAPI events must happen within Meta’s attribution window (default 7 days view, 1 day click). Check that your server’s system clock is accurate and you’re using Unix timestamps.

Problem: Low event matching quality scores (below 6.0) You’re missing key matching parameters. Add fbc and fbp from the client side, and include hashed email/phone when available. The client_ip_address and client_user_agent are critical — make sure they’re from the actual user’s browser, not your server.

Problem: CAPI events firing but pixel events not deduplicating Your event_ids don’t match between client and server. Make sure you generate the event_id client-side and pass it to both the pixel and your CAPI endpoint. The event_id format should be consistent (I use UUID v4).

Problem: High error rates in CAPI Gateway Check your access token permissions and dataset ID. Also verify your event payload structure — Meta is strict about field types. Values should be numbers, currencies should be strings like “USD”, and arrays should contain strings.

Problem: Container deployment failing or timing out Cloud Run has memory and CPU limits. For high-volume accounts (>10k events/day), bump the memory to 1GB and set max instances to 100. Also add request batching to reduce API calls to Meta.

Problem: Events appearing delayed in Meta (2+ hours) This is usually infrastructure lag. Check your container’s performance metrics. If you’re on the free tier of cloud services, upgrade to get guaranteed resources. Meta typically processes CAPI events within 15-30 minutes.

What To Do Next

This guide is part of the Meta CAPI Setup Hub — covering server-side conversion tracking implementation across all major ad platforms using Meta’s Conversions API.