Store research and responsible outreach
How to Find Shopify Stores and Build a Compliant E-Commerce Lead List (2026)
Last reviewed: October 28, 2026
Build a useful prospect list from public store domains: verify the platform, record observed technology and catalog signals, then review contact permissions before outreach.
Where store domains come from
Start with your own niche research: describe the product category, region and type of merchant you can serve. Collect store domains from public directories, search results, competitor customer pages and trade show exhibitor lists. Follow each source's terms and published crawling policy; a public page is not automatic permission for bulk reuse.
Keep the domain, source URL, collection date and a short relevance note. Deduplicate hostnames and review redirects to avoid counting the same business repeatedly. Bulk lists go stale: merchants close, migrate platforms or restrict access.
In our October 27, 2026 scan of 2,600 domains, 68.31% were reachable with a successful storefront response. That is evidence that verification matters, not a universal failure rate for every list. Preserve blocked and failed results separately from reachable stores without an observed technology.
Verify the platform
Look for code-level Shopify signals such as Shopify.shop and Shopify.theme in scripts, then check a permitted public /products.json response. For WooCommerce, look for asset paths containing wp-content/plugins/woocommerce and, where allowed, inspect /wp-json/wc/store/v1/products.
Use the Shopify and WooCommerce tech-stack detection guide for the detailed evidence method. Headless storefronts, blocked feeds and consent-dependent scripts can leave the platform unknown. A product-shaped response alone is a candidate signal; a missing feed does not prove that the store is not Shopify.
Segment the list
Segment by observed email/SMS apps, review tools and subscriptions so your offer matches the merchant's current stack. Keep tracking pixels separate from apps and retain the evidence and inspection date. A missing tag is an unknown observation, not proof that a merchant does no advertising.
| Observed tool | Segment | Share of verified Shopify stores |
|---|---|---|
| Klaviyo | Email/SMS | 58.80% |
| Judge.me | Reviews | 23.97% |
| Recharge | Subscriptions | 9.58% |
| Meta Pixel | Tracking pixel | 7.46% |
These are overlapping code-surface observations within our sample, not worldwide market shares or proof of active subscriptions. Initial HTML can miss server-side integrations, sandboxed pixels and consent-gated tags.
For catalog price tiers, compare sampled priceMedian within the same currency and product niche, and retain the sample size. Our October 27, 2026 scan of 2,600 domains found a US$52.00 median of store median prices among 691 USD Shopify catalogs. That sample benchmark is not revenue or a universal budget/premium cutoff. Define tiers for your own category rather than mixing currencies or inferring merchant revenue.
Python 3 standard-library script
Save a CSV with a domain header and bare store hostnames, then download this script. Replace its User-Agent contact URL with your crawler information page. Run it locally only on reviewed public store domains.
python3 check-stores.py domains.csv checked.csvThe script uses HTTPS, timeouts, a response-size cap, sequential requests and a delay that also honours parsed crawl-delay and request-rate rules. It checks robots before storefront and feed requests. Missing robots files are allowed; other robots failures stop inspection. Redirects are deliberately left for manual review: update the domain to its canonical host and rerun so that host's robots policy is checked.
product_count is the number of products in the returned sample, not a full published catalog count. A blank value means unavailable or not checked. WooCommerce is identified from an asset signal here; this script does not count its catalog or collect contacts. shopify_candidate means a product feed was found without a corroborating Shopify script signal. Python's robots parser is a basic policy check; the script stops on wildcard or end-anchor path rules that it cannot fully interpret. Review complex policies and site terms yourself. Do not bypass login, consent or access restrictions.
#!/usr/bin/env python3
"""Check a reviewed CSV of public store domains; no contact collection."""
import csv
from html.parser import HTMLParser
import json
import re
import sys
import time
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, Request, build_opener
from urllib.robotparser import RobotFileParser
# Replace the contact URL with your own public crawler information page.
AGENT = "StoreListResearch/1.0 (+https://example.com/crawler-info)"
TIMEOUT = 15
DELAY = 2.0
MAX_BYTES = 2_000_000
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
opener = build_opener(NoRedirect())
last_request = 0.0
def fetch(url, delay=DELAY):
global last_request
time.sleep(max(0, delay - (time.monotonic() - last_request)))
try:
with opener.open(Request(url, headers={"User-Agent": AGENT}),
timeout=TIMEOUT) as response:
if response.status != 200:
raise ValueError("non-success response")
body = response.read(MAX_BYTES + 1)
if len(body) > MAX_BYTES:
raise ValueError("response too large")
return body.decode("utf-8", errors="replace")
finally:
last_request = time.monotonic()
class Signals(HTMLParser):
def __init__(self):
super().__init__()
self.in_script = False
self.shopify = False
self.woo = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
self.in_script = self.in_script or tag == "script"
asset = attrs.get("src", "") if tag == "script" else (
attrs.get("href", "") if tag == "link" else "")
if "wp-content/plugins/woocommerce/" in asset.lower():
self.woo = True
def handle_endtag(self, tag):
if tag == "script":
self.in_script = False
def handle_data(self, data):
if self.in_script and re.search(r"\bShopify\.(?:shop|theme)\s*=", data):
self.shopify = True
def inspect(domain):
result = dict(domain=domain, reachability="unknown", platform="unknown",
product_count="", catalog_status="not_checked")
# Accept bare DNS names only. Review your input; do not use untrusted lists.
if not re.fullmatch(r"[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?", domain):
result["reachability"] = "invalid_domain"
return result
if "." not in domain or domain.replace(".", "").isdigit():
result["reachability"] = "invalid_domain"
return result
base = "https://" + domain
policy = RobotFileParser(base + "/robots.txt")
try:
robots = fetch(policy.url)
# Fail closed on syntax RobotFileParser does not fully implement.
for line in robots.splitlines():
rule = line.split("#", 1)[0].strip()
key, separator, value = rule.partition(":")
if "<html" in rule.lower() or (
separator and key.lower() in ("allow", "disallow")
and ("*" in value or "$" in value)
):
raise ValueError("robots policy needs manual review")
policy.parse(robots.splitlines())
except HTTPError as error:
if error.code == 404:
policy.parse([])
else:
result["reachability"] = "robots_unavailable"
return result
except (OSError, ValueError):
result["reachability"] = "robots_unavailable"
return result
delay = max(DELAY, policy.crawl_delay(AGENT) or 0)
rate = policy.request_rate(AGENT)
if rate and rate.requests > 0:
delay = max(delay, rate.seconds / rate.requests)
if not policy.can_fetch(AGENT, base + "/"):
result["reachability"] = "robots_disallowed"
return result
try:
signals = Signals()
signals.feed(fetch(base + "/", delay))
result["reachability"] = "reachable"
if signals.shopify:
result["platform"] = "shopify"
elif signals.woo:
result["platform"] = "woocommerce"
except (OSError, ValueError):
result["reachability"] = "request_failed"
return result
if result["platform"] == "woocommerce":
return result
catalog = base + "/products.json?limit=50"
if not policy.can_fetch(AGENT, catalog):
result["catalog_status"] = "robots_disallowed"
return result
try:
data = json.loads(fetch(catalog, delay))
products = data.get("products") if isinstance(data, dict) else None
if not isinstance(products, list) or not all(
isinstance(p, dict) and "id" in p and "variants" in p for p in products
):
raise ValueError("unexpected catalog shape")
result["product_count"] = len(products)
result["catalog_status"] = "sampled"
if result["platform"] == "unknown" and products:
result["platform"] = "shopify_candidate"
except (OSError, ValueError):
result["catalog_status"] = "unavailable"
return result
def main():
if len(sys.argv) != 3 or sys.argv[1] == sys.argv[2]:
raise SystemExit("Usage: python3 check-stores.py domains.csv checked.csv")
with open(sys.argv[1], newline="", encoding="utf-8-sig") as source:
reader = csv.DictReader(source)
if "domain" not in (reader.fieldnames or []):
raise SystemExit("Input CSV needs a domain header")
with open(sys.argv[2], "w", newline="", encoding="utf-8") as target:
writer = csv.DictWriter(target, fieldnames=[
"domain", "reachability", "platform", "product_count", "catalog_status"
])
writer.writeheader()
seen = set()
for row in reader:
domain = (row.get("domain") or "").strip().lower().rstrip(".")
if domain and domain not in seen:
seen.add(domain)
writer.writerow(inspect(domain))
if __name__ == "__main__":
main()
Keep outreach compliant
General information only, not legal advice. Use only generic role inboxes such as info@, support@ and sales@ found on public pages. Do not harvest personal data or buy personal email lists. A public role inbox does not establish consent or certify compliance.
- Canada, CASL and PIPEDA: assess consent or an applicable exception before commercial electronic messages; identify yourself and provide an unsubscribe mechanism. Where personal information is involved, assess applicable privacy obligations, limit collection and use, and protect retained records.
- US, CAN-SPAM: business outreach is covered too. Use truthful sender details and subjects, identify advertising as required, include a valid postal address and provide a working opt-out.
- EU/UK, GDPR: if personal data is involved, establish a lawful basis, be transparent, minimise retention and honour objections to direct marketing. Electronic marketing rules also apply; GDPR alone does not authorise email outreach. Check local rules and UK PECR, including recipient type.
Keep the public source, business relevance, permission or lawful-basis record and a suppression list. Honour unsubscribes and do not re-add suppressed recipients on the next import. The linked official sources below explain the requirements.
The easy option
The README describes platform and theme identification, observed app and pixel evidence, public catalog price distributions, generic role contacts, social profiles and repeat-run app churn diffs. Named personal employee emails are excluded. Observed scripts do not prove an active paid subscription; contact extraction does not provide permission to send marketing.
Frequently asked questions
Where can I find Shopify store domains?
Start with niche research, public directories, search results, competitor customer pages and trade show exhibitor lists. Review source terms, deduplicate domains and verify the current platform before qualifying a store.
Does a public role inbox give me permission to send marketing?
No. A published business inbox is not blanket consent. Assess the applicable privacy and anti-spam rules, keep evidence of your permission or lawful basis, identify yourself and honour unsubscribes.
Does the script count the whole product catalog?
No. The product_count field counts only products returned by the sampled public feed. A blank count means unavailable or not checked; it does not mean an empty catalog.
Does an app signature prove an active subscription?
No. Public code can be stale, disabled or consent-dependent. Treat app and pixel detections as observed signals to review, not proof of paid subscriptions or complete backend configuration.
Sources
Empirical figures come from shopify-ecommerce-benchmark-2026-10-summary.json and its README-DATASET.md: our October 27, 2026 scan of 2,600 domains. The existing benchmark guide supplies the public sample link. Product scope and pricing come from actors/ecommerce-store-enrichment/README.md; pricing is not a scan statistic. Software versions, example settings and review dates are not empirical scan measurements.
- Shopify developer docs: Ajax API (checked October 28, 2026)
- WooCommerce Store API: products (checked October 28, 2026)
- CRTC: CASL (checked October 28, 2026)
- OPC: PIPEDA (checked October 28, 2026)
- FTC: CAN-SPAM compliance guide (checked October 28, 2026)
- ICO: business-to-business marketing (checked October 28, 2026)