Meta AdsConversions APIWordPressGTMTrackingPHPElementor

How to Set Up Meta Pixel + Conversions API on WordPress (Hybrid Approach with GTM + PHP)

A complete guide to implementing Meta Pixel and Conversions API on WordPress using a hybrid browser + server approach. Includes deduplication, WP Rocket compatibility, Elementor form tracking, and GTM setup.

Milan PavlákMilan Pavlák
14 min čítania
How to Set Up Meta Pixel + Conversions API on WordPress (Hybrid Approach with GTM + PHP)

How to Set Up Meta Pixel + Conversions API on WordPress (Hybrid Approach with GTM + PHP)

If you're running Facebook or Instagram ads and relying solely on the browser-based Meta Pixel, you're losing data — and you probably don't know how much.

Ad blockers. iOS privacy restrictions. Browser ITP. All of these silently drop a portion of your conversion events before they ever reach Meta. The result: your campaigns are optimizing on incomplete data, your reported ROAS looks worse than it is, and you're potentially scaling the wrong ad sets.

The fix is the Conversions API (CAPI) — a server-side channel that sends events directly from your server to Meta, completely bypassing the browser. Combined with the browser Pixel, you get maximum coverage from both sides.

This guide covers a complete hybrid implementation for WordPress: browser events via GTM, server events via PHP in functions.php, and a deduplication system that ensures Meta counts each conversion only once.


Why Hybrid? Why Not Just One or the Other?

Neither channel alone is sufficient.

Browser-only (Meta Pixel):

  • Blocked by ad blockers and Firefox/Safari ITP
  • Loses data from users who have opted out of tracking
  • Dependent on cookie availability

Server-only (CAPI):

  • Not affected by ad blockers or ITP
  • But has no access to browser cookies like _fbp (Meta's browser fingerprint)
  • Lower Event Match Quality without the cookie data

Hybrid (both, with deduplication):

  • Browser events bring cookie data (_fbp, _fbc)
  • Server events guarantee delivery when the browser is blocked
  • Deduplication via a shared event_id ensures Meta counts each event once

This is now the recommended approach in Meta's own documentation, and it's the setup this guide implements.


System Architecture

The implementation has three components:

1. Meta Pixel via GTM (browser-side) GTM tags fire on page load and form submission, sending events directly from the visitor's browser with full cookie context.

2. Conversions API via PHP (server-side) WordPress hooks send the same events from the server using wp_remote_post() to Meta's Graph API. This fires even when the browser Pixel is blocked.

3. Deduplication Both channels use the same event_id format — assembled from a session token and a page load ID. Meta matches on event_name + event_id and counts the event once, regardless of how many channels reported it.


Events Covered

Event Trigger Purpose
PageView Every page Traffic tracking
ViewContent Service/product pages Interest signal
Lead Quote/offer form submission Primary conversion
Contact Contact form submission Conversion
Subscribe Newsletter form submission Micro conversion
SubmitApplication Career form submission Micro conversion

You can adjust this list to match your own site structure.


Step 1 — PHP Setup in functions.php

All server-side logic lives in your theme's functions.php. It's split into five sections.

Configuration

Start with your credentials. Never hardcode the Access Token in a public repo — store it via environment variables or wp-config.php defines in production.

// CONFIGURATION
define('META_PIXEL_ID',       'YOUR_PIXEL_ID');
define('META_ACCESS_TOKEN',   'YOUR_ACCESS_TOKEN');
define('META_API_VERSION',    'v22.0');
define('META_TEST_EVENT_CODE', ''); // Set to e.g. 'TEST1234' during testing only

This hook fires on init and creates a _fbevt cookie — a 32-character random token that identifies the session. It's used as part of every event_id and as a hashed external_id for user matching.

add_action('init', function() {
    if (!isset($_COOKIE['_fbevt'])) {
        $token = bin2hex(random_bytes(16));
        setcookie('_fbevt', $token, time() + 3600, '/', '', true, false);
        $_COOKIE['_fbevt'] = $token;
    }
});

Section 2 — AJAX JavaScript Injector

This hook injects JavaScript into wp_footer. The JS sends PageView (and ViewContent on service pages) to a WordPress AJAX endpoint on every page load — including cached pages served by WP Rocket.

add_action('wp_footer', function() {
    ?>
    <script>
    (function() {
        var evtToken = (document.cookie.match(/(?:^|; )_fbevt=([^;]*)/) || [])[1] || '';
        if (!evtToken) return;

        var pid = window._fbPageId || Math.random().toString(36).substr(2, 9);
        var pageUrl = window.location.href;
        var isServicePage = window.location.pathname.indexOf('/services/') !== -1;

        var xhr = new XMLHttpRequest();
        xhr.open('POST', '<?php echo admin_url("admin-ajax.php"); ?>', true);
        xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
        xhr.send(
            'action=meta_capi_event'
            + '&event_name=PageView'
            + '&event_id=PageView_' + evtToken + '_' + pid
            + '&page_url=' + encodeURIComponent(pageUrl)
            + '&page_title=' + encodeURIComponent(document.title)
        );

        if (isServicePage) {
            var xhr2 = new XMLHttpRequest();
            xhr2.open('POST', '<?php echo admin_url("admin-ajax.php"); ?>', true);
            xhr2.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
            xhr2.send(
                'action=meta_capi_event'
                + '&event_name=ViewContent'
                + '&event_id=ViewContent_' + evtToken + '_' + pid
                + '&page_url=' + encodeURIComponent(pageUrl)
                + '&page_title=' + encodeURIComponent(document.title)
            );
        }
    })();
    </script>
    <?php
});

Note: Update the /services/ path to match your site's URL structure.


Section 3 — AJAX Handler

This handler receives the AJAX requests from Section 2, validates them, filters bots, and sends the event to Meta's Graph API.

add_action('wp_ajax_meta_capi_event',        'meta_capi_ajax_handler');
add_action('wp_ajax_nopriv_meta_capi_event', 'meta_capi_ajax_handler');

function meta_capi_ajax_handler() {
    // Filter bots
    if (isset($_SERVER['HTTP_USER_AGENT']) &&
        preg_match('/bot|crawl|spider|slurp|googlebot/i', $_SERVER['HTTP_USER_AGENT'])) {
        wp_die();
    }

    $event_name  = sanitize_text_field($_POST['event_name']  ?? '');
    $event_id    = sanitize_text_field($_POST['event_id']    ?? '');
    $page_url    = esc_url_raw($_POST['page_url']            ?? '');
    $page_title  = sanitize_text_field($_POST['page_title']  ?? '');

    if (!$event_name || !$event_id) wp_die();
    if (!in_array($event_name, ['PageView', 'ViewContent'])) wp_die();

    $user_data  = meta_capi_get_user_data();
    $event_data = [
        'event_name'        => $event_name,
        'event_time'        => time(),
        'event_id'          => $event_id,
        'event_source_url'  => $page_url,
        'action_source'     => 'website',
        'user_data'         => $user_data,
    ];

    if ($event_name === 'ViewContent') {
        $event_data['custom_data'] = [
            'content_name'     => $page_title,
            'content_category' => 'services',
        ];
    }

    meta_capi_send_event($event_data);
    wp_die();
}

Section 4 — Elementor Form Hook

This hook fires when an Elementor Pro form is submitted. It maps form IDs to Meta events, hashes all personal data with SHA256 before sending, and assembles the event ID from the session cookies.

add_action('elementor_pro/forms/new_record', function($record, $handler) {
    $form_id = $record->get_form_settings('form_id')
             ?: $record->get_form_settings('id');

    // Map your Elementor form IDs to Meta events
    $form_event_map = [
        'YOUR_QUOTE_FORM_ID'   => 'Lead',
        'YOUR_CONTACT_FORM_ID' => 'Contact',
        'YOUR_NEWSLETTER_ID'   => 'Subscribe',
        'YOUR_CAREER_FORM_ID'  => 'SubmitApplication',
    ];

    $meta_event = null;
    foreach ($form_event_map as $fid => $event_name) {
        if (stripos($form_id, $fid) !== false || $form_id === $fid) {
            $meta_event = $event_name;
            break;
        }
    }

    if (!$meta_event) return;

    $raw_fields = $record->get('fields');
    $fields     = [];
    foreach ($raw_fields as $field) {
        $fields[$field['id']] = $field['value'];
    }

    $user_data = meta_capi_get_user_data();

    // Hash all PII with SHA256 before sending
    if (!empty($fields['email']))      $user_data['em'] = [hash('sha256', strtolower(trim($fields['email'])))];
    if (!empty($fields['phone']))      $user_data['ph'] = [hash('sha256', preg_replace('/[^0-9+]/', '', $fields['phone']))];
    if (!empty($fields['first_name'])) $user_data['fn'] = [hash('sha256', strtolower(trim($fields['first_name'])))];
    if (!empty($fields['last_name']))  $user_data['ln'] = [hash('sha256', strtolower(trim($fields['last_name'])))];
    if (!empty($fields['city']))       $user_data['ct'] = [hash('sha256', strtolower(trim($fields['city'])))];
    if (!empty($fields['zipcode']))    $user_data['zp'] = [hash('sha256', trim($fields['zipcode']))];

    $user_data['country'] = [hash('sha256', 'sk')]; // Update for your country

    // Build event_id from cookies (same format as GTM)
    $event_token = $_COOKIE['_fbevt'] ?? '';
    $page_id     = $_COOKIE['_fbpid'] ?? '';

    $event_id = $page_id
        ? $meta_event . '_' . $event_token . '_' . $page_id
        : $meta_event . '_' . $event_token . '_' . bin2hex(random_bytes(4));

    $content_names = [
        'Lead'              => 'Quote',
        'Contact'           => 'Contact',
        'Subscribe'         => 'Newsletter',
        'SubmitApplication' => 'Application',
    ];

    $event_data = [
        'event_name'       => $meta_event,
        'event_time'       => time(),
        'event_id'         => $event_id,
        'event_source_url' => wp_get_referer() ?: home_url(),
        'action_source'    => 'website',
        'user_data'        => $user_data,
        'custom_data'      => [
            'content_name'     => $content_names[$meta_event] ?? $meta_event,
            'content_category' => 'form_submission',
        ],
    ];

    meta_capi_send_event($event_data);
}, 10, 2);

Section 5 — Helper Functions

meta_capi_get_user_data() collects all available user signals for Meta's matching algorithm:

function meta_capi_get_user_data(): array {
    $user_data = [];

    // IP address (handle proxies and load balancers)
    $ip = $_SERVER['HTTP_X_FORWARDED_FOR']
       ?? $_SERVER['HTTP_X_REAL_IP']
       ?? $_SERVER['REMOTE_ADDR']
       ?? '';
    if (strpos($ip, ',') !== false) {
        $ip = trim(explode(',', $ip)[0]);
    }
    if ($ip) $user_data['client_ip_address'] = $ip;

    // User agent
    if (!empty($_SERVER['HTTP_USER_AGENT'])) {
        $user_data['client_user_agent'] = $_SERVER['HTTP_USER_AGENT'];
    }

    // Meta browser cookies
    if (!empty($_COOKIE['_fbp'])) $user_data['fbp'] = $_COOKIE['_fbp'];
    if (!empty($_COOKIE['_fbc'])) {
        $user_data['fbc'] = $_COOKIE['_fbc'];
    } elseif (!empty($_GET['fbclid'])) {
        $user_data['fbc'] = 'fb.1.' . time() . '.' . $_GET['fbclid'];
    }

    // Session identifier as external_id (hashed)
    if (!empty($_COOKIE['_fbevt'])) {
        $user_data['external_id'] = [hash('sha256', $_COOKIE['_fbevt'])];
    }

    return $user_data;
}

meta_capi_send_event() posts the event to Meta's Graph API:

function meta_capi_send_event(array $event_data): void {
    $url  = 'https://graph.facebook.com/'
           . META_API_VERSION . '/'
           . META_PIXEL_ID
           . '/events?access_token='
           . META_ACCESS_TOKEN;

    $body = ['data' => [$event_data]];

    if (META_TEST_EVENT_CODE) {
        $body['test_event_code'] = META_TEST_EVENT_CODE;
    }

    wp_remote_post($url, [
        'headers'   => ['Content-Type' => 'application/json'],
        'body'      => json_encode($body),
        'timeout'   => 5,
        'blocking'  => false,
        'sslverify' => true,
    ]);
}

Note the 'blocking' => false — this makes the call fire-and-forget so it doesn't slow down form submission response time.


Step 2 — Google Tag Manager Setup

Base Tag (fires on All Pages)

This tag initializes the Pixel, generates the _fbpid page load ID, and fires the PageView browser event. It must fire before all other Meta tags — use GTM's tag sequencing to enforce this.

<script>
!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');

// Generate _fbpid only once per page load
// The if-guard is critical: GTM tag sequencing fires this tag before
// every form tag, so without it the ID would be regenerated and break deduplication
if (!window._fbPageId) {
    window._fbPageId = Math.random().toString(36).substr(2, 9);
    document.cookie = '_fbpid=' + window._fbPageId + ';path=/;max-age=1800';
}

var evtToken = {{Cookie - _fbevt}} || 'no_token';
fbq('track', 'PageView', {}, {
    eventID: 'PageView_' + evtToken + '_' + window._fbPageId
});
</script>

The if (!window._fbPageId) guard is not optional. Because tag sequencing re-runs the Base tag before every form tag, without this condition the page load ID would be overwritten and the event_id would no longer match between browser and server.


ViewContent Tag

Trigger: Pages with /services/ in the URL (or equivalent).

<script>
var evtToken = {{Cookie - _fbevt}} || 'no_token';
var pid      = window._fbPageId || 'nopid';

fbq('track', 'ViewContent', {
    content_name:     document.title,
    content_category: 'services'
}, {
    eventID: 'ViewContent_' + evtToken + '_' + pid
});
</script>

Form Tags (Lead, Contact, Subscribe, SubmitApplication)

All four form tags follow the same structure. Trigger on your form's submission event (Form ID or class in GTM). Example for Lead:

<script>
var evtToken = {{Cookie - _fbevt}} || 'no_token';
var pid      = window._fbPageId || 'nopid';

fbq('track', 'Lead', {
    content_name:     'Quote',
    content_category: 'form_submission'
}, {
    eventID: 'Lead_' + evtToken + '_' + pid
});
</script>

For each other form, change the event name and content_name:

Tag Event Name content_name
Quote form Lead Quote
Contact form Contact Contact
Newsletter form Subscribe Newsletter
Career form SubmitApplication Application

Step 3 — How Deduplication Works

The event_id has this structure:

EventName_sessionToken_pageLoadId

Example:

Lead_a3f7b2c1d4e5f6a7b8c9d0e1f2a3b4c5_8qjw3p1ba

When Meta receives an event from the browser (Pixel via GTM) and the same event from the server (CAPI via PHP), it compares event_name + event_id. If both fields match, it counts the event once and marks it as deduplicated.

Because the session token (_fbevt) and page load ID (_fbpid) are set in cookies, both the browser JS and the PHP server code read the same values and produce identical event IDs.

Important: Different event types on the same page (e.g. PageView and ViewContent) have different event_name values, so they never deduplicate each other even when they share the same pageLoadId.


Event Match Quality

Event Match Quality (EMQ) is Meta's 0–10 score for how confidently it can match an event to a real user. Higher EMQ = better attribution = better ad optimization.

This implementation sends:

Parameter Source When available
client_ip_address PHP $_SERVER Always
client_user_agent PHP $_SERVER Always
fbp _fbp cookie After Meta Pixel fires
fbc _fbc cookie / fbclid URL param After clicking a Facebook ad
external_id _fbevt cookie (SHA256 hashed) Always
em (email) Form field (SHA256 hashed) On form submission
ph (phone) Form field (SHA256 hashed) On form submission
fn, ln Form fields (SHA256 hashed) On form submission
country Hardcoded (SHA256 hashed) On form submission

Note on fbc coverage: Meta's Events Manager may warn about low fbc coverage. This is expected — the fbc cookie only exists for visitors who arrived via a Facebook ad click. Organic visitors will never have it. Coverage will improve once you're running paid campaigns.


WP Rocket Compatibility

WP Rocket caches full HTML pages. This means PHP hooks like wp_footer don't fire on cached page loads — they only run when the cache is generated.

The AJAX approach in Section 2 works around this cleanly:

  1. wp_footer injects the AJAX JavaScript when the page is first rendered
  2. WP Rocket saves that HTML (including the JavaScript) into the cache
  3. Every visitor loads the cached HTML, which includes the JavaScript
  4. The JavaScript fires an AJAX request to admin-ajax.php
  5. admin-ajax.php is never cached — it always runs live PHP
  6. The PHP handler sends the CAPI event to Meta

Critical: After any changes to Section 2 (the JavaScript), clear your WP Rocket cache. The old JavaScript is baked into cached HTML and won't update until the cache is flushed.


Testing

Enable Test Mode

  1. In functions.php, set META_TEST_EVENT_CODE to your test code (found in Meta Events Manager → Test Events)
  2. Clear WP Rocket cache
  3. Open Meta Events Manager → Test Events
  4. Visit your site and submit a form
  5. Verify both Browser and Server events appear

What to Check

  • event_id must be identical for the Browser event and the Server event
  • Meta should show "Deduplicated: Yes" for the pair
  • user_data should include ip, user_agent, and fbp on all events
  • Form events should additionally include em, ph, and fn/ln

Debug Logging

To diagnose IP or cookie issues, add temporary logging to the AJAX handler (Section 3):

error_log('CAPI DEBUG: IP=' . ($_SERVER['REMOTE_ADDR'] ?? 'empty')
    . ' | X_FORWARDED=' . ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? 'none'));

Enable WordPress debug logging in wp-config.php:

define('WP_DEBUG',         true);
define('WP_DEBUG_LOG',     true);
define('WP_DEBUG_DISPLAY', false);

Logs will appear in /wp-content/debug.log. Disable WP_DEBUG after you're done — never leave it on in production.


Common Issues

event_id mismatch between Browser and Server Check that the GTM Base tag contains the if (!window._fbPageId) guard. Without it, the page load ID regenerates on every tag sequence run and the IDs won't match.

Server events not appearing in Events Manager Verify that your Access Token is valid and that the API version constant is current. Meta deprecates older versions periodically — check developers.facebook.com for the latest supported version.

admin-ajax.php returning errors Some security plugins or firewall rules block admin-ajax.php requests. Whitelist it if needed, or check your debug.log for specific errors.

Events are duplicating (not deduplicating) The event_id must match exactly — including capitalization. Verify both sides are reading from the same cookies and assembling the ID in the same order (EventName_token_pageId).

Low IP coverage warning This typically reflects historical data from before the current setup. Give it a few days of live traffic and the percentage will normalize.


Maintenance Checklist

Weekly: Check Event Match Quality and the diagnostics tab in Events Manager. Meta will flag data quality issues there.

After Elementor Pro updates: Verify form hooks still fire correctly by submitting a test form and checking Events Manager.

After WP Rocket updates: Run a test page load and confirm AJAX events are appearing as expected.

Adding a new form:

  1. Find the form ID in Elementor editor or the page HTML
  2. Add it to $form_event_map in Section 4
  3. Create a new GTM tag with the correct event name and trigger
  4. Set tag sequencing so Base fires first
  5. Test in Test Events mode before going live

Updating the API version:

  1. Check the current stable version at developers.facebook.com/docs/graph-api/changelog
  2. Update META_API_VERSION in functions.php
  3. Test a PageView and a form submission in Test Events mode

Summary

This hybrid setup covers the full tracking lifecycle:

  • GTM handles browser-side events with full cookie context
  • PHP handles server-side events that survive ad blockers and ITP
  • Deduplication via shared event_id ensures Meta doesn't double-count
  • WP Rocket compatibility is handled via the AJAX pattern — no cache conflicts
  • All PII is SHA256-hashed before leaving your server

The result is a Meta Pixel setup with significantly better data quality than a Pixel-only implementation — which translates directly to better ad attribution and more efficient campaign optimization.

If you have questions or need help adapting this to a different form plugin or caching setup, feel free to reach out.

Let's Connect

Ready to discuss your project? Reach out through any of these channels.

Based in Bratislava, Slovakia. Available for projects worldwide.

Slovenská verzia stránky sa stále pripravuje a jej obsah nie je 100%

How to Set Up Meta Pixel + Conversions API on WordPress (Hybrid Approach with GTM + PHP)