Skip to content

CLOSRADS — Facebook Ads Geo Sync

Linear Project: CLOSRADS Project
Engineers: Juanes and Nat
Status as of: 2026-04-16
Dry-run verified: 2026-04-15


What It Does in One Sentence

Every day at 8am, it reads which US states need leads from CLOSRTECH, then updates the geographic targeting of every active Facebook adset in the VND_VETERAN_LEADS campaign to match exactly.


Quick Reference

Property Value
Client Mike (veteran lead generation operation)
CLOSRTECH campaign VND_VETERAN_LEADS
Facebook ad account act_XXXXXXXXXX (configured via env var)
Language Python 3.13
Deployment GitHub Actions — cron 8:00 AM Colombia (13:00 UTC)
Trigger Automatic daily + manual via workflow_dispatch
Default mode DRY_RUN=true (safe by default, no writes)
Branch structure devlop (development) → main (production)
Current state Dry-run validated. Pending: IP whitelist + merge to main

Key Dependencies

Package Version Purpose
facebook-business 25.0.1 Official Meta SDK for Graph API
requests 2.32.3 HTTP calls to CLOSRTECH API
tenacity 9.1.2 Automatic retry with exponential backoff
python-dotenv 1.1.0 Load credentials from .env file
pytest 8.3.5 Test runner
pytest-mock 3.14.0 Mocking for offline tests

Current Status

Item Status Notes
Core automation script ✅ Done All modules implemented and tested
Offline test suite ✅ Done 18 tests, 100% passing
Dry-run against production APIs ✅ Done Verified 2026-04-15
CLOSRTECH credentials in .env ⚠️ Pending Must be added before production run
IP whitelist for GitHub Actions ⚠️ Pending Blocking for cron — CLOSRTECH only accepts known IPs
Mike confirmation of dry-run states ⚠️ Pending 35 states shown — Mike must verify
GitHub Secrets configured ⚠️ Pending Required for GitHub Actions to work
devlop → main merge ⚠️ Pending All work is on devlop, not yet in production branch
orders.php integration ⏭️ Deferred to v2 Endpoint returns 404 — Mike must escalate to CLOSRTECH dev

Dry-Run Results (2026-04-15)

First real execution against production APIs with DRY_RUN=true. No changes were made to Facebook — only logged what would have happened.

Metric Result
Active states from CLOSRTECH 35
Active adsets found 5
Adsets that would have been updated 5/5
Errors 0

Active states detected: AK, AR, AZ, CA, CO, CT, DE, FL, GA, HI, IA, IL, LA, MA, MD, ME, MN, MO, MS, MT, NC, ND, NH, NJ, NM, NV, OH, PA, RI, TN, TX, VA, WA, WI, WV

Active adsets: - 120243386906050363 — BROAD - Copy 2 - 120243287242520363 — BROAD - Copy 2 - 120243238391470363 — BROAD - Copy 2 - 120243079782840363 — BROAD - OLDER ORDERS URGENT - 120240834861880363 — BROAD - Copy


Background & Problem

The Manual Process Before CLOSRADS

Mike runs a veteran lead generation operation in the USA. His business depends on buying leads through Facebook Ads, but not all US states need leads every day — demand shifts based on orders placed through CLOSRTECH, a platform his clients use to signal which states they need covered.

The daily routine before this automation existed: 1. Someone opens CLOSRTECH and reads which states have active demand 2. They open Facebook Ads Manager 3. They navigate to each active adset in the VND_VETERAN_LEADS campaign 4. They manually edit the geographic targeting of each adset to match the states from CLOSRTECH 5. They repeat for every active adset

This took 20 to 40 minutes every day. It was error-prone: a state left in when it shouldn't be means budget burned on leads nobody ordered. A state missed means a client with demand gets nothing. It also required someone to be available every morning to do it, with no fallback if they weren't.

Why Automation Was Straightforward Here

Unlike the ReadyMode bot (which required UI automation because ReadyMode has no API), both systems here have proper APIs: - CLOSRTECH exposes a demand.php endpoint that returns a JSON dict of states and quantities - Facebook has the Graph API with full support for reading and updating adset targeting

This meant the automation could be a clean script: read from one API, write to the other. No browser, no DOM, no headless Chrome. Just HTTP calls and the official Meta SDK.

The Two Systems

CLOSRTECH is a platform Mike uses to manage lead orders. His clients place orders specifying which states they need leads from and how many. CLOSRTECH aggregates this into a demand view per campaign.

The API endpoint used:

GET https://closrtech.com/mergers/api/demand.php
  ?campaign=VND_VETERAN_LEADS
  &email=<email>
  &pass=<password>

Response format: a JSON object where keys are USPS state codes and values are the quantity demanded. States with zero demand are included in the response but filtered out by the script.

Important constraint: CLOSRTECH has an IP whitelist. Only known IPs can call the API. This is the main blocker for running the automation from GitHub Actions, since GitHub uses dynamic IPs that change on every run.

A second endpoint (orders.php) exists but returns 404 — it was planned for v2 but is currently broken on CLOSRTECH's side. Mike needs to escalate this to the CLOSRTECH developer.

Facebook Graph API — The script uses the facebook-business Python SDK (v25.0.1) to list active adsets, read their current targeting, compare against CLOSRTECH demand, and update only if there's a difference. The access token used is a System User token from Mike's Meta Business account — non-expiring, unlike regular user tokens.

The Critical Fail-Safe

The most important design decision: if CLOSRTECH returns a non-empty response where every state has demand == 0, the script treats this as an error (not valid data). It raises ClosrtechDataError and aborts immediately — Facebook is never touched. This prevents a CLOSRTECH bug from zeroing out all of Mike's advertising.

What the Automation Changed

Before After
20–40 min manual work daily ~30 seconds automated
Human must be available every morning Runs at 8am regardless
Errors from copy-paste or forgetting a state Exact sync from CLOSRTECH data
No audit trail of what changed Full log in GitHub Actions + optional Slack notification
If person is sick or unavailable, nothing runs Automatic with failure alerts

Scope of v1

This automation handles one campaign (VND_VETERAN_LEADS) and one type of change (geographic targeting). Everything else in the adset targeting — age ranges, genders, interests, behaviors, location types — is explicitly left untouched. The script reads the full current targeting, makes a deep copy, replaces only geo_locations.regions, and writes it back.

Expanding to other campaigns or other targeting dimensions would require explicit new scope.


Documentation

File Contents
architecture.md Data flow, file structure, layer separation, secrets architecture, execution sequence, fb_region_keys.json
module-reference.md Detailed docs for all 7 modules in src/
github-actions.md Workflow YAML, trigger config, secrets, IP whitelist problem, branch strategy
tests.md 18 tests, offline strategy, conftest, fixtures, coverage gaps
activation-plan.md 6 ordered steps to go live, blockers, post-activation monitoring
design-decisions.md D01–D10: every significant architectural decision with full reasoning