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:
- Your mobile app collects raw event data
- The app sends this data to your backend server
- Your server processes, enriches, and formats the data
- 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
extinfoarray (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-10b96e40000d→384000008cf011bdb23e10b96e40000d
❌ 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
madidANDanon_idif both are available - If only one is available, send that one
- Never send
00000000-0000-0000-0000-000000000000as MADID (iOS ATT denied) – use onlyanon_idin 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:
- Collecting user data (email, phone, name, etc.)
- Normalizing the data
- Hashing it with SHA-256
- 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):
| Field | Parameter | Description | Example (pre-hash) |
|---|---|---|---|
em | User’s email address | test@example.com | |
| Phone | ph | User’s phone number | +33612345678 |
| First name | fn | User’s first name | john |
| Last name | ln | User’s last name | doe |
| City | ct | User’s city | paris |
| State/Region | st | User’s state/province | ile de france |
| Zip code | zp | Postal code | 75001 |
| Country | country | 2-letter country code | fr |
| Gender | ge | Gender (m/f) | m |
| Date of birth | db | Birth date YYYYMMDD | 19900515 |
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
countryis 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:
| Field | Type | Description | Values |
|---|---|---|---|
application_tracking_enabled | integer | Whether app-level tracking is enabled | 0 or 1 |
advertiser_tracking_enabled | integer | Whether ad tracking is enabled (iOS ATT) | 0 or 1 |
extinfo | array | Device/app metadata array | See 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"
}
}
| Field | Description | Example |
|---|---|---|
installer_package | Where the app was installed from | com.android.vending (Google Play), com.apple (App Store) |
url_schemes | Deep link URL schemes your app supports | ["myapp://", "https://myapp.com"] |
consider_views | Whether to consider view-through attribution | 1 (yes) or 0 (no) |
campaign_ids | Campaign 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
| Value | When to Use | Description |
|---|---|---|
app | Mobile app events | Events from your iOS/Android app |
website | Web events | Events from your website |
email | Email events | Conversions from email campaigns |
phone_call | Phone conversions | Conversions from phone calls |
chat | Chat conversions | Conversions from chat/messaging |
physical_store | In-store events | Offline conversions in physical locations |
system_generated | System events | Events generated by automated systems |
other | Other sources | Any 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
- Go to Meta Events Manager
- Select your Pixel/Dataset
- Click “Test Events”
- 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_timein 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_idgenerated and persisted - [ ]
extinfoarray has all 16 values in correct order - [ ] All PII fields normalized BEFORE hashing
- [ ] PII hashed with SHA-256 (verify hash output manually)
- [ ]
action_sourceset to"app" - [ ]
event_timein seconds (not milliseconds) - [ ]
event_idunique and deterministic - [ ]
client_ip_addressandclient_user_agentpresent - [ ]
application_tracking_enabledandadvertiser_tracking_enabledset 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):
- 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 });
}
- 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);
}
}
- 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:
- Test thoroughly with Meta’s Test Events tool
- Verify Event Match Quality (EMQ) scores
- Monitor event delivery and error rates
- Implement robust retry and fallback mechanisms
- 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
- Meta Conversions API Overview: https://developers.facebook.com/docs/marketing-api/conversions-api
- CAPI Server Event Parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
- CAPI Customer Information Parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
- Meta App Events Parameters: https://developers.facebook.com/docs/app-events/getting-started-app-events-android#app-events-parameters
- CAPI Best Practices: https://developers.facebook.com/docs/marketing-api/conversions-api/best-practices
- Advanced Matching: https://developers.facebook.com/docs/meta-pixel/advanced/advanced-matching
- Meta Data Hashing Guide: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters#hashing
- iOS App Tracking Transparency: https://developer.apple.com/documentation/apptrackingtransparency
- Android Advertising ID: https://support.google.com/googleplay/android-developer/answer/6048248
- Meta CAPI Deduplication: https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events