Class: Facebook::AdvertiserApiClient

Inherits:
BaseService show all
Defined in:
app/services/facebook/advertiser_api_client.rb

Overview

Service object: advertiser API client.

Constant Summary collapse

API_VERSION =

Marketing Graph API version. Meta versions the URL path; bump this
in lockstep when migrating to a newer API version (versions stay
supported for ~24 months, then auto-upgrade with possible breaking
changes — keep an eye on the changelog).

'v25.0'
BASE_URL =

URL for base.

"https://graph.facebook.com/#{API_VERSION}".freeze
PAGE_LIMIT =

Page size for the campaigns list endpoint. Facebook caps limit at
100 for campaign listings; well above the realistic active-campaign
count for one ad account.

100
CAMPAIGN_FIELDS =

Fields to request on each campaign — keeps the payload focused and
avoids Meta's "Please reduce the amount of data you're asking for"
errors when a large account has many campaigns.

%w[
  id
  name
  status
  effective_status
  objective
  daily_budget
  lifetime_budget
  created_time
  updated_time
].freeze
USER_BATCH_SIZE =

Meta caps a Custom Audience users payload at 10,000 rows per request.

10_000

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#campaigns_with_spend_since(ad_account_id:, token:, since:) ⇒ Set<String>

Campaign ids that recorded any spend on or after since. Drives
FacebookCampaignSyncWorker Source visibility — a campaign that
hasn't spent within the window is mirrored as an archived Source.

Walks the same cursor pagination as #list_campaigns. The insights
endpoint only returns rows for campaigns with delivery in the range,
so campaigns that never spent simply don't appear.

Parameters:

  • ad_account_id (String)

    numeric ad account id (no act_ prefix).

  • token (String)

    Facebook System User access token (bearer).

  • since (Date)

    inclusive start of the spend window.

Returns:

  • (Set<String>)

    campaign ids with spend > 0 in [since, today].

Raises:

  • (RuntimeError)

    on a non-200 response.



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'app/services/facebook/advertiser_api_client.rb', line 113

def campaigns_with_spend_since(ad_account_id:, token:, since:)
  spenders = Set.new
  cursor   = nil

  loop do
    response = connection(token).get("act_#{}/insights") do |req|
      req.params['level']      = 'campaign'
      req.params['fields']     = 'campaign_id,spend'
      req.params['time_range'] = { since: since.iso8601, until: Date.current.iso8601 }.to_json
      req.params['limit']      = PAGE_LIMIT
      req.params['after']      = cursor if cursor
    end

    unless response.status == 200
      body = safe_parse(response.body)
      err  = body.dig('error', 'message') || body['message'] || "HTTP #{response.status}"
      raise "Facebook::AdvertiserApiClient: campaign insights failed (HTTP #{response.status}): #{err}"
    end

    body = safe_parse(response.body)
    Array(body['data']).each do |row|
      spenders << row['campaign_id'].to_s if row['spend'].to_f.positive?
    end

    next_cursor = body.dig('paging', 'cursors', 'after')
    break if next_cursor.blank? || body.dig('paging', 'next').blank?
    break if next_cursor == cursor # defensive — same cursor twice would loop forever

    cursor = next_cursor
  end

  spenders
end

#create_custom_audience(ad_account_id:, name:, token:, description: 'Heatwave-managed audience') ⇒ Hash

Create an empty user-provided custom audience (members land via
#replace_users).

Parameters:

  • ad_account_id (String)

    numeric ad account id (no act_ prefix).

  • name (String)

    the deterministic audience name.

  • token (String)

    Facebook System User access token.

  • description (String) (defaults to: 'Heatwave-managed audience')

    human-readable description (the Audiences Manager subtitle).

Returns:

  • (Hash)

    the created audience ({ 'id' => ... })

Raises:

  • (RuntimeError)

    on a non-200 response.



194
195
196
197
198
199
200
# File 'app/services/facebook/advertiser_api_client.rb', line 194

def create_custom_audience(ad_account_id:, name:, token:, description: 'Heatwave-managed audience')
  response = connection(token).post("act_#{}/customaudiences") do |req|
    req.body = { name:, subtype: 'CUSTOM', customer_file_source: 'USER_PROVIDED_ONLY', description: }.to_json
  end
  raise_audience_error(response, 'create_custom_audience') unless response.status == 200
  safe_parse(response.body)
end

#find_custom_audience(ad_account_id:, name:, token:) ⇒ Hash?

Our custom audience with this exact name, or nil. ponytail: single page
(limit 1000); an account with more custom audiences would need cursor
walking like #list_campaigns.

Parameters:

  • ad_account_id (String)

    numeric ad account id (no act_ prefix).

  • name (String)

    exact custom-audience name to match.

  • token (String)

    Facebook System User access token.

Returns:

  • (Hash, nil)

    the audience ({ 'id' =>, 'name' =>, 'description' => }) or nil

Raises:

  • (RuntimeError)

    on a non-200 response.



158
159
160
161
162
163
164
165
# File 'app/services/facebook/advertiser_api_client.rb', line 158

def find_custom_audience(ad_account_id:, name:, token:)
  response = connection(token).get("act_#{}/customaudiences") do |req|
    req.params['fields'] = 'id,name,description'
    req.params['limit']  = 1000
  end
  raise_audience_error(response, 'find_custom_audience') unless response.status == 200
  Array(safe_parse(response.body)['data']).find { |a| a['name'] == name }
end

#list_campaigns(ad_account_id:, token:) ⇒ Array<Hash>

List all campaigns on the ad account. Walks Meta's paging.cursors.after
cursor pagination until paging.next stops being present and returns
the concatenated list.

Parameters:

  • ad_account_id (String)

    Facebook ad account ID (numeric — the
    act_ prefix is added here, do NOT include it in the parameter).

  • token (String)

    Facebook System User access token (bearer)
    with ads_read scope.

Returns:

  • (Array<Hash>)

    campaign objects per Meta's spec —
    id, name, status ("ACTIVE"|"PAUSED"|"ARCHIVED"|"DELETED"),
    effective_status, objective, daily_budget/lifetime_budget
    (in account currency minor units), created_time/updated_time
    (ISO 8601 strings).

Raises:

  • (RuntimeError)

    on non-200 responses (Sidekiq retry surface).



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'app/services/facebook/advertiser_api_client.rb', line 69

def list_campaigns(ad_account_id:, token:)
  campaigns = []
  cursor    = nil

  loop do
    response = connection(token).get("act_#{}/campaigns") do |req|
      req.params['limit']  = PAGE_LIMIT
      req.params['fields'] = CAMPAIGN_FIELDS.join(',')
      req.params['after']  = cursor if cursor
    end

    unless response.status == 200
      body = safe_parse(response.body)
      err  = body.dig('error', 'message') || body['message'] || "HTTP #{response.status}"
      raise "Facebook::AdvertiserApiClient: list_campaigns failed (HTTP #{response.status}): #{err}"
    end

    body = safe_parse(response.body)
    page = Array(body['data'])
    campaigns.concat(page)

    next_cursor = body.dig('paging', 'cursors', 'after')
    break if next_cursor.blank? || body.dig('paging', 'next').blank?
    break if next_cursor == cursor # defensive — same cursor twice would loop forever

    cursor = next_cursor
  end

  campaigns
end

#replace_users(audience_id:, schema:, data:, token:) ⇒ Integer

Replace ALL members of a custom audience with data (a full snapshot, so
opt-outs simply vanish). Meta's session flow: chunk into ≤10k-row batches
under one session_id, flagging the last. Rows are pre-hashed and conform to
schema.

ponytail: sends session+payload as a JSON body. If Meta's usersreplace
rejects that for the form-encoded payload=/session= param shape, switch
the encoding here — verify on the first LIVE run (dry-run never calls this).

Parameters:

  • audience_id (String)

    the custom audience's Meta id.

  • schema (Array<String>)

    e.g. %w[EMAIL]

  • data (Array<Array<String>>)

    one row per user, values matching schema

  • token (String)

    Facebook System User access token.

Returns:

  • (Integer)

    rows sent

Raises:

  • (RuntimeError)

    on a non-200 response.



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'app/services/facebook/advertiser_api_client.rb', line 217

def replace_users(audience_id:, schema:, data:, token:)
  session_id = SecureRandom.random_number(1 << 31)
  batches    = data.each_slice(USER_BATCH_SIZE).to_a
  batches.each_with_index do |batch, i|
    response = connection(token).post("#{audience_id}/usersreplace") do |req|
      req.body = {
        session: { session_id:, batch_seq: i + 1, last_batch_flag: i == batches.size - 1,
                   estimated_num_total: data.size },
        payload: { schema:, data: batch }
      }.to_json
    end
    raise_audience_error(response, 'replace_users') unless response.status == 200
  end
  data.size
end

#update_custom_audience(audience_id:, token:, description:) ⇒ Hash

Update mutable fields on an existing custom audience — today just the
description (the grey subtitle shown in Audiences Manager; Meta's
"Audience label" column has no Marketing API field). A partial POST to the
audience node; members are untouched (that's #replace_users).

Parameters:

  • audience_id (String)

    the custom audience's Meta id.

  • token (String)

    Facebook System User access token.

  • description (String)

    the new description text.

Returns:

  • (Hash)

    Meta's ack (e.g. { 'success' => true })

Raises:

  • (RuntimeError)

    on a non-200 response.



177
178
179
180
181
182
183
# File 'app/services/facebook/advertiser_api_client.rb', line 177

def update_custom_audience(audience_id:, token:, description:)
  response = connection(token).post(audience_id.to_s) do |req|
    req.body = { description: }.to_json
  end
  raise_audience_error(response, 'update_custom_audience') unless response.status == 200
  safe_parse(response.body)
end