Pixels
Meta CAPI Setup for Shopify in South Africa.
The Meta Conversions API (CAPI) is how South African Shopify stores recover the 30 to 45 percent of iOS and Safari conversions that client-side Pixel now loses. This guide covers the CAPI install options — Shopify native, Stape server-side GTM, or a custom Cloudflare Worker — plus event deduplication, POPIA-safe hashing of SA identifiers, and the Meta Event Match Quality score you need above 7.0 for Advantage+ shopping to bid aggressively.
Prerequisites
- A working client-side Meta Pixel (see the Meta Pixel guide)
- Meta Business Manager access to generate a CAPI Access Token
- The Pixel ID from Events Manager
- Shopify Admin with Customer events access or a server endpoint you control (Worker/Node)
- POPIA consent banner that reports marketing consent state
Step 1. Generate a CAPI Access Token
Events Manager > your Pixel > Settings > Conversions API > Generate access token. Scope it to your Pixel. Store it in Shopify metafields, Cloudflare secrets, or an env var — never paste it into theme.liquid.
Step 2. Option A: Shopify native CAPI via the Meta sales channel
In the Meta app for Shopify > Settings > Data sharing, set to "Maximum". Shopify sends purchase, begin_checkout, and view_content server-side to Meta with your Pixel ID. This is the least flexible option but needs no engineering. Shopify auto-generates event_id for dedup.
Step 3. Option B: Cloudflare Worker relay
For more control, deploy a Worker that accepts a client event via fetch and forwards it to Meta with the access token. This keeps the token off the client and lets you enrich the payload with server-known fields like the Shopify order_id. Check current Meta Graph API docs for the latest version string — API versions bump every quarter.
export interface Env { META_ACCESS_TOKEN: string; META_PIXEL_ID: string; }
async function sha256(str: string) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str.trim().toLowerCase()));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
const ev = await req.json<any>();
const userData: Record<string, unknown> = {
client_ip_address: req.headers.get('cf-connecting-ip'),
client_user_agent: req.headers.get('user-agent'),
fbp: ev.fbp,
fbc: ev.fbc,
};
if (ev.email) userData.em = [await sha256(ev.email)];
if (ev.phone) userData.ph = [await sha256(ev.phone.replace(/[^0-9]/g, ''))];
if (ev.country) userData.country = [await sha256(ev.country)];
const payload = {
data: [{
event_name: ev.event_name,
event_time: Math.floor(Date.now() / 1000),
event_id: ev.event_id,
action_source: 'website',
event_source_url: ev.source_url,
user_data: userData,
custom_data: ev.custom_data,
}],
};
const res = await fetch(`https://graph.facebook.com/v19.0/${env.META_PIXEL_ID}/events?access_token=${env.META_ACCESS_TOKEN}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
return new Response(await res.text(), { status: res.status });
}
}; Step 4. Fire the matching client event with a shared event_id
Meta deduplicates the client Pixel event against the server CAPI event by (event_name, event_id). Generate event_id in the browser, send it to the server immediately via the Worker, and include it in the client fbq call so the pair matches.
analytics.subscribe('checkout_completed', async (event) => {
const c = event.data.checkout;
const eventId = crypto.randomUUID();
const payload = {
event_name: 'Purchase',
event_id: eventId,
source_url: window.location.href,
fbp: document.cookie.match(/_fbp=([^;]+)/)?.[1],
fbc: document.cookie.match(/_fbc=([^;]+)/)?.[1],
email: c.order.customer?.email,
phone: c.order.customer?.phone,
country: 'ZA',
custom_data: {
currency: c.totalPrice.currencyCode,
value: Number(c.totalPrice.amount),
content_ids: c.lineItems.map((li) => li.variant.sku),
content_type: 'product',
order_id: c.order.id,
},
};
// Fire CAPI first so the server timestamp leads
fetch('https://capi.yourstore.co.za/', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
});
// Then the client Pixel with the same event_id
window.fbq && fbq('track', 'Purchase', payload.custom_data, { eventID: eventId });
}); Step 5. Hash SA identifiers correctly
Meta expects SHA-256 hex lowercase. For SA phone numbers, strip all non-digits and keep the country code (27...). For email, lowercase and trim. Do not hash IP or user_agent — Meta wants those plain.
Step 6. Check Event Match Quality
Events Manager > Data Sources > Pixel > Overview > Event Match Quality. Aim for 7.0+ on Purchase. You get there by sending em, ph, fbp, fbc, client_ip, user_agent, fn (first name), ln (last name), ct (city), st (province), zp (postcode). SA merchants typically hit 8.0 once they include phone from Shopify checkout.
Step 7. Gate CAPI on POPIA marketing consent
Server-side is not a POPIA loophole. If the shopper rejected marketing cookies, do not call the Worker for Purchase / AddToCart. The server event still links to the data subject, so the lawful basis (consent) must exist. Send a consent flag with each event so auditors can verify.
Step 8. Monitor with Events Manager Test Events
In Events Manager, Test Events tab, paste your test browser's event_test_code. Run a R1 Shopify test order. Confirm Purchase appears once (not twice) — if it appears twice, event_id dedup is broken. Common cause: the client event uses a different eventID than the server.
SA gotchas
- Sending the same event from client and server without a matching event_id doubles conversion count in Ads Manager and tanks your ROAS reporting.
- Missing country code on SA phone numbers (e.g. "0821234567" instead of "+27821234567") drops Event Match Quality by around 1.5 points. Normalise to E.164 before hashing.
- Apple Private Relay sends Meta a proxy IP, which Meta flags as low-quality. Compensate by raising match quality with em and ph.
- Cloudflare Workers have a 10ms CPU limit on the free plan — the SHA-256 + fetch chain fits, but adding more enrichment can trip it. Upgrade to Workers Paid for 50ms.
- Shopify's native CAPI under the Meta sales channel does not let you dedupe against a custom Pixel install. Pick one path end-to-end.
Frequently asked questions
Do I still need the browser Meta Pixel if I have CAPI?
Yes. The Pixel captures the fbp and fbc cookies which CAPI needs for attribution, plus it covers consent-happy users. CAPI and Pixel together is Meta's recommended pattern.
Is Meta CAPI POPIA compliant?
Same consent rules apply as the client Pixel. POPIA is not about where the code runs, it is about lawful basis. Collect marketing consent before firing Purchase via either channel.
Will CAPI fix my iOS 17 signal loss completely?
It recovers most of it. Expect to close 25 to 35 percent of the client-side gap, not 100 percent. The remainder needs Advanced Matching and a strong Event Match Quality score.
Cloudflare Workers or Stape for server-side?
Cloudflare Workers is cheaper and lower-latency from SA. Stape is plug-and-play with Shopify and gtag, but you pay per event and add another cross-border hop.