CONFIDENTIAL · INTERNAL HANDOFF · DRAFT FOR REVIEW
Homa LAB Paid Media → Monochrome
Complete handoff of Homa's in-house (LAB) Meta & Google paid programs for Monochrome to own and optimize. Built 2026-08-10.
Read this first. The LAB program is not a set of manual campaigns you can just take a screenshot of — its audiences, Customer-Match lists, and conversion signals are refreshed nightly by a fleet of Cloudflare Workers reading PostHog. If those workers stop, the audiences and Customer Match lists go stale within days and delivery degrades. The single most important handoff decision (§6) is whether Monochrome keeps that automation running or rebuilds it. Everything else is standard.
📝 New: step-by-step operator guide — every command for Cloudflare, both vendors and the catalog, written assuming no prior knowledge. What to run, what you should see, and what to do when it fails.
👋 README — start here
This is the operator's front door. The source for everything below ships in the bundle lab-fleet-handoff.zip (download it right here); this page is its companion. Read this, then §15 for day-to-day management.
📦 Download the source — everything below lives here, no unzip-from-email needed:
lab-fleet-handoff.zip — the full bundle (7 Cloudflare Workers + cron scripts + docs, ~120 KB). Verified: no secrets inside.
⚠️ There are TWO kinds of infra — they transfer differently.
1. Cloudflare Workers (/workers) — deploy in your Cloudflare account
Each folder = one worker (worker.js or src/worker.js + wrangler.toml, which carries the cron + KV bindings).
signal-audiences — refreshes 10 Meta behavioral custom audiences (every 6h)Show all 10 audiences
Each is rebuilt from analytics every 6 hours. The worker never wipes an audience to empty on a failed query — it keeps the last good set.
Audience
Who is in it
Id
deep_browsers
Viewed 10 or more properties in the last 30 days.
120248788691350553
high_price
Viewed a property at $500K or above in the last 30 days.
120248788692500553
qualified_not_closed
Qualified in the last 90 days but never got approved — the stalled high-intent pool.
120248788693720553
price_drop
Viewed a home whose asking price has since been reduced. Two-step build.
120248788723900553
active_savers
Saved a search or clicked property cards in the last 30 days.
120248788723950553
viewed_homes
Any identified property view in the last 180 days — the broad retargeting pool.
120248809430390553
sub300_viewers
Browsed under $300K in the last 30 days. Powers the wallet-copy band ads.
120248838896260553
mid_band_viewers
Browsed $300K–$500K in the last 30 days.
120248838896350553
engaged_leads
Viewed 2 or more distinct listings in a single day, last 30 days. The cross-platform quality tier.
120248826629650553
abandoners
Started a tour, offer or onboarding in the last 180 days and never qualified.
120248789563390553
The same worker also grades 3 DPA product sets. "Graded" appears all over this page as a label on campaigns and audiences — here is what it actually means.
A save is worth three views. Saving a home is a much stronger signal than looking at one, so it is weighted accordingly.
Savers come from our own saved-homes data; viewers are unique people with a property view in the last 60 days.
On-market only. Anything sold or withdrawn drops out on the next refresh automatically.
Must have at least one save or one view. A listing nobody has engaged with never enters a graded set.
Sorted by score, then cut to a limit per set — Miami 400, Tampa+Orlando 500, statewide 1000.
Why Miami has a price floor. Miami raw inventory is condo-heavy entry stock, but actual Miami shoppers compete in the $400–700K range. The set carries a $350K floor so the carousel shows homes our buyers are actually shopping for.
One mechanic to know: each refresh replaces the product set filter with the freshly ranked list of ids. It does not add to it. So if a graded carousel looks wrong, the fix is to re-run the worker, not to hand-edit the set — a manual edit is overwritten within 6 hours.
Product set
What it contains
Id
graded_miami
Demand-graded Miami listings, $350K+.
2096659120944087
graded_tampa_orlando
Demand-graded Tampa and Orlando listings, top 500.
These are the funnel outcomes, hashed and uploaded daily. They exist to seed lookalikes. Note: the housing special ad category blocks lookalikes on property catalog campaigns, so these seed the non-housing commerce campaigns only.
Seed
Who is in it
Id
Registered
Everyone who completed onboarding. Seeds a lookalike.
120248926719090553
Qualified
Everyone who took a high-intent action on a property page.
120248926719550553
Approved
Everyone the back office marked approved — the highest-value seed.
120248926719890553
homa-lab-customer-match-sync — PostHog → Google Data Manager Customer Match + offline value feed (daily)Show all 7 Customer Match lists
Rebuilt nightly and pushed through Data Manager. Attach these to Search campaigns as Observation, never Targeting. Customer Match does not serve on the Display network — Search and Demand Gen only.
List
Who is in it
Google list id
LAB_Registered
Completed onboarding, last 365 days.
9421548525
LAB_Approved
Marked approved, last 365 days.
9420715417
LAB_Qualified
Fired a qualified event, last 365 days.
9435049850
LAB_HighIntentBrowsers
3 or more property-detail views in the last 180 days.
9420332747
LAB_PropertyRecent
Any property page view in the last 14 days — the freshest signal.
9422082465
LAB_QualifiedNotClosed
Qualified in 180 days with no approval. Mirrors the Meta cohort of the same name.
9443622931
LAB_PriceDropViewers
Viewed a home that has since dropped in price. Two-step warehouse join.
9443073155
homa-metro-go — redirect worker for metro-carousel DPA links
Deploy:cd workers/<name> && npm i && npx wrangler secret put <NAME> (see the secrets table below) then npx wrangler deploy. Recreate any KV namespaces and paste their new ids into wrangler.toml.
2. Cron scripts (/cron-scripts) — NOT Cloudflare; MUST be re-hosted
These run today as launchd jobs on Shawn's Mac and stop when it's off. Re-host them on your own scheduler/server (or port to Workers).
catalog_sync.py — DPA catalog / product-set refresh. Its SCHEDULE stays OFF — see §18. Re-enabling the hourly full-feed refresh recreates the crawler flood. Product-set updates are fine; full-feed re-ingest is not.
(+ the com.homa.*.plist launchd definitions showing schedule/command to replicate)
Secrets
🔐 The actual secret values live in a separate access-restricted Google Sheet, shared directly with your team — not in this bundle and not on this page. It's keyed by the exact wrangler secret put <NAME> names below. A few (Slack webhooks) are marked CONFIRM — they're already set on the live workers.
No secrets are in the bundle (verified). The names each worker needs:
Accept the Cloudflare invite (Workers Admin) → you'll see the workers under Workers & Pages.
Unzip the bundle. For each worker: cd workers/<name>, npm i, set its secrets (table above), recreate KV namespaces, npx wrangler deploy.
Re-host the two cron scripts (or port to Workers) so CAPI conversions don't go stale. Catalog is different — see §18, its schedule stays OFF..
Get Meta (Business Manager partner access to act_9501970176547289) + Google (MCC access to cust 5046504750) so you can manage the campaigns too.
→ Day-to-day editing, deploying, monitoring & troubleshooting: see §15 below.
1. What you're taking over
18
active Meta LAB campaigns
act_9501970176547289
15
active Google LAB campaigns
cust 5046504750
68
Meta custom audiences
worker-refreshed nightly
18
Google Customer Match lists
worker-refreshed nightly
Scope of this handoff: everything prefixed [HOMA-LAB] / [HOMA-WILDCAT] / [HOMA-EXT] on Meta and Google, the PostHog-fed automation that powers it, and the measurement conventions. NOT in scope (Monochrome already owns these): the MM_ Meta campaigns and the G_ "High-Intent Buyers"/"Asterisk" Google campaigns — those are already yours.
2. Access & accounts
System
Identifier
What's needed
Meta Business Manager
683046734288362 (Homa)
Monochrome partner access to the ad account below
Meta Ad Account
act_9501970176547289
All LAB campaigns live here (alongside your MM_ campaigns)
Meta Pixel
1434262110992642
Fires all funnel events; connected to catalogs
Meta Page / IG
Page 326942450513460 · IG 17841468128656503
All LAB creative posts from here
Google Ads
cust 5046504750 (MCC 2225490048)
MCC-level access
PostHog
project 141181 (Homa)
Source of truth for all leads/reg/qual/approved + audience queries
GTM
GTM-K9Z6RJKS
Tagging / enhanced conversions
Credential files (never in this doc — this is a public URL): Meta system-user token, Google Ads OAuth, PostHog keys, Data Manager OAuth all live on the ops machine. Hand these over securely, out of band. Do not paste secrets into any shared page.
3. Meta LAB campaigns — purpose, fuel & setup
Each campaign below: what it's trying to accomplish (Purpose) and the audience/data that feeds it (Fuel), then its live ad sets.
Prospecting — net-new reach
[HOMA-LAB] Meta DPA Cold v2 - Price EngagedACTIVE$84/d
🎯 Purpose — Cold DPA prospecting on a price-engaged / double-discount angle.
⛽ Fuel — Full home catalog + broad price-engaged cold audiences.
Ad set
Status
$/d
Optimizes
Placements
Cold v2 - graded FL+TX metros - price_engaged
ACTIVE
CBO
lab_price_engaged
Advantage+ (incl Reels)
[HOMA-LAB] Meta Metro Carousels - Prospecting v1ACTIVEad-set budgets
🎯 Purpose — Cold prospecting via a metro-specific carousel of that city's most-viewed homes; the click lands on the metro SEARCH page, not a dead-end single listing.
⛽ Fuel — Non-housing commerce catalog of per-metro product sets + 1% lookalikes of converters (Registered/Qualified/Approved) + 40-mi metro geo. Links resolve through the homa-metro-go redirect worker.
Ad set
Status
$/d
Optimizes
Placements
Tampa - converter LALs + metro geo (pixel-event v3)
ACTIVE
$76
lab_engaged_lead
Feed-only
Orlando - converter LALs + metro geo (pixel-event v3)
ACTIVE
$82
lab_engaged_lead
Feed-only
Miami - converter LALs + metro geo (pixel-event v3)
ACTIVE
$19
lab_engaged_lead
Feed-only
Jacksonville - converter LALs + metro geo (pixel-event v3)
ACTIVE
$27
lab_engaged_lead
Feed-only
Dallas - converter LALs + metro geo (pixel-event v3)
ACTIVE
$28
lab_engaged_lead
Feed-only
Houston - converter LALs + metro geo (pixel-event v3)
ACTIVE
$46
lab_engaged_lead
Feed-only
Austin - converter LALs + metro geo (pixel-event v3)
ACTIVE
$22
lab_engaged_lead
Feed-only
San Antonio - converter LALs + metro geo (pixel-event v3)
[HOMA-LAB] Meta DPA Prospecting - Full Catalog v2 - CC Onboarding Completed BidACTIVE$31/d
🎯 Purpose — Cold DPA prospecting across the full listing catalog, run as several audience angles (FL/TX mortgage-intent, cold cash-back, conquest, metro-graded, broad).
⛽ Fuel — Full home catalog + Speedeon FL/TX mortgage-intent lists + cold/broad + demand-graded metro product sets.
[HOMA-LAB] Meta DPA Prospecting - Full Catalog v1ACTIVEad-set budgets
🎯 Purpose — Cold DPA prospecting across the full listing catalog, run as several audience angles (FL/TX mortgage-intent, cold cash-back, conquest, metro-graded, broad).
⛽ Fuel — Full home catalog + Speedeon FL/TX mortgage-intent lists + cold/broad + demand-graded metro product sets.
Ad set
Status
$/d
Optimizes
Placements
Conquest - engaged_lead
ACTIVE
$7
lab_engaged_lead
Feed-only
Money Flow - cold intent + abandoners
ACTIVE
$20
property_viewed
Feed-only
Conquest - Found it on Zillow (broad + purchase flag)
[HOMA-LAB] Meta Price-Drop Missile v1PAUSEDad-set budgets
🎯 Purpose — Re-engage people who viewed a home whose price then DROPPED — shows that exact home with a 'price cut, up to 2% back' message.
⛽ Fuel — Price-drop-viewers signal (warehouse join: viewed homes now below original list price) + viewed-homes 180d.
Ad set
Status
$/d
Optimizes
Placements
Price-Drop Viewers
CAMPAIGN_PAUSED
$24
LINK_CLICKS
Advantage+ (incl Reels)
[HOMA-LAB] - Meta Retargeting v1PAUSEDad-set budgets
🎯 Purpose — General retargeting of prior site visitors.
⛽ Fuel — Site-visitor / viewed-homes audiences.
Ad set
Status
$/d
Optimizes
Placements
warm-all | FL
CAMPAIGN_PAUSED
$40
LANDING_PAGE_VIEWS
Advantage+ (incl Reels)
4. Google LAB campaigns — purpose, fuel & setup
Google is the down-funnel engine — it drives the majority of qualified/approved (see §10). Metro Search captures in-market demand; RLSA Warm closes returning users; Brand harvests demand.
[HOMA-LAB] - Hero Video v1$10/d · DEMAND_GEN · MAXIMIZE_CONVERSIONS
🎯 Purpose — YouTube brand / awareness video.
⛽ Fuel — Video audiences.
Google LAB = the metro Search campaigns (Tampa/Orlando/Jax/Miami + TX metros), RLSA Warm (best-CPA retargeting layer), Brand, Wildcat exploration, Display remarketing, Hero Video, Extension. Google is the down-funnel engine — it drives the majority of qualified/approved (see §10).
5. Audiences (top 30 by size)
All refreshed nightly by workers (§6) — do not treat as static uploads.
The audiences, Customer-Match lists, and conversion signals are refreshed by automation reading PostHog. It is TWO kinds of infra that transfer very differently — the single biggest handoff risk.
Source is bundled for you (no secrets): all worker + script source, a README, a re-host guide, and an auto-extracted secrets checklist — handed over as a zip / private repo. Deploy the Workers in your Cloudflare account; re-host the cron scripts on your own scheduler.
A. Cloudflare Workers — deploy in your Cloudflare account
Worker
Schedule
What it does
signal-audiences
every 6h
Refreshes ~10 Meta behavioral custom audiences (deep browsers, price-drop viewers, qualified-not-closed, active savers, viewed-homes 180d, engaged leads, abandoners, price-band viewers) from PostHog HogQL → SHA-256 → Meta. Also refreshes DPA "graded" product sets.
PostHog → Google Data Manager → Customer Match lists + the offline value-ladder conversion feed.
homa-metro-go
on-request
Redirect worker for metro-carousel DPA links (short slug → 302 to metro search + UTM). Source was rescued from ephemeral storage during handoff prep and is now in the bundle.
B. Cron jobs on the ops machine — ⚠️ MUST be re-hosted
These do NOT run on Cloudflare. They run as launchd jobs on the Homa ops Mac and stop the moment that machine is off. You cannot share these via Cloudflare — re-host on your own server/scheduler (or port to Workers). See REHOST.md in the bundle.
Script
Schedule
What it does
capi_sync.py
~every 2h (launchd)
Meta CAPI: replays lab_ funnel conversions server-side (Mono-safe).
catalog_sync.py
launchd
DPA catalog / product-set refresh.
Decommissioned / do not use:audience-sync (never deployed, dead code).
7. UTM conventions & attribution truth
How to tell LAB from Mono (critical)
LAB (this handoff)
Mono (already yours)
Meta campaign
homa-lab-meta-*
MM_*
Meta medium
lab_prospecting_* / lab_remarketing_*
MM_*
Google campaign / utm
homa-lab-* / homa-wildcat-* (medium g_lab_*)
G_* (case-sensitive uppercase G)
"All Google = LAB" is WRONG. Mono runs Google too (the G_ "High-Intent Buyers"/"Asterisk" campaigns). Case-sensitive G_ vs lowercase g_lab is the discriminator.
Attribution reality — do not trust event-UTM
Only ~8% of qualified_lead events carry a UTM (92% fire in a direct/untagged session). So a per-campaign "qualified by UTM" view under-counts massively and over-credits whatever channel drove the in-session qualify (usually retargeting).
Person-level first/last/any-touch is the truth. Roughly: ~45% of qualified/approved are direct/untracked, ~40% single-touch, ~20% multi-touch (double-countable across channels).
Role split: Meta LAB = top-of-funnel volume (drove ~0 approvals last week). Google + direct = the down-funnel closers. Mono Meta opens journeys; LAB Google closes them.
PostHog is the source of truth. Ignore Google's own conversion column. gclid→click_view recovery + enhanced-conversions gclid backfill are used to reclaim UTM-severed Google.
8. Optimization events & value ladder
Primary optimization event = engaged_lead (2+ distinct property views in a day) — frequent enough to exit learning, unlike the rarer onboarding_completed. Google Smart Bidding is fed the offline value ladder (via Data Manager):
Event
Meta CAPI value
Google offline value
lead:approved
$700
$200
qualified_lead
$400
$40
disclosures / package
$300
$300
tour request / onboarding
$150
$150
engaged_lead
$50
$15
property_view
$5
$5
Recent change (8/6): 5 chronically learning-limited ad sets that optimized the rare onboarding_completed were rebuilt to optimize engaged_lead — currently in learning; let them stabilize before judging.
9. Hard-won gotchas
DPA creative link limit: can't be a macro or >~1,000 chars → use the homa-metro-go redirect worker.
Catalog ad sets must use the raw pixel-event form in promoted_object ({pixel_id, custom_event_str}), NOT custom_conversion_id — the latter is accepted, shows no error, and silently never serves.
New catalog needs its pixel connected (external_event_sources) or a conversion-optimized campaign serves 0 impressions with everything reading green.
promoted_object is locked after publish — to change the optimization event you must build a new ad set (deep-copy then edit works while unpublished).
Meta learning resets on: budget change >20%, placement change, audience/optimization change, creative content change. url_tags-only (UTM) changes do NOT reset learning (verified via last_sig_edit_ts).
Customer Match does not serve on GDN display (search sizes populate, display ~0). Use rule-based/pixel lists for display.
Placements / Reels: 22 Meta ad sets are Feed-only; 9 remain on Advantage+ (incl Reels) — intentionally left to avoid learning resets. Build any NEW ad set Feed-only at creation to avoid the reset entirely.
OnSpot open-house audiences are partner-provided/shared and not targetable ("custom audience not available / missing perms"), and can't seed lookalikes. Unblock = get the raw address file and upload as first-party. Currently NOT running.
Housing special-ad-category blocks lookalike audiences; the metro-carousel commerce catalog is deliberately non-housing to keep LALs + Advantage+ available.
Reddit is fully paused (foreign/low-quality traffic).
10. Current performance baseline
~2,035
leads / week
CPL $2-3/day (was $16)
~44-54
newly-qualified / wk
+38% WoW (Mon-Sat)
~20-23
newly-approved / wk
~$288K
median qualified home price
~$5.8K rebate @ 2%
Watch the right metric. CPL has crashed to $2-3 but that's volume dilution — leads grew +240% while qualified grew ~17%, so the lead→qualified rate fell from 7.7% to 2.6%. Optimize toward cost-per-qualified / cost-per-approved, not CPL. Down-funnel is carried by Google + direct; Meta LAB is cheap reach + retargeting-pool fuel.
11. Open items & handoff checklist
In-flight (mid-change)
5 ad sets switched onboarding→engaged_lead on 8/6 — in learning, let stabilize.
Cold v2 (Price-Engaged) launched 8/7 — new, on Advantage+ placements.
Fleet budgets glided -20% on 8/6 (LAB only; Mono reverted). Revert values on the ops machine.
OnSpot open-house audiences loaded but blocked (see §9).
Monochrome onboarding checklist
Get partner access: Meta act_9501970176547289 + Google MCC 2225490048.
Get PostHog project 141181 access (measurement) + review the attribution truth in §7.
Decide on the automation fleet (§6): keep the PostHog→audience/CM/CAPI workers running (recommended — receive the repos + credentials) or plan a replacement before decommission, or audiences go stale.
Adopt the UTM conventions (§7) so LAB stays distinguishable from your MM_/G_ work in reporting.
Re-baseline against §10 and optimize toward cost-per-qualified/approved.
12. How we run it — operating principles
Leads are the headline; qualified/approved are the scoreboard. CPL is a vanity metric at scale (it crashes as you buy cheap reach). Optimize to cost-per-qualified and cost-per-approved, and track median viewed price & break-even close rate weekly.
Signal-first, spend-second. The moat is fresh first-party audiences (the worker fleet), not any bought list. Keep spend capped behind campaigns that target those signals. Data thesis: first-party conversion signal fed to the algorithm beats any static list, and the flywheel is more converters → better lookalike seeds.
Division of labor is real — fund the closers. Meta = cheap top-of-funnel reach + fuel for the retargeting pool (shallow: ~18% engaged, ~0 direct approvals). Google Search + retargeting = the down-funnel closers (Google runs ~2.5× better CAC). Don't over-fund shallow Meta prospecting because CPL looks great.
Value-based bidding. Feed the platforms the dollar value ladder (Meta CAPI values + Google Data Manager offline conversions) so Smart Bidding optimizes toward $, not raw event counts.
Respect the learning phase. Budget steps ≤20% every 2-3 days. Avoid placement / audience / optimization-event / creative-content edits on live ad sets — they reset learning. UTM (url_tags-only) changes are safe. Build every NEW ad set with its final placements + optimization event from birth so you never owe a reset.
Attribution humility. PostHog is the source of truth — ignore Google's own conversion column. ~45% of qualified/approved are direct/untracked. Judge channels person-level (first/last/any touch), never by event-UTM (only ~8% coverage). Keep LAB (homa-lab) vs Mono (MM_/G_) UTM discipline so reporting stays honest.
Brand posture is non-negotiable. Buyer-trust over any single close — never anti-realtor, always "up to 2% / $10K-$25K+" framing, ads always show tryhoma.com. Homa will tell a buyer not to buy when that's right; the paid program must never undercut that.
Reversible & gated. Ship reversible changes freely; gate large spend and anything irreversible. Log outcomes and fail loud (Slack alerts on worker failures) — a silently-broken audience feed is the worst failure mode.
13. Tools & stack to maintain
Layer
Tool
Why it matters
Truth / analytics
PostHog (project 141181, HogQL)
Every lead/reg/qual/approved + all audience queries + attribution. Locked event taxonomy. This is the brain.
Skills the operator needs: HogQL, Meta & Google Ads APIs, Cloudflare Workers/wrangler, the value-ladder + person-level attribution model, and the DPA catalog + redirect-worker pattern. Credentials (Meta token, Google/Data-Manager OAuth, PostHog keys, Cloudflare) are handed over out of band — never in this page.
14. Roadmap — how to improve the system
Ranked, highest-leverage first:
Fix measurement. Move the dashboards off event-UTM (8% coverage) to person-level journey attribution, and put CPQ / CPA next to CPL so the vanity metric stops being the headline.
Populate suppression. Fill the empty Closed + In-Escrow and Qualified exclusion lists (source from Attio) — you're currently paying to re-serve people who already transacted.
Unblock OnSpot via Google Customer Match. Get the raw open-house address file from OnSpot and upload it as a first-party address CM list (targetable + lookalike-able) — sidesteps the Meta partner-permission block entirely.
Close the reg→qualified gap. Registered users browse deep (median 5 homes) but stall before the tour request; Meta-sourced regs qualify at ~13% vs 26-33% for direct/Google. Add a mid-funnel nudge and tighten Meta registration quality.
Scale the closers. Google metro Search + RLSA Warm drive the down-funnel; TX metros have been spend-gated/dark. Push budget toward the best-CAC surfaces (Google), not the cheapest-CPL ones (Meta prospecting).
Mature value bidding. As approved volume grows, graduate optimization toward the approved event / higher-value targets; keep feeding accurate ladder values.
Creative velocity. Systematic angle testing; DPA dynamic for retargeting, static/carousel for cold audiences; scheduled catalog + creative refresh.
Harden automation. Failure alerting on every worker (a stale audience feed reads "green" while degrading), and automate the product-set refresh cadence.
15. Managing the workers — operator guide
Once you have Cloudflare access (Manage Account → Members) and the source bundle, here's how to run, edit, and monitor the fleet.
Where the files are
Cloudflare dashboard: Workers & Pages → each worker by name (signal-audiences, homa-lab-customer-match-sync, etc.).
Source: the handoff bundle — each worker is a folder with src/worker.js (or worker.js) + wrangler.toml (which carries its cron schedule + KV bindings).
Editing & deploying
Quick tweak: dashboard → the worker → Edit code → Save & Deploy.
Proper way (recommended):cd workers/<name> && npm i, edit src/worker.js, then npx wrangler deploy. The wrangler.toml ships the cron + bindings automatically.
Secrets & config
Secrets are set with npx wrangler secret put <NAME> — never in source. See the SECRETS.md checklist for each worker's required names; get values from Shawn out of band.
List what's set: npx wrangler secret list. Non-secret config lives in wrangler.toml [vars].
KV namespaces: bindings are in wrangler.toml. If you redeploy into your OWN account, create the namespace (npx wrangler kv namespace create <BINDING>) and paste the new id into wrangler.toml.
Schedules & manual runs
Cron is in wrangler.toml under [triggers] crons. Change the schedule → edit → redeploy.
Most workers expose a manual trigger for testing without waiting for cron: POST /run?token=<MANUAL_TRIGGER_TOKEN> (or /sync with a bearer). The token is a worker secret.
Heartbeats: every worker posts a Slack summary + writes a KV heartbeat on each run (silent-failure rule). A missing nightly heartbeat = a broken audience/CM feed — watch that channel.
Health endpoint: most expose /health or /healthz returning last-run status.
Troubleshooting
PostHog 504s on heavy queries are common — the workers retry / run sequentially. A one-off 504 is fine; repeated failures mean the query needs narrowing.
Stale audience (count not growing) → check that worker's heartbeat + wrangler tail; usually a rotated PostHog/Meta/Google token.
Customer Match not serving on Display is expected — CM only serves Search (see §9).
Hard rules
Never touch the MM_ Meta or G_ Google campaigns from LAB tooling — those are your own Mono campaigns; keep them separate.
Do not let capi_sync.py / catalog_sync.py lapse — they're the re-host-required cron jobs (§6.B). If it stops, CAPI conversions degrade silently. catalog_sync.py is the exception — its schedule is deliberately OFF (§18) and the catalog is maintained by hand.
Keep the lab_ UTM conventions so LAB stays distinguishable from Mono in reporting (§7).
16. Vendor data refresh — Speedeon (mortgage intent, INBOUND)
Direction matters. Speedeon is inbound — they send us a file, we ingest it and build audiences. OnSpot (§17) is the opposite. Conflating the two breaks both.
What it is: DataWatch Intent, FL + TX. Weekly refresh of people showing mortgage-shopping behaviour. Contacts, pricing and credential locations live in the vendor refresh workbook — deliberately not on this public page.
The weekly loop
Request the file. Ask explicitly for NET-NEW ONLY, suppressed against every prior delivery. Without that we pay twice for the same person and re-contact them — the most expensive mistake available here.
Approve counts. They return record counts by state and intent tier before posting. Sanity-check against the selects below.
Inspect:python3 intake.py — schema, rows, state split, email coverage. Confirm it matches the approved counts.
Suppress anyone already registered, in a recovery flow, or unsubscribed.
Activate across all three: Instantly drip, Meta multi-key Custom Audience, Google Customer Match (Observation only).
Current selects
P1 — Mortgage Purchase Intent: 2+ events in the last 60 days, FL + TX.
P2 — Mortgage General Intent: 2+ events in the last 60 days (widened from 3+ on 2026-07-17 to reach volume).
Shortfall rule: fill P1 first, top up from P2 to the target quantity. Ship what exists rather than holding. Only call if the total is under roughly half the target.
What's in the file
Identity: FNAME, LNAME, ADDRESS, City, State, ZIP, ZIP4, SPEEDEONID.
Contact:DEPLOYMENT_EMAIL (the one to mail) plus DIGITAL_EMAIL_1..5 for ad-platform onboarding.
Intent depth:MORTGAGE_GENERAL(_EVENTS_TOTAL) and MORTGAGE_PURCHASE(_EVENTS_TOTAL). Prioritise the drip by event count — it is the only intensity signal in the file.
Open with the vendor — asked 2026-08-12, still unanswered. Whoever owns this needs to chase all five: (1) net-new suppression against prior deliveries, (2) a fixed weekly day + filename notification, (3) expected net-new volume per week at current selects, (4) incremental pricing vs the initial order and whether it draws down existing budget, (5) whether a previously-delivered person who fires a NEW intent event reappears or stays suppressed forever — if they can reappear we need a flag or event-recency date.
Baseline from the first delivery (2026-07-17)
20,000 records — FL 10,472 / TX 9,528.
17,055 with a deployment email (85.3% coverage), zero duplicates.
Intent mix: 1,790 purchase-intent (P1) vs ~18.2K general-only (P2). P1 is the priority tier.
17. Vendor data refresh — OnSpot (open-house geofence, OUTBOUND)
This one runs the other way.We send them a file of open houses. They match devices seen at those locations during those time windows and push Custom Audiences straight into act_9501970176547289.
The weekly loop
Pull open houses:python3 oh_fetch_raw.py "<dates>" oh_raw.json — all 16 FL MLS sources. Save raw first, always — a combined fetch+enrich run that dies mid-flight loses the fetch.
Export our warehouse:python3 wh_export.py.
Backfill the gap:python3 oh_backfill.py — warehouse wins where present, MLS property endpoint fills the rest. Resumable.
Split per weekend:python3 split_weekends.py — Sunday buckets to its own Saturday.
RUN THE QA GATE (below). Do not skip it.
Send the weekend CSVs. Raw CSV is fine.
Verify what landed: list all custom audiences sorted by time_created desc. Do not name-match — their naming varies (Open-Houses vs Open_Houses, hyphen vs underscore) and a name filter silently misses real deliveries.
Check real reach with a delivery estimate on the union (see the reach trap).
Build the adset. Audiences do nothing on their own.
⚠️ QA GATE — run before every send. On 2026-08-12 their QA rejected 287 of our records for invalid open-house windows. That was our data quality, not theirs, and it cost a full delivery cycle. Five checks:
end_time_utc must be afterstart_time_utc — an inverted window is an unusable geofence. 462 rows failed.
start_time_utc present — 87 rows failed.
end_time_utc present — 2 rows failed.
latitude and longitude present and non-zero — lat/long is the targeting method now. 5 rows failed.
One row per (source, listing, date, start) — duplicates inflate the apparent audience.
Root cause: one MLS feed is broken
The bad rows are not random. They cluster in a single source:
citrus — 264 of 445 rows bad (59.3%). Systemic: it emits a start on one date and an end on the prior date, a date-rollover/timezone bug at the source. Example: start 2026-08-01T02:00:00Z, end 2026-07-31T20:00:00Z — six hours before it began.
Zero bad rows: realmls, ftl, nabor, ecar, ftlrmls, dbar, spacecoast, rairc, catrs, cpar.
Fix options: (A) drop invalid rows before sending — cheapest, costs ~1% of volume; (B) repair the citrus rollover by advancing the end date a day, only where local times validate — a wrong window is worse than a dropped row; (C) escalate upstream, since 59% broken is not something to patch forever.
Agreed terms
Lat/long only going forward. Do not send both address-match and lat/long — that is what created the duplicate-audience confusion. Lat/long is accurate to ~90 ft with a near-100% match rate; address-match is tighter but only ~80%+ matches.
One cohort per weekend, kept separate. We OR them together in the adset and Meta dedupes, so separation is free and lets us read decay by cohort.
Turnaround 24–72 hours, partly manual, not guaranteed. A standing schedule was requested (our file Monday AM → audience live end of day Wednesday); unconfirmed.
Pricing: month 1 is a locked rate; month 2 reverts to a CPM that moves with monthly spend.
💰 The reach trap — never price against the headline. The 2026-08-05 delivery was 9 audiences totalling ~117,000 devices on paper. Meta's own delivery estimate on the union put deduplicated reach at 23,700–27,900 — roughly a quarter. Cause: both address-match and lat/long files were pushed, plus apparent duplicate pushes of the same file (vendor-confirmed). Always plan and price against the union estimate.
Unfinished business. The $5,000 display pilot (7/13–8/13) came back ~87%+ non-human on IP classification — ad-verification scanners plus datacenter/proxy traffic, with only ~3% verifiable residential. Four questions were put to the vendor (placement list, served-impression geo, expansion settings, IVT/make-good policy) and none were answered. That flight has now ended. Treat it as open leverage in any renewal conversation.
18. The listing catalog — maintain by hand, never on a schedule
Read this before touching catalog_sync.py or re-enabling any catalog schedule. The hourly full-feed refresh is switched off on purpose. Turning it back on recreates a real incident.
What happened on 2026-07-29
It was not an outside attack — it was Meta's own crawler (meta-externalads/1.1), and we triggered it. The crawler fetches a listing's landing URL whenever it needs to read that item. The hourly full-feed re-ingest re-validated all ~16,000 listing URLs against /property/, our most expensive render, across two catalogs. On the worst day the crawler accounted for roughly 10,500 of 12,500 apparent Meta-source visitors — it buried the real traffic in analytics and hammered the origin.
The cost model — the whole rule in four lines
Action
What it costs
Safe?
DELETE a listing
Nothing. Removal needs no URL fetch.
Always
CREATE a listing
One URL fetch per new item. Bounded, predictable.
In small batches
Change an item's image URL
Re-fetches the image only, not /property/.
Yes
Full-feed re-upload / hourly schedule
Every listing URL, all at once, repeatedly.
NEVER
Safe operations
Remove stale items.POST /{catalog}/items_batch, item_type: HOME_LISTING, method: DELETE on home_listing_id. Chunk ≤200 per call; it's async and returns handles. Use DELETE, not an availability update — availability=pending propagated inconsistently, and an UPDATE against an absent item creates bare stubs that throw missing-image/name/address warnings.
Add new items incrementally. Same endpoint, method: CREATE, only genuinely new on-market ids, a few hundred per call at most. Each new id costs one fetch — a bounded trickle, not a flood.
Verify:home_listings?filter={home_listing_id:{is_any:[...]}}. An empty result means the item is invalid and will not display.
CREATE payload — two traps that fail silently
image MUST be an array — image:[{url:...}]. Passing a plain string, or the bracket-key form 'image[0].url', both fail with a generic "invalid values" / "missing image" error and the item quietly never displays.
Use the RAW MLS photo on create, not the badged URL. The image compositor hasn't run for a brand-new listing, so the badged object doesn't exist yet and the ad renders a broken image.
Then badge the new items
Hand-added items bypass the nightly badger, which reads the frozen feed. Correct order: CREATE with the raw photo → composite → PUT to R2 → items_batch UPDATE the image to the badged URL. That final image swap re-fetches only the image, so it does not trigger a crawl.
Choosing what to add
Don't add everything. Start from demand: a property_viewed CTE over the last 21 days with HAVING viewers >= 2, then INNER JOIN the listings table on that set — the full-table left-join version times out. Diff against a full catalog id dump to find what's genuinely new. Note the analytics query API applies a default limit of ~100, so always set an explicit LIMIT.
Cadence & scale
Monthly, or when spend shifts: remove off-market listings. The last pass found 476 of 3,562 served listings (13%) were off-market, but they were drawing only ~1% of spend — this is hygiene, not an emergency. Catalog went 16,336 → 16,150 with zero crawl.
As needed: add in-demand new listings. Last pass added 55.
Never: full re-upload, or re-enabling the hourly schedule.
The permanent fix is an engineering change, not a process one. Cache /property/ by user-agent for meta-externalads, and a full refresh stops being expensive. Until that ships, this manual process is the protection.