Webhooks / Direct API + Meta Ads Setup Guide

Most webhooks to Meta Ads setups I audit are missing critical data points or sending malformed events. About 60% have attribution breaking silently because they’re not passing the required fbp and fbc parameters correctly, and another 30% are losing mobile app events entirely because they’re not handling the Conversions API payload structure properly.

This guide walks you through building a bulletproof webhook-to-Meta setup that actually attributes conversions correctly.

What You’ll Have Working By The End

Prerequisites

Step 1: Set Up Meta Conversions API Access

First, get your API credentials sorted. In Meta Business Manager, go to Events Manager → Data Sources → your pixel → Settings → Conversions API.

Generate an access token with these permissions:

You’ll need three key values:

Save these in your environment variables:

META_PIXEL_ID=123456789012345
META_ACCESS_TOKEN=EAAxxxxxxxxx
META_TEST_EVENT_CODE=TEST12345

Step 2: Build Your Webhook Endpoint

Here’s a production-ready webhook endpoint that handles Meta Conversions API events. I’m using Node.js because it’s what I see working most reliably in client setups:

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

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

// Meta Conversions API endpoint
const META_API_URL = `https://graph.facebook.com/v19.0/${process.env.META_PIXEL_ID}/events`;

app.post('/webhook/meta-conversion', async (req, res) => {
  try {
    const { 
      email, 
      phone, 
      first_name, 
      last_name, 
      event_name = 'Lead',
      fbp, 
      fbc, 
      user_agent,
      ip_address,
      event_source_url,
      value,
      currency = 'USD'
    } = req.body;

    // Hash PII data
    const hashedEmail = email ? crypto.createHash('sha256').update(email.toLowerCase().trim()).digest('hex') : null;
    const hashedPhone = phone ? crypto.createHash('sha256').update(phone.replace(/\D/g, '')).digest('hex') : null;
    const hashedFirstName = first_name ? crypto.createHash('sha256').update(first_name.toLowerCase().trim()).digest('hex') : null;
    const hashedLastName = last_name ? crypto.createHash('sha256').update(last_name.toLowerCase().trim()).digest('hex') : null;

    // Build event payload
    const eventData = {
      data: [{
        event_name: event_name,
        event_time: Math.floor(Date.now() / 1000),
        action_source: 'website',
        event_source_url: event_source_url,
        user_data: {
          em: hashedEmail ? [hashedEmail] : undefined,
          ph: hashedPhone ? [hashedPhone] : undefined,
          fn: hashedFirstName ? [hashedFirstName] : undefined,
          ln: hashedLastName ? [hashedLastName] : undefined,
          client_ip_address: ip_address,
          client_user_agent: user_agent,
          fbp: fbp,
          fbc: fbc
        },
        custom_data: value ? {
          value: parseFloat(value),
          currency: currency
        } : undefined,
        event_id: `${hashedEmail || 'anon'}_${Date.now()}` // For deduplication
      }],
      test_event_code: process.env.META_TEST_EVENT_CODE // Remove in production
    };

    // Remove undefined fields
    eventData.data[0].user_data = Object.fromEntries(
      Object.entries(eventData.data[0].user_data).filter(([_, v]) => v != null)
    );

    // Send to Meta
    const response = await axios.post(META_API_URL, eventData, {
      headers: {
        'Authorization': `Bearer ${process.env.META_ACCESS_TOKEN}`,
        'Content-Type': 'application/json'
      }
    });

    console.log('Meta API Response:', response.data);
    res.status(200).json({ success: true, meta_response: response.data });

  } catch (error) {
    console.error('Webhook error:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to process webhook' });
  }
});

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

Deploy this to Cloud Run, AWS Lambda, or your preferred hosting. You’ll need a public HTTPS endpoint.

Step 3: Configure Browser Data Collection

Meta attribution requires browser parameters that most webhook setups miss. Add this JavaScript to your forms to collect the required data:

// Add this to your form pages
function collectMetaParams() {
  // Get fbp from Meta pixel cookie
  const fbp = getCookie('_fbp');
  
  // Get fbc from URL parameter (if available)
  const urlParams = new URLSearchParams(window.location.search);
  const fbc = urlParams.get('fbclid') ? `fb.1.${Date.now()}.${urlParams.get('fbclid')}` : getCookie('_fbc');
  
  return {
    fbp: fbp,
    fbc: fbc,
    user_agent: navigator.userAgent,
    ip_address: null, // Server will detect this
    event_source_url: window.location.href
  };
}

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

// Include this data in your form submission webhook payload
document.getElementById('your-form').addEventListener('submit', function(e) {
  const metaParams = collectMetaParams();
  
  // Add to your webhook payload
  const webhookData = {
    email: document.getElementById('email').value,
    first_name: document.getElementById('first_name').value,
    last_name: document.getElementById('last_name').value,
    phone: document.getElementById('phone').value,
    event_name: 'Lead',
    ...metaParams
  };
  
  // Send to your webhook endpoint
  fetch('/webhook/meta-conversion', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(webhookData)
  });
});

Step 4: Set Up Event Deduplication

If you’re also using Meta Pixel on the client-side (which you should), you need to deduplicate events to avoid double-counting. Here’s how to sync the event IDs:

// Client-side pixel event with matching event_id
function fireMetaPixelEvent(email, eventName = 'Lead') {
  const eventId = `${email || 'anon'}_${Date.now()}`;
  
  // Fire pixel event
  fbq('track', eventName, {}, { eventID: eventId });
  
  // Return the same event ID for your webhook
  return eventId;
}

// Use in your form submission
document.getElementById('your-form').addEventListener('submit', function(e) {
  const email = document.getElementById('email').value;
  const eventId = fireMetaPixelEvent(email, 'Lead');
  
  const webhookData = {
    email: email,
    // ... other fields
    event_id: eventId // This prevents double-counting
  };
  
  fetch('/webhook/meta-conversion', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(webhookData)
  });
});

Step 5: Handle Mobile App Events

If your webhook receives events from mobile apps, the payload structure is different. Here’s how to handle both web and app events:

// Add this to your webhook endpoint after the web event handling
if (req.body.action_source === 'app') {
  const appEventData = {
    data: [{
      event_name: event_name,
      event_time: Math.floor(Date.now() / 1000),
      action_source: 'app',
      app_data: {
        application_tracking_enabled: true,
        advertiser_tracking_enabled: true,
        consider_views: true
      },
      user_data: {
        em: hashedEmail ? [hashedEmail] : undefined,
        ph: hashedPhone ? [hashedPhone] : undefined,
        fn: hashedFirstName ? [hashedFirstName] : undefined,
        ln: hashedLastName ? [hashedLastName] : undefined,
        madid: req.body.mobile_ad_id, // Mobile Advertising ID
        anon_id: req.body.anonymous_id
      },
      custom_data: value ? {
        value: parseFloat(value),
        currency: currency
      } : undefined,
      event_id: req.body.event_id || `${hashedEmail || 'anon'}_${Date.now()}`
    }],
    test_event_code: process.env.META_TEST_EVENT_CODE
  };
}

Testing & Verification

1. Test Event Code Verification

With the test event code in your payload, go to Meta Events Manager → Test Events. You should see your webhook events appearing in real-time with this structure:

2. Debug Mode Check

In Events Manager, enable “Debug Mode” for your pixel. Submit a test form and verify:

3. Attribution Verification

Check your Meta Ads reporting after 24-48 hours. Look for:

Acceptable variance: 5-10% difference between server-side and client-side event counts is normal due to ad blockers and browser restrictions.

Troubleshooting

ProblemSolution

Events showing in Test Events but not attributing in Ads Manager
→ Check that your webhook is passing fbp and fbc parameters correctly. These are required for attribution. Also verify your access token has ads_management permissions.

“Invalid parameter” errors in Meta API response
→ Make sure you’re hashing PII data with SHA256 and removing all special characters from phone numbers before hashing. Email should be lowercased and trimmed.

Duplicate events showing in reporting
→ Your event_id isn’t consistent between client-side pixel and server-side API calls. Use the same ID generation logic on both sides.

Mobile app events not attributing
→ Verify you’re including madid (Mobile Advertising ID) in the user_data object and setting action_source: 'app'. App events require different parameters than web events.

High match rate in Test Events but low conversion attribution
→ Your webhook might be firing too late. Meta prefers events within 7 days of ad click, ideally within 1-2 hours. Check your webhook processing delay.

403 “Insufficient permissions” errors
→ Regenerate your access token in Business Manager with explicit ads_management and business_management permissions. System-generated tokens sometimes lack required scopes.

What To Do Next

Ready to expand your server-side tracking? Check out these related setups:

This guide is part of the Webhooks Tracking Hub — comprehensive guides for setting up direct API integrations with major ad platforms.