Skip to content

Microsoft Advertising: reads, reports and campaign management - #130

Merged
kLOsk merged 6 commits into
kLOsk:mainfrom
illia-sapryga:feat/microsoft-ads
Nov 9, 2026
Merged

kLOsk merged 6 commits into
kLOsk:mainfrom
illia-sapryga:feat/microsoft-ads

Conversation

@illia-sapryga

@illia-sapryga illia-sapryga commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

This adds Microsoft Advertising (Bing, Yahoo, AOL and DuckDuckGo search ads) as a new toolset, microsoft_ads, through the REST API v13. It uses the same draft → preview → confirm_and_apply gate as Google Ads and Reddit.

Builds on #134 (the shared write-gate change), not on #127, so it can land on its own. The commits are the Microsoft module rebased onto upstream 0.40.1 and #134, then the review fixes (4651a2c), the split of manage.py into per-area handlers (f2a4ca4), and one more fix from the live run (8fb66c1).

Reads: accounts, campaigns (budget, bidding, languages, locations and location intent), ad groups, keywords, ads, performance with impression share, search terms, location performance, a conversion-setup audit (UET tags, goals, campaign goals), negatives and extensions. Rows carry the status of every parent level.

Writes:

  • Budget, bid strategy, tracking template and URL suffix.
  • Targeting: included and excluded locations, location intent, ad schedule.
  • New Search campaigns (created paused, with languages and locations required) and ad groups.
  • Keywords and keyword bids.
  • Direct negatives, and shared negative lists with their attachments.
  • RSAs (created paused) and RSA edits.
  • Sitelinks, callouts, structured snippets and call extensions, on a campaign or the account.
  • Image extensions on a campaign, an ad group or the account (see Image extensions below).
  • Pause, enable and remove.

Sign-in. You need a developer token and an Entra app registration (public client flows, delegated msads.manage, redirect http://localhost/callback; Entra ignores the port for localhost, and the listener binds a free one). Then adloop microsoft-login runs the authorization code flow with PKCE in the browser. The device code flow stays as --device-code, but directories with security defaults block it (sign-in error 530035), so the browser is the default. The new oauth_redirect.py is the one-shot localhost listener. It binds loopback on a free port, checks the path, keeps listening until a request carries the right state, and gives every connection an absolute deadline, so a client that trickles its request can't hold the sign-in open. CredentialsProvider gains microsoft_ads_credentials(). A hosted provider without it doesn't get the tools, and health_check reports not_configured.

The gate. Microsoft has no validate-only mode, so the dry run re-reads the target and refuses any field the plan changes that was edited since the preview. That includes the target CPA and the ad schedule's time zone mode, which Microsoft returns only when asked through ReturnAdditionalFields. The dry run also re-checks:

  • max_daily_budget, explaining Microsoft's 30.4 × daily monthly spend
  • max_bid_increase_pct on ad group and keyword bids, including a keyword that falls back to the ad group bid
  • broad match only with conversion-based bidding (Maximize Conversions, Maximize Conversion Value, target CPA, target ROAS): refused otherwise for new keywords and ad groups, and a switch to any other strategy is refused while active broad keywords exist
  • a conversion-based strategy needs an active conversion goal (refused at the draft and the dry run, naming Maximize Clicks, Enhanced CPC and Manual CPC as the alternatives)

The real apply runs the same checks first.

Some applies take several requests, like a campaign and then its targeting, or a list and then its attachments. If a later step fails, or Microsoft accepts a batch only in part, the apply returns PARTIAL_APPLY with the IDs that exist. If a create's response never arrives, it returns UNKNOWN_OUTCOME. Both retire the plan, because these creates have no idempotency and a replay would create them twice. This is safety/outcomes.PartialApplyError from #134.

Schedules. Microsoft can't edit a DayTime window in place, windows may not overlap, and a day holds at most seven. So a replacement keeps unchanged windows, adds new ones first only where they overlap nothing and the day has room, deletes the old ones, then adds the rest. When that briefly leaves the campaign with no schedule, the preview says so. If the last step then fails, the error names the windows that stay, or says the campaign is unscheduled when none do. The preview also says whether the hours follow the account's time zone or each searcher's.

Smaller things worth knowing:

  • Time zones. Account time zones map to IANA from Microsoft's two tables (the scripts mapping and the time-zone guide spell several zones differently; both spellings are accepted). An unknown zone is named in insights instead of silently using UTC.
  • Conversions. Reports use the ConversionsQualified columns, since the old Conversions columns are deprecated and read 0 in keyword and search-query reports.
  • Secrets in errors. API errors have the access, refresh and developer tokens and the client secret scrubbed.
  • New keywords and ad groups are created Active, as draft_keywords does for Google Ads. The preview shows status_on_create and the parent statuses, and warns when the keywords serve as soon as they're applied.

Payloads: request bodies, snapshots and ids live in ChangePlan.apply_only_payload. changes holds names and before → after, which the dialog and the card show as they are.

Cloud contract: TOOLSETS gains microsoft_ads, so config/toolsets.php and the runtime filter need the slug.

Review fixes are listed in the review reply below. The live transcript of every write is linked there.

Image extensions. draft_microsoft_image_extension adds images to search ads on a campaign, an ad group or the account, one image extension per image. Images come from local files, public URLs or AdLoop uploads, through the same loading as draft_image_assets, and each is pinned by checksum.

  • Draft checks: JPEG, PNG or GIF, not animated (animated GIFs and APNGs are detected); 1.91:1 within 1%; at least 703×368 and at most 2592×2048; at most 5 MB; alt text required (≤35), display text ≤35, description ≤100; at most 10 images per draft. More than 25 image extensions on one entity is refused, and more than 6 gets a warning.
  • Dry run: re-checks the target, fetches every image again and refuses one that changed, recounts the linked image extensions and re-checks the final URL.
  • Apply: uploads the media (POST Media, as GenericImage), creates the extensions, then links them. A failure after the upload returns PARTIAL_APPLY with the created media ids (and extension ids, if any) and retires the plan.
  • Reads: get_microsoft_ad_extensions now lists image extensions and ad-group-level links, with alt text, display text, layouts, media ids and a remove_id. Each image row also has images: media type, size and a url that opens the picture, so a photo can be checked before its link is removed. The association query leaves Images, DisplayText and Layouts out, so the read asks for them through AdExtensions/QueryByIds with ReturnAdditionalFields and then reads MediaMetaData/QueryByIds. If that second pass fails, the rows stay and an insight says the details are missing. remove_microsoft_entity accepts ad-group remove ids, and unlinking keeps the extension and its images in the account library.
  • Where the docs disagree: the Image reference caps the upload data at 102,400 base64 characters (about 75 KB), while the ResponsiveAd remarks allow 5 MB for the same media library. Live, JPEGs of 125–300 KB uploaded without trouble, so the draft enforces the 5 MB limit only. The per-entity limit is 6 in one reference page and 25 in another. The API pages don't name the image sub-type an image extension takes; LandscapeImageMedia follows Microsoft's .NET sample and bulk example. Image extensions can also carry search ads into native placements (Microsoft's audience ads guide), which the rules doc points out.

What it does not do:

  • Shopping, Audience or Performance Max management, audience targeting, or ad customizers.
  • Create conversion goals or UET tags. The setup audit reads them.
  • Edit a shared budget. A campaign on a shared budget is refused, with the reason.
  • Add an adloop init step. It's configured by hand plus adloop microsoft-login, as the README says.

Tested with the full suite plus tests/test_microsoft_ads.py, tests/test_microsoft_ads_manage.py and tests/test_oauth_redirect.py. The management tests drive the real gate against a stateful fake of the Campaign Management API. The fake also behaves like Microsoft in a few places: optional fields come back only when requested, overlapping DayTime windows are refused, and batches can partly fail or lose their response. The listener tests fail on the old listener. I've run the reads, report tools, drafts and dry runs against a live ad account. Real applies have since gone to that account too: new campaigns with their ad groups and keywords, RSAs, shared negative lists and their attachments, sitelinks, callouts and a structured snippet, a bid strategy change, ad group bids, and pause, enable and remove. That live use is where the in-store pilot error (28ed3d0) turned up. The image extension tool has since run live too: two campaigns got six images each, and the fixed read shows the picture, size, display text and layouts for every image extension on the account.

@kLOsk

kLOsk commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Thanks, Illia. Microsoft Advertising is the platform we had planned to build next, so we're glad to take this into the open-source package. We'd like to bring it to AdLoop Cloud later; at that point we'll take over maintenance of the module and may restructure it on our side, with credit to you.

Please rebase this onto the shared-changes PR (see my comment on #127) instead of #127, so Microsoft can land independently of ChatGPT Ads.

Must fix

  • Broad match: _SMART = {"MaxClicks", "MaxConversions"} (manage.py:56) lets broad keywords onto Maximize Clicks campaigns, and wrongly refuses them on Maximize Conversion Value and target CPA/ROAS. Our rule is broad match only with conversion-based bidding; Google's _SMART_BIDDING_STRATEGIES leaves Maximize Clicks out. Please use a conversion-based set, and refuse any move to a non-conversion strategy while active broad keywords exist (as Google's leaves_smart does). The rules text and the get_microsoft_keywords insight need the same change.
  • remove_microsoft_entity with campaign_criterion (manage.py ~2033) never reads the criterion, so the double confirmation shows two ids and nothing else. Removing the last included location makes the campaign serve everywhere, which update_microsoft_campaign_targeting refuses. Please resolve the criterion type and name at draft, refuse removing the last location, and point to the targeting tool.

Should fix

  • Found live: draft_microsoft_campaign defaults to MaxConversions and only warns when no conversion goal counts toward Conversions. The dry run passed, then Microsoft refused the create with ConversionGoalCriteriaNotMetForBiddingScheme ("There must be a conversion goal set up to use this bid strategy"). A DRY_RUN_SUCCESS should mean the apply goes through: please refuse conversion-based strategies at draft and in preflight when the account has no active goal (the conversion-setup read already knows), and name the alternatives.
  • Throttling (client.py:182): code 117 is retried after 5 s, Microsoft says to wait 60 s. Please raise straight away with retry_after=60, and handle Reporting code 207 (ConcurrentRequestOverLimit).
  • Account scans: RSA updates and keyword or ad removal scan every ad group in the account three times (draft, dry run, apply), although ad_group_id is in the plan. get_microsoft_campaigns makes three criterion calls per campaign.
  • The plans keep the raw request (campaign, criteria) in changes, so it shows up in the confirmation dialog (we saw 16 lines for a campaign create). Please move request payloads into ChangePlan.apply_only_payload, as suggested on ChatGPT Ads: reads, campaign management and conversion diagnostics #127.
  • approval_card.html doesn't hide image_payloads, which approval.py does, so the card shows raw image payloads. This goes away with the apply_only_payload approach suggested on ChatGPT Ads: reads, campaign management and conversion diagnostics #127; otherwise add a test pinning both lists.
  • Reads: the retrieved criterion Type is "Radius", not "RadiusCriterion" (read.py:448); a null AudienceAdsBidAdjustment inherits the campaign's setting rather than meaning off (read.py:582); draft_microsoft_campaign should set or warn about the Audience Network default.
  • Sign-in: Entra ignores the port on localhost redirects, so bind port 0 instead of a fixed 8080; let listen_once keep listening until the state matches; open the token temp file with O_EXCL.
  • Users who sign in to Microsoft Advertising with Google: our own account is one, and every call returns a bare 105 InvalidCredentials after a successful microsoft-login, because the Microsoft identity behind the same Gmail address isn't a user of the ad account. Microsoft's quick-start describes Google-identity users authenticating with a Google OAuth token and the IdentityProvider: Google header. Please at least recognise this case and explain it in the error (sign in with Google is not supported yet; add a Microsoft account as a user); supporting the Google route would be the full fix.
  • languages=["All"] passes the languages-required check, and target_cpa or max_cpc on a ManualCpc campaign without a strategy are silently dropped.
  • Dead code: the "earlier AdLoop version" check (manage.py:2109) and its test, is_local_redirect, is_microsoft_plan, read.reset_account_cache, PartialApplyError.retire_plan. The schedule limit says 6 windows per day in validation and 7 in _MAX_WINDOWS_PER_DAY.
  • manage.py (2.4k lines) with two parallel ~20-branch dispatchers is hard to maintain; a per-operation handler table, split by area, would help.

Before merge
Sign-in works on our side: adloop microsoft-login at 78eeab6 with a fresh multi-tenant public-client Entra app (authorization code + PKCE, msads.manage offline_access) stored a refresh token on the first try.

We ran the reads and a first write against our own account (78eeab6): list_microsoft_ads_accounts, get_microsoft_campaigns, get_microsoft_conversion_setup, get_microsoft_performance and search_microsoft_locations all work, and draft_microsoft_campaign → dry run → apply created a paused MaxClicks campaign whose LocationIntent (PeopleIn) landed in the same batch as the first location, so that question is settled.

A redacted sandbox (or live) transcript of the remaining write operations, please (ad groups, keywords and bids, RSAs, extensions, negatives and lists, targeting updates, pause/enable/remove).

…ies, shared draft helpers

- confirm_and_apply refuses a plan whose operation was blocked after the
  draft (BLOCKED, dry run and apply alike), logs the refusal, and keeps the
  plan pending. The confirmation dialog is not shown for such a plan.
- safety/outcomes.py: PartialApplyError. An applier raises it when part of
  a plan reached the account or a create's outcome is unknown;
  confirm_and_apply retires the plan and answers PARTIAL_APPLY or
  UNKNOWN_OUTCOME with what was created.
- safety/guards.py: blocked_operation_error, store_draft and
  blocked_plan_refusal, used by Reddit and Tag Manager instead of their
  own copies.
- Approval card: Microsoft Advertising and ChatGPT Ads labels, as in the
  confirmation dialog.
Microsoft Advertising (Bing) through the REST API v13: Entra sign-in
(authorization code + PKCE, device code as fallback), account, campaign,
ad group, keyword, ad and extension reads, performance, search-term and
location reports, the conversion setup audit, and campaign management
behind the draft -> preview -> confirm_and_apply gate: budgets, status,
campaigns and targeting, ad groups, keywords and bids, negatives and shared
lists, RSAs, sitelinks, callouts, snippets, call and image extensions, and
removal.

Rebased onto the shared write-gate change instead of kLOsk#127.
- Broad match only with conversion-based bidding (MaxConversions,
  MaxConversionValue, TargetCpa, TargetRoas); Maximize Clicks no longer
  counts. Moving to any other strategy is refused while active broad
  keywords exist, at the draft and again at the dry run and apply; the
  keyword read's insight follows the same rule.
- remove_microsoft_entity reads a campaign criterion at the draft, names
  its type and target, and refuses removing the last targeted location
  (the campaign would serve everywhere), again at the dry run.
- Request bodies, snapshots and ids move from plan.changes to
  ChangePlan.apply_only_payload; preflight and apply read
  plan.apply_payload(). Drafts use the shared store_draft and
  blocked_operation_error.
- MaxConversions without an active conversion goal is refused at the draft
  and the dry run (Microsoft refused such a create live with
  ConversionGoalCriteriaNotMetForBiddingScheme) and names the alternatives.
- Throttling: codes 117 and 207 (and HTTP 429) raise at once with
  retry_after 60 instead of retrying after 5 s.
- Plans store their campaign and ad group, so dry runs and applies read
  three lists instead of every ad group of the account; campaign reads ask
  once per campaign for its targeting (CriterionType Targets, with the
  per-type queries as fallback).
- Reads: criterion type Radius, Audience Network inherited from the
  campaign when an ad group has no adjustment of its own; new campaigns
  warn about the Audience Network default.
- Sign-in binds a free localhost port (Entra ignores the port), keeps
  listening until the state matches, and writes the token through an
  O_EXCL temp file. A bare 105 after a working sign-in is explained:
  the identity is not a user of the account, often because its users
  sign in with Google, which is not supported yet.
- languages=['All'] is refused; target_cpa and max_cpc on a strategy that
  cannot use them are refused instead of dropped.
- Dead code removed; the schedule limit is 7 windows a day throughout.
manage.py (2.4k lines, two parallel ~20-branch dispatchers) becomes the
package microsoft_ads/manage: _common (constants, request and plan
helpers, bidding, Step and Handler), campaigns, targeting, ad_groups,
negatives, ads, extensions and removal. Each area drafts its plans and
registers its operations in HANDLERS (operation -> dry-run check, apply);
the package merges them into one table that preflight and apply_plan look
up. The code of every check and apply moved unchanged; imports from
adloop.microsoft_ads.manage keep working.
… one query

Found in the live run: CampaignCriterions/QueryByIds refuses the "Targets"
shorthand (4503 CampaignCriterionTypeInvalid, "Invalid API Campaign
Criterion Type : Targets on API tier"), which adds, updates and deletes
accept. The types joined by commas ("Location,LocationIntent,Radius") are
accepted, so get_microsoft_campaigns now makes one criterion call per
campaign; the per-type fallback stays for a refusal. The test fakes refuse
"Targets" in a query, as Microsoft does.
@illia-sapryga

illia-sapryga commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks, Daniel, and glad Microsoft fits. You taking it over for Cloud later is fine with me. All done; the branch is force-pushed.

Landing

Must fix

  • Broad match.
    • It is allowed only with conversion-based bidding (MaxConversions, MaxConversionValue, TargetCpa, TargetRoas). Maximize Clicks no longer counts.
    • Moving to any other strategy is refused while active broad keywords exist, at the draft and again at the dry run and apply.
    • The rules text, README and get_microsoft_keywords insight say the same.
  • Removing a campaign_criterion.
    • The draft reads the criterion and names its type and target.
    • Removing the last targeted location is refused and points to update_microsoft_campaign_targeting. The dry run checks it again.
    • It's step 31 of the transcript.

Should fix

  • Conversion goals. Conversion-based strategies are refused at the draft and in preflight when no active goal counts for bidding. That covers new campaigns and strategy switches, and the error names Maximize Clicks, Enhanced CPC and Manual CPC.
  • Throttling. Code 117, Reporting code 207 and HTTP 429 raise at once with retry_after=60 (longer if Microsoft names a longer wait). The structured error says to wait that long.
  • Account scans.
    • Plans store their campaign_id and ad_group_id, so the dry run and apply read three lists instead of every ad group. An RSA update or a keyword/ad removal still scans once, at the draft, because an ad or keyword id alone doesn't name its ad group.
    • get_microsoft_campaigns makes one criterion query per campaign.
    • The live run showed that CampaignCriterions/QueryByIds refuses the Targets shorthand that adds and deletes accept: "4503 CampaignCriterionTypeInvalid: Invalid API Campaign Criterion Type : Targets on API tier". The types joined by commas ("Location,LocationIntent,Radius") work, which is 8fb66c1. Re-run live afterwards: 3 campaigns, 3 queries, all 200. The per-type queries remain as the fallback.
  • Payloads. Bodies, snapshots and ids are in apply_only_payload, and preflight and apply read plan.apply_payload(), so the card no longer gets image_payloads. A test drafts 12 kinds of plan and checks that no payload reaches changes, the preview, the dialog or the audit log.
  • Reads.
    • The Radius criterion type is matched without the Criterion suffix.
    • A null AudienceAdsBidAdjustment on an ad group inherits the campaign's setting; null at both levels means Microsoft's default (on).
    • draft_microsoft_campaign warns about that default.
  • Sign-in.
    • The listener binds port 0, and the redirect URI is built from the real port. The app registers http://localhost/callback, and the config example, README and rules say so.
    • It keeps listening until a request carries the right state; stray requests get a 400.
    • The token is written through a randomly named temp file opened with O_EXCL | O_NOFOLLOW.
  • Google-identity users.
    • A 105 after a sign-in whose token refreshed now explains the likely cause: the identity isn't a user of the ad account, often because the account's users sign in with Google. It says to invite a Microsoft account or a work (Entra) user and sign in as that user.
    • GoogleAccountIsRequired and IdentityTypeMismatch get the same explanation. Our own setup ran into exactly this.
    • The Google route isn't implemented yet. Per the OAuth quick start and Get access and refresh tokens, it needs a Google OAuth client (with a secret), the profile email scope, a Google refresh flow and the IdentityProvider: Google header. I'd rather add that as its own PR once it can be tested against a Google-identity account.
  • Validation.
    • languages=["All"] is refused.
    • target_cpa or max_cpc on a strategy that can't use them is refused instead of dropped.
  • Dead code and limits.
  • The manage.py split.
    • microsoft_ads/manage/ now has _common, campaigns, targeting, ad_groups, negatives, ads, extensions and removal.
    • Each area module registers HANDLERS (operation → dry-run check, apply), and the package merges them, so preflight and apply_plan look an operation up.
    • adloop.microsoft_ads.manage imports keep working.

Before merge

  • Live transcript (redacted, a real account, no sandbox): https://gist.github.com/illia-sapryga/de38796914cfb720b53a9ecf178423ff
    • Everything happened inside one test campaign that was created paused and removed at the end.
    • 35 steps, each draft → dry run → apply, with every HTTP request:
      • campaign create (MaxClicks), then an update (name, Enhanced CPC), the budget and targeting (a second location plus an ad schedule);
      • ad group with keywords and its update, a new keyword and keyword bids;
      • an RSA and its update;
      • sitelinks, callouts, a snippet, and call and image extensions;
      • campaign negatives, plus a shared list: created attached, added to, detached and attached again;
      • pause and enable for a keyword, the ad and the ad group;
      • removal of a negative, a list item, an extension link and a location, plus the last-location refusal;
      • removal of a keyword, the ad, the ad group and the campaign.
    • Every apply succeeded. Only the criterion read in step 1b fell back, as described above.

Full suite: 2221 passed, 2 skipped.

@kLOsk
kLOsk merged commit 68d4676 into kLOsk:main Nov 9, 2026
3 checks passed
@kLOsk

kLOsk commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Merged, thanks Illia. Everything from the review is in, and the transcript made it easy to check.

We re-ran the core of our live test on 8fb66c1 against our own account:

  • Maximize Conversions without a conversion goal is refused at the draft.
  • A paused Maximize Clicks campaign went through draft, dry run and apply.
  • A broad keyword on it was refused, and so was removing its only location.
  • The campaign was removed again.

Two notes:

  • main now has a startup step that hides a platform's tools in a local server when the config doesn't set it up (_PLATFORM_SETUP in server.py). Microsoft is in it: a developer token or a client_id counts as set up.
  • Adding the Google-identity sign-in as its own PR later sounds right.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants