diff --git a/docs/research-otc-markets-lead-source.md b/docs/research-otc-markets-lead-source.md
index 331de7f..c65aadc 100644
--- a/docs/research-otc-markets-lead-source.md
+++ b/docs/research-otc-markets-lead-source.md
@@ -92,6 +92,27 @@ So **~93-95% of the US-domestic OTC universe is sub-$75M-float "smaller reportin
**Counterpoint - don't they all use law firms?** Many *touch* a lawyer, but for microcaps that lawyer is usually a solo/small securities boutique billing hourly, and **routine state filings (RA, annual report, franchise tax, foreign qualification, even a straightforward TBOC Ch.10 conversion) are exactly the commoditized work microcaps want to NOT pay $400/hr for.** Our pitch isn't "replace your lawyer," it's "we do the filing legwork flat-fee so your counsel only does the parts that need a lawyer." That framing both respects the relationship and lowers their cost - and it's the same value prop that already works in our FCC/CMS verticals.
+## 4c. Where are companies actually reincorporating TO? (Nevada #1, Texas the fast riser)
+
+Don't lead with Texas alone - the data says **offer the move, name the best destination per client.** EDGAR full-text search, counting filings that mention reincorporating/redomesticating to each state:
+
+| Destination state | All-time reincorporation-mention filings | "reincorporate in X" since 2024 |
+|---|---|---|
+| **Nevada** | **281** | **33** |
+| **Texas** | **99** | **27** |
+| Florida | 23 | 1 |
+| Wyoming | 8 | - |
+| (South Dakota, Tennessee) | 0 | 0 |
+
+Read:
+- **Nevada is the #1 actual destination** by a wide margin (the long-standing Delaware alternative) - no corporate/franchise income tax, strong statutory director-liability protection (NRS 78.138), no public-float-scaled fees.
+- **Texas is the fast riser**: nearly tied with Nevada on *recent* (since-2024) filings (27 vs 33), driven by the Texas Business Court (2024), TXSE, and no corporate income tax. It's the timely headline but a smaller installed base.
+- **Florida** is a real-but-modest third (no state income tax).
+- **Wyoming** is niche (cheapest fees + privacy, but thin case law) - mostly tiny shells; small volume.
+- South Dakota / Tennessee: not a thing for public companies.
+
+**Implication for our offer + script:** lead the campaign with the broad hook ("leaving Delaware? we handle the conversion") and let the client pick **Nevada (cost/liability), Texas (court + TXSE + growth), or Florida.** In the lead CSV we prioritize **DE/NV-incorporated** issuers (DE = ripe to leave; NV = already made one move, open to optimizing / re-domesticating their HQ state), since those are where the conversation lands. A TBOC Ch.10-style conversion exists in NV (NRS Ch. 92A) and FL too, so the same flat-fee service productizes across all three destinations.
+
## 5. Which of OUR services fit this list
From `api/src/service-catalog.ts` (corporate vertical), these all fit OTC microcap issuers:
diff --git a/scripts/otc_lead_pull.py b/scripts/otc_lead_pull.py
new file mode 100644
index 0000000..5b924db
--- /dev/null
+++ b/scripts/otc_lead_pull.py
@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+"""Pull the OTC-Markets (pink-sheets) corporate-services lead list from SEC EDGAR.
+
+EDGAR is free, public, and explicitly OK to bulk-download (max 10 req/sec, declare
+a User-Agent with a contact). We pull two things:
+
+ 1. company_tickers_exchange.json -- the master ticker/exchange map. We keep
+ issuers whose exchange is "OTC" (or blank/None, which is also off-major-
+ exchange). This is the ~2,771-issuer OTC SEC-filer universe.
+ 2. submissions/CIK{cik}.json (one per issuer) -- per-company detail: state of
+ incorporation, business + mailing address, phone, SIC industry, entity type,
+ filer category (= public-float size class), and last filing date.
+
+We then FILTER to the genuine prospect set (see docs/research-otc-markets-lead-
+source.md, sections 3 + 4b):
+
+ - US-domestic incorporation only (drops foreign ADRs we can't redomesticate;
+ also keeps us cleanly under CAN-SPAM, away from CASL/GDPR).
+ - microcaps only: drop "Large accelerated filer" / "Accelerated filer"
+ (those keep securities counsel on retainer -- not our lane). Keep
+ Non-accelerated / Smaller reporting / Emerging growth / blank.
+ - actively filing: drop issuers with no filing in the last ~13 months
+ (delinquent / dark shells).
+
+Output: a CSV of the ~700-850 active US-domestic microcap issuers with everything
+needed to segment + reach them (EDGAR has no email, so we capture phone + address
++ website for enrichment / direct mail / cold call).
+
+Usage:
+ python3 scripts/otc_lead_pull.py # full pull -> data/otc_leads.csv
+ python3 scripts/otc_lead_pull.py --out PATH
+ python3 scripts/otc_lead_pull.py --limit 200 # sample run (first N OTC ciks)
+ python3 scripts/otc_lead_pull.py --include-large # keep accelerated/large filers
+ python3 scripts/otc_lead_pull.py --include-foreign # keep foreign-incorporated
+ python3 scripts/otc_lead_pull.py --max-stale-days 395 # active-filer cutoff (default 395)
+ python3 scripts/otc_lead_pull.py --rps 6 # requests/sec (<=10 per SEC policy)
+
+Set OTC_SEC_CONTACT (or pass --contact) to your real contact e-mail for the
+User-Agent header. SEC requires a contact; default falls back to the ops address.
+"""
+from __future__ import annotations
+import argparse, csv, datetime, json, os, sys, tempfile, time
+import urllib.request, urllib.error
+
+TICKERS_EXCHANGE_URL = "https://www.sec.gov/files/company_tickers_exchange.json"
+SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik:010d}.json"
+
+DATA_DIR = os.getenv("OTC_DATA_DIR", os.path.join(os.path.dirname(__file__), "..", "data"))
+DEFAULT_OUT = os.path.join(DATA_DIR, "otc_leads.csv")
+DEFAULT_CONTACT = os.getenv("OTC_SEC_CONTACT", "compliance@performancewest.net")
+
+# OTC issuers carry these exchange tags in company_tickers_exchange.json. Blank/None
+# = off-major-exchange (also OTC/expert-market in practice), so we keep it too.
+OTC_EXCHANGES = {"OTC", "", None}
+
+# SEC public-float size classes (Rule 12b-2). We drop these "large" ones -- they
+# keep securities counsel on retainer and won't buy a flat-fee filing service.
+LARGE_FILER_MARKERS = ("large accelerated filer", "accelerated filer")
+# Note: "non-accelerated filer" contains the substring "accelerated filer", so we
+# match on word-boundary-ish logic in is_large_filer() rather than naive `in`.
+
+US_STATES = set(
+ "AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO "
+ "MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC".split()
+)
+
+OUT_FIELDS = [
+ "cik", "name", "ticker", "all_tickers", "exchange",
+ "state_of_incorporation", "incorporation_desc", "is_us_domestic",
+ "sic", "sic_desc", "entity_type", "filer_category", "filer_size_bucket",
+ "biz_street1", "biz_street2", "biz_city", "biz_state", "biz_zip",
+ "mail_street1", "mail_city", "mail_state", "mail_zip",
+ "phone", "website", "investor_website", "ein",
+ "former_names", "last_filing_date", "last_filing_form", "is_active",
+]
+
+
+def log(msg: str) -> None:
+ print(f"[{datetime.datetime.now():%Y-%m-%d %H:%M:%S}] {msg}", flush=True)
+
+
+def http_json(url: str, contact: str, timeout: int = 20) -> dict:
+ req = urllib.request.Request(
+ url, headers={"User-Agent": f"Performance West OTC lead pull ({contact})",
+ "Accept-Encoding": "gzip, deflate"}
+ )
+ # urllib does not auto-decompress; ask for identity to keep it simple.
+ req.add_header("Accept-Encoding", "identity")
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return json.load(resp)
+
+
+def is_large_filer(category: str) -> bool:
+ """True only for accelerated / large-accelerated filers (>= $75M float).
+
+ "Non-accelerated filer" must NOT match even though it contains "accelerated
+ filer" as a substring, so check the leading words of each category line.
+ """
+ cat = (category or "").lower()
+ for line in cat.replace("
", "\n").split("\n"):
+ line = line.strip()
+ if line.startswith("large accelerated filer") or line.startswith("accelerated filer"):
+ return True
+ return False
+
+
+def size_bucket(category: str) -> str:
+ cat = (category or "").lower()
+ if "large accelerated filer" in cat:
+ return "large_accelerated_>=700M"
+ # accelerated but not large, and not non-accelerated
+ if is_large_filer(category):
+ return "accelerated_75M-700M"
+ if "smaller reporting company" in cat or "non-accelerated" in cat or "emerging growth" in cat or not cat:
+ return "smaller_reporting_<75M"
+ return "other"
+
+
+def addr(d: dict, key: str) -> dict:
+ return (d.get("addresses", {}) or {}).get(key, {}) or {}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--out", default=DEFAULT_OUT)
+ ap.add_argument("--contact", default=DEFAULT_CONTACT,
+ help="contact e-mail for the SEC User-Agent header")
+ ap.add_argument("--limit", type=int, default=0,
+ help="only process the first N OTC ciks (sampling/testing)")
+ ap.add_argument("--rps", type=float, default=6.0,
+ help="requests/sec to data.sec.gov (SEC policy max is 10)")
+ ap.add_argument("--max-stale-days", type=int, default=395,
+ help="drop issuers whose last filing is older than this (active filter)")
+ ap.add_argument("--include-large", action="store_true",
+ help="keep accelerated/large-accelerated filers (default: drop)")
+ ap.add_argument("--include-foreign", action="store_true",
+ help="keep foreign-incorporated issuers (default: US-domestic only)")
+ ap.add_argument("--include-stale", action="store_true",
+ help="keep delinquent/dark issuers (default: drop stale filers)")
+ ap.add_argument("--keep-rejects", default="",
+ help="optional path to also write the dropped issuers (for QA)")
+ args = ap.parse_args()
+
+ if args.rps > 10:
+ log("WARNING: --rps above SEC fair-access limit (10); clamping to 8")
+ args.rps = 8.0
+ delay = 1.0 / args.rps if args.rps > 0 else 0.0
+ today = datetime.date.today()
+
+ log("Fetching master ticker/exchange map ...")
+ master = http_json(TICKERS_EXCHANGE_URL, args.contact)
+ fields = master["fields"]
+ ix = {f: i for i, f in enumerate(fields)}
+ rows = master["data"]
+ otc = [r for r in rows if r[ix["exchange"]] in OTC_EXCHANGES]
+ if args.limit:
+ otc = otc[: args.limit]
+ log(f"OTC/off-exchange issuers to inspect: {len(otc)} (of {len(rows)} total tickers)")
+
+ kept: list[dict] = []
+ rejects: list[dict] = []
+ stats = {"foreign": 0, "large": 0, "stale": 0, "fetch_err": 0, "kept": 0}
+
+ for n, r in enumerate(otc, 1):
+ cik = r[ix["cik"]]
+ try:
+ j = http_json(SUBMISSIONS_URL.format(cik=cik), args.contact, timeout=15)
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as e:
+ stats["fetch_err"] += 1
+ time.sleep(delay)
+ continue
+
+ soi = (j.get("stateOfIncorporation") or "").strip()
+ is_us = soi in US_STATES
+ category = j.get("category") or ""
+ large = is_large_filer(category)
+
+ recent = j.get("filings", {}).get("recent", {}) or {}
+ dates = recent.get("filingDate") or []
+ forms = recent.get("form") or []
+ last_date = dates[0] if dates else ""
+ last_form = forms[0] if forms else ""
+ is_active = False
+ if last_date:
+ try:
+ d = datetime.date.fromisoformat(last_date)
+ is_active = (today - d).days <= args.max_stale_days
+ except ValueError:
+ pass
+
+ biz, mail = addr(j, "business"), addr(j, "mailing")
+ rec = {
+ "cik": cik,
+ "name": j.get("name", ""),
+ "ticker": r[ix["ticker"]],
+ "all_tickers": "|".join(j.get("tickers") or []),
+ "exchange": r[ix["exchange"]] or "",
+ "state_of_incorporation": soi,
+ "incorporation_desc": j.get("stateOfIncorporationDescription", ""),
+ "is_us_domestic": "Y" if is_us else "N",
+ "sic": j.get("sic", ""),
+ "sic_desc": j.get("sicDescription", ""),
+ "entity_type": j.get("entityType", ""),
+ "filer_category": category.replace("
", " / "),
+ "filer_size_bucket": size_bucket(category),
+ "biz_street1": biz.get("street1", "") or "",
+ "biz_street2": biz.get("street2", "") or "",
+ "biz_city": biz.get("city", "") or "",
+ "biz_state": biz.get("stateOrCountry", "") or "",
+ "biz_zip": biz.get("zipCode", "") or "",
+ "mail_street1": mail.get("street1", "") or "",
+ "mail_city": mail.get("city", "") or "",
+ "mail_state": mail.get("stateOrCountry", "") or "",
+ "mail_zip": mail.get("zipCode", "") or "",
+ "phone": j.get("phone", "") or "",
+ "website": j.get("website", "") or "",
+ "investor_website": j.get("investorWebsite", "") or "",
+ "ein": j.get("ein", "") or "",
+ "former_names": "|".join(fn.get("name", "") for fn in (j.get("formerNames") or [])),
+ "last_filing_date": last_date,
+ "last_filing_form": last_form,
+ "is_active": "Y" if is_active else "N",
+ }
+
+ drop_reason = None
+ if not args.include_foreign and not is_us:
+ drop_reason = "foreign"; stats["foreign"] += 1
+ elif not args.include_large and large:
+ drop_reason = "large_filer"; stats["large"] += 1
+ elif not args.include_stale and not is_active:
+ drop_reason = "stale"; stats["stale"] += 1
+
+ if drop_reason:
+ rec["drop_reason"] = drop_reason
+ rejects.append(rec)
+ else:
+ kept.append(rec)
+ stats["kept"] += 1
+
+ if n % 200 == 0:
+ log(f" {n}/{len(otc)} inspected; kept {stats['kept']} so far")
+ time.sleep(delay)
+
+ # sort: DE/NV (reincorporation targets) first, then by state, then name
+ priority = {"DE": 0, "NV": 1}
+ kept.sort(key=lambda x: (priority.get(x["state_of_incorporation"], 2),
+ x["state_of_incorporation"], x["name"].lower()))
+
+ out = os.path.abspath(args.out)
+ os.makedirs(os.path.dirname(out), exist_ok=True)
+ tmp = out + ".tmp"
+ with open(tmp, "w", newline="", encoding="utf-8") as f:
+ w = csv.DictWriter(f, fieldnames=OUT_FIELDS)
+ w.writeheader()
+ for rec in kept:
+ w.writerow({k: rec.get(k, "") for k in OUT_FIELDS})
+ os.replace(tmp, out)
+
+ if args.keep_rejects:
+ rj = os.path.abspath(args.keep_rejects)
+ with open(rj, "w", newline="", encoding="utf-8") as f:
+ w = csv.DictWriter(f, fieldnames=OUT_FIELDS + ["drop_reason"])
+ w.writeheader()
+ for rec in rejects:
+ w.writerow({k: rec.get(k, "") for k in OUT_FIELDS + ["drop_reason"]})
+
+ # summary
+ from collections import Counter
+ by_inc = Counter(r["state_of_incorporation"] for r in kept)
+ log("=" * 60)
+ log(f"DONE. wrote {len(kept)} leads -> {out}")
+ log(f" inspected {len(otc)} OTC issuers; fetch errors {stats['fetch_err']}")
+ log(f" dropped: foreign={stats['foreign']} large_filer={stats['large']} stale={stats['stale']}")
+ de, nv = by_inc.get("DE", 0), by_inc.get("NV", 0)
+ log(f" incorporation of kept: DE={de} NV={nv} (DE+NV={de+nv}) TX={by_inc.get('TX',0)}")
+ log(f" top incorporation states: {by_inc.most_common(8)}")
+ have_phone = sum(1 for r in kept if r["phone"])
+ have_web = sum(1 for r in kept if r["website"] or r["investor_website"])
+ log(f" reachability: phone {have_phone}/{len(kept)} website {have_web}/{len(kept)} (EDGAR has no email)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())