Class: MicrosoftAds::CampaignRestClient
- Inherits:
-
Object
- Object
- MicrosoftAds::CampaignRestClient
- 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 /AdGroupsrequires a top-levelCampaignIdalongside the array.PUT /Campaignsrequires a top-levelAccountId.POST /EntityNegativeKeywordsappends — 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
-
#ad_groups(campaign_id:) ⇒ Array<Hash>
Ad groups with their CPC bids.
-
#add_negative_keywords(campaign_id:, keywords:, match_type: 'Phrase') ⇒ Object
Append campaign-level negative keywords.
-
#campaigns ⇒ Array<Hash>
Campaigns with budget, status and bid strategy.
-
#initialize(developer_token:, access_token:, customer_id:, account_id:) ⇒ CampaignRestClient
constructor
A new instance of CampaignRestClient.
-
#negative_keywords(campaign_id:) ⇒ Array<Hash>
Campaign-level negative keywords.
-
#update_ad_group(campaign_id:, ad_group_id:, cpc_bid: nil, status: nil) ⇒ Object
Update an ad group's CPC bid and/or status.
-
#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.
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 = account_id end |
Instance Method Details
#ad_groups(campaign_id:) ⇒ Array<Hash>
Returns 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.
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.}" } 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.}", requested: words_to_add, already_present:, match_type: } end |
#campaigns ⇒ Array<Hash>
Returns 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.
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, (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. } 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.
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. } end |