Class: MicrosoftAds::CampaignRestClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/microsoft_ads/campaign_rest_client.rb

Overview

REST client for the Microsoft Advertising (Bing Ads) Campaign Management
v13 entity surface — campaign/ad-group reads plus the write levers an
ads operator actually pulls: campaign budget/status/MaxCpc, ad-group
bid/status, and campaign negative keywords. Backs
Assistant::MicrosoftAdsToolBuilder.

Not to be confused with CampaignManagementClient, the
older hand-rolled SOAP client that serves the one read op
MicrosoftAdsCampaignSyncWorker needs. Net-new code is REST-first:
Microsoft deprecates SOAP on 2027-01-31 and asks integrators to migrate
before 2026-10-01.

Writes are typed, never a passthrough

Every mutating method takes named, validated scalars — there is deliberately
no "execute arbitrary operation" method. The caller is an LLM tool, so the
values arriving here are untrusted input against a live ad account that
spends real money: budgets and bids are range-checked before they leave.

REST quirks worth knowing (each one cost a 400 to discover)

  • PUT /AdGroups requires a top-level CampaignId alongside the array.
  • PUT /Campaigns requires a top-level AccountId.
  • POST /EntityNegativeKeywords appends — it does not replace, so
    pre-existing (e.g. agency-managed) negatives survive. Max one
    EntityNegativeKeyword per call.
  • Responses are HTTP 200 with per-item failures in PartialErrors /
    NestedPartialErrors, so a 200 is not by itself success.

rubocop:disable Metrics/ClassLength -- cohesive v13 entity client; write verification and partial-error parsing share transport internals

Defined Under Namespace

Classes: NegativeKeywordReadError, NegativeKeywordWriteError, ValidationError

Constant Summary collapse

BASE_URL =
'https://campaign.api.bingads.microsoft.com/CampaignManagement/v13'
MAX_DAILY_BUDGET =

Guard rails on model-supplied money. These are not Microsoft limits — they
are our blast-radius ceiling, sized well above WarmlyYours' current spend
($40/day is the largest live budget) and well below "incident".
ponytail: flat ceilings, not per-campaign policy — swap for a config/role
lookup if different campaigns ever need genuinely different headroom.

500.0
MAX_CPC_BID =
50.0
MONEY_VERIFICATION_TOLERANCE =
0.005
CAMPAIGN_TYPES =
%w[App Audience DynamicSearchAds Hotel PerformanceMax Search Shopping].freeze
CAMPAIGN_STATUS_EQUIVALENTS =
{ 'Active' => %w[Active BudgetPaused], 'Paused' => %w[Paused BudgetAndManualPaused] }.freeze
AUTOMATED_BID_STRATEGIES =

Under automated bidding Microsoft owns ad-group and keyword bids. The API
accepts CpcBid updates without an error but does not use them. MaxClicks
exposes its effective ceiling at the campaign BiddingScheme instead.

%w[
  MaxClicks
  MaxConversions
  MaxConversionValue
  TargetCpa
  TargetRoas
  TargetImpressionShare
].freeze
CAMPAIGN_MAX_CPC_STRATEGIES =
%w[MaxClicks].freeze
SETTABLE_STATUSES =

Statuses a tool may set. Narrower than the v13 enum on purpose: of
Active / Paused / BudgetPaused / BudgetAndManualPaused / Suspended /
Expired / Deleted, only Active and Paused are advertiser-settable.
BudgetPaused and BudgetAndManualPaused are set by Microsoft when a budget
is exhausted, Suspended is a Microsoft policy action, Expired is derived
from the end date, and Deleted is internal-only. Writing any of them here
would be rejected or meaningless.

%w[Active Paused].freeze
MAX_NEGATIVE_KEYWORD_LENGTH =

Microsoft caps a negative keyword at 100 characters (spaces included) at
every level. Checked locally so an over-long term fails with a usable
message instead of an opaque PartialError.

100
MAX_NEGATIVE_KEYWORD_ADDITIONS =

Local blast-radius ceiling, deliberately below Microsoft's 20,000-keyword
request limit. Counted after case-insensitive dedupe and the existing-term
read, so this limits records actually added rather than raw input size.

1_000
NEGATIVE_KEYWORD_MATCH_TYPES =
%w[Phrase Exact].freeze

Instance Method Summary collapse

Constructor Details

#initialize(developer_token:, access_token:, customer_id:, account_id:) ⇒ CampaignRestClient

Returns a new instance of CampaignRestClient.



86
87
88
89
90
91
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 86

def initialize(developer_token:, access_token:, customer_id:, account_id:)
  @developer_token = developer_token
  @access_token    = access_token
  @customer_id     = customer_id
  @account_id      = 
end

Instance Method Details

#ad_groups(campaign_id:) ⇒ Array<Hash>

Returns ad groups with their CPC bids.

Parameters:

  • campaign_id (Integer)

Returns:

  • (Array<Hash>)

    ad groups with their CPC bids



103
104
105
106
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 103

def ad_groups(campaign_id:)
  body = post_json('AdGroups/QueryByCampaignId', { CampaignId: campaign_id.to_i })
  Array(body['AdGroups']).compact.map { |g| present_ad_group(g) }
end

#add_negative_keywords(campaign_id:, keywords:, match_type: 'Phrase') ⇒ Object

Append campaign-level negative keywords. Existing negatives are preserved
(the operation appends), which matters because the account carries
agency-managed negatives we must not clobber.

Parameters:

  • keywords (Array<String>)
  • match_type (String) (defaults to: 'Phrase')

    "Phrase" or "Exact"



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 186

def add_negative_keywords(campaign_id:, keywords:, match_type: 'Phrase')
  words = Array(keywords).map { |k| k.to_s.strip }.reject(&:empty?).uniq(&:downcase)
  return { status: :failed, error: 'No keywords given.' } if words.empty?

  match_type = match_type.to_s
  return { status: :failed, error: "match_type must be Phrase or Exact, got #{match_type}" } unless NEGATIVE_KEYWORD_MATCH_TYPES.include?(match_type)

  too_long = words.select { |w| w.length > MAX_NEGATIVE_KEYWORD_LENGTH }
  if too_long.any?
    return { status: :failed,
             error: "Negative keywords must be #{MAX_NEGATIVE_KEYWORD_LENGTH} characters or fewer: " \
                    "#{too_long.map { |w| w.truncate(40) }.join(', ')}" }
  end

  existing = read_existing_negative_keyword_keys!(campaign_id:)

  already_present, words_to_add = words.partition do |word|
    existing.key?([word.downcase, match_type])
  end
  return negative_keyword_result(added: [], already_present:, match_type:) if words_to_add.empty?

  if words_to_add.size > MAX_NEGATIVE_KEYWORD_ADDITIONS
    return {
      status: :failed,
      error: "The request has #{words_to_add.size} actual additions; narrow it to " \
             "#{MAX_NEGATIVE_KEYWORD_ADDITIONS.to_fs(:delimited)} or fewer. No write was attempted.",
      actual_additions: words_to_add.size,
      already_present:
    }
  end

  body = append_negative_keywords!(campaign_id:, words: words_to_add, match_type:)

  negative_keyword_result(body:, requested: words_to_add, already_present:, match_type:)
rescue NegativeKeywordReadError => e
  {
    status: :failed,
    error: "Existing negative keywords could not be read; no write was attempted: #{e.message}"
  }
rescue NegativeKeywordWriteError => e
  {
    status: :failed,
    error: "The Microsoft keyword write could not be confirmed. Re-read the current keywords, then " \
           "request a new approval code before retrying; the prior approval was consumed: #{e.message}",
    requested: words_to_add,
    already_present:,
    match_type:
  }
end

#campaignsArray<Hash>

Returns campaigns with budget, status and bid strategy.

Returns:

  • (Array<Hash>)

    campaigns with budget, status and bid strategy



96
97
98
99
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 96

def campaigns
  body = post_json('Campaigns/QueryByAccountId', { AccountId: @account_id.to_i, CampaignType: 'Search' })
  Array(body['Campaigns']).compact.map { |c| present_campaign(c) }
end

#negative_keywords(campaign_id:) ⇒ Array<Hash>

Returns campaign-level negative keywords.

Parameters:

  • campaign_id (Integer)

Returns:

  • (Array<Hash>)

    campaign-level negative keywords

Raises:

  • (Faraday::Error)


110
111
112
113
114
115
116
117
118
119
120
121
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 110

def negative_keywords(campaign_id:)
  body = post_json('NegativeKeywords/QueryByEntityIds', {
                     EntityIds: [campaign_id.to_i],
                     EntityType: 'Campaign'
                   })
  errors = Array(body['PartialErrors']).compact
  raise Faraday::Error, error_messages(errors) if errors.any?

  entities = Array(body['EntityNegativeKeywords']).compact
  entities.flat_map { |entity| Array(entity['NegativeKeywords']).compact }
          .map { |keyword| present_negative_keyword(keyword) }
end

#update_ad_group(campaign_id:, ad_group_id:, cpc_bid: nil, status: nil) ⇒ Object

Update an ad group's CPC bid and/or status.

PUT /AdGroups needs the parent CampaignId at the top level or Microsoft
rejects the whole call with CampaignServiceInvalidAdGroupId.



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 158

def update_ad_group(campaign_id:, ad_group_id:, cpc_bid: nil, status: nil)
  changes = {}
  unless cpc_bid.nil?
    amount = validate_money!(cpc_bid, MAX_CPC_BID, 'cpc_bid')
    reject_ignored_ad_group_cpc!(campaign_id:)
    changes[:CpcBid] = { Amount: amount }
  end
  changes[:Status] = validate_status!(status) unless status.nil?
  return { status: :failed, error: 'Nothing to update: pass cpc_bid and/or status.' } if changes.empty?

  body = put_json('AdGroups', {
                    CampaignId: campaign_id.to_i,
                    AdGroups:   [{ Id: ad_group_id.to_i }.merge(changes)]
                  })
  result = partial_errors_result(body, changed: changes)
  return result if result[:status] == :failed

  verify_ad_group_update(campaign_id:, ad_group_id:, changes:)
rescue ValidationError, Faraday::Error => e
  { status: :failed, error: e.message }
end

#update_campaign(campaign_id:, daily_budget: nil, status: nil, max_cpc: nil) ⇒ Hash

Update a campaign's daily budget, status, and/or MaxClicks CPC ceiling.

Parameters:

  • campaign_id (Integer)
  • daily_budget (Numeric, nil) (defaults to: nil)
  • status (String, nil) (defaults to: nil)

    "Active" or "Paused"

  • max_cpc (Numeric, nil) (defaults to: nil)

    campaign-level MaxCpc for MaxClicks

Returns:

  • (Hash)

    { status: :ok, changed: {...} } or { status: :failed, error: }



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'app/services/microsoft_ads/campaign_rest_client.rb', line 132

def update_campaign(campaign_id:, daily_budget: nil, status: nil, max_cpc: nil)
  changes = {}
  changes[:DailyBudget] = validate_money!(daily_budget, MAX_DAILY_BUDGET, 'daily_budget') unless daily_budget.nil?
  changes[:Status]      = validate_status!(status) unless status.nil?
  changes[:BiddingScheme] = max_cpc_change(campaign_id:, max_cpc:) unless max_cpc.nil?
  if changes.empty?
    return { status: :failed,
             error: 'Nothing to update: pass daily_budget, status, and/or max_cpc.' }
  end

  body = put_json('Campaigns', {
                    AccountId: @account_id.to_i,
                    Campaigns: [{ Id: campaign_id.to_i }.merge(changes)]
                  })
  result = partial_errors_result(body, changed: changes)
  return result if result[:status] == :failed

  verify_campaign_update(campaign_id:, changes:)
rescue ValidationError, Faraday::Error => e
  { status: :failed, error: e.message }
end