Pro eMarketing LLC
Case study — Live — cltintelhub.com

CLT Intel Hub

Multi-tenant B2B lead intelligence built on Charlotte/Mecklenburg public records: 32 signal sources, AI scoring and profession-specific lenses, and a subscription business on top.

Role
Pro eMarketing — design, build, ops
Timeline
April 2026 · ongoing
Stack
Next.js 16React 19TypeScriptSupabase + RLSStripeClaude APICheerio + PlaywrightResendMapbox GL
01Problem

Local B2B service professionals — insurance agents, estate attorneys, CPAs, contractors, real estate investors — buy stale lead lists and cold-call blind. Meanwhile the public record broadcasts buying intent every day: a building permit, a probate filing, a business registration, a foreclosure notice. The signal exists; it's just scattered across dozens of county, state, and federal systems that nobody has time to watch.

CLT Intel Hub watches all of them for one market. It ingests Mecklenburg County's public exhaust daily, uses AI to turn raw records into scored leads, explains each one through the subscriber's professional lens — why this matters to you, an insurance agent, this week — and charges a subscription for the result.

02What it does

The product is a pipeline from public record to explained opportunity. Each stage below is shipped and running on daily crons:

  1. Signal ingestion at county scale

    Thirty-two declared sources across five families: real property (deeds, permits, rezoning, code violations, foreclosures, tax liens), vital and estate records, business formation and licensing, federal/regulatory (SEC, SBA, OSHA, EPA, FDIC, USPTO), and local reputation (health inspections, BBB, 311 requests). Thirty-nine scheduled jobs run the fleet daily.

    32 sources · 39 Vercel crons · Cheerio for static HTML, Playwright for JS-rendered sources

  2. Scraper reliability framework

    Scrapers run inside a framework, not as loose scripts: a registry gates each source on config and required keys, circuit breakers stop hammering broken endpoints, retries back off exponentially, and every run writes phase-timed observability rows. The framework even detects upstream schema drift by diffing sampled records against expected fields — early warning when a county API quietly changes shape.

    scraper_registry gate · circuit breaker · scrape_runs/scraper_health tables · schema-drift detection · dead-letter conversion queue

  3. AI classification and entity extraction

    Every raw record is classified and its entities extracted — who is this about, what kind of event, which business — turning heterogeneous county records into one normalized lead shape with full source attribution shown to the subscriber.

    Claude Haiku per-record · source URL transparency on every signal

  4. Scoring, decay, and routing

    Leads are scored by signal type and freshness with per-type decay half-lives — a permit from yesterday outranks a lien from March — then routed to subscribers whose plan, chosen signal types, and geography match. High scorers are flagged as hot leads.

    weighted scoring + decay half-lives · segment-matched routing · hot-lead threshold at 80

  5. The Lead Lens

    The signature feature: the same lead reads differently to an estate attorney than to a moving company, so the Lens synthesizes each one per profession — nineteen profession contexts producing structured output: why this matters, deal-value reasoning, suggested angle and channel, timing, competitive context, and risk flags. Results cache per lead-and-profession and invalidate when the underlying signals change.

    19 profession contexts · strict JSON contract · Anthropic prompt caching · signal-hash invalidation

  6. Opportunity Radar & briefings

    Subscribers don't query a database; they open a radar. Daily briefings surface the big opportunity and what changed; a weekly intelligence brief summarizes the market. The product does the watching.

    Radar (4-layer architecture) · daily_briefings per subscriber · weekly brief cron

  7. Pipeline & drafted outreach

    A kanban pipeline tracks each lead from new to won, with AI-drafted email, letter, and LinkedIn copy per lead. Sensitive signal types carry enforced cooling periods before outreach is suggested — fourteen days on probate, twenty-one on obituary-sourced leads — because being first matters less than being decent.

    pipeline kanban · Claude Sonnet outreach drafts · per-signal-type cooling periods

  8. Plans, API, and billing

    Four subscription tiers from $29 to $499 gate signal types, monthly lead volume, outreach drafting, API access, and white-labeling. Enterprise tiers get a REST API keyed by hashed API keys. Stripe handles checkout and lifecycle webhooks; usage counters reset monthly by cron.

    4 tiers · plan-gated /api/v1 with SHA-256-hashed keys · Stripe lifecycle webhooks

Asset pending

{{TODO: Opportunity Radar screen with scored leads (redact names)}}

Asset pending

{{TODO: Lead Lens output for one lead viewed as two different professions}}

Asset pending

{{TODO: Admin scraper-health board (circuit states, run history)}}

Asset pending

{{TODO: Pricing page ($29 entry, tier ladder)}}

03Technical decisions

Shared data, tenant-scoped access — enforced in the database

Leads are a shared asset; what each subscriber sees is governed by Row Level Security policies that join through their subscription — one policy even scopes by the overlap between a subscriber's chosen signal types and a signal's target segments, folding tenancy and plan entitlement into a single database rule. Eleven of thirty migrations touch RLS; the application never gets to be the only line of defense.

Column-level grants where row-level policies aren't enough

An audit found that a subscriber's own-row update policy let any user upgrade their own plan and limits. The fix layered column-level REVOKEs on top of row-level policies: users can edit their profile row, but billing fields — plan, limits, Stripe IDs, API keys — are writable only by the service role. Defense in depth, at the exact granularity the threat required.

Scrapers as managed infrastructure, pruned loudly

The registry/circuit-breaker/health-table framework treats each of 32 sources as a monitored service. Just as deliberately: sources that proved dead or blocked were removed in explicit commits rather than left silently failing — the git history reads 'drop 6 dead scrapers', 'pause UCC/USPTO/OSHA'. An honest fleet beats an impressive-sounding one.

Fail closed, in the boring places too

Cron and scrape endpoints authenticate with constant-time comparison against a bearer secret and refuse to run if the secret is unset — closing the classic 'Bearer undefined' fail-open. The Stripe webhook returns 5xx when a database write fails so Stripe retries, and 500s outright if the signing secret is missing. The unglamorous endpoints are where silent failures live.

A 10-minute cold compile, fixed by architecture

Statically importing ~40 scraper modules — including Playwright and a bundled Chromium — gave the cron route a ten-minute cold compile. The fix was per-source lazy imports: each cron invocation loads only the scraper it runs. Serverless constraints are real architecture inputs, documented in the code where the next person will look.

04What verification caught

This project is where the Planner/Generator/Evaluator harness ran as a formal loop — an evaluator agent driving the live app and filing findings before any phase closed. Its catches, each fixed in a traceable migration or commit:

  • Caught:The origin story: the Stripe webhook returned 200 while silently swallowing Supabase write errors — payments could succeed without the subscription recording. Now it returns 5xx on write failure so Stripe retries
  • Caught:RLS missing or hollow on tenant tables — including two tables with RLS enabled but zero policies (silent deny-all masking a design gap); fixed across a dedicated lockdown sweep
  • Caught:Self-service plan escalation: any authenticated user could update their own plan and lead limits; closed with column-level grants over the row policy
  • Caught:An IDOR in a SECURITY DEFINER function that trusted a caller-supplied subscriber ID for usage counting
  • Caught:Cron auth failing open when the secret env var was unset ('Bearer undefined' passed); replaced with a constant-time, fail-closed shared helper
  • Caught:A Mecklenburg GIS sentinel price over $3.33B overflowed the numeric(10,2) value column mid-May; column widened and the scraper fleet hardened against county API slowdowns
  • Caught:A production schema-drift incident: two harness-authored fixes broke prod against a database that had drifted from the repo's migrations — rolled back to a known-good deploy, then migrations reconciled so the repo is the source of truth again
05Traction
32
declared public-record signal sources
39
scheduled cron jobs running daily
22K+
leads in the live database
19
profession contexts in the Lead Lens
30
database migrations (11 touching RLS)
~32K
lines of production TypeScript
{{TODO: subscriber count / trials}}
active subscribers
{{TODO: MRR band}}
recurring revenue
06Status & next
  • Migrate the remaining scraper fleet onto the Stage-1 adapter framework (five sources converted, pattern proven)
  • Metro expansion beyond Mecklenburg — the stated 2026 roadmap once the Charlotte playbook is repeatable
  • {{TODO: confirm current growth focus — subscriber acquisition vs data-product depth}}