Server-side Facebook Conversions API events sourced from a BigQuery contacts / purchases table — multi-region, multi-pixel, with a small token-refresh helper.
The browser-side Pixel is increasingly unreliable: iOS 14+ App Tracking Transparency, GDPR / ePrivacy consent banners, and ad-blockers all eat into the events Facebook actually receives. Without server-side events, Meta Ads optimization runs on incomplete data, CPA bids drift, and lookalike-audiences degrade.
The fix is well-known — Conversions API (CAPI) — but in a multi-region setup it requires more than the official tutorial: per-region pixel mapping, token rotation, and feeding the right events from the right CRM date window.
Two small Python entry points:
send_lead_events.py— for each region, reads recent contacts (or purchases) from BigQuery, builds one CAPIEventper row (Lead or Purchase), and POSTs them through the officialfacebook_businessSDK. The same script handles every region; the per-region pixel + token + currency map lives insites.json.tokens.py— exchanges short-lived page access tokens for long-lived (60-day) ones via Graph API'sfb_exchange_tokenflow. Run as a monthly maintenance task; updatessites.jsonin place.
Hashing of user identifiers (email, phone) is delegated to the SDK so the warehouse never has to worry about leaking PII over the wire.
- Python 3.10+
- BigQuery —
pandas-gbq,google-auth - Facebook Conversions API — official
facebook_businessSDK - Graph API for token refresh —
requests
┌──────────────────────────────────────────┐
│ BigQuery: crm.contacts_all (all sites) │
└────────────┬─────────────────────────────┘
│ per-site SELECT (date window)
▼
┌───────────────────────────────────┐
│ send_lead_events.py │
│ for site in sites.json: │
│ read contacts │
│ build Event(s) (Lead/Purchase)│
│ EventRequest.execute() │
└────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Facebook Conversions API │
│ (per-pixel; PII hashed by SDK) │
└────────────────────────────────────┘
┌──────────────────────────────────────┐
│ tokens.py (monthly) │
│ for each site: short -> long-lived │
│ rewrite sites.json in place │
└──────────────────────────────────────┘
- Same script handles many regions in one run; if a site has no
pixel_idorpage_access_token, it's skipped with a warning rather than failing. EVENT_NAME=LeadorPurchaseswitches the script's behavior (withPurchasean extracontact_type LIKE '%orderFull%'filter is applied to the SQL).- Token expiry: long-lived page tokens last ~60 days.
tokens.pyrenews them in place. Add a monthly Cloud Scheduler trigger.
- Create a Facebook App in Meta for Developers, add the Marketing API product, and grab the App ID + App Secret.
- For each Page / Pixel pair:
- Get a short-lived page access token from Graph API Explorer (or your in-house OAuth flow).
- Get the Pixel ID from Meta Events Manager.
- Create a GCP service-account with BigQuery read access; download the JSON key.
- Copy
.env.exampleto.env, fill inGCP_PROJECT_ID,FB_APP_ID,FB_APP_SECRET. - Copy
sites.example.jsontosites.jsonand replace each placeholder with real per-region values. - Install and run:
pip install -r requirements.txt
# Daily: send events
python send_lead_events.py
EVENT_NAME=Purchase python send_lead_events.py
# Monthly: refresh tokens
python tokens.pyIn production: schedule send_lead_events.py daily (or hourly), tokens.py monthly, and pipe both into Cloud Functions / Cloud Run via Cloud Scheduler.
send_lead_events.py— main: BQ contacts → CAPI eventstokens.py— short-lived → long-lived page-token exchange (rewritessites.json)sites.example.json— per-site pixel / token / currency mapping template.env.example— environment templaterequirements.txt— pinned dependencies
- PII handling. Don't put raw emails / phones in the event body — let
UserDatahash them. The SDK does this automatically. - Event deduplication with the browser Pixel. When both browser and server fire for the same purchase, Meta dedupes by
event_id. This pipeline doesn't emitevent_id, so it assumes the browser Pixel either doesn't fire or has been disabled. If both fire, add anevent_idmatching the browser side (e.g., the order number) to avoid double-counting. - No retry / dead-letter queue. A 5xx from the API just logs. For tighter delivery, wrap
EventRequest.execute()in a retry-with-backoff and route persistent failures to acapi_dead_letterBQ table. - Test events. During development, attach a
Test Event Codefrom Events Manager to the EventRequest so events flow into the test view rather than the real Pixel. - Page rating monitoring (a separate module from the original codebase, Telegram-alerting page review changes) is not included here — it's a different topic from CAPI and belongs in its own repo.