Writing

Detecting fraudulent affiliate traffic by crossing data

It started with a spike in Cumulative Layout Shift (CLS) on one page. I looked into it and found that only the visits carrying the affiliate network’s tracking parameter had it. That is how I found the popunders. In September 2026 the affiliate team removed five publishers from the program. At the start of October a second pattern turned up: publishers buying their own Google Ads with their affiliate links inside. This post is the method behind both.

The problem

An affiliate program pays publishers a commission on the orders the affiliate network credits to their links. Two practices collect that commission in ways the program was not meant to pay for:

  • Popunders. The publisher opens our page in a window behind the one the visitor is using. The affiliate link fires on load, so a later order is credited to the publisher.
  • The publisher’s own paid search. The publisher buys Google Ads with its affiliate link in the ad. When a searcher clicks it and orders, the order is credited to the publisher.

The affiliate team confirmed that the program had approved no paid search partner and does not allow bidding on the brand.

The popunders showed up in our field data as that CLS spike. They ran on our homepage and on a landing page. The paid search showed up in the orders: the affiliate team flagged a publisher with 35 orders in two days. They thought it was another popunder. The field data showed the publisher was buying Google Ads with its affiliate link fixed inside the ad. The same checks across the whole site turned up more publishers with the same pattern.

The data

The method crosses four sources. Each one answers one question.

  • The network report. Orders and attributed revenue per publisher per day: who got paid and how much.
  • Real user monitoring (RUM). One record per page load, in Elastic, with the full landing URL, the referrer, the client IP, the user agent and Core Web Vitals such as Largest Contentful Paint (LCP) and CLS: how each visit arrived.
  • Our Google Ads account. The IDs of the campaigns we run: whether an ad is ours. This cross was still pending, so a proxy from RUM stood in for it (signal 3).
  • The program terms. Held by the affiliate team: whether a pattern is allowed.

The keys that join them

The sources join on values that travel on the landing URL or in the request:

  • Publisher ID. The network adds it to the landing URL with its tracking parameters. It joins a page load to the network report and marks the visit as affiliate traffic.
  • Click reference. The network adds a click reference parameter for each click. In our data the click reference held the Unix time of the click.
  • Ad click ID and campaign ID. A visit from a Google ad carries Google’s click ID (gclid) and the campaign ID (gad_campaignid). They join a page load to an ad account.
  • Client IP and user agent. They tell one visitor from many. They also tell a person from AdsBot-Google, the crawler Google sends to the pages that ads point to.
The data sources crossed The network report joins RUM page loads on the publisher ID. Our Google Ads account would join them on the campaign ID; that cross was pending, so a RUM proxy stood in for it. Four signals come out of the page loads: popunder, ad arrival, not our ad, fixed link. They feed a publisher list that goes to the affiliate team for review against the program terms. Network report orders, revenue per publisher, per day Google Ads our campaign IDs cross pending publisher ID campaign ID pending: RUM proxy RUM page loads landing URL: publisher ID, click ref gclid, campaign ID, referrer client IP, user agent, LCP, CLS, page signals 1 popunder Web Vitals per page 2 ad arrival gclid + affiliate tags 3 not our ad only with affiliates 4 fixed link 1 click ref, many IPs publisher list over 50 page loads with the evidence program terms the affiliate team reviews the list
The four sources and the keys that join them. The dashed line is the pending Ads account cross.

Crossing it

The method runs as a sequence. Each step narrows the list the next one reads.

The method in order Nine steps: start from an anomaly; pull 30 days of page loads split by the affiliate tracking parameter; four signals (Web Vitals per page, arrival from an ad, whose campaign, one click reference with many IPs); rank publishers over 50 loads; the affiliate team reviews against the program terms; watch with alerts. 1 Start from an anomaly a CLS spike on a page, or an orders tip 2 Pull 30 days of page loads split by the affiliate tracking parameter 3 Web Vitals per publisher, per page popunder alert: 30+ loads in 24 h, LCP p75 over 8 s 4 Arrival from an ad gclid + campaign ID next to affiliate parameters 5 Whose campaign is it? the Ads account, or 60 days of non-affiliate loads 6 One click reference, many IPs the affiliate link is fixed inside an ad 7 Rank publishers over 50 loads with the evidence and the limits 8 Affiliate team reviews against the program terms 9 Watch an alert per signal, plus removed publishers
The method in order. Steps 3 to 6 are the four signals.

Start from the anomaly, then widen

Start from whatever raised the flag and split that traffic by the affiliate network’s tracking parameter. For a tip, pull 30 days of page loads that carry the publisher’s ID and compare them with all other traffic on the same landing pages. Once a pattern is clear on one publisher, run the same query for every publisher in the program.

Signal 1: pages that load unseen

In our field data popunder loads stood out on Core Web Vitals, starting with the CLS spike. Compare each publisher’s Core Web Vitals on a landing page with the rest of that page’s traffic. Our pages had different normal speeds, so a site-wide average would hide a slow publisher on a fast page.

The popunder alert I set up keys on LCP; its threshold is in the Result section.

The October tip passed this check: on its main landing page its loads were faster than the page’s normal, with layout shift at the same level.

Signal 2: visits that arrive from an ad

A page load that carries gclid and gad_campaignid next to the network’s tracking parameters, with google.com as the referrer, is an affiliate visit that started with an ad click. For the October tip, 582 of 611 page loads over 30 days carried a Google click ID. They came from two Google Ads campaigns. AdsBot-Google also loaded the publisher’s affiliate URLs.

Signal 3: campaigns that are not ours

A campaign ID on an affiliate visit can still belong to you. The direct test is to match it against the campaign IDs in your own Google Ads account. Ours was still pending when the list went to the affiliate team, so I used a proxy from RUM: a campaign that never appears in traffic without an affiliate is not one of ours.

Look back further than the scan. For the tip’s two campaigns I checked 60 days: they appeared only with that one publisher’s ID. Across the site, campaigns that also appeared in traffic without an affiliate were probably ours. I did not classify them; their publishers went on a second list.

Campaign With affiliate Without In our account Call
cmp-1 1,000 0 no theirs
cmp-2 100 0 no theirs
cmp-3 10 10,000 yes ours
cmp-4 10 100 not checked second list
Illustrative. Made-up round page-load counts over a 60-day window. The account column is the direct test; the two load columns are the proxy.

Signal 4: one click reference, many visitors

Since the click reference held the time of one click, a real one should come from one visitor at about that time. When a publisher pastes an affiliate link with the reference already in it into an ad, everyone who clicks the ad lands with the same reference. Count page loads and distinct client IPs per click reference.

The October tip had one fixed reference per ad campaign. Each one first appeared seconds after the time it held, then kept arriving for days. Together the two references showed up in about 600 page loads from about 440 distinct IPs.

Rank the publishers

I listed the publishers with more than 50 page loads over 30 days on campaigns seen only with affiliates. For each one the list carried the evidence: page loads, the main landing page and the last day seen.

The query shape

Most of this runs on two queries. Here is an illustrative shape in ES|QL, Elastic’s query language, with made-up names. The first serves signals 2 and 4:

// Illustrative. Made-up names.
FROM page_loads
| WHERE @timestamp >= NOW() - 30 days
    AND publisher_id IS NOT NULL
    AND ad_click_id IS NOT NULL
| STATS loads = COUNT(*), ips = COUNT_DISTINCT(client_ip)
     BY publisher_id, ad_campaign_id, click_ref
| SORT loads DESC

For signal 1, drop the ad click filter. Group by publisher and landing page; take PERCENTILE(lcp_ms, 75) or the same percentile of CLS. The second query serves signal 3 over the longer window:

// Illustrative. Made-up names.
FROM page_loads
| WHERE @timestamp >= NOW() - 60 days
    AND ad_campaign_id IS NOT NULL
| EVAL with_affiliate = CASE(publisher_id IS NOT NULL, 1, 0)
| STATS loads = COUNT(*), affiliate_loads = SUM(with_affiliate)
     BY ad_campaign_id
| EVAL only_affiliate = loads == affiliate_loads
| SORT loads DESC

Result

In August, the affiliate traffic I traced accounted for about $14.3K in attributed revenue over 30 days. The popunder phase ended in September 2026, when the affiliate team removed five publishers from the program.

Those five also had their own paid search traffic. It dropped four days before the removal and stayed at a few page loads a day from then on.

The paid search phase produced a ranked list, which went to the affiliate team for review.

I set up three alerts:

  1. Affiliate paid search. Page loads with affiliate parameters and a Google click ID on a campaign that is not ours. It gets stronger with the list of campaign IDs from our Ads account.
  2. Popunder signature. One publisher with 30 or more page loads in 24 hours and an LCP p75 above 8 seconds.
  3. Removed publisher with traffic. Any page load that carries a publisher ID from a block list the affiliate team keeps.

What the data could not say

  • RUM does not see browsers with ad blockers. Every count here is a floor.
  • “Not ours” came from RUM. The cross with the Ads account was still open.
  • Only the affiliate network or Google know which keywords the publishers bought.
  • Order counts came from the network. We did not check orders on our side.
  • I checked the October tip’s traffic per hour only.

Do this on Monday

You need your field data, your list of ad campaigns and time with whoever runs your affiliate program.

  1. Check that your RUM keeps the full landing URL with its query string. Without it the keys above are gone.
  2. When a Core Web Vitals metric spikes on one page, split its page loads by the affiliate network’s tracking parameter.
  3. Export the campaign IDs from every Google Ads account you run.
  4. Query 30 days of affiliate page loads. Group them by publisher, landing page, ad campaign and click reference. Count loads and distinct IPs; take the LCP p75.
  5. Flag affiliate page loads that carry gclid or gad_campaignid. If your terms ban paid search, each one is a finding.
  6. Sort those campaigns. In your account means yours. Missing from it and never seen without an affiliate over a longer window means theirs. The rest go on a second list.
  7. Flag click references with many distinct IPs. A reference from a real click should belong to one visitor.
  8. Compare each publisher’s Core Web Vitals with the rest of the same page. Tune the popunder threshold to your pages.
  9. Search the user agents on affiliate URLs for AdsBot-Google.
  10. Send the publishers above a load floor to the affiliate team with the evidence and the limits. The decision is theirs.
  11. Turn each signal into an alert. Add one for traffic from removed publishers.