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 anddoc/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)
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
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
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
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
- The canonical secret store is 1Password, vault
IT. Treat
credentials.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, thencredentials: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 acapi_token: TBDplaceholder was sent to
Meta as a literal token (77× "Cannot parse access token").
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
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::AdvertiserApiClient
lists 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
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 to
facebook_conversion_metaon the Order/Opportunity.
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
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
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
- AppSignal — errors surface under
FacebookConversionWorker#perform
andFacebookCampaignSyncWorker#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)
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
| 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
- 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.