Skip to content

Facebook / Meta Ad Management Integration

How Heatwave talks to Facebook/Meta for advertising. Two independent flows, two independent credentials — keep them straight:

FlowDirectionWhat it doesMeta surface
Campaign syncMeta → HeatwaveMirrors ad campaigns into the sources tree nightlyMarketing Graph API
Conversions (CAPI)Heatwave → MetaServer-to-server purchase/lead events for attribution & biddingConversions 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.


All of the following live in the Meta Business Suite under the WarmlyYours.com, Inc. Business Portfolio.

AssetIDNotes
Business Portfolio1926095454286139”WarmlyYours.com, Inc.” — owns the Page, Pixel, ad account, and apps. Formerly called “Business Manager”.
Meta Pixel261770050896557Conversions API target. Browser pixel (fbq/ln) and CAPI both post to this Pixel and dedupe via shared event_id.
Ad Account1926097344285950”WY Ads Account”. In API paths it is prefixed: act_1926097344285950.
App — Marketing API1561011075368245”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.

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”.

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:

  1. 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.
  2. The Pixel and Ad Account must also be assigned to the System User.
  3. Generate the token with scopes ads_read + ads_management.
  4. 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.)

All Facebook secrets live in config/credentials.yml.enc under the top-level :facebook: key (read via Heatwave::Configuration.fetch). Edit with:

Terminal window
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
KeyUsed byPurpose
business_idreferenceBookkeeping; not read at runtime.
marketing_api_application_idreferenceApp ID of “WarmlyYours Marketing API”. Stashed for reference — not read at runtime (System User tokens carry their own auth).
pixel_idFacebook::ApiClientCAPI endpoint path segment.
capi_tokenFacebook::ConversionReporterBearer auth for CAPI event posts.
ad_account_idFacebookCampaignSyncWorkerCampaign list endpoint (act_ prefix added in code).
advertiser_access_tokenFacebookCampaignSyncWorkerBearer auth for the Marketing API.
test_event_codeFacebook::ConversionReporterCanary 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.

  • The canonical secret store is 1Password, vault IT. Treat credentials.yml.enc as 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 — see FacebookCampaignSyncWorker#configured? / Facebook::ConversionReporter#configured?. This was added after AppSignal #5248, where a capi_token: TBD placeholder was sent to Meta as a literal token (77× “Cannot parse access token”).

FileRole
app/workers/facebook_campaign_sync_worker.rbNightly campaign → Source mirror.
app/workers/facebook_conversion_worker.rbThin Sidekiq wrapper around the conversion reporter.
app/services/facebook/advertiser_api_client.rbMarketing Graph API HTTP client (campaign list, cursor pagination).
app/services/facebook/api_client.rbConversions API HTTP client (event posts).
app/services/facebook/conversion_reporter.rbCAPI 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.


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).

  1. perform checks marketing_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.
  2. Under a Source advisory lock, Facebook::AdvertiserApiClient lists every campaign on the ad account (cursor-paginated, 100/page).
  3. Each ACTIVE/PAUSED campaign gets a 1:1 Source row under the “Facebook Ads” sub-parent (campaign_provider: 'facebook', campaign_external_id: <campaign id>).
  4. ARCHIVED/DELETED campaigns, and any campaign that disappears from the API response, get visibility: :archived locally — 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.


FacebookConversionWorker.perform_async(id, type) is enqueued from order/opportunity lifecycle hooks alongside the Google/Pinterest/OpenAI conversion workers:

  • Ordersapp/models/order.rb (after the order is invoiced), next to GoogleOfflineConversionWorker / PinterestConversionWorker / OpenaiAdsConversionWorker.
  • Opportunitiesapp/subscribers/opportunity_followed_up_handler.rb.

The worker delegates to Facebook::ConversionReporter, which:

  1. Bails early unless credentials_present? (pixel_id + capi_token, neither blank nor 'TBD').
  2. Skips if already reported (idempotency via the facebook_conversion_meta JSONB column) or if a sibling opportunity was already reported (the rep-opp / online-opp convergence dedup — mirrors Google/Pinterest/OpenAI; prevents ~9% double-counting).
  3. Builds a Meta CAPI event (Purchase for orders, Lead for opps), SHA-256-hashing all PII via Tracking::Hashing.
  4. Reuses the shared tracking_event_id as Meta’s event_id so the browser pixel (fbq/ln, see app/javascript/services/analytics.js) and the CAPI call dedupe instead of double-counting.
  5. POSTs via Facebook::ApiClient and persists the outcome to facebook_conversion_meta on the Order/Opportunity.

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.


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):

MigrationEffect
20260515110900_add_facebook_campaign_id_to_sources(superseded by the typed campaign_* triple)
20260515110901_add_facebook_conversion_meta_to_orders_and_opportunitiesfacebook_conversion_meta JSONB columns.
20260515110902_seed_facebook_ads_and_organic_sub_sourcesCreates the “Facebook Ads” / “Facebook Organic” sub-parents under 1210.
20260515111003_migrate_facebook_parent_attribution_to_organicMoves 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, 5464Facebook Ads; 2508Facebook Organic.


/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_id still 'TBD').
  • Not configured — none present.

Pinterest is special-cased: it shows its actual OAuth connection state.

  • AppSignal — errors surface under FacebookConversionWorker#perform and FacebookCampaignSyncWorker#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_visit firing a state event), #5248 (capi_token: TBD placeholder sent as a literal token).

Confirm the Marketing API token works without touching the DB:

Terminal window
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
'

SymptomLikely causeFix
”Invalid OAuth access token - Cannot parse access token”capi_token/advertiser_access_token is a placeholder, truncated, or malformedRe-copy the System User token from 1Password into credentials:edit.
”No permissions available” when generating a System User tokenThe Marketing API app isn’t assigned to the System UserBusiness Settings → System Users → Assigned Assets → add the app.
Sync worker logs “credentials not configured” and skipsadvertiser_access_token or ad_account_id blank/'TBD'Populate the :facebook: block.
Token works, then breaks ~60 days laterToken generated with a 60-day expiryRegenerate with expiration “Never”.
”Facebook Ads parent source not found”Seed migration 20260515110902 hasn’t runRun migrations before enabling the worker in an environment.

  • 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.