· 2 min read
Blocking invalid ad clicks while the campaign is still running
How ClickFortify turns a stream of ad clicks into exclusions inside Google Ads and Meta, and why it is built as event-driven services over NATS.

Advertisers pay for every click. Some of those clicks come from bots, VPNs, proxies and data centers, and a report that arrives weeks later only documents the loss. ClickFortify is an independent project I architect and operate on my own. Its job is to notice invalid traffic and exclude it while the campaign is still spending.
The shape of the problem
Three properties drive almost every design decision:
- It never stops. Clicks arrive continuously, around the clock, across time zones.
- Every advertiser is a tenant. Each one has its own accounts, rules and data, and they must never leak into each other.
- The action happens somewhere else. Detecting a bad click is only half the job. The exclusion has to be written back into the advertiser's Google Ads or Meta ad account through their APIs, which have quotas and occasionally fail.
Event-driven, on purpose
Collecting clicks, scoring them and syncing exclusions have very different load profiles. Collection is spiky and must stay fast. Scoring is CPU-bound. Sync is limited by someone else's API. Putting them in one process would let the slowest part set the pace for all of them.
So each concern is its own service, connected through events on NATS. A click is collected and published; scoring subscribes, decides, and publishes a decision; sync picks up decisions that need an exclusion. Each service scales on its own, and a slow ad-platform API never backs up collection.
Scoring a click
No single signal proves a click is invalid, so scoring combines several: behavioural signals, device fingerprinting, and filtering for VPN, proxy and data-center traffic. Keeping scoring separate from collection means the rules can change without touching the part that has to stay up.
Writing back without hammering the APIs
The write-back is the part that fails in interesting ways. Exclusions go through Redis Bull job queues so that every sync has retries with backoff, and a burst of decisions turns into a steady stream of API calls instead of a spike.
// Illustrative: one queued exclusion per tenant, platform and IP
await exclusions.add(
'exclude-ip',
{tenantId, platform, ip},
{
jobId: `${tenantId}:${platform}:${ip}`, // idempotent: the same IP is never queued twice
attempts: 5,
backoff: {type: 'exponential', delay: 30_000},
removeOnComplete: true
}
);A stable job id makes the operation idempotent: if the same IP is flagged ten times in a minute, it is synced once.
Tenancy, and a live migration
Because every advertiser has an isolated tenant database, large changes can be planned tenant by tenant instead of as one risky switch. That is how I ran a live production database migration across 500+ tenant databases at near-zero downtime.
What I would tell someone building the same thing
- Treat the write-back to the ad platform as the hard part, not the detection.
- Separate services by load profile, not by noun.
- Make every external side effect idempotent before you need to retry it.