Facebook / Meta Ad Management Integration
How Heatwave talks to Facebook/Meta for advertising. Two independent flows, two independent credentials — keep them straight:
| Flow | Direction | What it does | Meta surface |
|---|---|---|---|
| Campaign sync | Meta → Heatwave | Mirrors ad campaigns into the sources tree nightly | Marketing Graph API |
| Conversions (CAPI) | Heatwave → Meta | Server-to-server purchase/lead events for attribution & bidding | Conversions API (Pixel) |
This is the direct parallel of the Google Ads, ChatGPT/OpenAI Ads, and Pinterest Ads integrations. When in doubt, the Pinterest integration is the closest mirror.
Not covered here: “Log in with Facebook” (social login). That is a separate Meta app with its own credentials — see Credentials and
doc/development/SOCIAL_LOGIN_SETUP.md.Privacy / data-deletion requests for either app (driven by Meta Platform Term 4.b, GDPR, or CCPA) follow the team runbook at
doc/operations/PRIVACY_REQUESTS_RUNBOOK.md.
Meta-side setup (what was provisioned)
Section titled “Meta-side setup (what was provisioned)”All of the following live in the Meta Business Suite under the WarmlyYours.com, Inc. Business Portfolio.
| Asset | ID | Notes |
|---|---|---|
| Business Portfolio | 1926095454286139 | ”WarmlyYours.com, Inc.” — owns the Page, Pixel, ad account, and apps. Formerly called “Business Manager”. |
| Meta Pixel | 261770050896557 | Conversions API target. Browser pixel (fbq/ln) and CAPI both post to this Pixel and dedupe via shared event_id. |
| Ad Account | 1926097344285950 | ”WY Ads Account”. In API paths it is prefixed: act_1926097344285950. |
| App — Marketing API | 1561011075368245 | ”WarmlyYours Marketing API” — a Business-type app. Hosts the System User token used for campaign sync + CAPI. |
| App — Facebook Login | (see omniauth creds) | The pre-existing Consumer-type app behind “Log in with Facebook”. Unrelated to ads; do not reuse it for Marketing/CAPI. |
Why two apps, not one
Section titled “Why two apps, not one”Marketing/CAPI and social login are kept in separate Meta apps on purpose:
- App-type mismatch — System User tokens and the Marketing API require a Business-type app; the Login app is Consumer-type.
- Blast radius — a policy strike or review hold on one app does not freeze the other.
- Least privilege — the Login app never needs
ads_*scopes.
Do not consolidate them into one “mega app”.
System User token
Section titled “System User token”The campaign-sync and CAPI calls authenticate as a System User (a non-human Business account), not as a person. Provisioned via Business Settings → System Users:
- The “WarmlyYours Marketing API” app must be assigned to the System User (Business Settings → System Users → Assigned Assets → Apps). Skipping this yields “No permissions available” at token generation.
- The Pixel and Ad Account must also be assigned to the System User.
- Generate the token with scopes
ads_read+ads_management. - Token expiration: set to “Never”. Meta recommends 60 days, but
Heatwave has no auto-refresh worker for this token — a 60-day token
would silently break the nightly sync and CAPI two months in. (This
differs from Pinterest, whose OAuth tokens are auto-refreshed by
PinterestTokenRefreshWorker.)
Credentials
Section titled “Credentials”All Facebook secrets live in config/credentials.yml.enc under the
top-level :facebook: key (read via Heatwave::Configuration.fetch).
Edit with:
mise exec -- bin/rails credentials:edit:facebook: :business_id: 1926095454286139 # Business Portfolio ID :marketing_api_application_id: 1561011075368245 # Marketing API app ID :pixel_id: 261770050896557 # CAPI target Pixel :capi_token: EAB... # CAPI System User token :ad_account_id: 1926097344285950 # numeric — NO act_ prefix :advertiser_access_token: EAA... # Marketing API System User token # :test_event_code: TEST12345 # optional — canary only, see below| Key | Used by | Purpose |
|---|---|---|
business_id | reference | Bookkeeping; not read at runtime. |
marketing_api_application_id | reference | App ID of “WarmlyYours Marketing API”. Stashed for reference — not read at runtime (System User tokens carry their own auth). |
pixel_id | Facebook::ApiClient | CAPI endpoint path segment. |
capi_token | Facebook::ConversionReporter | Bearer auth for CAPI event posts. |
ad_account_id | FacebookCampaignSyncWorker | Campaign list endpoint (act_ prefix added in code). |
advertiser_access_token | FacebookCampaignSyncWorker | Bearer auth for the Marketing API. |
test_event_code | Facebook::ConversionReporter | Canary toggle — see Conversions flow. |
capi_token and advertiser_access_token may be the same System User
token if it carries both scopes, but they are stored as separate keys so
they can be rotated independently.
Social login (separate app) lives under a different key —
:omniauth → :facebook_id / :facebook_secret. Do not confuse the two.
Master copy & rotation
Section titled “Master copy & rotation”- The canonical secret store is 1Password, vault
IT. Treatcredentials.yml.encas a deployment artifact, not the source of truth — if it and 1Password disagree, 1Password wins. - Never paste tokens into chat, PRs, or commit messages. Put them in 1Password and reference them.
- To rotate: regenerate in Meta Business Settings → System Users →
Generate Token, update 1Password, then
credentials:edit. 'TBD'is a recognized placeholder. The campaign sync worker and the CAPI reporter both treat a'TBD'value as absent (graceful skip) rather than attempting a doomed API call — seeFacebookCampaignSyncWorker#configured?/Facebook::ConversionReporter#configured?. This was added after AppSignal #5248, where acapi_token: TBDplaceholder was sent to Meta as a literal token (77× “Cannot parse access token”).
Code map
Section titled “Code map”| File | Role |
|---|---|
app/workers/facebook_campaign_sync_worker.rb | Nightly campaign → Source mirror. |
app/workers/facebook_conversion_worker.rb | Thin Sidekiq wrapper around the conversion reporter. |
app/services/facebook/advertiser_api_client.rb | Marketing Graph API HTTP client (campaign list, cursor pagination). |
app/services/facebook/api_client.rb | Conversions API HTTP client (event posts). |
app/services/facebook/conversion_reporter.rb | CAPI business logic — builds events, dedup, persists result meta. |
app/controllers/admin/campaign_integrations_controller.rb | /admin/campaign_integrations — status + on-demand sync. |
db/migrate/2026051511090* | Schema + Source-tree seed migrations (see below). |
Graph API version is pinned in API_VERSION (currently v25.0) in both
client classes and in the omniauth :facebook client_options
override in config/initializers/130_devise.rb (the upstream
omniauth-facebook v10.0.0 gem still hardcodes v19.0 as its default).
Bump all three in lockstep when migrating. Meta supports a version for
~24 months, then auto-upgrades with possible breaking changes; watch the
changelog.
Campaign sync flow
Section titled “Campaign sync flow”FacebookCampaignSyncWorker runs nightly (Sidekiq cron
45 1 * * * America/Chicago — 01:45, staggered after Google 01:00,
OpenAI 01:15, Pinterest 01:30 so the four API ceilings don’t compete).
performchecksmarketing_api_credentials_present?(advertiser_access_token+ad_account_id, neither blank nor'TBD'). If not configured it logs a warning and returns — no raise, so an in-progress setup never floods AppSignal.- Under a
Sourceadvisory lock,Facebook::AdvertiserApiClientlists every campaign on the ad account (cursor-paginated, 100/page). - Each
ACTIVE/PAUSEDcampaign gets a 1:1Sourcerow under the “Facebook Ads” sub-parent (campaign_provider: 'facebook',campaign_external_id: <campaign id>). ARCHIVED/DELETEDcampaigns, and any campaign that disappears from the API response, getvisibility: :archivedlocally — rows are never destroyed, so historical visit/order attribution survives.
On-demand: /admin/campaign_integrations → “Sync Campaigns” enqueues the same worker.
Volume note: the WY ad account currently exposes ~320+ campaigns (many are individual boosted posts). The first sync will create a correspondingly large “Facebook Ads” sub-tree. This is expected — the mirror is 1:1.
Conversions (CAPI) flow
Section titled “Conversions (CAPI) flow”FacebookConversionWorker.perform_async(id, type) is enqueued from
order/opportunity lifecycle hooks alongside the Google/Pinterest/OpenAI
conversion workers:
- Orders —
app/models/order.rb(after the order is invoiced), next toGoogleOfflineConversionWorker/PinterestConversionWorker/OpenaiAdsConversionWorker. - Opportunities —
app/subscribers/opportunity_followed_up_handler.rb.
The worker delegates to Facebook::ConversionReporter, which:
- Bails early unless
credentials_present?(pixel_id+capi_token, neither blank nor'TBD'). - Skips if already reported (idempotency via the
facebook_conversion_metaJSONB column) or if a sibling opportunity was already reported (the rep-opp / online-opp convergence dedup — mirrors Google/Pinterest/OpenAI; prevents ~9% double-counting). - Builds a Meta CAPI event (
Purchasefor orders,Leadfor opps), SHA-256-hashing all PII viaTracking::Hashing. - Reuses the shared
tracking_event_idas Meta’sevent_idso the browser pixel (fbq/ln, seeapp/javascript/services/analytics.js) and the CAPI call dedupe instead of double-counting. - POSTs via
Facebook::ApiClientand persists the outcome tofacebook_conversion_metaon the Order/Opportunity.
Canary rollout — test_event_code
Section titled “Canary rollout — test_event_code”Set facebook.test_event_code to a Meta-issued code (Events Manager →
Test Events) to route events to the Test tab without counting them
toward attribution. Use it to confirm the event shape is accepted, then
clear the key for real sends. While set, the reporter stamps
attempted_at but not reported_at.
Source tree
Section titled “Source tree”The Facebook split mirrors the ChatGPT and Pinterest splits — the parent becomes a pure aggregator, paid vs. organic live underneath:
Social Media > Facebook (Source 1210, parent — aggregator only)├── Facebook Ads (sub-parent — campaign sync writes here)│ └── <one Source per campaign> (campaign_provider: 'facebook')└── Facebook Organic (sub-parent — non-paid facebook.com referrals)Seeded / migrated by (May 2026):
| Migration | Effect |
|---|---|
20260515110900_add_facebook_campaign_id_to_sources | (superseded by the typed campaign_* triple) |
20260515110901_add_facebook_conversion_meta_to_orders_and_opportunities | facebook_conversion_meta JSONB columns. |
20260515110902_seed_facebook_ads_and_organic_sub_sources | Creates the “Facebook Ads” / “Facebook Organic” sub-parents under 1210. |
20260515111003_migrate_facebook_parent_attribution_to_organic | Moves historical facebook.com referrer/UTM attribution off the parent onto “Facebook Organic” (one-way — not reversible). |
Pre-existing campaign children of 1210 were relocated: campaigns
5448, 2463, 5464 → Facebook Ads; 2508 → Facebook Organic.
Operations
Section titled “Operations”Admin screen
Section titled “Admin screen”/admin/campaign_integrations (admin-only) shows every ad platform with
a credential-readiness badge and an on-demand “Sync Campaigns”
button. The badge is config-presence only (no live API call):
- Credentials configured — every required key present.
- Partially configured — some keys present (e.g. token set,
ad_account_idstill'TBD'). - Not configured — none present.
Pinterest is special-cased: it shows its actual OAuth connection state.
Monitoring
Section titled “Monitoring”- AppSignal — errors surface under
FacebookConversionWorker#performandFacebookCampaignSyncWorker#perform. Both workers degrade gracefully on missing credentials, so a raised error means a real API/logic failure, not a setup gap. - Past incidents: #5235 (CAPI degradation), #5246 (
find_visitfiring a state event), #5248 (capi_token: TBDplaceholder sent as a literal token).
Health check (manual)
Section titled “Health check (manual)”Confirm the Marketing API token works without touching the DB:
mise exec -- bin/rails runner 'acct = Heatwave::Configuration.fetch(:facebook, :ad_account_id)token = Heatwave::Configuration.fetch(:facebook, :advertiser_access_token)puts Facebook::AdvertiserApiClient.new.list_campaigns(ad_account_id: acct, token: token).size'Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Fix |
|---|---|---|
| ”Invalid OAuth access token - Cannot parse access token” | capi_token/advertiser_access_token is a placeholder, truncated, or malformed | Re-copy the System User token from 1Password into credentials:edit. |
| ”No permissions available” when generating a System User token | The Marketing API app isn’t assigned to the System User | Business Settings → System Users → Assigned Assets → add the app. |
| Sync worker logs “credentials not configured” and skips | advertiser_access_token or ad_account_id blank/'TBD' | Populate the :facebook: block. |
| Token works, then breaks ~60 days later | Token generated with a 60-day expiry | Regenerate with expiration “Never”. |
| ”Facebook Ads parent source not found” | Seed migration 20260515110902 hasn’t run | Run migrations before enabling the worker in an environment. |
Outstanding
Section titled “Outstanding”- Facebook Login app policy violations — the (separate) social-login app has 2 open Meta policy violations due 2026-06-03. Tracked independently of the ad integration; does not affect campaign sync / CAPI.