The Complete Guide to Meta CAPI for Mobile Apps Without SDK

Last updated: November 2025
Reading time: ~25 minutes
Level: Advanced

Introduction

When implementing Meta (Facebook) tracking for mobile applications, most developers rely on the official Meta SDK to handle event collection and transmission. The SDK automatically collects device information, handles user identification, manages attribution, and sends events to Meta’s servers.

However, there are compelling reasons to implement a CAPI-only approach without integrating the Meta SDK into your mobile application:

  • Privacy compliance: Greater control over what data is collected and when
  • Reduced app size: No third-party SDK bloat
  • Server-side control: All data processing happens on your infrastructure
  • Flexibility: Custom event logic without SDK constraints
  • Reduced dependencies: Fewer third-party libraries to maintain

But here’s the catch: the Meta SDK does A LOT automatically. When you remove it, you must recreate approximately 80% of its functionality manually.

This guide provides a complete, technical breakdown of every parameter you need to handle when implementing Meta CAPI for mobile apps without the SDK.

Prerequisites:

  • Familiarity with Meta Conversions API basics
  • Backend infrastructure capable of receiving data from your mobile app
  • Understanding of mobile app development (iOS/Android)
  • Access to Meta Events Manager and a configured Pixel/Dataset

Why Go CAPI-Only Without SDK?

The Standard Approach: SDK-Based Tracking

The Meta SDK for mobile apps (iOS and Android) provides automatic event collection including:

  • App installs and launches
  • In-app purchases
  • Session tracking
  • Device fingerprinting
  • Automatic Advanced Matching

The SDK sends events directly from the device to Meta’s servers, handling all the technical complexity of data collection, formatting, and transmission.

The CAPI-Only Alternative

With a CAPI-only implementation:

  1. Your mobile app collects raw event data
  2. The app sends this data to your backend server
  3. Your server processes, enriches, and formats the data
  4. Your server sends events to Meta via the Conversions API

Key advantages:

  • Full data control: You decide exactly what gets sent and when
  • GDPR/CCPA compliance: Easier to implement consent management server-side
  • No SDK overhead: Smaller app bundle, faster app launch
  • Unified tracking: Same server-side infrastructure for web and mobile
  • Better data quality: Server-side validation and enrichment before sending to Meta

Key challenges:

  • Manual implementation: Everything the SDK did automatically, you now do manually
  • Attribution complexity: iOS ATT (App Tracking Transparency) requires careful handling
  • Server infrastructure: Additional backend load and maintenance
  • Higher risk of errors: Incorrect parameter formatting breaks attribution

What the SDK Does Automatically (That You Must Now Handle)

The Meta SDK automatically collects and sends:

Device Identification

  • IDFA (iOS) or GAID (Android) – the Mobile Advertising ID
  • Anonymous ID (UUID) as fallback when MADID unavailable
  • Device model, OS version, screen resolution
  • App version, build number, bundle ID

User Identification

  • Facebook User ID (if user logged in with Facebook)
  • Hashed email, phone number (Advanced Matching)
  • Installation source tracking

Event Context

  • Event timestamp (precise to the millisecond)
  • Session information
  • App state (foreground/background)
  • Network connection type

Attribution Data

  • Campaign IDs from ad clicks
  • Deep link parameters
  • Deferred deep linking for post-install attribution

Technical Metadata

  • The infamous extinfo array (16+ device/app attributes)
  • IP address and User Agent
  • Locale, timezone, carrier information
  • ATT (App Tracking Transparency) consent status

Without the SDK, you must collect, format, and send ALL of this manually.

Architecture Overview

Here’s the high-level flow for CAPI-only mobile tracking:

┌─────────────────┐
│   Mobile App    │
│  (iOS/Android)  │
└────────┬────────┘
         │ 1. Collect event data
         │    (purchase, install, etc.)
         │
         ▼
┌─────────────────┐
│  Device Data    │
│   Collection    │
│                 │
│ • IDFA/GAID     │
│ • Device info   │
│ • User data     │
└────────┬────────┘
         │ 2. Send to backend
         │    via HTTPS POST
         │
         ▼
┌─────────────────┐
│  Your Backend   │
│    Server       │
│                 │
│ • Validate      │
│ • Hash PII      │
│ • Enrich data   │
└────────┬────────┘
         │ 3. Send to Meta CAPI
         │    POST /events
         │
         ▼
┌─────────────────┐
│   Meta CAPI     │
│   Endpoint      │
│                 │
│ graph.facebook  │
│ .com/v18.0/     │
│ {pixel_id}/     │
│ events          │
└─────────────────┘

Critical decision point: Should your mobile app send PII (emails, phone numbers) to your backend?

Recommendation:

  • Send PII unhashed from app to backend (use HTTPS)
  • Hash PII on the backend before sending to Meta
  • Never hash PII in the mobile app (inconsistent implementations lead to matching failures)

Critical Parameter #1: Device Identifiers (madid & anon_id)

What is MADID?

MADID (Mobile Advertising ID) is the primary identifier for mobile attribution:

  • iOS: IDFA (Identifier for Advertisers)
  • Android: GAID (Google Advertising ID)

The Meta SDK automatically retrieves and sends the MADID. Without the SDK, you must:

iOS: Retrieving IDFA

Since iOS 14.5, you must request permission via Apple’s App Tracking Transparency (ATT) framework.

Swift example:

import AppTrackingTransparency
import AdSupport

func requestIDFA() {
    if #available(iOS 14.5, *) {
        ATTrackingManager.requestTrackingAuthorization { status in
            switch status {
            case .authorized:
                // User granted permission
                let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
                print("IDFA: \(idfa)")
                // Send to your backend
                self.sendToBackend(idfa: idfa)
                
            case .denied, .restricted, .notDetermined:
                // User denied or permission not determined
                // IDFA will be 00000000-0000-0000-0000-000000000000
                // Use anonymous ID instead
                let anonId = self.getOrCreateAnonymousId()
                self.sendToBackend(anonId: anonId)
                
            @unknown default:
                break
            }
        }
    } else {
        // iOS < 14.5: IDFA available without permission
        let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
        self.sendToBackend(idfa: idfa)
    }
}

React Native example:

import { requestTrackingPermission } from 'react-native-tracking-transparency';
import DeviceInfo from 'react-native-device-info';

async function getIDFA() {
    try {
        const trackingStatus = await requestTrackingPermission();
        
        if (trackingStatus === 'authorized' || trackingStatus === 'unavailable') {
            const idfa = await DeviceInfo.getAdvertisingId();
            console.log('IDFA:', idfa);
            return idfa;
        } else {
            // Permission denied
            console.log('ATT denied, using anonymous ID');
            return null; // Use anon_id instead
        }
    } catch (error) {
        console.error('Error getting IDFA:', error);
        return null;
    }
}

Android: Retrieving GAID

Android’s GAID is more straightforward (no ATT-style permission required as of Android 12, but user can reset it).

Kotlin example:

import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun getGAID(): String? {
    return withContext(Dispatchers.IO) {
        try {
            val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(applicationContext)
            if (!adInfo.isLimitAdTrackingEnabled) {
                adInfo.id // Returns GAID
            } else {
                null // User opted out of ad tracking
            }
        } catch (e: GooglePlayServicesNotAvailableException) {
            null
        } catch (e: Exception) {
            null
        }
    }
}

React Native example:

import { getAdvertisingId } from 'react-native-device-info';

async function getGAID() {
    try {
        const gaid = await getAdvertisingId();
        console.log('GAID:', gaid);
        return gaid;
    } catch (error) {
        console.error('Error getting GAID:', error);
        return null;
    }
}

Formatting MADID for Meta CAPI

Meta requires MADID in a specific format:

Correct format:

  • Lowercase
  • No dashes/hyphens
  • Example: 38400000-8cf0-11bd-b23e-10b96e40000d384000008cf011bdb23e10b96e40000d

Incorrect format:

  • Mixed case: 38400000-8CF0-11BD-B23E-10B96E40000D
  • With dashes: 38400000-8cf0-11bd-b23e-10b96e40000d

Formatting code:

function formatMADID(madid) {
    if (!madid) return null;
    return madid.toLowerCase().replace(/-/g, '');
}

// Example
const rawIDFA = "38400000-8CF0-11BD-B23E-10B96E40000D";
const formattedMADID = formatMADID(rawIDFA);
// Result: "384000008cf011bdb23e10b96e40000d"

Anonymous ID (anon_id) – Critical Fallback

When IDFA/GAID is unavailable (iOS ATT denied, user opted out, etc.), you must generate and persist an anonymous ID.

Requirements:

  • UUIDv4 format
  • Generated on first app launch
  • Persisted locally (AsyncStorage, SharedPreferences, Keychain)
  • Sent with every event when MADID unavailable

iOS Swift example:

import Foundation

func getOrCreateAnonymousId() -> String {
    let key = "anon_id"
    
    if let existingId = UserDefaults.standard.string(forKey: key) {
        return existingId
    }
    
    let newId = UUID().uuidString.lowercased()
    UserDefaults.standard.set(newId, forKey: key)
    return newId
}

React Native example:

import AsyncStorage from '@react-native-async-storage/async-storage';
import uuid from 'react-native-uuid';

async function getOrCreateAnonId() {
    const key = 'anon_id';
    
    try {
        let anonId = await AsyncStorage.getItem(key);
        
        if (!anonId) {
            anonId = uuid.v4().toLowerCase();
            await AsyncStorage.setItem(key, anonId);
        }
        
        return anonId;
    } catch (error) {
        console.error('Error with anon_id:', error);
        return uuid.v4().toLowerCase(); // Fallback to session ID
    }
}

Sending to Meta CAPI

In your CAPI payload:

{
    "user_data": {
        "madid": "384000008cf011bdb23e10b96e40000d",
        "anon_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef"
    }
}

Best practices:

  • Always send both madid AND anon_id if both are available
  • If only one is available, send that one
  • Never send 00000000-0000-0000-0000-000000000000 as MADID (iOS ATT denied) – use only anon_id in this case

Sources:

  • Meta CAPI User Data Parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
  • iOS ATT Framework: https://developer.apple.com/documentation/apptrackingtransparency
  • Android Advertising ID: https://support.google.com/googleplay/android-developer/answer/6048248

Critical Parameter #2: The extinfo Array

What is extinfo?

The extinfo array is a fixed-position array of 16+ values containing device and app metadata. The Meta SDK generates this automatically. Without the SDK, you must construct it manually.

Meta uses extinfo for:

  • Device-level attribution
  • Fraud detection
  • Campaign optimization
  • Audience targeting

Without extinfo, Meta loses critical context for attribution and optimization.

extinfo Structure

The array has a fixed order – each position has a specific meaning:

[
    "a2",              // [0]  Version (always "a2")
    "com.myapp",       // [1]  Bundle ID / Package name
    "1.2.3",           // [2]  App version (short)
    "1.2.3.456",       // [3]  App build number (long)
    "iOS",             // [4]  OS name
    "17.2.1",          // [5]  OS version
    "iPhone14,2",      // [6]  Device model
    "en_US",           // [7]  Locale
    "Europe/Paris",    // [8]  Timezone
    "Orange",          // [9]  Carrier/Mobile operator
    1170,              // [10] Screen width (pixels)
    2532,              // [11] Screen height (pixels)
    2.5,               // [12] Screen density/scale
    4,                 // [13] CPU cores
    123456,            // [14] Free disk space (MB)
    256000             // [15] Total disk space (MB)
]

Collecting extinfo Data

React Native implementation:

import DeviceInfo from 'react-native-device-info';
import { Platform, Dimensions } from 'react-native';

async function buildExtinfo() {
    try {
        const screenDimensions = Dimensions.get('screen');
        
        const extinfo = [
            "a2",
            DeviceInfo.getBundleId(),                          // com.myapp
            DeviceInfo.getVersion(),                           // 1.2.3
            DeviceInfo.getBuildNumber(),                       // 456
            Platform.OS === 'ios' ? 'iOS' : 'Android',        // OS
            DeviceInfo.getSystemVersion(),                     // 17.2.1
            await DeviceInfo.getDeviceId(),                    // iPhone14,2
            DeviceInfo.getDeviceLocale(),                      // en_US
            DeviceInfo.getTimezone(),                          // Europe/Paris
            await DeviceInfo.getCarrier(),                     // Orange
            Math.round(screenDimensions.width),                // 1170
            Math.round(screenDimensions.height),               // 2532
            screenDimensions.scale || screenDimensions.fontScale, // 2.5
            await DeviceInfo.getProcessorCount() || 2,         // 4
            Math.round(await DeviceInfo.getFreeDiskStorage() / 1024 / 1024), // MB
            Math.round(await DeviceInfo.getTotalDiskCapacity() / 1024 / 1024) // MB
        ];
        
        return extinfo;
    } catch (error) {
        console.error('Error building extinfo:', error);
        // Return minimal extinfo on error
        return [
            "a2",
            DeviceInfo.getBundleId(),
            DeviceInfo.getVersion(),
            DeviceInfo.getBuildNumber(),
            Platform.OS === 'ios' ? 'iOS' : 'Android',
            DeviceInfo.getSystemVersion(),
            "unknown",
            "en_US",
            "UTC",
            "unknown",
            0, 0, 1.0, 2, 0, 0
        ];
    }
}

iOS Swift implementation:

import UIKit
import CoreTelephony

func buildExtinfo() -> [Any] {
    let screen = UIScreen.main
    let bundle = Bundle.main
    let device = UIDevice.current
    let fileManager = FileManager.default
    
    // Get carrier info
    let networkInfo = CTTelephonyNetworkInfo()
    let carrier = networkInfo.serviceSubscriberCellularProviders?.values.first
    let carrierName = carrier?.carrierName ?? "unknown"
    
    // Calculate disk space
    let systemAttributes = try? fileManager.attributesOfFileSystem(
        forPath: NSHomeDirectory()
    )
    let freeSpace = (systemAttributes?[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
    let totalSpace = (systemAttributes?[.systemSize] as? NSNumber)?.int64Value ?? 0
    
    let freeMB = Int(freeSpace / 1024 / 1024)
    let totalMB = Int(totalSpace / 1024 / 1024)
    
    // Get CPU count
    let cpuCount = ProcessInfo.processInfo.processorCount
    
    // Build extinfo array
    let extinfo: [Any] = [
        "a2",
        bundle.bundleIdentifier ?? "unknown",
        bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
        bundle.infoDictionary?["CFBundleVersion"] as? String ?? "1",
        "iOS",
        device.systemVersion,
        deviceModelIdentifier(),
        Locale.current.identifier,
        TimeZone.current.identifier,
        carrierName,
        Int(screen.bounds.width * screen.scale),
        Int(screen.bounds.height * screen.scale),
        screen.scale,
        cpuCount,
        freeMB,
        totalMB
    ]
    
    return extinfo
}

func deviceModelIdentifier() -> String {
    var systemInfo = utsname()
    uname(&systemInfo)
    let machineMirror = Mirror(reflecting: systemInfo.machine)
    let identifier = machineMirror.children.reduce("") { identifier, element in
        guard let value = element.value as? Int8, value != 0 else { return identifier }
        return identifier + String(UnicodeScalar(UInt8(value)))
    }
    return identifier
}

Android Kotlin implementation:

import android.content.Context
import android.os.Build
import android.util.DisplayMetrics
import android.view.WindowManager
import android.telephony.TelephonyManager
import java.io.File

fun buildExtinfo(context: Context): List<Any> {
    val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
    val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    val displayMetrics = DisplayMetrics()
    windowManager.defaultDisplay.getMetrics(displayMetrics)
    
    // Get carrier
    val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
    val carrierName = telephonyManager.networkOperatorName ?: "unknown"
    
    // Get disk space
    val statFs = android.os.StatFs(Environment.getDataDirectory().path)
    val freeMB = (statFs.availableBytes / 1024 / 1024).toInt()
    val totalMB = (statFs.totalBytes / 1024 / 1024).toInt()
    
    // CPU cores
    val cpuCores = Runtime.getRuntime().availableProcessors()
    
    return listOf(
        "a2",
        context.packageName,
        packageInfo.versionName ?: "1.0",
        packageInfo.versionCode.toString(),
        "Android",
        Build.VERSION.RELEASE,
        Build.MODEL,
        context.resources.configuration.locales[0].toString(),
        java.util.TimeZone.getDefault().id,
        carrierName,
        displayMetrics.widthPixels,
        displayMetrics.heightPixels,
        displayMetrics.density,
        cpuCores,
        freeMB,
        totalMB
    )
}

Sending extinfo to Meta CAPI

In your CAPI payload:

{
    "app_data": {
        "extinfo": [
            "a2",
            "com.myapp",
            "1.2.3",
            "456",
            "iOS",
            "17.2.1",
            "iPhone14,2",
            "en_US",
            "Europe/Paris",
            "Orange",
            1170,
            2532,
            2.5,
            4,
            123456,
            256000
        ]
    }
}

Critical notes:

  • The array order MUST be respected
  • Missing values should be filled with "unknown", 0, or appropriate defaults
  • Never send an empty array
  • All string values should be strings, all numeric values should be numbers (not strings)

Sources:

  • Meta App Events Parameters: https://developers.facebook.com/docs/app-events/getting-started-app-events-android#app-events-parameters
  • CAPI Server Event Parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event

Critical Parameter #3: Advanced Matching (Hashed PII)

What is Advanced Matching?

Advanced Matching allows Meta to match events to users even when device identifiers (IDFA/GAID) are unavailable. This is critical for iOS 14.5+ where most users deny ATT.

The Meta SDK handles Advanced Matching automatically by:

  1. Collecting user data (email, phone, name, etc.)
  2. Normalizing the data
  3. Hashing it with SHA-256
  4. Sending it to Meta

Without the SDK, you must do all of this manually.

Supported PII Fields

Meta accepts the following user data fields (all must be hashed):

FieldParameterDescriptionExample (pre-hash)
EmailemUser’s email addresstest@example.com
PhonephUser’s phone number+33612345678
First namefnUser’s first namejohn
Last namelnUser’s last namedoe
CityctUser’s cityparis
State/RegionstUser’s state/provinceile de france
Zip codezpPostal code75001
Countrycountry2-letter country codefr
GendergeGender (m/f)m
Date of birthdbBirth date YYYYMMDD19900515

Normalization Rules (CRITICAL)

Meta is extremely strict about normalization. If you don’t normalize correctly before hashing, matching will fail.

Email normalization:

function normalizeEmail(email) {
    if (!email) return null;
    
    // 1. Trim whitespace
    email = email.trim();
    
    // 2. Convert to lowercase
    email = email.toLowerCase();
    
    // 3. No further processing (keep dots, plus signs, etc.)
    
    return email;
}

// Examples
normalizeEmail("  Test@Example.com  ") // → "test@example.com"
normalizeEmail("John.Doe+tag@Gmail.com") // → "john.doe+tag@gmail.com"

Phone normalization:

function normalizePhone(phone, countryCode = '+33') {
    if (!phone) return null;
    
    // 1. Remove all non-digit characters
    phone = phone.replace(/[^0-9]/g, '');
    
    // 2. Remove leading zero if present
    if (phone.startsWith('0')) {
        phone = phone.substring(1);
    }
    
    // 3. Add country code if not present
    if (!phone.startsWith('+')) {
        phone = countryCode + phone;
    }
    
    return phone;
}

// Examples
normalizePhone("06 12 34 56 78", "+33")  // → "+33612345678"
normalizePhone("+33-6-12-34-56-78")      // → "+33612345678"
normalizePhone("0612345678", "+33")      // → "+33612345678"

Name normalization:

function normalizeName(name) {
    if (!name) return null;
    
    // 1. Trim whitespace
    name = name.trim();
    
    // 2. Convert to lowercase
    name = name.toLowerCase();
    
    // 3. Remove special characters (keep only letters, spaces, hyphens)
    name = name.replace(/[^a-z\s-]/g, '');
    
    // 4. Remove extra spaces
    name = name.replace(/\s+/g, ' ');
    
    return name;
}

// Examples
normalizeName("  Jean-Pierre  ")  // → "jean-pierre"
normalizeName("O'Connor")         // → "oconnor"

City/State normalization:

function normalizeLocation(location) {
    if (!location) return null;
    
    // 1. Trim whitespace
    location = location.trim();
    
    // 2. Convert to lowercase
    location = location.toLowerCase();
    
    // 3. Remove special characters (keep only letters, spaces, hyphens)
    location = location.replace(/[^a-z\s-]/g, '');
    
    // 4. Remove extra spaces
    location = location.replace(/\s+/g, ' ');
    
    return location;
}

// Examples
normalizeLocation("  Paris  ")       // → "paris"
normalizeLocation("Île-de-France")   // → "ile-de-france"

Zip code normalization:

function normalizeZipCode(zip) {
    if (!zip) return null;
    
    // 1. Trim whitespace
    zip = zip.trim();
    
    // 2. Remove spaces and hyphens
    zip = zip.replace(/[\s-]/g, '');
    
    // 3. Keep only digits
    zip = zip.replace(/[^0-9]/g, '');
    
    return zip;
}

// Examples
normalizeZipCode("75 001")   // → "75001"
normalizeZipCode("75-001")   // → "75001"

Date of birth normalization:

function normalizeDateOfBirth(dob) {
    if (!dob) return null;
    
    // Expected format: YYYYMMDD
    // Input can be Date object or string
    
    if (dob instanceof Date) {
        const year = dob.getFullYear();
        const month = String(dob.getMonth() + 1).padStart(2, '0');
        const day = String(dob.getDate()).padStart(2, '0');
        return `${year}${month}${day}`;
    }
    
    // If string, remove all non-digits
    dob = String(dob).replace(/[^0-9]/g, '');
    
    // Should be 8 digits: YYYYMMDD
    if (dob.length === 8) {
        return dob;
    }
    
    return null;
}

// Examples
normalizeDateOfBirth("1990-05-15")      // → "19900515"
normalizeDateOfBirth("15/05/1990")      // → (would need parsing logic)
normalizeDateOfBirth(new Date(1990, 4, 15)) // → "19900515"

Gender normalization:

function normalizeGender(gender) {
    if (!gender) return null;
    
    gender = gender.trim().toLowerCase();
    
    // Meta accepts only 'm' or 'f'
    if (gender === 'm' || gender === 'male' || gender === 'homme') {
        return 'm';
    }
    if (gender === 'f' || gender === 'female' || gender === 'femme') {
        return 'f';
    }
    
    return null; // Don't send if ambiguous
}

Hashing with SHA-256

CRITICAL: Hash on the server, never in the mobile app.

Why?

  • Consistent hashing implementation
  • Easier to debug and update hashing logic
  • Better security (keys/secrets stay server-side)

Node.js example:

const crypto = require('crypto');

function hashSHA256(value) {
    if (!value) return null;
    return crypto.createHash('sha256').update(value).digest('hex');
}

// Example usage
const email = normalizeEmail("Test@Example.com");
const hashedEmail = hashSHA256(email);
// Result: "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"

Python example:

import hashlib

def hash_sha256(value):
    if not value:
        return None
    return hashlib.sha256(value.encode('utf-8')).hexdigest()

# Example usage
email = normalize_email("Test@Example.com")
hashed_email = hash_sha256(email)
# Result: "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"

Complete Normalization + Hashing Pipeline

Backend implementation (Node.js):

const crypto = require('crypto');

class AdvancedMatcher {
    
    static normalizeEmail(email) {
        if (!email) return null;
        return email.trim().toLowerCase();
    }
    
    static normalizePhone(phone, countryCode = '+1') {
        if (!phone) return null;
        phone = phone.replace(/[^0-9]/g, '');
        if (phone.startsWith('0')) {
            phone = phone.substring(1);
        }
        if (!phone.startsWith('+')) {
            phone = countryCode + phone;
        }
        return phone;
    }
    
    static normalizeName(name) {
        if (!name) return null;
        return name.trim().toLowerCase().replace(/[^a-z\s-]/g, '').replace(/\s+/g, ' ');
    }
    
    static normalizeLocation(location) {
        if (!location) return null;
        return location.trim().toLowerCase().replace(/[^a-z\s-]/g, '').replace(/\s+/g, ' ');
    }
    
    static normalizeZip(zip) {
        if (!zip) return null;
        return zip.trim().replace(/[\s-]/g, '').replace(/[^0-9]/g, '');
    }
    
    static normalizeGender(gender) {
        if (!gender) return null;
        gender = gender.trim().toLowerCase();
        if (['m', 'male'].includes(gender)) return 'm';
        if (['f', 'female'].includes(gender)) return 'f';
        return null;
    }
    
    static normalizeDOB(dob) {
        if (!dob) return null;
        if (dob instanceof Date) {
            const year = dob.getFullYear();
            const month = String(dob.getMonth() + 1).padStart(2, '0');
            const day = String(dob.getDate()).padStart(2, '0');
            return `${year}${month}${day}`;
        }
        const cleaned = String(dob).replace(/[^0-9]/g, '');
        return cleaned.length === 8 ? cleaned : null;
    }
    
    static hash(value) {
        if (!value) return null;
        return crypto.createHash('sha256').update(value).digest('hex');
    }
    
    static buildUserData(rawUserData, countryCode = '+1') {
        const normalized = {
            email: this.normalizeEmail(rawUserData.email),
            phone: this.normalizePhone(rawUserData.phone, countryCode),
            firstName: this.normalizeName(rawUserData.firstName),
            lastName: this.normalizeName(rawUserData.lastName),
            city: this.normalizeLocation(rawUserData.city),
            state: this.normalizeLocation(rawUserData.state),
            zip: this.normalizeZip(rawUserData.zip),
            country: rawUserData.country?.trim().toLowerCase() || null,
            gender: this.normalizeGender(rawUserData.gender),
            dob: this.normalizeDOB(rawUserData.dob)
        };
        
        return {
            em: this.hash(normalized.email),
            ph: this.hash(normalized.phone),
            fn: this.hash(normalized.firstName),
            ln: this.hash(normalized.lastName),
            ct: this.hash(normalized.city),
            st: this.hash(normalized.state),
            zp: this.hash(normalized.zip),
            country: normalized.country, // Country is NOT hashed
            ge: this.hash(normalized.gender),
            db: this.hash(normalized.dob)
        };
    }
}

// Usage
const rawData = {
    email: "  Test@Example.com  ",
    phone: "06 12 34 56 78",
    firstName: "Jean-Pierre",
    lastName: "Dupont",
    city: "Paris",
    state: "Île-de-France",
    zip: "75 001",
    country: "FR",
    gender: "M",
    dob: "1990-05-15"
};

const hashedUserData = AdvancedMatcher.buildUserData(rawData, '+33');

console.log(hashedUserData);
/* Output:
{
    em: "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b",
    ph: "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92",
    fn: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
    ln: "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4",
    ct: "d2b3df0e25a7d31b9a8e5a8b5d8c9f7e6a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d",
    st: "a3c4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2g3h4",
    zp: "1f32aa4c9a1d2ea010adcf2348166a45",
    country: "fr",
    ge: "62af5c3cb8da3e4f25061e829ebeea5c",
    db: "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
}
*/

Sending to Meta CAPI

In your CAPI payload:

{
    "user_data": {
        "em": "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b",
        "ph": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92",
        "fn": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
        "ln": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4",
        "ct": "d2b3df0e25a7d31b9a8e5a8b5d8c9f7e6a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d",
        "zp": "1f32aa4c9a1d2ea010adcf2348166a45",
        "country": "fr",
        "ge": "62af5c3cb8da3e4f25061e829ebeea5c"
    }
}

Best practices:

  • Send as many fields as you have available
  • More fields = higher Event Match Quality (EMQ)
  • Don’t send empty strings or “unknown” – omit the field entirely
  • country is the ONLY field that should NOT be hashed

Sources:

  • Meta Hashing Guide: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters#hashing
  • Advanced Matching: https://developers.facebook.com/docs/meta-pixel/advanced/advanced-matching

Critical Parameter #4: app_data Parameters

What is app_data?

The app_data object contains metadata about your app and the tracking environment. The Meta SDK populates this automatically.

Required app_data Fields

{
    "app_data": {
        "application_tracking_enabled": 1,
        "advertiser_tracking_enabled": 1,
        "extinfo": [ /* array shown in section 2 */ ]
    }
}

Field definitions:

FieldTypeDescriptionValues
application_tracking_enabledintegerWhether app-level tracking is enabled0 or 1
advertiser_tracking_enabledintegerWhether ad tracking is enabled (iOS ATT)0 or 1
extinfoarrayDevice/app metadata arraySee section 2

application_tracking_enabled

This indicates whether your app has permission to track the user at the application level.

When to set to 0:

  • User opted out in app settings
  • GDPR/CCPA consent denied
  • Privacy mode enabled

When to set to 1:

  • User gave consent
  • No privacy restrictions apply

advertiser_tracking_enabled (iOS Specific)

This specifically reflects iOS App Tracking Transparency (ATT) status.

iOS implementation:

import AppTrackingTransparency

func getAdvertiserTrackingEnabled() -> Int {
    if #available(iOS 14.5, *) {
        let status = ATTrackingManager.trackingAuthorizationStatus
        return (status == .authorized) ? 1 : 0
    } else {
        return 1 // Pre-iOS 14.5, always enabled
    }
}

React Native implementation:

import { getTrackingStatus } from 'react-native-tracking-transparency';

async function getAdvertiserTrackingEnabled() {
    const status = await getTrackingStatus();
    return (status === 'authorized' || status === 'unavailable') ? 1 : 0;
}

Optional app_data Fields

{
    "app_data": {
        "installer_package": "com.android.vending",
        "url_schemes": ["myapp://"],
        "consider_views": 1,
        "campaign_ids": "campaign_123"
    }
}
FieldDescriptionExample
installer_packageWhere the app was installed fromcom.android.vending (Google Play), com.apple (App Store)
url_schemesDeep link URL schemes your app supports["myapp://", "https://myapp.com"]
consider_viewsWhether to consider view-through attribution1 (yes) or 0 (no)
campaign_idsCampaign identifiers if tracking specific campaigns"campaign_123,campaign_456"

Getting installer_package

Android (Kotlin):

fun getInstallerPackage(context: Context): String? {
    return try {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
        } else {
            @Suppress("DEPRECATION")
            context.packageManager.getInstallerPackageName(context.packageName)
        }
    } catch (e: Exception) {
        null
    }
}

// Common values:
// "com.android.vending" - Google Play Store
// "com.amazon.venezia" - Amazon App Store
// null or "com.google.android.packageinstaller" - Sideloaded

iOS: iOS doesn’t provide installer package info directly. You can use:

let installerPackage = "com.apple" // Always App Store for production apps

Complete app_data Example

{
    "app_data": {
        "application_tracking_enabled": 1,
        "advertiser_tracking_enabled": 0,
        "extinfo": [
            "a2",
            "com.myapp",
            "1.2.3",
            "456",
            "iOS",
            "17.2.1",
            "iPhone14,2",
            "en_US",
            "Europe/Paris",
            "Orange",
            1170,
            2532,
            2.5,
            4,
            123456,
            256000
        ],
        "installer_package": "com.apple",
        "url_schemes": ["myapp://", "https://myapp.com"],
        "consider_views": 1
    }
}

Sources:

  • Meta CAPI App Data: https://developers.facebook.com/docs/marketing-api/conversions-api/app-events

Critical Parameter #5: action_source

What is action_source?

The action_source parameter tells Meta where the event originated. This is absolutely critical – if you set it wrong, attribution will fail completely.

Valid Values

ValueWhen to UseDescription
appMobile app eventsEvents from your iOS/Android app
websiteWeb eventsEvents from your website
emailEmail eventsConversions from email campaigns
phone_callPhone conversionsConversions from phone calls
chatChat conversionsConversions from chat/messaging
physical_storeIn-store eventsOffline conversions in physical locations
system_generatedSystem eventsEvents generated by automated systems
otherOther sourcesAny other source

For Mobile Apps: ALWAYS Use “app”

{
    "action_source": "app"
}

What happens if you use “website” by mistake?

  • Meta treats the event as a web conversion
  • Attribution fails because web campaigns expect web events
  • Mobile install campaigns won’t see conversions
  • Your Event Match Quality (EMQ) drops

Complete Event Structure with action_source

{
    "data": [{
        "event_name": "Purchase",
        "event_time": 1701234567,
        "event_id": "purchase_ORDER789_1701234567",
        "action_source": "app",
        "user_data": {
            "madid": "384000008cf011bdb23e10b96e40000d",
            "anon_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
            "em": "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b",
            "client_ip_address": "203.0.113.42",
            "client_user_agent": "Mozilla/5.0..."
        },
        "app_data": {
            "application_tracking_enabled": 1,
            "advertiser_tracking_enabled": 1,
            "extinfo": [/* ... */]
        },
        "custom_data": {
            "value": 129.99,
            "currency": "EUR"
        }
    }]
}

Sources:

  • CAPI Action Source: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#action-source

Additional Important Parameters

event_time

The Unix timestamp (in seconds, not milliseconds) when the event occurred.

const eventTime = Math.floor(Date.now() / 1000);

CRITICAL: Meta expects seconds, not milliseconds. JavaScript’s Date.now() returns milliseconds, so divide by 1000.

❌ Wrong: 1701234567890 (milliseconds)
✅ Correct: 1701234567 (seconds)

event_id

A unique identifier for deduplication. If you’re sending the same event from multiple sources (e.g., both app SDK and CAPI), use the same event_id so Meta can deduplicate.

const eventId = `${eventName}_${orderId}_${timestamp}`;
// Example: "purchase_ORDER789_1701234567"

Best practices:

  • Make it deterministic (same input = same ID)
  • Include event type + unique business identifier + timestamp
  • Max length: 50 characters

event_source_url

The deep link or route within your app where the event occurred.

{
    "event_source_url": "myapp://product/12345"
}

This helps Meta understand the user journey within your app.

opt_out

Whether the user opted out of tracking (GDPR/CCPA).

{
    "opt_out": false
}

Set to true if the user explicitly opted out. When true, Meta will still receive the event but won’t use it for ad targeting (only for attribution reporting).

client_ip_address & client_user_agent

These should be collected by your backend server (not the mobile app) to ensure accuracy.

{
    "user_data": {
        "client_ip_address": "203.0.113.42",
        "client_user_agent": "MyApp/1.2.3 (iPhone; iOS 17.2.1)"
    }
}

Why collect server-side?

  • Prevents spoofing
  • Accurate IP geolocation
  • Consistent User-Agent format

Node.js example (Express):

app.post('/api/track-event', (req, res) => {
    const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
    const userAgent = req.headers['user-agent'];
    
    const eventData = {
        ...req.body,
        client_ip_address: clientIP,
        client_user_agent: userAgent
    };
    
    sendToMetaCAPI(eventData);
    res.sendStatus(200);
});

Complete Implementation Example

Mobile App (React Native)

// EventTracker.js
import AsyncStorage from '@react-native-async-storage/async-storage';
import DeviceInfo from 'react-native-device-info';
import { requestTrackingPermission } from 'react-native-tracking-transparency';
import { Platform, Dimensions } from 'react-native';
import uuid from 'react-native-uuid';

class EventTracker {
    constructor(backendUrl) {
        this.backendUrl = backendUrl;
        this.deviceData = null;
    }
    
    async initialize() {
        // Collect device data once at app start
        this.deviceData = await this.collectDeviceData();
    }
    
    async collectDeviceData() {
        const madid = await this.getMADID();
        const anonId = await this.getOrCreateAnonId();
        const extinfo = await this.buildExtinfo();
        const attEnabled = await this.getATTStatus();
        
        return {
            madid,
            anonId,
            extinfo,
            applicationTrackingEnabled: 1, // Adjust based on app consent
            advertiserTrackingEnabled: attEnabled
        };
    }
    
    async getMADID() {
        try {
            if (Platform.OS === 'ios') {
                const status = await requestTrackingPermission();
                if (status === 'authorized' || status === 'unavailable') {
                    const idfa = await DeviceInfo.getAdvertisingId();
                    return this.formatMADID(idfa);
                }
                return null;
            } else {
                // Android
                const gaid = await DeviceInfo.getAdvertisingId();
                return this.formatMADID(gaid);
            }
        } catch (error) {
            console.error('Error getting MADID:', error);
            return null;
        }
    }
    
    formatMADID(madid) {
        if (!madid || madid === '00000000-0000-0000-0000-000000000000') {
            return null;
        }
        return madid.toLowerCase().replace(/-/g, '');
    }
    
    async getOrCreateAnonId() {
        const key = 'anon_id';
        try {
            let anonId = await AsyncStorage.getItem(key);
            if (!anonId) {
                anonId = uuid.v4().toLowerCase();
                await AsyncStorage.setItem(key, anonId);
            }
            return anonId;
        } catch (error) {
            console.error('Error with anon_id:', error);
            return uuid.v4().toLowerCase();
        }
    }
    
    async buildExtinfo() {
        try {
            const screenDimensions = Dimensions.get('screen');
            
            return [
                "a2",
                DeviceInfo.getBundleId(),
                DeviceInfo.getVersion(),
                DeviceInfo.getBuildNumber(),
                Platform.OS === 'ios' ? 'iOS' : 'Android',
                DeviceInfo.getSystemVersion(),
                await DeviceInfo.getDeviceId(),
                DeviceInfo.getDeviceLocale(),
                DeviceInfo.getTimezone(),
                await DeviceInfo.getCarrier() || 'unknown',
                Math.round(screenDimensions.width),
                Math.round(screenDimensions.height),
                screenDimensions.scale || 1.0,
                await DeviceInfo.getProcessorCount() || 2,
                Math.round(await DeviceInfo.getFreeDiskStorage() / 1024 / 1024),
                Math.round(await DeviceInfo.getTotalDiskCapacity() / 1024 / 1024)
            ];
        } catch (error) {
            console.error('Error building extinfo:', error);
            return ["a2", "unknown", "1.0", "1", Platform.OS, "1.0", "unknown", "en_US", "UTC", "unknown", 0, 0, 1.0, 2, 0, 0];
        }
    }
    
    async getATTStatus() {
        if (Platform.OS === 'ios') {
            try {
                const status = await requestTrackingPermission();
                return (status === 'authorized' || status === 'unavailable') ? 1 : 0;
            } catch (error) {
                return 0;
            }
        }
        return 1; // Android doesn't have ATT
    }
    
    async trackEvent(eventName, customData = {}, userData = {}) {
        if (!this.deviceData) {
            await this.initialize();
        }
        
        const eventId = `${eventName}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
        
        const payload = {
            eventName,
            eventId,
            eventTime: Math.floor(Date.now() / 1000),
            deviceData: this.deviceData,
            customData,
            userData: {
                email: userData.email,
                phone: userData.phone,
                firstName: userData.firstName,
                lastName: userData.lastName,
                city: userData.city,
                state: userData.state,
                zip: userData.zip,
                country: userData.country,
                gender: userData.gender,
                dob: userData.dob
            }
        };
        
        try {
            const response = await fetch(`${this.backendUrl}/track-event`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(payload)
            });
            
            if (!response.ok) {
                throw new Error(`Backend error: ${response.status}`);
            }
            
            console.log(`Event ${eventName} tracked successfully`);
        } catch (error) {
            console.error('Error tracking event:', error);
            // Implement retry logic or queue for later
        }
    }
    
    // Convenience methods for common events
    
    async trackPurchase(order) {
        await this.trackEvent('Purchase', {
            value: order.total,
            currency: order.currency,
            content_type: 'product',
            content_ids: order.items.map(i => i.id),
            contents: order.items.map(i => ({
                id: i.id,
                quantity: i.quantity,
                item_price: i.price
            })),
            num_items: order.items.length,
            order_id: order.id
        });
    }
    
    async trackAddToCart(product) {
        await this.trackEvent('AddToCart', {
            value: product.price,
            currency: product.currency,
            content_type: 'product',
            content_ids: [product.id],
            content_name: product.name
        });
    }
    
    async trackRegistration(user) {
        await this.trackEvent('CompleteRegistration', {}, {
            email: user.email,
            phone: user.phone,
            firstName: user.firstName,
            lastName: user.lastName
        });
    }
}

export default EventTracker;

// Usage in your app
// const tracker = new EventTracker('https://your-backend.com/api');
// await tracker.initialize();
// await tracker.trackPurchase(orderData);

Backend Server (Node.js/Express)

// server.js
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');

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

const PIXEL_ID = process.env.META_PIXEL_ID;
const ACCESS_TOKEN = process.env.META_ACCESS_TOKEN;

class AdvancedMatcher {
    static normalizeEmail(email) {
        return email ? email.trim().toLowerCase() : null;
    }
    
    static normalizePhone(phone, countryCode = '+1') {
        if (!phone) return null;
        phone = phone.replace(/[^0-9]/g, '');
        if (phone.startsWith('0')) phone = phone.substring(1);
        return phone.startsWith('+') ? phone : countryCode + phone;
    }
    
    static normalizeName(name) {
        return name ? name.trim().toLowerCase().replace(/[^a-z\s-]/g, '').replace(/\s+/g, ' ') : null;
    }
    
    static normalizeLocation(location) {
        return location ? location.trim().toLowerCase().replace(/[^a-z\s-]/g, '').replace(/\s+/g, ' ') : null;
    }
    
    static normalizeZip(zip) {
        return zip ? zip.trim().replace(/[\s-]/g, '').replace(/[^0-9]/g, '') : null;
    }
    
    static normalizeGender(gender) {
        if (!gender) return null;
        gender = gender.trim().toLowerCase();
        if (['m', 'male'].includes(gender)) return 'm';
        if (['f', 'female'].includes(gender)) return 'f';
        return null;
    }
    
    static normalizeDOB(dob) {
        if (!dob) return null;
        if (dob instanceof Date) {
            const year = dob.getFullYear();
            const month = String(dob.getMonth() + 1).padStart(2, '0');
            const day = String(dob.getDate()).padStart(2, '0');
            return `${year}${month}${day}`;
        }
        const cleaned = String(dob).replace(/[^0-9]/g, '');
        return cleaned.length === 8 ? cleaned : null;
    }
    
    static hash(value) {
        return value ? crypto.createHash('sha256').update(value).digest('hex') : null;
    }
    
    static buildUserData(raw, countryCode = '+1') {
        const normalized = {
            email: this.normalizeEmail(raw.email),
            phone: this.normalizePhone(raw.phone, countryCode),
            firstName: this.normalizeName(raw.firstName),
            lastName: this.normalizeName(raw.lastName),
            city: this.normalizeLocation(raw.city),
            state: this.normalizeLocation(raw.state),
            zip: this.normalizeZip(raw.zip),
            country: raw.country?.trim().toLowerCase() || null,
            gender: this.normalizeGender(raw.gender),
            dob: this.normalizeDOB(raw.dob)
        };
        
        const hashed = {};
        if (normalized.email) hashed.em = this.hash(normalized.email);
        if (normalized.phone) hashed.ph = this.hash(normalized.phone);
        if (normalized.firstName) hashed.fn = this.hash(normalized.firstName);
        if (normalized.lastName) hashed.ln = this.hash(normalized.lastName);
        if (normalized.city) hashed.ct = this.hash(normalized.city);
        if (normalized.state) hashed.st = this.hash(normalized.state);
        if (normalized.zip) hashed.zp = this.hash(normalized.zip);
        if (normalized.country) hashed.country = normalized.country; // NOT hashed
        if (normalized.gender) hashed.ge = this.hash(normalized.gender);
        if (normalized.dob) hashed.db = this.hash(normalized.dob);
        
        return hashed;
    }
}

async function sendToMetaCAPI(eventData, clientIP, userAgent) {
    const { deviceData, userData, customData, eventName, eventId, eventTime } = eventData;
    
    // Build user_data
    const hashedUserData = AdvancedMatcher.buildUserData(userData || {});
    
    const userDataPayload = {
        ...hashedUserData,
        madid: deviceData.madid?.toLowerCase().replace(/-/g, ''),
        anon_id: deviceData.anonId,
        client_ip_address: clientIP,
        client_user_agent: userAgent
    };
    
    // Remove null/undefined values
    Object.keys(userDataPayload).forEach(key => {
        if (userDataPayload[key] == null) delete userDataPayload[key];
    });
    
    // Build app_data
    const appDataPayload = {
        application_tracking_enabled: deviceData.applicationTrackingEnabled,
        advertiser_tracking_enabled: deviceData.advertiserTrackingEnabled,
        extinfo: deviceData.extinfo
    };
    
    // Build complete event
    const event = {
        event_name: eventName,
        event_time: eventTime,
        event_id: eventId,
        action_source: 'app',
        user_data: userDataPayload,
        app_data: appDataPayload,
        custom_data: customData || {}
    };
    
    // Send to Meta
    try {
        const response = await axios.post(
            `https://graph.facebook.com/v18.0/${PIXEL_ID}/events`,
            { data: [event] },
            {
                params: { access_token: ACCESS_TOKEN },
                headers: { 'Content-Type': 'application/json' }
            }
        );
        
        console.log('Meta CAPI response:', response.data);
        return response.data;
    } catch (error) {
        console.error('Error sending to Meta CAPI:', error.response?.data || error.message);
        throw error;
    }
}

app.post('/api/track-event', async (req, res) => {
    try {
        const clientIP = req.headers['x-forwarded-for']?.split(',')[0] || req.connection.remoteAddress;
        const userAgent = req.headers['user-agent'];
        
        await sendToMetaCAPI(req.body, clientIP, userAgent);
        
        res.json({ success: true });
    } catch (error) {
        console.error('Error processing event:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

Testing & Debugging

Use Meta’s Test Events Tool

Meta provides a Test Events tool in Events Manager that lets you send test events and verify they’re formatted correctly.

Step 1: Get a test event code

  1. Go to Meta Events Manager
  2. Select your Pixel/Dataset
  3. Click “Test Events”
  4. Copy the test event code (e.g., TEST12345)

Step 2: Send a test event with the code

const testEvent = {
    // ... your normal event payload
    test_event_code: 'TEST12345'
};

await sendToMetaCAPI(testEvent);

Step 3: Verify in Test Events UI

Check that:

  • Event appears in the Test Events panel
  • All required parameters are present
  • No warnings or errors
  • Event Match Quality score is visible

Common Issues & Solutions

Issue: Events not showing in Test Events

Possible causes:

  • Wrong Pixel ID
  • Invalid access token
  • event_time in milliseconds instead of seconds
  • Missing required parameters

Issue: Low Event Match Quality (EMQ)

Possible causes:

  • Missing MADID and anon_id
  • Missing Advanced Matching fields (email, phone)
  • Incorrect hashing (didn’t normalize before hashing)
  • Missing client_ip_address or client_user_agent

Issue: Events showing but not in Ads Manager

Possible causes:

  • Wrong action_source (set to “website” instead of “app”)
  • Events sent but outside attribution window
  • User opted out of ad tracking

Debugging Checklist

Before going to production, verify:

  • [ ] MADID collected and formatted correctly (lowercase, no dashes)
  • [ ] anon_id generated and persisted
  • [ ] extinfo array has all 16 values in correct order
  • [ ] All PII fields normalized BEFORE hashing
  • [ ] PII hashed with SHA-256 (verify hash output manually)
  • [ ] action_source set to "app"
  • [ ] event_time in seconds (not milliseconds)
  • [ ] event_id unique and deterministic
  • [ ] client_ip_address and client_user_agent present
  • [ ] application_tracking_enabled and advertiser_tracking_enabled set correctly
  • [ ] Test events appear in Test Events tool
  • [ ] Event Match Quality > 6.0 (Good or Great)

Common Pitfalls & How to Avoid Them

Pitfall #1: MADID Formatting

Wrong:

madid: "38400000-8CF0-11BD-B23E-10B96E40000D" // Mixed case, with dashes

Correct:

madid: "384000008cf011bdb23e10b96e40000d" // Lowercase, no dashes

Pitfall #2: Hashing Before Normalization

Wrong:

const hashedEmail = hashSHA256("  Test@Example.com  "); // Hashing raw input

Correct:

const normalized = normalizeEmail("  Test@Example.com  "); // → "test@example.com"
const hashedEmail = hashSHA256(normalized);

Pitfall #3: Sending Timestamps in Milliseconds

Wrong:

event_time: Date.now() // 1701234567890 (milliseconds)

Correct:

event_time: Math.floor(Date.now() / 1000) // 1701234567 (seconds)

Pitfall #4: Missing anon_id Fallback

Wrong:

// Only sending MADID, no fallback when ATT denied
user_data: {
    madid: idfa // Will be "00000000..." when ATT denied
}

Correct:

user_data: {
    madid: idfa !== "00000000-0000-0000-0000-000000000000" ? formatMADID(idfa) : null,
    anon_id: anonId // Always include fallback
}

Pitfall #5: Wrong action_source

Wrong:

action_source: "website" // Treating app events as web events

Correct:

action_source: "app"

Pitfall #6: Incomplete extinfo

Wrong:

extinfo: ["a2", "com.myapp", "1.0"] // Only 3 values

Correct:

extinfo: [
    "a2", "com.myapp", "1.2.3", "456", "iOS", "17.2.1",
    "iPhone14,2", "en_US", "Europe/Paris", "Orange",
    1170, 2532, 2.5, 4, 123456, 256000
] // All 16 values

Pitfall #7: Hashing Country Code

Wrong:

country: hashSHA256("FR") // Country should NOT be hashed

Correct:

country: "fr" // Lowercase, NOT hashed

Pitfall #8: Not Handling iOS ATT Properly

Wrong:

// Requesting IDFA without ATT permission
const idfa = await ASIdentifierManager.shared().advertisingIdentifier.uuidString;

Correct:

// Request ATT permission first
const status = await ATTrackingManager.requestTrackingAuthorization();
if (status === 'authorized') {
    const idfa = await ASIdentifierManager.shared().advertisingIdentifier.uuidString;
} else {
    // Use anon_id instead
}

Performance & Scale Considerations

Mobile App Performance

Minimize data collection overhead:

  • Collect device data once at app launch, cache it
  • Don’t block UI thread for data collection
  • Use background threads for network requests

Example optimization:

class EventTracker {
    constructor() {
        this.deviceDataCache = null;
        this.isInitialized = false;
    }
    
    async initialize() {
        if (this.isInitialized) return;
        
        // Collect device data in background
        this.deviceDataCache = await this.collectDeviceData();
        this.isInitialized = true;
    }
    
    async trackEvent(eventName, data) {
        // Use cached device data
        const payload = {
            ...data,
            deviceData: this.deviceDataCache
        };
        
        // Send asynchronously, don't wait
        this.sendToBackend(payload).catch(console.error);
    }
}

Backend Performance

For high-volume apps (>10k events/minute):

  1. Implement batching:
// Send events in batches of up to 1000
const batches = [];
for (let i = 0; i < events.length; i += 1000) {
    batches.push(events.slice(i, i + 1000));
}

for (const batch of batches) {
    await sendToMetaCAPI({ data: batch });
}
  1. Use a queue system (Redis, RabbitMQ):
// Add events to queue
await redisClient.rPush('meta_events_queue', JSON.stringify(event));

// Worker process consumes queue
while (true) {
    const eventJSON = await redisClient.lPop('meta_events_queue');
    if (eventJSON) {
        const event = JSON.parse(eventJSON);
        await sendToMetaCAPI(event);
    }
}
  1. Implement retry logic with exponential backoff:
async function sendWithRetry(event, maxRetries = 3) {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            await sendToMetaCAPI(event);
            return; // Success
        } catch (error) {
            if (attempt === maxRetries) {
                // Final failure, log to dead letter queue
                await logToDeadLetterQueue(event, error);
                throw error;
            }
            // Exponential backoff: 2^attempt seconds
            await sleep(Math.pow(2, attempt) * 1000);
        }
    }
}

Monitoring & Alerting

Key metrics to monitor:

  • Event delivery success rate
  • Average Event Match Quality (EMQ)
  • Latency (app → backend → Meta)
  • Error rates by error type
  • Events with missing critical fields

Example monitoring setup (Node.js + Prometheus):

const promClient = require('prom-client');

const eventsSent = new promClient.Counter({
    name: 'meta_capi_events_sent_total',
    help: 'Total number of events sent to Meta CAPI',
    labelNames: ['event_name', 'status']
});

const eventLatency = new promClient.Histogram({
    name: 'meta_capi_event_latency_seconds',
    help: 'Latency of Meta CAPI requests',
    buckets: [0.1, 0.5, 1, 2, 5]
});

async function sendToMetaCAPI(event) {
    const start = Date.now();
    try {
        await axios.post(/* ... */);
        eventsSent.inc({ event_name: event.event_name, status: 'success' });
    } catch (error) {
        eventsSent.inc({ event_name: event.event_name, status: 'error' });
        throw error;
    } finally {
        eventLatency.observe((Date.now() - start) / 1000);
    }
}

Conclusion

Implementing Meta CAPI for mobile apps without the SDK is technically complex but provides significant benefits:

Full control over data collection and transmission
Better privacy compliance (GDPR, CCPA)
Reduced app size and dependencies
Unified tracking architecture (web + mobile)
Server-side validation and data enrichment

But it requires meticulous attention to detail:

80% of SDK functionality must be recreated manually
High risk of attribution failure if parameters are incorrect
Additional server infrastructure and maintenance
iOS ATT complexity requires careful handling

Before going to production:

  1. Test thoroughly with Meta’s Test Events tool
  2. Verify Event Match Quality (EMQ) scores
  3. Monitor event delivery and error rates
  4. Implement robust retry and fallback mechanisms
  5. Document your implementation for future maintenance

Key takeaways:

  • MADID + anon_id: Always have a fallback for iOS ATT denials
  • extinfo: Don’t skip it – Meta needs this for attribution
  • Advanced Matching: Normalize THEN hash – this is critical
  • action_source: Always "app" for mobile events
  • Testing: Use Test Events extensively before production

This implementation approach is not for everyone. If you’re just getting started with Meta ads on mobile, use the SDK. But if you need maximum control, privacy compliance, or have specific architectural requirements, the CAPI-only approach is powerful once properly implemented.

Sources & References

  1. Meta Conversions API Overview: https://developers.facebook.com/docs/marketing-api/conversions-api
  2. CAPI Server Event Parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
  3. CAPI Customer Information Parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
  4. Meta App Events Parameters: https://developers.facebook.com/docs/app-events/getting-started-app-events-android#app-events-parameters
  5. CAPI Best Practices: https://developers.facebook.com/docs/marketing-api/conversions-api/best-practices
  6. Advanced Matching: https://developers.facebook.com/docs/meta-pixel/advanced/advanced-matching
  7. Meta Data Hashing Guide: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters#hashing
  8. iOS App Tracking Transparency: https://developer.apple.com/documentation/apptrackingtransparency
  9. Android Advertising ID: https://support.google.com/googleplay/android-developer/answer/6048248
  10. Meta CAPI Deduplication: https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events

Laurent Fidahoussen
Laurent Fidahoussen

Ads & Tracking & Analytics & Dataviz for better Data Marketing and boost digital performance

25 years in IT, 10+ in digital data projects — I connect the dots between tech, analytics, reporting & media (not a pure Ads expert—but I’ll make your campaigns work for you)
- Finding it hard to launch, track, or measure your digital campaigns?
- Not sure if your marketing budget is working—or how your audiences behave?
- Messy tracking makes reporting a nightmare, and fast decisions impossible?
- Still wrestling with Excel to build dashboards, without real actionable insights?

I can help you:
- Launch and manage ad campaigns (Google, Meta, LinkedIn…)
- Set up robust, clean tracking—so you know what every euro gives you back
- Build and optimize events: visits, product views, carts, checkout, purchases, abandons
- Create dashboards and analytics tools that turn your data into real growth drivers
- Streamline reporting and visualization for simple, fast decisions

Curious? Let’s connect—my promise: clear, no jargon, just better results.

Stack:
Ads (Google Ads, Meta Ads, LinkedIn Ads) | Analytics (Adobe Analytics, GA4, GTM client & server-side) | Dataviz (Looker Studio, Power BI, Python/Jupyter)

Articles: 28