· 6 min read · by the Devil Scrapes team

Google Ads Transparency advertiser ID: the silent 400 we found

google ads transparency advertiser id lookups 400'd since launch on one wrong protobuf field shape. How a green run hid it, and the live regression test that now catches it.

web-scrapingapify

Quick answer: Searching our Google Ads Transparency Scraper by advertiserIds returned zero rows on every single run since launch — not because Google’s Ad Library had nothing to show, but because one filter field in the request body needed to be a list, and we were sending a plain string. Google’s RPC rejected the request with a 400 every time, and the Actor’s own pagination logic quietly treated that 400 as “no more pages” and finished with a clean-looking SUCCEEDED status and zero rows.

The bug that never threw an error the customer could see

The Google Ads Transparency Center has no public API. This Actor works by replaying the same internal RPC call the Transparency Center’s own web UI makes — a batched, positional request format Google calls SearchCreatives, where every filter lives in a numbered field instead of a named one. Field "2" is the page size. Field "3" is a nested object holding the date range and the actual search target. Inside that nested object, field "12" is the domain filter, and field "13" is the advertiser-ID filter.

When this Actor first shipped, searching by domain (nike.com) worked immediately and was verified against a live run that returned 40,000 real ads. Searching by advertiserIds was built the same session, against the same reverse-engineered request shape, but its own recon notes are candid about the fact: it was “inferred from the SPA… not yet probed live.” The domain path got tested against real traffic before ship. The advertiser-ID path didn’t. It shipped on inference.

The inferred shape for field "13" was {"1": "<advertiser id>"} — a single string value, matching the intuitive read of “one advertiser, one ID.” Google’s actual SearchCreativesRequest schema wanted {"1": ["<advertiser id>"]} — a list, even when you’re only searching for one ID. That’s a one-character difference in the JSON you’d write by hand, and it’s exactly the kind of thing that’s invisible unless you’ve seen the real request or you get a very specific error back.

Why the run reported success anyway

This is the part that let the bug live since launch instead of getting caught on day one: Google’s RPC didn’t fail silently — it returned an honest HTTP 400, “Trouble converting f.req… SearchCreativesRequest.” That’s a real signal. But this Actor’s pagination loop wasn’t written to distinguish “the request itself was malformed” from “we’ve reached the last page of results,” and it treated any non-200 response the same way it treats an empty final page: stop looping, return whatever creatives have already been collected so far. On the very first request of an advertiser-ID search, “whatever’s been collected so far” is nothing.

The result: the run finished, reported SUCCEEDED, charged the customer the actor-start fee, and delivered an empty dataset — for every advertiser-ID search, since the Actor first went live. Nothing about the run’s outcome distinguished “this advertiser genuinely has no ads” from “the request never actually reached a valid state.” A customer running this the normal way — pasting an advertiser ID they found on a competitor’s Transparency Center page — got a clean-looking empty result and no reason to suspect the tool, not their input.

How it surfaced

Two unit tests for the request-body builder had been pinning the broken shape as correct since they were written — because they were written against the same never-probed guess the code shipped with, not against a live response. A green test suite told you the code matched its own assumption; it didn’t tell you the assumption was wrong. That’s the trap with unit tests for a wire-format contract you’ve never actually verified against the real service: they check internal consistency, not correctness against Google’s black box.

The fix came from doing what should have happened before the original ship: probing the live RPC directly with both shapes and reading what actually came back.

# scripts/recon/probe_advertiser_shapes.py — live-probed 2026-09-09
# {"13": {"1": "AR0123456789"}}     -> HTTP 400
# {"13": {"1": ["AR0123456789"]}}   -> HTTP 200, real creative rows

One field, one list wrapper, the difference between zero rows forever and a working search.

The fix and what now guards it

The request builder now emits payload["3"]["13"] = {"1": [advertiser_id]} — a list of one, matching what Google’s schema actually expects. The two unit tests that had pinned the wrong shape were rewritten against the corrected one, with a comment explaining why the old assertion was wrong rather than just silently changing it — the point is that the next person reading this code understands it was live-verified, not re-guessed.

The more durable fix is a new opt-in live regression test, test_advertiser_id_search_returns_creatives, that actually calls the real Google RPC with a known advertiser ID and asserts real rows come back. Unit tests on a pinned fixture can only ever tell you the code does what you told it to do; they can’t tell you Google’s schema changed underneath you. This one runs against the live target (marked -m smoke, so it’s opt-in rather than part of every CI run, since it makes a real network call) specifically so a future protobuf renumbering on Google’s side — which does happen — trips a test before it trips a customer’s dataset.

Verified end to end: a live cloud run before the fix (hWoZhTGYAeMFJ6fvu) shows SUCCEEDED with the ad-result event charged zero times and an HTTP 400 sitting in the log. The QA run on the fixed build (GCfxRuW2QrWyDRieq) shows SUCCEEDED with ad-result charged five times against five real Nike creative rows — same advertiser, same code path, one field shape different.

What this is worth to a buyer

This bug is the argument for buying access to a maintained scraper instead of reverse-engineering a private RPC yourself and calling it done once it compiles. The request format here isn’t documented anywhere — there’s no schema to check your guess against, only trial and error against a live endpoint that returns generic error strings. Getting the domain-search path right on the first try and the advertiser-ID path wrong is exactly the kind of asymmetric outcome you get when you’re inferring a wire contract from a minified frontend bundle instead of testing every path against real traffic. The value isn’t that we never guess wrong — it’s that when we do, we go find out, fix it, and leave a live test behind so it can’t happen the same way twice.

Run it on Apify:

FAQ

Why did advertiser-ID searches return zero results instead of an error?

Google’s server correctly rejected the malformed request with an HTTP 400, but this Actor’s pagination logic treated any non-200 response the same as “no more results left to fetch” and returned whatever had been collected — which, on the very first request, was nothing. The run still reported SUCCEEDED because nothing crashed; it just never actually searched.

What was the actual bug in the request?

The advertiser-ID filter field in Google’s internal SearchCreativesRequest format expects a JSON list ({"1": ["AR0123456789"]}), even for a single ID. This Actor was sending a plain string ({"1": "AR0123456789"}), which Google’s schema validator rejects outright.

Does this affect domain-based searches too?

No. Domain search (searchDomains) uses a different field in the request and was verified live against real traffic (40,000+ ads for a test domain) before this Actor first shipped. Only the advertiser-ID path carried the unverified guess.

How do you make sure this specific bug can’t come back silently?

A live regression test now calls Google’s real RPC with a known advertiser ID and asserts real creative rows come back, rather than only checking the request body against a fixture we wrote ourselves. If Google changes the schema again, that test fails before a customer’s dataset comes back empty.

Want this data without the engineering?

Every Actor in the fleet is pay per result and maintained daily. If the one you need doesn't exist, we'll build it.