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)

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:

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

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

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

  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.


Conversions (CAPI) flow

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.

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, 5464Facebook Ads; 2508Facebook 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_id still 'TBD').
  • Not configured — none present.

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

Monitoring

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

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.