# EcomScout Public API — instructions for coding agents You are calling a unified ecommerce-intelligence API. Every resource is one JSON object: stores, products, apps, analytics, changes, signals, and scrapes. You do not pick a backend. Do not scrape storefronts, invent SQL, or guess domains. Search first, then fetch by the domain the API returned. ## Base https://api.ecomscout.com ## Auth Send one of: - Authorization: Bearer - X-Api-Key: Do not put the key in the query string. If the server is in open-dev mode (no PUBLIC_API_KEYS), omit the key. User-issued keys share the same daily UI buckets as the app (store searches, store lookups, product searches, product/app lookups). A Starter key cannot list more stores via API than the Starter UI allows. There is also a per-key daily ceiling and a per-minute rate (X-RateLimit-* headers). On 429, wait Retry-After seconds. **Metering:** only successful **2xx** responses count toward daily quota and plan buckets. Client mistakes (**4xx**, including unknown routes like /v1/contacts) are logged for operators but do **not** burn quota or rate-limit slots (429 itself still counts). Server failures (**5xx**) are flagged for ops; they also do not burn daily quota. Docs and discovery are always open (no key, no rate limit): /, /v1/llms.txt, /v1/docs.md, /v1/openapi.json, /v1/health, /v1/examples. Human UI (no login): https://app.ecomscout.com/docs ## Error shape (always) {"error":{"code":"not_found|bad_request|unauthorized|rate_limited|query_timeout|internal","message":"...","details":...}} ## Pagination Every list: ?limit=20&offset=0 (limit max 100, offset max 10000 — beyond that narrow filters or use audiences export). Response: {"data":[...],"page":{"limit":N,"offset":N,"count":N,"hasMore":true,"total":N}} Stop when hasMore=false. Never invent a next page. ## Routing (pick the smallest call that answers the user) | User intent | Call | | --- | --- | | Is the API up? | GET /v1/health | | Copy-paste workflows for this API | GET /v1/examples | | Brand / URL → store | GET /v1/stores?q= | | Filtered store table, grouped like the app UI | GET /v1/stores?groupBy=country (same filters as search) | | One merchant profile | GET /v1/stores/{domain} | | Company / emails / phones | GET /v1/stores/{domain}/contacts (or GET /v1/contacts?domain=) | | Verified (mailbox-checked) emails, founders | `emailStatus` + `people` on contacts (store profile, /contacts, audience rows) | | Re-check / find more contacts for one store | POST /v1/stores/{domain}/verify-contacts, then poll GET the same path | | Store traffic / products / apps / history / changes | GET /v1/stores/{domain}/traffic|products|apps|history|changes | | Who competes with this store | GET /v1/stores/{domain}/competitors (same list as the app's Competitors tab) | | Can sales be measured on this store? (before POST /v1/signals) | GET /v1/stores/{domain} → `inventoryProbe.probable`, or GET /v1/stores/{domain}/inventory-probe for per-product tags | | Compare 2–8 merchants | GET /v1/stores/compare?domains=a.com,b.com | | Catalog / listing search | GET /v1/products?q= (at least one filter required) | | Product idea / crowding / trend | GET /v1/products/search?q= | | Report for one product idea | GET /v1/products/research/{id} | | Who sells that idea | GET /v1/products/research/{id}/sellers | | One listing | GET /v1/products/{domain}/{handle} | | What moved (one feed) | GET /v1/changes | | Price cuts, new and removed SKUs | GET /v1/products/changes | | Market size by vertical | GET /v1/analytics/verticals | | Market size by country (unfiltered TAM, or one vertical) | GET /v1/analytics/countries | | Country / vertical / category breakdown of a **filtered** cohort | GET /v1/stores?groupBy=country&… | | Who is growing / shrinking | GET /v1/analytics/movers | | Visit growth by vertical / country over months (one call) | GET /v1/analytics/trends?groupBy=vertical&from=2026-01&to=2026-06 | | Stores behind a month-over-month trend | GET /v1/analytics/trends/stores?from=&to= | | How many stores have traffic history per month | GET /v1/analytics/trends/coverage | | Coverage snapshot | GET /v1/analytics/overview | | Exact filter values (verticals, categories, app slugs, tiers) | GET /v1/audiences/options?field=vertical | | How big is this segment, what share has an email | GET /v1/audiences/preview?countryCode=US&vertical=Skincare&appSlug=klaviyo | | Export a segment (compact rows, optional contacts, CSV/NDJSON) | GET or POST /v1/audiences/export (page with cursor) | | Tag my own domain list with tiers / category / contacts | POST /v1/audiences/enrich {domains[]} | | Stores like these seeds | POST /v1/audiences/lookalike {seeds[]} | | Save a segment and get only new entrants later | POST /v1/audiences then GET /v1/audiences/{id}/changes?commit=true | | Manage saved audiences (read, rename, change definition, delete) | GET / PATCH / DELETE /v1/audiences/{id} | | Shopify app + install base | GET /v1/apps?q= then /v1/apps/{slug}/stores | | Track a competitor (your Signals) | POST /v1/signals {domain} then poll GET /v1/signals/{domain}/probe | | Daily units (worked: gruntstyle.com) | GET /v1/signals/gruntstyle.com/sales?from=2026-07-01 | | Daily store payload (summary → products → variants) | GET /v1/signals/{domain}/daily?date=YYYY-MM-DD | | Ads vs SKUs (worked: magbak.com) | GET /v1/signals/magbak.com/ads/overview?days=90 | | Insights / app / product moves on a signal | GET /v1/signals/{domain}/insights , /apps , /products/changes | | Pause or delete a signal | POST /v1/signals/{domain}/stop or DELETE /v1/signals/{domain} | | Store missing from catalog | POST /v1/scrapes {domain} then poll GET /v1/scrapes/{id} | | Refresh a known store | POST /v1/scrapes {domain, force:true} | | Scrape done without polling | POST /v1/scrapes {domain, callbackUrl} — HTTPS webhook | There is **no** people/org-chart endpoint here. Company name, emails, phones, LinkedIn, and address (when scraped) live on GET /v1/stores/{domain} under `contacts` / `company` / `emails` / `phones`, or dedicated GET /v1/stores/{domain}/contacts. **Verified contacts.** Wherever contacts appear (GET /v1/stores/{domain} `contacts`, GET /v1/stores/{domain}/contacts, audience export / enrich rows with include=contacts) emails are annotated, never listed twice: `emails` puts verified addresses first and drops ones that bounced; `emailStatus` maps each verified address to `valid` (mailbox exists) or `likely` (server accepts any address, but the email is published or otherwise evidenced) — an email not in the map is unchecked; `people` lists named people with a verified mailbox, leaders first: `{name, title, department, email, status, linkedinUrl}` (up to 15 on /contacts, 5 on the profile, 3 in audience rows). `department` is one of leadership, sales, marketing, ecommerce, engineering, operations (supply chain, procurement, buyers), finance, people, support, design, other. Narrow with `GET /v1/stores/{domain}/contacts?department=marketing,sales`. Verification searches for founders and, on requested runs, sales / marketing / ecommerce / engineering / supply-chain staff. We check mailboxes against the store's mail server and never send email. /contacts and the profile also carry `verifiedCount`. Audience CSV adds `verifiedEmails` and `topContact` columns. Stores are checked in traffic order; for one that is missing or stale, POST /v1/stores/{domain}/verify-contacts queues a site re-scan + founder and team search + mailbox check (usually 1-3 min). Poll GET /v1/stores/{domain}/verify-contacts until `status` is `done`, then re-read /contacts. It costs 1 `contactReveals`; a repeat call while a run is pending or within 1 hour of one finishing returns `deduped: true` and is free. Domain path params accept "gruntstyle.com" or "https://www.gruntstyle.com". Signal {id} is the watch id or the store domain. Signals are **per API-key owner**. Catalog routes stay global. First sale needs ≥2 inventory snapshots. Manual probe cooldown 5 minutes. ### Signal data quality — read before trusting zero sales - Every signal has `health` = {state, reason, since, checkedAt}, recomputed after each probe. Quote `health.reason` to the user instead of guessing. - `healthy`: stock is readable and sales were recorded in the last 7 days. - `warming_up`: new signal; sales appear after the next probes (a follow-up probe runs ~5h after creation). - `no_movement`: stock is readable but nothing sold in 7 days. Zero sales here is a real observation. - `capped`: the store limits cart quantity (e.g. max 10 per order), so stock is hidden. Zero sales here means "unknown", not "none". - `unlimited`: the store does not track stock (dropship, made to order, or sells past zero). Sales cannot be measured. - `blocked`: probes were blocked. `no_products`: tracked products were not found (they are replaced weekly). `stale`: no probe in 48h. - `unknown`: not computed yet; it fills in after the next probe. - `dataMethod` = `cart_probe` (stock read from the cart) or `html_inventory` (stock read from the product page because the cart is capped). `cartQtyCap` is the detected per-order limit, or null. - **Check before you watch.** Every scrape cart-probes the store's best sellers and stores a verdict. GET /v1/stores/{domain} returns `inventoryProbe` = {status, probable, method, attempted, measurable, capped, unlimited, failed, cartQtyCap, checkedAt, fresh, reason} or null (never probed). `status`: `ok` (sales measurable), `capped` (flat cart limit hides stock), `unlimited` (stock not tracked / dropship), `blocked` (cart refused), `no_inventory` / `no_products` (undecided). `probable` is true only for `ok`. If `probable=false` tell the user sales cannot be measured on that store **before** creating a Signal; you may still create it for ads, apps and store changes. A new Signal inherits this verdict as its initial `health`, and POST /v1/signals returns it as `precheck`. GET /v1/stores/{domain}/inventory-probe adds `products[]` with the same tags per best seller (status `unknown` = not resolved). `fresh=false` means the verdict is older than 14 days — re-scrape with POST /v1/scrapes {domain, force:true} if the decision matters. - GET /v1/signals/{id} includes `products[]` with `stock` = {status, total, measurable, capped, unlimited, source: probe|scrape, checkedAt} per tracked product, and `precheck` (the scrape verdict at creation). - GET /v1/signals/{domain}/daily adds `availability_changes[]` (variants that went `sold_out` or `back_in_stock` that day). It is a low-confidence demand hint for stores whose stock count is hidden; never add it to units or revenue. `coverage.health` and `coverage.data_method` repeat the signal's state for that payload. ### Signal webhooks (push instead of poll) - Configured in the app (Integrations → Webhooks, paid plans), not via the API. If the user wants data pushed to their server, send them there instead of building a polling loop. - Every POST body: `{id, type, created_at, signal:{id, domain, store_name}, data}`. Dedupe on `id` (a retry reuses it). - Data events: `signal.sales` (per-variant units/revenue after each probe with sales or restocks), `signal.daily_payload` (same JSON as GET /daily, for the UTC day that just ended, once per store per day), `signal.availability_changed` (sold_out / back_in_stock), `signal.probe_completed` (every probe: health, counts), `signal.health_changed` (previous_state → state, reason). - Insight events use the insight kind as `type` (sales_spike, restock_detected, ad_new_campaign, …) with severity/title/body in `data`. - Verify `X-EcomScout-Signature: t=,v1=` = HMAC-SHA256(secret, `t + "." + raw_body`); reject if |now − t| > 300s. Respond 2xx within 15s; one retry on 5xx/429/timeout. Full guide: https://app.ecomscout.com/docs#webhooks ## Persona: app-builder Engineers building store look-ups, competitive catalogs, price alerts, app-stack reports, or Shopify-adjacent tools. Start: GET /v1/stores?q={domain-or-brand}&limit=5 ### store-profile — Resolve a merchant and render a profile card When: User pastes a URL, domain, or brand name and you need one canonical store object. - GET /v1/stores?q=gruntstyle&limit=5 # Fuzzy domain search. Pick the best match; do not invent a domain. - GET /v1/stores/gruntstyle.com # Unified profile: metrics, traffic mix, apps, screenshot, socials, contacts/emails/phones when known. - GET /v1/stores/gruntstyle.com/contacts # Company name, emails, phones, LinkedIn, address, with emailStatus marking mailbox-verified addresses and people for named founders/staff. - GET /v1/stores/gruntstyle.com/products?isBestSeller=true&limit=12 # Hero SKUs for the card. ### catalog-search — Find listings across stores When: User looks up a titled SKU across merchants — who sells it and at what price. - GET /v1/products?q=dog%20toy&isBestSeller=true&priceMin=10&priceMax=80&limit=20 # Title search + price band + bestseller flag. - GET /v1/products/{domain}/{handle} # Canonical product after the user clicks a row. - GET /v1/stores/{domain} # Seller context (grade, visits, dropship flag) next to the SKU. ### niche-research — Is this product idea crowded? When: User wants to know if a product idea is already everywhere, what it sells for, and who sells it. - GET /v1/products/search?q=dog%20cooling%20mat # How many stores sell it, usual price, crowded or not, trend. - GET /v1/products/research/{id} # Price spread, similar ideas, sample stores. Copy id from search. - GET /v1/products/research/{id}/sellers?sort=price&limit=25 # Who sells it, cheapest first. ### price-watch — Price and assortment change feed When: You are building alerts, a watcher, or a daily digest of what moved. - GET /v1/products/changes?changeType=price_decrease&minPriceChangePct=10&since=2026-08-01&limit=50 # Meaningful markdowns only. - GET /v1/changes?entity=product&domain=gruntstyle.com&since=2026-08-01 # One store's product+store movements, already summarized. ### stack-intel — App-stack intelligence for a merchant or a Shopify app When: You need installed apps on a store, or merchants using a given app. - GET /v1/apps?q=klaviyo&limit=5 # Resolve the App Store slug. - GET /v1/apps/klaviyo-email-marketing # Catalog row + observed install-base sample. - GET /v1/apps/klaviyo-email-marketing/stores?limit=20 # Merchants observed running that app, sorted by visits. - GET /v1/stores/gruntstyle.com/apps # Full stack for one store (BuiltWith slugs × shopify_apps). ### catalog-filters — App + product + geo filters (same as the app store table) When: User wants stores that sell a product niche, already run an app, and sit in a region — include/exclude, not one slug. - GET /v1/stores?country=US&includeProducts=sub:Dog|sub:Cat&excludeProducts=sub:Other%20Pets&includeApps=klaviyo&limit=25 # US shops selling Dog or Cat, not Other Pets, with Klaviyo. Product tokens: cat:|sub:|type:|name:. - GET /v1/stores?country=Europe&excludeCountries=Lithuania&excludeApps=klaviyo,omnisend&limit=25 # Europe minus Lithuania, without those ESPs. Regions: Europe, EU, Nordics, DACH, Baltics, Benelux. - GET /v1/stores?country=US&includeProducts=sub:Dog|sub:Cat&excludeProducts=sub:Other%20Pets&includeApps=klaviyo&groupBy=vertical&limit=20 # Same cohort, grouped. Filters apply before groupBy. ## Persona: vc-analyst Analysts at VC / PE / corp-dev desks who need TAM slices, peer sets, growth movers, and tech-stack tells — not storefront scrapes. Start: GET /v1/analytics/overview ### market-brief — One-page market brief When: Partner asks 'how big is DTC apparel' or 'what does the coverage look like'. - GET /v1/analytics/overview # Coverage counts, averages, top verticals and countries. - GET /v1/analytics/verticals?limit=30 # Store count + modeled revenue/visits/ad-spend by vertical. - GET /v1/analytics/countries?vertical=Fashion&limit=20 # Geo mix inside one vertical. ### product-white-space — Find white space in a product niche When: Partner asks if a SKU idea is already saturated across Shopify. - GET /v1/products/search?q=posture%20corrector&market=UNTAPPED,LOW%20COMPETITION # Product ideas with few sellers. - GET /v1/products/research/{id} # How crowded it is, price band, similar ideas. ### peer-set — Build a comparable set When: You have a target domain and need peers by vertical, country, and scale. - GET /v1/stores/{domain} # Read vertical, country, visits, modeled revenue, grade, dropship flag. - GET /v1/stores?vertical=Fashion&country=US&minVisits=50000&sort=revenue&limit=25 # Same-vertical, same-country, similar-scale screen. - GET /v1/stores?country=US&includeProducts=sub:Dog|sub:Cat&excludeProducts=sub:Other%20Pets&includeApps=klaviyo&limit=25 # App + product filters: US dog/cat shops with Klaviyo, not Other Pets. - GET /v1/stores/compare?domains=gruntstyle.com,titan.fitness,michaeltoddbeauty.com # Side-by-side cards; missing domains land in notFound, not 404. ### competitors — Competitors of one store (the app's Competitors tab) When: User asks 'who competes with X' or wants a ranked peer list without hand-picking filters. - GET /v1/stores/{domain}/competitors?limit=12 # Ranked by score: shared product types, traffic proximity, same country → region → global. match.geo says which pass. - GET /v1/stores/compare?domains={domain},{competitor1},{competitor2} # Full cards for the top names; quote sharedTokens as the 'why'. ### cohort-breakdown — Grouped store list (country / vertical trends for content) When: You need the same filtered store table as the app, rolled up by country, vertical, industry, or category — not the unfiltered TAM snapshot. - GET /v1/stores?vertical=Fashion&minVisits=10000&isDropshipper=false&groupBy=country&groupMetric=median&sort=stores&limit=25 # UI Breakdown: Fashion cohort, ≥10k visits, no dropship, by country. meta.mode=grouped. - GET /v1/stores?countryCode=US&minVisits=10000&groupBy=vertical&sort=revenue&limit=20 # Same endpoint, US cohort sliced by vertical. - GET /v1/stores?vertical=Fashion&groupBy=country&groupBy2=category&limit=50 # Nested: country → category. parent is the country; leaf is the category. - GET /v1/stores?vertical=Fashion&countryCode=US&minVisits=10000&sort=visits&limit=25 # Drop groupBy to list the stores behind one cell. ### growth-screen — Who is growing or shrinking When: You want momentum, not a static rank. - GET /v1/analytics/movers?metric=visits&direction=up&minPct=15&limit=25 # Largest recent visit jumps. - GET /v1/analytics/movers?metric=revenue&direction=down&vertical=Fashion&minPct=10 # Modeled-revenue declines inside a thesis vertical. - GET /v1/stores/{domain}/history?limit=90 # Daily snapshots for the names that survive the screen. - GET /v1/stores/{domain}/traffic?limit=24 # Monthly series + latest SimilarWeb mix (organic/paid/social). ### diligence-pack — Diligence pack for one name When: IC memo / first-look on a single merchant. - GET /v1/stores/{domain} # Metrics, conversion band, AOV, traffic mix, apps, screenshot, socials. - GET /v1/stores/{domain}/history?limit=180 # Trajectory of revenue, visits, rating, catalog size. - GET /v1/stores/{domain}/products?isBestSeller=true&limit=20 # What actually sells. - GET /v1/changes?domain={domain}&since=2026-01-01 # Assortment, price, and store-level movements in one feed. - GET /v1/stores/{domain}/apps # Stack tells (ESP, reviews, subscriptions, payments). ## Persona: market-analyst Analysts, journalists and content teams who need defensible month-over-month statistics (which verticals, countries or size tiers grew) without looping over stores. Start: GET /v1/analytics/trends/coverage?minVisits=10000 ### coverage-first — Check the sample before quoting a trend When: Before any 'X grew Y%' sentence: know how many stores have readings in both months. - GET /v1/analytics/trends/coverage?minVisits=10000 # storeCount and storesAboveFloor per month. History is thin before 2025-10; pick months with thousands of stores. - GET /v1/analytics/trends/coverage?countryCode=US&vertical=Skincare # Same, scoped to the cohort you will write about. Prompts: "How many US skincare stores have traffic history for both January and June 2026?" | "Which months are safe to compare for a year-over-year apparel story?" ### vertical-growth-table — Which verticals grew this half-year When: Headline table for an article: median growth per vertical, paired cohort, one call. - GET /v1/analytics/trends?groupBy=vertical&from=2026-01&to=2026-06&minVisits=10000&sort=growth&limit=25 # Per vertical: storeCount, medianGrowthPct, totalGrowthPct, risingSharePct. Quote medianGrowthPct with storeCount. - GET /v1/analytics/trends?groupBy=category&from=2026-01&to=2026-06&vertical=Apparel&minStores=30 # Drill one vertical into product categories (Clothing, Activewear, ...). Prompts: "Give me a table of Shopify verticals ranked by median visit growth, Jan to Jun 2026, at least 50 stores each." | "Inside Apparel, which product categories grew fastest in H1 2026?" ### country-and-size — Country and size-tier angle When: The story is geographic ('DACH beauty is up') or about small vs big merchants. - GET /v1/analytics/trends?groupBy=country&from=2026-01&to=2026-06&vertical=Skincare&sort=growth # Country rows for one vertical. - GET /v1/analytics/trends?groupBy=sizeTier&from=2026-01&to=2026-06&country=Europe # Did sub-10k-visit stores or 1M+ stores grow more? sizeTier is the visits band in the from month. - GET /v1/analytics/trends?groupBy=month&from=2025-10&to=2026-06&countryCode=US&vertical=Skincare&cohort=all # Monthly series for a chart; cohort=all keeps only stores present in every month. Prompts: "Which European countries had the strongest skincare traffic growth in the first half of 2026?" | "Chart monthly visits for US skincare stores from October 2025 to June 2026, same stores every month." ### named-examples — Name the stores behind the statistic When: The editor wants three examples for the paragraph. - GET /v1/analytics/trends/stores?from=2026-03&to=2026-06&countryCode=DE&vertical=Skincare&direction=up&sort=delta&limit=10 # Biggest absolute visit gains in the cohort, with page.total for the whole list. - GET /v1/stores/{domain} # Profile for the quote: category, modeled revenue band, apps, socials. Prompts: "Which German skincare stores gained the most monthly visits between March and June 2026? Give me five with their traffic." | "Find three fast-growing Nordic home decor stores I can mention in a newsletter." ### app-adoption-angle — Tech-stack angle (Klaviyo, Recharge, Shop Pay...) When: Story about a tool's merchants: do Klaviyo stores grow faster? - GET /v1/analytics/trends?groupBy=vertical&from=2026-01&to=2026-06&appSlug=klaviyo&minStores=30 # Growth by vertical among stores using one app. - GET /v1/analytics/trends?groupBy=vertical&from=2026-01&to=2026-06&excludeApps=klaviyo&minStores=30 # Same window without the app; compare medianGrowthPct side by side. Prompts: "Do Klaviyo stores grow faster than non-Klaviyo stores? Compare median visit growth by vertical for H1 2026." ## Persona: audience-builder Data partners, ad-tech and outbound teams who segment Shopify merchants (market, stack, size, product space), suppress what they already have, export compact rows, enrich their own lists and find lookalikes. Rows are compact tiers; per-store detail stays on GET /v1/stores/{domain}. Start: GET /v1/audiences/preview?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000 ### vocabulary — Learn the filter vocabulary before guessing values When: You need the exact vertical / category / country / app values the index uses. - GET /v1/audiences/options?field=vertical&limit=50 # Verticals with store counts. - GET /v1/audiences/options?field=category&vertical=Skincare # Categories inside one vertical. - GET /v1/audiences/options?field=appSlug&q=klaviyo # App slugs as stored (klaviyo, not the App Store handle). - GET /v1/audiences/options?field=revenueTier # Tier keys: under_10k, 10k_50k, 50k_250k, 250k_1m, 1m_plus. Prompts: "What verticals and categories does EcomScout use for beauty brands?" | "What is the app slug for Klaviyo and how many stores use it?" ### size-then-export — Size a segment, then export it When: Default flow for any audience: preview is one store search, export costs rows. - GET /v1/audiences/preview?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000 # count, emailCoveragePct, breakdown by country / vertical / category / sizeTier / revenueTier / apps, 10 sample rows, exportable flag. - GET /v1/audiences/export?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000&include=contacts,channel&limit=500 # Compact rows + emails. page.nextCursor until null. Each row = 1 exportRows; rows with contacts = 1 contactReveals. - GET /v1/audiences/export?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000&format=csv&requireEmail=true&limit=1000 # CSV with only rows that have a verified email. Cursor arrives in the x-next-cursor header. Prompts: "How many US skincare stores on Klaviyo make over 50k a month, and what share has an email?" | "Export US skincare Shopify stores using Klaviyo with 50k+ monthly revenue as CSV, emails only." ### suppress-and-page — Suppress what you already have and page safely When: You hold a customer or prospect list and want only net-new stores. - POST /v1/audiences/export {"countryCode":"US","vertical":"Skincare","appSlug":"klaviyo","excludeDomains":["a.com","b.com"],"limit":1000,"format":"ndjson","sort":"domain"} # POST accepts up to 5,000 excludeDomains. Keep filters identical between pages: the cursor is signed to the definition. - POST /v1/audiences/export {"...same body...","cursor":""} # Next page. Cursors expire after 24h and are bound to your key. Prompts: "Give me US skincare Klaviyo stores we do not already have (here is our list of 2,000 domains), as NDJSON." ### enrich-own-list — Enrich a list you already own When: You have domains from a pixel, CRM or spreadsheet and want tiers, category, country and contacts for each. - POST /v1/audiences/enrich {"domains":["gymshark.com","tatcha.com"],"include":["contacts","channel"]} # Up to 500 domains per call. data[] compact rows, missing[] unknown domains. Charged per found row. - GET /v1/stores/{domain} # Full profile for the few you want to dig into (raw visits, revenue band, apps, products). Prompts: "Here are 300 Shopify domains from our pixel; tag each with vertical, revenue tier, country and contact email." ### lookalike — Lookalikes from seed stores When: A client says 'brands like these three' and you need the audience. - POST /v1/audiences/lookalike {"seeds":["tatcha.com","summerfridays.com","glowrecipe.com"],"limit":50,"filters":{"countryCode":"US"}} # Learns category / vertical / industry / country / revenue band / shared apps from the seeds; returns scored rows with matchedOn. - GET /v1/audiences/preview?countryCode=US&category=Skincare%20Products&revenueTier=250k_1m,1m_plus # Turn the learned profile into a reusable, exportable definition. Prompts: "Find 50 US stores that look like Tatcha, Summer Fridays and Glow Recipe." | "Build an audience of brands similar to our top three customers and tell me how big it is." ### save-and-diff — Save an audience and pull only what changed When: Weekly or monthly refresh: you want new entrants, not the whole list again. - POST /v1/audiences {"name":"US skincare Klaviyo 50k+","definition":{"countryCode":"US","vertical":"Skincare","appSlug":"klaviyo","minRevenue":50000}} # Stores the definition and a snapshot of member domains (ceiling applies). - GET /v1/audiences/{id}/changes?commit=true&include=contacts&limit=200 # entered[] rows and left[] domains since the last snapshot; commit=true moves the snapshot forward. - GET /v1/audiences/{id}/export?format=csv&limit=1000 # Full export of the saved definition whenever needed. Prompts: "Save this segment as 'US skincare Klaviyo 50k+' and every Monday tell me which stores joined or dropped out." ### growth-and-channel — Momentum and acquisition-channel segments When: Ad buyers want merchants who are growing or already buying paid traffic. - GET /v1/audiences/preview?countryCode=US&vertical=Apparel&minVisitsGrowthPct=20&growthWindow=3 # Stores whose latest traffic month is 20%+ above three months earlier. - GET /v1/audiences/preview?countryCode=US&vertical=Skincare&mainMarketingChannel=Paid&minAdSpend=5000 # Paid-first merchants with modeled ad spend of 5k+ per month. - GET /v1/audiences/export?countryCode=US&vertical=Skincare&mainMarketingChannel=Paid&minAdSpend=5000&minTrustpilotRating=4&updatedSince=2026-09-01&limit=500 # Add trust and freshness constraints, then export. Prompts: "US apparel stores growing 20%+ in traffic over the last quarter." | "US skincare brands whose main channel is paid, spend 5k+ a month on ads and have a Trustpilot rating of 4 or better." ## Persona: competitor-tracker Agents and operators who need daily sales, inventory, ads-vs-revenue, and change alerts on stores they track — not a one-shot catalog lookup. Two observed stores: gruntstyle.com (sales chart) and magbak.com (ads lift). Start: POST /v1/signals {domain} ### start-watch — Create a signal and wait for the first probe When: User says 'track gruntstyle' or pastes a competitor URL. - GET /v1/stores/gruntstyle.com # Read inventoryProbe first. probable=false (capped / unlimited / blocked) → tell the user sales cannot be measured here before creating the watch. - POST /v1/signals {"domain":"gruntstyle.com"} # Creates the watch. Auto-tracks bestsellers. 409 if already tracked. Response carries precheck (the scrape verdict) and health seeded from it. - GET /v1/signals/gruntstyle.com/probe # Poll until progress.status is done or failed. First sale needs two snapshots. - GET /v1/signals/gruntstyle.com/stats # After two probes: ~$10.8k / 360 units / 19 days on Grunt Style (Aug 2026 observe). ### sales-brief — Daily units on an apparel rival When: User wants yesterday's sales, a chart, or which size sold. - GET /v1/signals/gruntstyle.com/sales?from=2026-07-01 # Peak 2 Jul = 64 units / $1,919. Hero SKU american-reaper-2-0. - GET /v1/signals/gruntstyle.com/inventory?days=90 # Restocks + start/end stock per variant. - GET /v1/signals/gruntstyle.com/insights?kind=sales_spike # Pre-built spike / restock cards if you do not want to compute them. ### ads-impact — Which ads moved which SKUs (MagBak) When: User asks which ads moved sales, lift, or spend vs revenue. Prefer magbak.com over gruntstyle.com for this. - POST /v1/signals {"domain":"magbak.com"} # Watch MagBak if not already tracked. - GET /v1/signals/magbak.com/ads/overview?days=90 # iPhone 15 MagBak +82.7% lift on high-ad days; 729 Meta + 410 Google ads. One call. - GET /v1/signals/magbak.com/sales?from=2026-07-01 # $1.24M / 20k units / 51 days. Peak 5 Jul = $33.1k. - GET /v1/signals/magbak.com/ads # Paginated creatives if you need the library itself. - POST /v1/signals/magbak.com/ads/probe # Refresh Meta/TikTok/Google when events look stale. ### changes-insights — What changed on a tracked store When: Daily briefing: sales spikes, app installs, catalog moves. - GET /v1/signals/magbak.com/insights # sales_spike, restock, ad_new_campaign, app_change, … - GET /v1/signals/magbak.com/apps/changes # Install / uninstall history. - GET /v1/signals/magbak.com/products/changes # Price / new / removed SKUs on this watch. - GET /v1/signals/insights # Account-wide inbox across every watch on this key. ### lifecycle — Pause, edit, or delete a watch When: User is done tracking, or wants to swap SKUs. - PATCH /v1/signals/gruntstyle.com {"status":"paused"} # Stop cron probes without deleting history. - POST /v1/signals/gruntstyle.com/stop # Same as paused. Alias for agents. - DELETE /v1/signals/gruntstyle.com # Remove the watch. Domain CH data stays for other users. ## Persona: store-scraper Agents that hit a 404 on GET /v1/stores/{domain} and need the same on-demand scrape as Request a store — poll or webhook. Start: POST /v1/scrapes {domain} ### queue-poll — Queue a scrape and wait When: Search returned nothing. User pasted a URL that is not in the catalog. - GET /v1/stores?q=newbrand.com&limit=5 # Confirm it is missing before you spend a scrape credit. - POST /v1/scrapes {"domain":"newbrand.com"} # Queues the job. 202 + id. already_exists if we already have it. - GET /v1/scrapes/{id} # Poll queued → validating → scraping → done|failed. TTL 15 min. - GET /v1/stores/newbrand.com # Read the profile after done. ### callback — Webhook instead of poll When: Your agent cannot sit in a loop. You have an HTTPS hook. - POST /v1/scrapes {"domain":"newbrand.com","callbackUrl":"https://hooks.example.com/ecomscout"} # We POST scrape.done or scrape.failed once. HTTPS only. ### refresh-bulk — Force refresh or bulk ingest When: Store exists but looks stale, or the user pasted a list. - POST /v1/scrapes {"domain":"gruntstyle.com","force":true} # Re-run even if already_exists. 5 min cooldown. - POST /v1/scrapes/bulk {"urls":["a.com","b.com"]} # Max 10. Same scrapeRequests cap as the app. ## Audiences (segments, exports, enrichment, lookalikes) Use /v1/audiences/* when the user wants a *list* of merchants that match criteria, not one profile. - **Preview first.** GET /v1/audiences/preview returns `count`, `withEmail`, `emailCoveragePct`, breakdowns (country, vertical, category, sizeTier, revenueTier, apps), 10 sample rows and `exportable`. It costs one store search, never rows. - **Narrowing rule.** Export needs at least one of: country/countryCode, vertical, category, industry, appSlug/includeApps/requireApps, includeProducts, mainMarketingChannel, or an explicit `domains` list. minRevenue / minVisits / tiers alone are not an audience (422 segment_too_broad). - **Ceiling.** A segment must be under 25,000 stores (100,000 with the `audiences:bulk` partner scope). Over the ceiling the 422 carries a breakdown so you can split by country, vertical or tier. - **Compact rows.** Export, enrich, lookalike and changes return: domain, platform, industry, vertical, category, countryCode, revenueTier, sizeTier, grade, productTier, isDropshipper, adSpendTier, trustpilotTier, appCount, matchedApps (only the apps you filtered on), updatedAt; plus `contacts` {company, emails, phones, linkedin, socials, emailStatus, people} with include=contacts and `mainMarketingChannel` with include=channel. Raw visits, revenue numbers, rating, history, products and the full app stack are **not** in audience rows: fetch GET /v1/stores/{domain} for the few you need. - **Tiers.** sizeTier (monthly visits) and revenueTier (upper band of modeled monthly revenue USD) share one ladder: under_10k, 10k_50k, 50k_250k, 250k_1m, 1m_plus. productTier: none, 1_50, 51_500, 501_5000, 5000_plus. adSpendTier: none, under_5k, 5k_25k, 25k_100k, 100k_plus. trustpilotTier: unknown, low (<3.5), good (3.5 to 4.4), excellent (4.5+). - **Paging.** `limit` per page is capped by the plan (Starter 500, Pro 1,000, bulk scope 5,000). Follow `page.nextCursor` (JSON) or the `x-next-cursor` header (CSV/NDJSON) until it is null. Cursors are signed: same filters, same key, 24h. Changing a filter mid-walk returns 400. - **Billing.** Every compact row returned costs 1 `exportRows` (daily on Starter, monthly on Pro). Each row that carries a contact signal costs 1 `contactReveals` when include=contacts or requireEmail=true. Preview and lookalike also cost 1 store search. Export/enrich weigh 5 rate-limit slots per call, preview/lookalike 2. When a budget is short the page shrinks and `meta.limitedBy` says which bucket; when it is empty you get 429 with the metric. - **Extra cohort filters** (also on GET /v1/stores and /v1/analytics/trends): minAdSpend/maxAdSpend, minTrustpilotRating, minProducts/maxProducts, domains, excludeDomains (200 on GET, 5,000 on POST), updatedSince=YYYY-MM-DD, mainMarketingChannel=Organic|Direct|Paid|Social|Referral, minVisitsGrowthPct with growthWindow=1|3|6, sizeTier, revenueTier, appInstalledSince=YYYY-MM-DD and appRemovedSince=YYYY-MM-DD (stores that added or dropped an app on or after that date, scoped to appSlug/includeApps/requireApps when set, last 180 days), installedApps / removedApps (comma slugs: the app that moved; use these, not excludeApps, for "stores that uninstalled Klaviyo" — default window 30 days), newAdsSince=YYYY-MM-DD (first paid-ads budget seen on or after that date). - **Enrich** POST /v1/audiences/enrich {domains[≤500], include[]} returns compact rows for known domains and `missing[]` for the rest. **Lookalike** POST /v1/audiences/lookalike {seeds[1..25], limit≤100, filters{}} returns rows with `score` and `matchedOn` plus the learned `profile`. **Saved audiences** are full CRUD: POST /v1/audiences (create, 50 per account), GET /v1/audiences (list), GET /v1/audiences/{id}, PATCH /v1/audiences/{id} {name?, description?, definition?} (a new definition is validated like a create and resets the member snapshot), DELETE /v1/audiences/{id}, GET /v1/audiences/{id}/changes?commit=true (entered rows, left domains), GET /v1/audiences/{id}/export. ## Field notes - Money is USD unless a product sets currency. - GET /v1/stores without groupBy returns store cards. With groupBy it returns grouped rows (meta.mode=grouped). Do not treat grouped rows as stores. - GET /v1/stores catalog filters match the app store table: - Apps: includeApps=slug,slug (OR), requireApps=slug,slug (AND), excludeApps=slug,slug, appsMatch=any|all. appSlug is a single-app alias for includeApps. A slug matches the stored app_slugs value or a case-insensitive substring (klaviyo matches klaviyo-email-marketing). - App movement: removedApps=slug,slug = stores that had the app at an earlier scan and not at a scan inside the window (churn from a competitor); installedApps=slug,slug = stores that newly added it. Window via appRemovedSince / appInstalledSince (YYYY-MM-DD, max 180 days back), default last 30 days. Not the same as excludeApps (stores that simply lack the app). - Products: includeProducts / excludeProducts are pipe tokens: cat:Apparel, sub:Dog, type:Dog%20Beds, name:cooling%20mat. productMatch=any|all applies to include only. - Geo: country=United%20States or country=Europe (also EU, Nordics, DACH, Baltics, Benelux). Comma lists allowed. excludeCountries=Lithuania. countryCode=US still works (ISO-2). If both are set, country wins. - Verticals: vertical=Fashion or vertical=Fashion,Beauty. excludeVerticals=Fishing. - monthlyRevenueUsd is {low,high} on a store card. On a grouped row, metrics.monthlyRevenueUsd is one aggregated number from the high band (mean/median/max/min). Label it as modeled, not GMV. - monthlyVisits and SimilarWeb fields are modeled/third-party estimates, not first-party analytics. - isDropshipper is a classifier, not a legal fact. Always say "classified as". - ecomScoutRating is 0–100; ecomScoutGrade is the letter bucket. Traffic is one input, not a hard fail. - sources[] on a store lists which datasets filled the object (`inventory_probe` appears once the store has a stock-readability verdict). - GET /v1/stores/{domain}/competitors returns up to 24 stores ranked by `score` (product-type overlap × traffic proximity × geo/vertical match). `match.geo` says which pass found it: `country` → `region` → `global`. `sharedTokens` are the product-type words both stores sell. `trafficRatio` is competitor visits ÷ seed visits. It is a similarity ranking, not a market-share statement. - GET /v1/signals/{id}/apps/changes: `currentStack[]` and `changeLog[].installed/removed[]` are directory-resolved objects {slug, detectedSlug, name, inCatalog, developer, category, rating, reviewCount, startingPrice}; stack entries add confidence, firstDetected, lastSeen, tenureDays and `removedAt` (set when the log says the app was removed and not re-installed). `inCatalog=false` = fingerprint we detect but the App Store does not list. - Empty arrays / zeros mean "no observation", not an error. Missing SimilarWeb → traffic.latest is null. - change feeds are observation diffs between scrapes, not a complete commerce event stream. ## Do not - Do not invent SQL or guess undocumented query languages. Use the HTTP routes below. - Do not invent owner people lists — use /v1/stores/{domain}/contacts for company emails/phones when present. - Do not scrape storefronts when this API already has the field. - Do not page past hasMore=false. - Do not treat modeled revenue as audited financials in a memo. - Do not collapse {low,high} to a midpoint without labeling it as a midpoint of a model. - Do not scrape a storefront yourself — POST /v1/scrapes (missing store) or POST /v1/signals (watch). - Do not scrape a storefront to track a competitor — POST /v1/signals and poll probe. - Do not loop GET /v1/stores?offset= to dump a market; use /v1/audiences/preview then /v1/audiences/export with a narrowed definition and the cursor. - Do not call /v1/stores/{domain}/contacts in a loop for a list; use /v1/audiences/export?include=contacts or POST /v1/audiences/enrich. - Do not read another user's signals. {domain} is scoped to the key's clerkId. Partner service keys with signals:read may GET a domain already on Signals; they cannot create or alter watches. ## More - Human UI (playground + reference, no login): https://app.ecomscout.com/docs - Human + code samples: https://api.ecomscout.com/v1/docs.md - Machine recipes: https://api.ecomscout.com/v1/examples - OpenAPI 3.1: https://api.ecomscout.com/v1/openapi.json