# EcomScout Public API

Version 0.7.0. Unified ecommerce intelligence for **coding agents**, **app builders**, and **VC / PE analysts**.

One JSON object per resource. Search, then fetch by the domain or id the API returned. Clients never pick a storage backend.

- Human UI (no login): https://app.ecomscout.com/docs
- Agent brief: `https://api.ecomscout.com/v1/llms.txt`
- Machine recipes: `https://api.ecomscout.com/v1/examples`
- OpenAPI: `https://api.ecomscout.com/v1/openapi.json`

## Auth

```
Authorization: Bearer <API_KEY>
# or
X-Api-Key: <API_KEY>
```

Open-dev (empty `PUBLIC_API_KEYS`): omit the key. Rate limit 120 req / 60000ms.

## Errors

```json
{"error":{"code":"not_found","message":"Store not found","details":{"domain":"nope.example"}}}
```

Codes: `not_found` `bad_request` `unauthorized` `rate_limited` `query_timeout` `internal`.

**Billing:** only **2xx** count toward daily quota and plan buckets. Unknown routes return 404 and do **not** burn quota or rate-limit slots. Contact reveals (`/v1/stores/{domain}/contacts` or `/v1/contacts?domain=`) share the app `contactReveals` bucket. POST `/v1/stores/{domain}/verify-contacts` costs 1 `contactReveals` unless it returns `deduped: true`.

Store subresources: `/traffic` `/products` `/apps` `/history` `/changes` `/contacts`. Store profile also embeds `contacts`, `company`, `emails`, `phones` when available.

## Pagination

`GET …?limit=20&offset=0` (max 100).

```json
{"data":[],"page":{"limit":20,"offset":0,"count":0,"hasMore":false,"total":0}}
```

---

## Persona A — app builder

You are writing product software on top of merchant/catalog data. Prefer search → detail → subresource. Cache by domain. Treat `hasMore` as the only pager.

### A1. Store look-up widget

User pastes `https://www.gruntstyle.com`.

```bash
curl -sS 'https://api.ecomscout.com/v1/stores?q=gruntstyle&limit=5'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/products?isBestSeller=true&limit=12'
```

```js
const r = await fetch('https://api.ecomscout.com/v1/stores?q=gruntstyle&limit=5', {
  headers: { Authorization: 'Bearer ' + process.env.ECOM_API_KEY },
});
const { data } = await r.json();
const domain = data[0].domain; // never guess
const store = await fetch('https://api.ecomscout.com/v1/stores/' + domain, {
  headers: { Authorization: 'Bearer ' + process.env.ECOM_API_KEY },
}).then((x) => x.json());
```

```python
import os, requests
h = {"Authorization": f"Bearer {os.environ['ECOM_API_KEY']}"}
hits = requests.get("https://api.ecomscout.com/v1/stores", params={"q": "gruntstyle", "limit": 5}, headers=h).json()["data"]
store = requests.get(f"https://api.ecomscout.com/v1/stores/{hits[0]['domain']}", headers=h).json()["data"]
```

Render: `domain`, `metrics.monthlyVisits`, `metrics.monthlyRevenueUsd.low/high`, `ecomScoutGrade`, `screenshots.homepage`, `apps.names`.

### A2. Who sells this listing, and at what price

```bash
curl -sS 'https://api.ecomscout.com/v1/products?q=ceramide%20serum&isBestSeller=true&priceMin=15&priceMax=60&sort=price&order=asc&limit=20'
curl -sS 'https://api.ecomscout.com/v1/products/theordinary.com/niacinamide-10-zinc-1'
```

At least one filter is required — an unfiltered catalog scan is rejected (400). Filters: `q`, `storeDomain`, `vendor`, `productType`, `tag`, `storeVertical`, `isBestSeller`, `priceMin`, `priceMax`. `sort` is applied when `storeDomain` is set.

### A2b. Product ideas, crowding, and trends

Search a product idea. Each row is one concept (how many stores sell it, typical price, how crowded it looks) — not one listing on one store.

```bash
curl -sS 'https://api.ecomscout.com/v1/products/search?q=dog%20cooling%20mat'
curl -sS 'https://api.ecomscout.com/v1/products/search/suggest?q=dog%20cool'
curl -sS 'https://api.ecomscout.com/v1/products/search/trending'
curl -sS 'https://api.ecomscout.com/v1/products/research/{id}'
curl -sS 'https://api.ecomscout.com/v1/products/research/{id}/sellers?sort=price&page=1&limit=25'
```

Search params: `q` (min 2 chars), `page`, `market` (comma: `UNTAPPED,LOW COMPETITION,COMPETITIVE,SATURATED`), `priceMin`, `priceMax`, `trending`, `countries` (ISO-2 comma), optional `region` (North America, Europe, … — expanded to countries). Copy `id` from search into the report. Response rows are product ideas (`items`), not catalog listings (`data`/`page`). `/trending` ranks title clusters globally; pass `countryCode` (ISO-2) to keep ideas whose dominant seller country matches. It has no vertical filter; for vertical-scoped movement use `/v1/products/changes?storeVertical=`.

### A3. Price / assortment watcher

```bash
curl -sS 'https://api.ecomscout.com/v1/products/changes?changeType=price_decrease&minPriceChangePct=10&since=2026-08-01&limit=50'
curl -sS 'https://api.ecomscout.com/v1/changes?entity=product&domain=gruntstyle.com&since=2026-08-01'
```

`changeType`: `price_increase` `price_decrease` `new_product` `removed_product` `position_up` `position_down` `best_seller_entered` `best_seller_exited`. Scope by the owning store with `storeVertical` and `countryCode` (ISO-2), or by `storeDomain` / `productType`.

Poll `/v1/changes` (unified, pre-summarized) rather than two feeds. Persist `changeDate` as your cursor.

### A4. App-stack report

```bash
curl -sS 'https://api.ecomscout.com/v1/apps?q=klaviyo&limit=5'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/apps'
curl -sS 'https://api.ecomscout.com/v1/apps/klaviyo-email-marketing/stores?limit=20'
```

Use observed install-base (`installBaseSample`) as a sample, not the global Shopify census.

---

## Persona B — VC / PE analyst

You are writing a first-look, peer set, or market map. Quote modeled revenue as a **band**. Label traffic as an estimate. `isDropshipper` is a classifier.

### B1. Market brief

```bash
curl -sS 'https://api.ecomscout.com/v1/analytics/overview'
curl -sS 'https://api.ecomscout.com/v1/analytics/verticals?limit=30'
curl -sS 'https://api.ecomscout.com/v1/analytics/countries?vertical=Fashion&limit=20'
curl -sS 'https://api.ecomscout.com/v1/analytics/verticals?countryCode=US&limit=20'
```

`overview` = coverage + averages + top-8 verticals/countries. Use it as the first slide.

### B2. Comparable set

```bash
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com'
curl -sS 'https://api.ecomscout.com/v1/stores?vertical=Fashion&countryCode=US&minVisits=50000&minRating=60&sort=revenue&order=desc&limit=25'
curl -sS 'https://api.ecomscout.com/v1/stores/compare?domains=gruntstyle.com,titan.fitness,michaeltoddbeauty.com'
```

Compare returns `{ data: { stores: [...], notFound: [...] } }`. Missing names are not a 404.

Store list filters: `q`, `vertical` (comma list), `excludeVerticals`, `category`, `industry`, `country` (names, ISO codes, or regions Europe|EU|Nordics|DACH|Baltics|Benelux), `countryCode` (ISO-2 alias), `excludeCountries`, `isDropshipper`, `hasVerifiedPeople` (true = a named founder or staff email verified against the store's mail server), `appSlug`, `includeApps`, `requireApps`, `excludeApps`, `appsMatch=any|all`, `includeProducts`, `excludeProducts`, `productMatch=any|all`, `minVisits`, `maxVisits`, `minRevenue`, `maxRevenue`, `minRating`, `sort=visits|revenue|rating|domain|stores`.

App slugs resolve through `app_slug_map` (same as the app). Product tokens: `cat:Name|sub:Dog|type:Dog%20Beds|name:cooling%20mat`.

```bash
curl -sS 'https://api.ecomscout.com/v1/stores?country=US&includeProducts=sub:Dog%7Csub:Cat&excludeProducts=sub:Other%20Pets&includeApps=klaviyo&limit=25'
curl -sS 'https://api.ecomscout.com/v1/stores?country=Europe&excludeCountries=Lithuania&excludeApps=klaviyo,omnisend&limit=25'
```

### B2c. Competitors of one store (same list as the app's Competitors tab)

```bash
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/competitors?limit=12' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

```json
{"data":[{"domain":"…","countryName":"United States","vertical":"Fashion","monthlyVisits":412000,"monthlyRevenueUsdHigh":1800000,
  "ecomScoutRating":74,"ecomScoutGrade":"A","productCount":812,"visitsChangePct":-4.2,"trafficRatio":0.9,
  "tokenOverlap":5,"sharedTokens":["t-shirt","hoodie","hat"],
  "match":{"country":true,"region":true,"industry":true,"vertical":true,"category":false,"geo":"country"},"score":0.83}],
 "meta":{"domain":"gruntstyle.com","limit":12,"count":12}}
```

Ranked by `score` — product-type overlap (`sharedTokens`), traffic proximity (`trafficRatio` ≈ 1 is a peer) and country / region / vertical match. `match.geo` tells you which pass found it (`country` first, then `region`, then `global`). Pass each `domain` to `/v1/stores/{domain}` or `/v1/stores/compare` for the full cards. `limit` ≤ 24. Counts as a store detail view.

### B2d. Can sales be measured here? (stock-readability verdict)

```bash
curl -sS 'https://api.ecomscout.com/v1/stores/gymshark.com' -H "Authorization: Bearer $ECOM_SCOUT_KEY" | jq .data.inventoryProbe
curl -sS 'https://api.ecomscout.com/v1/stores/gymshark.com/inventory-probe' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

```json
{"status":"capped","probable":false,"method":"cart_direct","attempted":20,"measurable":0,"capped":18,"unlimited":0,"failed":2,
 "cartQtyCap":10,"checkedAt":"2026-10-15T09:12:40Z","fresh":true,
 "reason":"The store reports a flat 10 on most variants (cart limit or synced stock); real stock is hidden and sales would be under-counted."}
```

Written by the collector on every scrape: it adds the store's best sellers to a cart and reads the quantity the store echoes back. `probable` is true only for `status=ok`. `capped` / `unlimited` / `blocked` mean a Signal on this store **cannot measure sales** — say so before `POST /v1/signals` (ads, app and store-change tracking still work). The sub-resource adds `products[]` with the same tags per best seller so you can pick readable products (`status=ok`) when the store is mixed. `null` on the store card = never probed; `fresh=false` = older than 14 days, re-scrape with `POST /v1/scrapes {domain, force:true}`.

### B2b. Grouped / transformed store list (same endpoint as search)

This is the public equivalent of the app store table **Breakdown** view (`group_by` / `group_metric`). Same path, same filters. Add `groupBy`.

**Do not use** `GET /v1/analytics/countries` for this. That endpoint is an unfiltered (or single-vertical) TAM snapshot. It cannot apply `minVisits`, app/product/geo catalog filters, `isDropshipper`, rating, or revenue bands.

| Need | Call |
| --- | --- |
| Whole-index geo TAM | `GET /v1/analytics/countries?vertical=Fashion` |
| Fashion stores ≥10k visits, by country (content / country trends) | `GET /v1/stores?vertical=Fashion&minVisits=10000&groupBy=country` |
| US cohort, by vertical | `GET /v1/stores?countryCode=US&minVisits=10000&groupBy=vertical` |
| Nested: country → category inside Fashion | `GET /v1/stores?vertical=Fashion&groupBy=country&groupBy2=category` |

`groupBy` / `groupBy2`: `country` | `vertical` | `industry` | `category`.
`groupMetric`: `mean` (default) | `median` | `max` | `min` — applied to visits, modeled revenue **high**, ad spend, rating, product/app counts.
Grouped `sort`: `stores` | `visits` | `revenue` | `rating` (`domain` aliases `stores`).
Grouped mode drops `monthly_visits = 0` so averages are not pulled to zero. Rating mean/median uses stores with rating > 0 and ≥10k visits (same as the UI).

Response: `meta.mode = "grouped"`. Each row:

```json
{
  "key": "US",
  "label": "United States",
  "groupBy": "country",
  "countryCode": "US",
  "countryName": "United States",
  "parent": null,
  "storeCount": 18420,
  "scoredStoreCount": 9100,
  "metrics": {
    "monthlyVisits": 42000,
    "monthlyRevenueUsd": 88000,
    "monthlyAdSpendEstimate": 4100,
    "ecomScoutRating": 61.2,
    "productCount": 340,
    "appCount": 7.4,
    "trustpilotRating": 4.1
  },
  "totals": {
    "monthlyVisits": 780000000,
    "monthlyRevenueUsd": 1620000000,
    "monthlyAdSpendEstimate": 74000000,
    "productCount": 6200000,
    "dropshipperCount": 2100
  }
}
```

- `metrics.*` — one number per `groupMetric` (not a store card).
- `totals.*` — always sums / counts over the group.
- `parent` — set when `groupBy2` is used (parent = `groupBy`, leaf = `groupBy2`).
- Pagination is over **groups**, not stores. `page.total` = distinct group keys.

```bash
curl -sS 'https://api.ecomscout.com/v1/stores?vertical=Fashion&minVisits=10000&isDropshipper=false&groupBy=country&groupMetric=median&sort=stores&limit=25'
curl -sS 'https://api.ecomscout.com/v1/stores?countryCode=US&minVisits=10000&groupBy=vertical&sort=revenue&limit=20'
curl -sS 'https://api.ecomscout.com/v1/stores?vertical=Fashion&groupBy=country&groupBy2=category&limit=50'
```

```js
const r = await fetch('https://api.ecomscout.com/v1/stores?vertical=Fashion&minVisits=10000&groupBy=country&groupMetric=median&limit=25', {
  headers: { Authorization: 'Bearer ' + process.env.ECOM_API_KEY },
});
const { data, meta, page } = await r.json();
// meta.mode === 'grouped'
// data[0].key === 'US', data[0].storeCount, data[0].metrics.monthlyVisits
```

```python
import os, requests
h = {"Authorization": f"Bearer {os.environ['ECOM_API_KEY']}"}
rows = requests.get(
    "https://api.ecomscout.com/v1/stores",
    params={"vertical": "Fashion", "minVisits": 10000, "groupBy": "country", "groupMetric": "median", "limit": 25},
    headers=h,
).json()
assert rows["meta"]["mode"] == "grouped"
```

After you pick a country/vertical from the grouped table, drop `groupBy` and reuse the same filters to list the underlying stores.

### B3. Momentum screen

```bash
curl -sS 'https://api.ecomscout.com/v1/analytics/movers?metric=visits&direction=up&minPct=15&limit=25'
curl -sS 'https://api.ecomscout.com/v1/analytics/movers?metric=revenue&direction=down&vertical=Fashion&minPct=10'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/history?limit=90'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/traffic?limit=24'
```

Movers params: `metric=visits|revenue|any`, `direction=up|down|any`, `vertical`, `countryCode`, `since=YYYY-MM-DD` (default 30 days back; change history starts 2026-06), `minPct`, `minVisits` (metric value at the start of the window; use 1000+ to drop tiny bases), `sort=pct|delta|new`, `limit` (max 100), `offset`. One row per store: base = first observation at or after `since`, current = latest. Stores whose current value is 0 are excluded. Then pull history + traffic for survivors.

### B4. Month-over-month trends by group (no per-store loop)

```bash
curl -sS 'https://api.ecomscout.com/v1/analytics/trends/coverage?minVisits=10000'
curl -sS 'https://api.ecomscout.com/v1/analytics/trends?groupBy=vertical&from=2026-01&to=2026-06&minVisits=10000'
curl -sS 'https://api.ecomscout.com/v1/analytics/trends?groupBy=country&from=2026-01&to=2026-06&vertical=Skincare&sort=growth'
curl -sS 'https://api.ecomscout.com/v1/analytics/trends?groupBy=sizeTier&from=2026-01&to=2026-06&minVisits=0&minStores=1'
curl -sS 'https://api.ecomscout.com/v1/analytics/trends?groupBy=category&from=2026-01&to=2026-06&appSlug=klaviyo'
curl -sS 'https://api.ecomscout.com/v1/analytics/trends?groupBy=month&from=2025-10&to=2026-06&countryCode=US&vertical=Skincare&cohort=all'
curl -sS 'https://api.ecomscout.com/v1/analytics/trends/stores?from=2026-03&to=2026-06&countryCode=DE&direction=up&sort=delta&limit=50'
```

Start with `/v1/analytics/trends/coverage`: stores with a reading per month (`storeCount`) and above your floor (`storesAboveFloor`), optionally scoped by the same cohort filters. Traffic history starts 2025-04 but is thin before 2025-10 (a few hundred stores), so year-over-year windows have small cohorts; the response tells you before you ask.

`/v1/analytics/trends` rolls monthly visit history up by `groupBy=vertical|country|industry|category|sizeTier`, or returns a monthly series for the cohort with `groupBy=month`. `category` is the store's product bucket (Clothing, Mobile Accessories, Furniture...), `industry` the broad one (Fashion & Apparel, Electronics & Tech...), `sizeTier` the visits band in the from month (under 10k, 10k to 50k, 50k to 250k, 250k to 1M, 1M and above). Params: `from`/`to` as `YYYY-MM` (required, from < to), `minVisits` (default 10000) with `floorOn=from|to|both|avg` (default `avg`), `cohort=edges|all` (default `edges`), `minStores` (hide groups with fewer cohort stores, default 20), `sort=stores|growth|visits`, `limit` (max 200). Cohort filters are the same as store search: `vertical`, `excludeVerticals`, `category`, `industry`, `country`, `countryCode`, `excludeCountries`, `isDropshipper`, `appSlug`, `includeApps`, `requireApps`, `excludeApps`, `appsMatch`, `includeProducts`, `excludeProducts`. App slugs are the ones in `store.apps[].slug` and `/v1/apps` (for Klaviyo that is `klaviyo`, not the App Store handle). Each group carries `storeCount`, `medianVisitsFrom`/`To`, `medianGrowthPct` (median of per-store growth), `totalVisitsFrom`/`To`, `totalGrowthPct` and `risingSharePct`.

Why the knobs matter. `floorOn=from` picks stores for being big in the first month, so regression to the mean makes the whole market look like it shrank (Jan to Jun 2026 median growth is about -22% with `floorOn=from` and about -8% with `floorOn=avg`); `both` is the strictest and drops collapsed stores, which is what you want for `direction=down`. `cohort=edges` pairs only the two edge months, so interior months of a `groupBy=month` series can have fewer readings; `cohort=all` keeps only stores with every month and gives a like-for-like line (smaller cohort). `cohort.storeCount` in the response says how many stores the numbers rest on. Season matters too: January is the post-holiday peak for most retail, so compare groups against each other, or use a like-for-like window (Jun to Jun) for absolute growth.

`/v1/analytics/trends/stores` lists the stores behind the same cohort: `direction=up|down|any`, `sort=pct|delta|visits` (biggest move first in both directions), `limit` (max 100), `offset`, with `page.total` so you can walk the whole cohort.

### B4b. Article or newsletter numbers (prompts that work)

Ask the agent in plain words; the calls above answer them in one or two requests each:

- "Which Shopify verticals grew the most from January to June 2026? Table with median growth and store count." → `/v1/analytics/trends?groupBy=vertical&from=2026-01&to=2026-06&sort=growth`
- "Inside Apparel, which categories are growing fastest?" → `groupBy=category&vertical=Apparel&minStores=30`
- "Which European countries had the strongest skincare traffic growth in H1 2026?" → `groupBy=country&country=Europe&vertical=Skincare&sort=growth`
- "Did small stores (under 10k visits) grow faster than big ones?" → `groupBy=sizeTier`
- "Chart US skincare monthly visits since October 2025, same stores every month." → `groupBy=month&cohort=all`
- "Do Klaviyo stores grow faster than non-Klaviyo stores?" → run `appSlug=klaviyo` and `excludeApps=klaviyo` side by side
- "Give me five German skincare stores that gained the most traffic since March, for the article." → `/v1/analytics/trends/stores?from=2026-03&to=2026-06&countryCode=DE&vertical=Skincare&direction=up&sort=delta&limit=5`

Always print `cohort.storeCount` next to a percentage and say "stores with traffic readings in both months", not "all Shopify stores".

### B5. Diligence pack (one name)

```bash
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/history?limit=180'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/traffic?limit=24'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/products?isBestSeller=true&limit=20'
curl -sS 'https://api.ecomscout.com/v1/stores/gruntstyle.com/apps'
curl -sS 'https://api.ecomscout.com/v1/changes?domain=gruntstyle.com&since=2026-01-01'
```

Memo checklist: grade + modeled revenue band, visit trajectory, paid vs organic mix, bestseller concentration, stack (ESP / reviews / subs), dropship classifier, recent catalog/price moves.

---

## Persona C — competitor tracker (Signals)

Signals are **your** watches. The `esc_` key owns them. Catalog `GET /v1/stores/{domain}` is a snapshot (grade, visits, modeled revenue). A signal probes inventory, derives units/revenue, joins Meta/TikTok/Google ads, and emits insights.

`{id}` is the watch id **or** the store domain (`gruntstyle.com` and `https://www.gruntstyle.com` both work).

**Do not invent sales.** First sale needs ≥2 inventory snapshots. Manual inventory probe cooldown is 5 minutes (429 + `retryAfterSec`). GET Signals do not burn UI store/product buckets.

### Worked case A — Grunt Style (daily units)

Observed Aug 2026 after tracking bestsellers: **$10.8k / 360 units / 19 days**. Peak **2 Jul 2026 = 64 units / $1,919**. Hero SKU **American Reaper 2.0** = 371 units / $11.1k. Ad library: 225 Meta (30 live) + 395 Google (11 live), including Google brand ads running 400+ days.

Product-URL attribution is thin here — `ads/overview.revenue` can be empty while `/sales` and `/ads` still return data. Use this store when the user wants a sales chart, not creative-level dollars.

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/signals' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"domain":"gruntstyle.com"}'
curl -sS 'https://api.ecomscout.com/v1/signals/gruntstyle.com/probe' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/gruntstyle.com/sales?from=2026-07-01' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/gruntstyle.com/stats' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

Sample `/sales` day:

```json
{"date":"2026-07-02","totalUnits":64,"totalRevenue":1919.36,"variants":[{"productHandle":"american-reaper-2-0","variantTitle":"Medium","price":"29.99","unitsSold":18,"revenue":539.82,"inventoryStart":22,"inventoryEnd":4}]}
```

### Worked case B — MagBak (ads that move SKUs)

Observed Aug 2026: **$1.24M / 20,084 units / 51 days**. Peak **5 Jul = 450 units / $33.1k**. Ads: 729 Meta (30 live) + 410 Google (23 live). `productImpact`: iPhone 15 MagBak **+82.7%** on high-ad vs low-ad days; iPhone 16 +22.9%; iPhone 14 +34.9%. New Meta creatives (20 Aug) push Pixel 11 Pro/XL.

Prefer `ads/overview` here. `productImpact.mode=intensity` compares high-ad vs low-ad days on the same handle. `revenue` (est. $ per creative) only fills when the ad URL contains `/products/{handle}`.

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/signals' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"domain":"magbak.com"}'
curl -sS 'https://api.ecomscout.com/v1/signals/magbak.com/ads/overview?days=90' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/magbak.com/sales?from=2026-07-01' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

Sample `productImpact` row:

```json
{"product_handle":"magbak-for-iphone-15-series","total_units_sold":6844,"total_revenue":389662,"lift_pct":82.7,"mode":"intensity","high_ad_days":4,"low_ad_days":19}
```

### Daily store payload

`GET /v1/signals/{id|domain}/daily?date=YYYY-MM-DD` returns one UTC day as `daily_summary` → `products[]` → `variants[]` (default: yesterday). Same access as other signal reads; scoped to tracked products.

- `units_sold` / `total_revenue` come from stock decrements between probes × live price.
- `orders` is a **lower bound**: distinct stock-drop events per variant (several orders inside one probe gap count once).
- `discounts` is always 0 (not observable from storefront). `total_price` = `total_revenue`.
- `sku`, `options`, `product_type` come from the live product JSON (cached 12h). Empty when the store leaves them blank.
- `vs_previous_day` = `{previous, change, change_pct}`; `change_pct` is null when previous is 0.
- `availability_changes[]` = variants that sold out (`sold_out`) or restocked (`back_in_stock`) that day. Low confidence; useful when stock counts are hidden.
- `coverage` states the source and method, plus `data_method` (`cart_probe` | `html_inventory`) and `health`.

Signal objects (`GET /v1/signals`, `GET /v1/signals/{id}`) include `health` = `{state, reason, since, checkedAt}`. States: `healthy`, `warming_up`, `no_movement`, `capped` (cart limit hides stock), `unlimited` (store does not track stock), `blocked`, `no_products`, `stale` (no probe in 48h).

```bash
curl -sS 'https://api.ecomscout.com/v1/signals/deskboardbuddy.com/daily?date=2026-09-20' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

```json
{"date":"2026-09-20","website_url":"https://deskboardbuddy.com","currency":"USD","daily_summary":{"total_orders":3,"products_sold":1,"variants_sold":2,"units_sold":3,"total_revenue":89.97,"average_order_value":29.99,"vs_previous_day":{"total_orders":{"previous":5,"change":-2,"change_pct":-40},"units_sold":{"previous":6,"change":-3,"change_pct":-50},"total_revenue":{"previous":209.94,"change":-119.97,"change_pct":-57.1},"average_order_value":{"previous":41.99,"change":-12,"change_pct":-28.6}}},"products":[{"product_id":"deskboardbuddy","product_sku":"dbb-white-1","product_name":"DeskBoard Buddy","product_type":"","product_url":"https://deskboardbuddy.com/products/deskboardbuddy","orders":3,"variants_sold":2,"units_sold":3,"total_price":89.97,"discounts":0,"total_revenue":89.97,"share_of_store_revenue":1,"vs_previous_day":{"units_sold":{"previous":6,"change":-3,"change_pct":-50},"total_revenue":{"previous":209.94,"change":-119.97,"change_pct":-57.1}},"variants":[{"variant_id":42917043798228,"sku":"dbb-white-1","variant_title":"White / None","options":["White","None"],"unit_price":29.99,"orders":2,"units_sold":2,"total_price":59.98,"discounts":0,"total_revenue":59.98}]}],"availability_changes":[{"time":"2026-09-20 12:04:11","product_id":"deskboardbuddy","product_name":"DeskBoard Buddy","variant_id":42917043831252,"variant_title":"Black / None","status":"sold_out"}],"coverage":{"source":"inventory_probe","data_method":"cart_probe","health":"healthy","tracked_products":2,"orders_method":"stock_decrement_events","discounts_observable":false}}
```

### Webhooks for Signals

Instead of polling `/daily` every morning, add an HTTPS endpoint in the app (Integrations → Webhooks). Pick **Everything** or specific events. Each POST is `{id, type, created_at, signal, data}` and is signed with `X-EcomScout-Signature: t=<unix>,v1=<hmac>` (HMAC-SHA256 of `t.raw_body` with the endpoint's `whsec_` secret).

| type | when | data |
|---|---|---|
| `signal.sales` | after each probe that sees sales or restocks | `date, units_sold, revenue, currency, variants[] {variant_id, product_handle, variant_title, unit_price, units_sold, revenue, inventory_before, inventory_after, restocked}` |
| `signal.daily_payload` | once per store per day, after the first probe after 00:00 UTC | same JSON as `GET /v1/signals/{id}/daily` for the day that ended |
| `signal.availability_changed` | a tracked variant sold out or came back | `changes[] {variant_id, product_handle, variant_title, price, status, detected_at}` |
| `signal.probe_completed` | every finished probe | `health, variants_probed, units_sold_since_last_probe, revenue_since_last_probe, availability_changes` |
| `signal.health_changed` | health state changed | `previous_state, state, reason, since` |
| insight kinds (`sales_spike`, `restock_detected`, `ad_new_campaign`, …) | when the insight is created | `severity, title, body, label` + numbers |

Respond 2xx within 15 s. One retry on 5xx / 429 / timeout with the same `id`. Guide with payload examples and verification code: https://app.ecomscout.com/docs#webhooks

### C1. Start tracking

POST auto-tracks bestsellers (`autoTrack=bestsellers`). Pass `products` or `autoTrack=none` to pick SKUs. Poll `GET /probe` until `done`. 409 if you already track the domain.

### C2. Edit, pause, delete

```bash
curl -sS -X PATCH 'https://api.ecomscout.com/v1/signals/gruntstyle.com' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"status":"paused"}'
curl -sS -X POST 'https://api.ecomscout.com/v1/signals/gruntstyle.com/stop' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS -X DELETE 'https://api.ecomscout.com/v1/signals/gruntstyle.com' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

PATCH also accepts `storeName`, `products`, `salesEmailEnabled`. Stop = `status: paused`. DELETE removes **your** watch; shared CH rows stay.

### C3. Slice calls (only if overview is too big)

Granular twins: `/ads`, `/ads/correlation`, `/ads/revenue`, `/ads/product-impact`, `/ads/timeline`, `/ads/events`. `POST /ads/probe` refreshes the library (separate from inventory /probe). `/inventory` for restocks. `/social` for follower snapshots.

### C4. Apps, changes, insights

```bash
curl -sS 'https://api.ecomscout.com/v1/signals/magbak.com/apps' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/magbak.com/apps/changes' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/magbak.com/products/changes' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/magbak.com/insights' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
curl -sS 'https://api.ecomscout.com/v1/signals/insights' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

---

## Persona D — store scraper (on-demand)

Same engine as **Request a store** in the app. Job is async (minutes). Poll or pass `callbackUrl`.

`force=true` refreshes a store already in the catalog. Default: `already_exists` + a lite store card. Cooldown 5 minutes per domain. Progress TTL 15 minutes. HTTPS callbacks only (no localhost / RFC1918).

### D1. Queue + poll

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/scrapes' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"domain":"newbrand.com"}'
curl -sS 'https://api.ecomscout.com/v1/scrapes/{id}' -H "Authorization: Bearer $ECOM_SCOUT_KEY"
```

When `status=done`, fetch `GET /v1/stores/{domain}`.

### D2. Callback (no poll)

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/scrapes' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"domain":"newbrand.com","callbackUrl":"https://hooks.example.com/ecomscout"}'
```

We POST once on terminal status:

```json
{"event":"scrape.done","id":"…","domain":"newbrand.com","status":"done","message":"…","store":{"domain":"newbrand.com"}}
```

`event` is `scrape.done` or `scrape.failed`. 8s timeout, no redirects.

### D3. Bulk + force refresh

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/scrapes/bulk' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"urls":["a.com","b.com"],"callbackUrl":"https://hooks.example.com/ecomscout"}'
curl -sS -X POST 'https://api.ecomscout.com/v1/scrapes' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"domain":"gruntstyle.com","force":true}'
```

POST counts against the same `scrapeRequests` daily cap as the app (Starter 100, Pro unlimited). GET poll is free of that bucket.

---

## Persona E — audience builder (segments, exports, enrichment, lookalikes)

For data partners, ad-tech and outbound teams. The question is "which merchants", not "tell me about one merchant". Rows are **compact** (tiers and labels); per-store detail stays on `GET /v1/stores/{domain}`.

Rules in one breath: preview is free of row charges, export needs a narrowing filter and a segment under the ceiling (25,000 stores; 100,000 with the `audiences:bulk` partner scope), every row returned costs one `exportRows`, every row with a contact costs one `contactReveals` when you ask for contacts, pages follow a signed cursor.

### E1. Learn the vocabulary

```bash
curl -sS 'https://api.ecomscout.com/v1/audiences/options?field=vertical&limit=50'
curl -sS 'https://api.ecomscout.com/v1/audiences/options?field=category&vertical=Skincare'
curl -sS 'https://api.ecomscout.com/v1/audiences/options?field=appSlug&q=klaviyo'
curl -sS 'https://api.ecomscout.com/v1/audiences/options?field=countryCode&vertical=Skincare'
curl -sS 'https://api.ecomscout.com/v1/audiences/options?field=revenueTier'
```

`field` = industry | vertical | category | countryCode | appSlug | mainMarketingChannel | sizeTier | revenueTier | productTier | adSpendTier | trustpilotTier. Optional `vertical`, `industry`, `countryCode` scope the counts; `q` filters values. Cached 15 minutes, no plan charge.

### E2. Size, then export

```bash
curl -sS 'https://api.ecomscout.com/v1/audiences/preview?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000'
curl -sS 'https://api.ecomscout.com/v1/audiences/export?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000&include=contacts,channel&limit=500'
curl -sS 'https://api.ecomscout.com/v1/audiences/export?countryCode=US&vertical=Skincare&appSlug=klaviyo&minRevenue=50000&format=csv&requireEmail=true&limit=1000' -o skincare.csv
```

Preview returns `count`, `withEmail`, `emailCoveragePct`, `ceiling`, `exportable`, `reason`, `breakdown` {country, vertical, category, sizeTier, revenueTier, apps} and 10 `sample` rows. Export returns rows plus `page.nextCursor` (JSON) or the `x-next-cursor` header (CSV / NDJSON); `x-total-count` is the segment size. `meta.charged` tells you what the page cost; `meta.limitedBy` names the bucket when the page was shrunk (exportRowsMaxPerExport, exportRows, contactReveals).

```js
// Walk an export to the end (Node 18+)
const base = "https://api.ecomscout.com", key = process.env.ECOM_SCOUT_KEY;
const q = new URLSearchParams({ countryCode: "US", vertical: "Skincare", appSlug: "klaviyo", minRevenue: "50000", include: "contacts", limit: "500" });
let cursor = null, rows = [];
do {
  if (cursor) q.set("cursor", cursor); else q.delete("cursor");
  const r = await fetch(`${base}/v1/audiences/export?${q}`, { headers: { Authorization: `Bearer ${key}` } });
  if (r.status === 429) { await new Promise((ok) => setTimeout(ok, 1000 * Number(r.headers.get("retry-after") || 30))); continue; }
  const body = await r.json();
  if (!r.ok) throw new Error(JSON.stringify(body.error));
  rows.push(...body.data);
  cursor = body.page.nextCursor;
} while (cursor);
```

Compact row: `domain, platform, industry, vertical, category, countryCode, revenueTier, sizeTier, grade, productTier, isDropshipper, adSpendTier, trustpilotTier, appCount, matchedApps, updatedAt` + `contacts` {company, emails, phones, linkedin, socials, emailStatus, people} with include=contacts + `mainMarketingChannel` with include=channel. `matchedApps` lists only the apps you filtered on. Tier ladders: sizeTier / revenueTier `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, good, excellent`.

### E3. Suppression lists and POST bodies

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/audiences/export' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"countryCode":"US","vertical":"Skincare","appSlug":"klaviyo","minRevenue":50000,
       "excludeDomains":["tatcha.com","glowrecipe.com"],"include":["contacts"],
       "format":"ndjson","sort":"domain","limit":1000}'
```

POST accepts the same filters as GET with arrays: `domains` up to 500, `excludeDomains` up to 5,000 (GET: 200 each, comma lists). Keep the body identical between pages and add `cursor`; the cursor is HMAC-signed to the filter set and your key and expires after 24 hours. `sort` = visits (default) | revenue | domain | updated.

### E4. Enrich a list you own

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/audiences/enrich' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"domains":["gymshark.com","tatcha.com","unknown-shop.com"],"include":["contacts","channel"]}'
```

Up to 500 domains per call (bare hostnames or URLs). `data[]` compact rows for known stores, `missing[]` for unknown, `skipped[]` when your remaining budget was smaller than the list. Charged per found row (and per contact row with include=contacts). For the handful you want in depth, call `GET /v1/stores/{domain}`.

### E5. Lookalikes from seeds

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/audiences/lookalike' \
  -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"seeds":["tatcha.com","summerfridays.com","glowrecipe.com"],"limit":50,"filters":{"countryCode":"US"}}'
```

1 to 25 seeds. The response carries `profile` (categories, verticals, industries, countries, revenueBand p25..p75, visitsBand, topApps) and `data[]` compact rows with `score` and `matchedOn` (category 3, vertical 2, industry 1, revenue band 1, country 1, shared apps up to 2). `filters` are hard constraints on top of the learned profile (same keys as export). `excludeSeeds` defaults to true.

### E6. Save, then pull only what changed

```bash
curl -sS -X POST 'https://api.ecomscout.com/v1/audiences' -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"US skincare Klaviyo 50k+","definition":{"countryCode":"US","vertical":"Skincare","appSlug":"klaviyo","minRevenue":50000}}'
curl -sS 'https://api.ecomscout.com/v1/audiences'
curl -sS 'https://api.ecomscout.com/v1/audiences/{id}'
curl -sS -X PATCH 'https://api.ecomscout.com/v1/audiences/{id}' -H "Authorization: Bearer $ECOM_SCOUT_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"US skincare Klaviyo 75k+","definition":{"countryCode":"US","vertical":"Skincare","appSlug":"klaviyo","minRevenue":75000}}'
curl -sS 'https://api.ecomscout.com/v1/audiences/{id}/changes?commit=true&include=contacts&limit=200'
curl -sS 'https://api.ecomscout.com/v1/audiences/{id}/export?format=csv&limit=1000'
curl -sS -X DELETE 'https://api.ecomscout.com/v1/audiences/{id}'
```

Creating stores the definition and a snapshot of member domains (same ceiling as export; up to 50 saved audiences per account). PATCH takes any of `name`, `description`, `definition`; a new definition goes through the same exportable check as a create, replaces the snapshot and returns `count`. `changes` compares the live segment to the snapshot: `entered` {count, rows[]} and `left` {count, domains[]}; `commit=true` moves the snapshot forward. Entered rows are charged like export rows. DELETE returns 204.

### E7. Momentum, channel, trust and freshness filters

```bash
curl -sS 'https://api.ecomscout.com/v1/audiences/preview?countryCode=US&vertical=Apparel&minVisitsGrowthPct=20&growthWindow=3'
curl -sS 'https://api.ecomscout.com/v1/audiences/preview?countryCode=US&vertical=Skincare&mainMarketingChannel=Paid&minAdSpend=5000'
curl -sS 'https://api.ecomscout.com/v1/audiences/export?countryCode=US&vertical=Skincare&minTrustpilotRating=4&minProducts=50&updatedSince=2026-09-01&sizeTier=50k_250k,250k_1m&limit=500'
```

These filters also work on `GET /v1/stores` and `GET /v1/analytics/trends`: `minAdSpend`/`maxAdSpend` (modeled monthly USD), `minTrustpilotRating` (0 to 5), `minProducts`/`maxProducts`, `updatedSince` (last collection date), `mainMarketingChannel` (Organic | Direct | Paid | Social | Referral, comma list), `minVisitsGrowthPct` + `growthWindow` (1 | 3 | 6 months; the store's latest traffic month against the month N months earlier), `sizeTier`, `revenueTier`, `domains`, `excludeDomains`.

### E8. Prompts that map to this persona

- "How many US skincare stores on Klaviyo make over 50k a month, and what share has an email?" → preview
- "Export them as CSV, emails only." → export?format=csv&requireEmail=true
- "Only the ones we do not already have (here is our list)." → POST export with excludeDomains
- "Tag these 300 domains from our pixel with vertical, revenue tier and contact email." → POST enrich
- "Find 50 US brands like Tatcha, Summer Fridays and Glow Recipe." → POST lookalike
- "Save this as 'US skincare Klaviyo 50k+' and tell me every Monday who joined or left." → POST /v1/audiences, then changes?commit=true
- "US apparel stores growing 20%+ in traffic over the last quarter." → preview with minVisitsGrowthPct=20&growthWindow=3
- "Paid-first US skincare brands spending 5k+ a month on ads with Trustpilot 4+." → mainMarketingChannel=Paid&minAdSpend=5000&minTrustpilotRating=4

### E9. Errors you will see

- `422 segment_too_broad` with `details.hint`: add a narrowing filter, or split (the 422 for an over-ceiling segment includes `details.breakdown` by vertical and country).
- `429 rate_limited` with `details.metric` = exportRows | contactReveals: the plan bucket is empty; `details.resetAt` says when it refills. Preview still works.
- `400 bad_request` "Cursor does not belong to this audience definition": you changed a filter between pages. Restart from page one.
- `403 forbidden` "Cursor was issued to a different API key".

---

## Endpoint index

| Method | Path | Notes |
| --- | --- | --- |
| GET | `/v1/health` | Backends + `authRequired` |
| GET | `/v1/examples` | Structured persona recipes (this file, as JSON) |
| GET | `/v1/stores` | Search, or `?groupBy=` for UI-style grouped lists |
| GET | `/v1/stores/compare` | 2–8 domains, `?domains=a,b` |
| GET | `/v1/stores/{domain}` | Unified profile |
| GET | `/v1/stores/{domain}/history` | Daily snapshots, `limit` ≤ 365 |
| GET | `/v1/stores/{domain}/traffic` | Monthly series + SimilarWeb |
| GET | `/v1/stores/{domain}/apps` | Installed apps |
| GET | `/v1/stores/{domain}/competitors` | Ranked competitor stores (same as app tab), `limit` ≤ 24 |
| GET | `/v1/stores/{domain}/inventory-probe` | Stock-readability verdict + per-product tags (also `inventoryProbe` on the profile) |
| GET | `/v1/stores/{domain}/products` | Catalog for one store |
| GET | `/v1/stores/{domain}/changes` | Store-level movements |
| GET | `/v1/products` | Search listings across stores (title contains) |
| GET | `/v1/products/search` | Search product ideas (crowding, price, trend) |
| GET | `/v1/products/search/suggest` | Autocomplete product ideas |
| GET | `/v1/products/search/trending` | Ideas that are heating up |
| GET | `/v1/products/research/{id}` | Report for one product idea |
| GET | `/v1/products/research/{id}/sellers` | Stores selling that idea |
| GET | `/v1/products/{domain}/{handle}` | One listing |
| GET | `/v1/products/changes` | Price cuts, new and removed SKUs |
| GET | `/v1/changes` | Unified product + store feed |
| GET | `/v1/apps` | Shopify App Store catalog |
| GET | `/v1/apps/{slug}` | App + install-base sample |
| GET | `/v1/apps/{slug}/stores` | Merchants using the app |
| GET | `/v1/analytics/overview` | Coverage snapshot |
| GET | `/v1/analytics/verticals` | Optional `countryCode` |
| GET | `/v1/analytics/countries` | Unfiltered / one-vertical TAM. Filtered cohort → `/v1/stores?groupBy=country` |
| GET | `/v1/analytics/movers` | Growth / decline screen |
| GET | `/v1/analytics/trends` | Month-over-month visits by vertical / country / industry / category, or monthly series |
| GET | `/v1/analytics/trends/stores` | Stores behind a trend window |
| GET | `/v1/analytics/trends/coverage` | Traffic history coverage per month |
| GET | `/v1/audiences/options` | Filter vocabulary with store counts (verticals, categories, countries, app slugs, tiers) |
| GET, POST | `/v1/audiences/preview` | Segment size, email coverage, breakdowns, 10 sample rows |
| GET, POST | `/v1/audiences/export` | Compact rows for a segment (json, ndjson, csv), signed cursor paging |
| POST | `/v1/audiences/enrich` | Compact rows for up to 500 domains you supply |
| POST | `/v1/audiences/lookalike` | Scored lookalikes from 1 to 25 seed stores |
| POST, GET | `/v1/audiences` | Save / list audiences (definition + member snapshot) |
| GET, PATCH, DELETE | `/v1/audiences/{id}` | Read, update (name, description, definition) or delete a saved audience |
| GET | `/v1/audiences/{id}/changes` | Entered rows and left domains since the snapshot (commit=true to advance) |
| GET | `/v1/audiences/{id}/export` | Export the saved definition |
| GET | `/v1/signals` | Your tracked stores + plan caps |
| POST | `/v1/signals` | Create + queue probe (`autoTrack=bestsellers`) |
| GET | `/v1/signals/{id}` | One signal card |
| PATCH | `/v1/signals/{id}` | Edit status / products / name |
| DELETE | `/v1/signals/{id}` | Remove watch |
| POST | `/v1/signals/{id}/stop` | Pause probes |
| POST | `/v1/signals/{id}/probe` | Manual inventory probe |
| GET | `/v1/signals/{id}/probe` | Probe status |
| GET | `/v1/signals/{id}/stats` | Totals + store profile |
| GET | `/v1/signals/{id}/sales` | Daily + variant sales |
| GET | `/v1/signals/{id}/daily` | One-day payload: summary → products → variants (`date`) |
| GET | `/v1/signals/{id}/inventory` | Stock timeline |
| GET | `/v1/signals/{id}/social` | Follower snapshots |
| GET | `/v1/signals/{id}/ads` | Ad library |
| GET | `/v1/signals/{id}/ads/overview` | Correlation + revenue/ad + lift |
| GET | `/v1/signals/{id}/apps` | Installed apps |
| GET | `/v1/signals/{id}/insights` | Per-signal insight feed |
| GET | `/v1/signals/insights` | Account insight feed |
| POST | `/v1/scrapes` | Queue on-demand scrape (`callbackUrl`, `force`) |
| GET | `/v1/scrapes/{id}` | Poll scrape job |
| POST | `/v1/scrapes/bulk` | Up to 10 domains |

## Field contract (do not reinterpret)

- `monthlyRevenueUsd.{low,high}` — modeled USD range on a **store card**.
- Grouped row `metrics.monthlyRevenueUsd` — one aggregated high-band number (`groupMetric`). Not a {low,high} pair.
- `monthlyVisits` / SimilarWeb — third-party estimate.
- `isDropshipper` — classifier + `dropshipping.confidence`.
- `sources[]` — which backends filled the object.
- `inventoryProbe.probable` — true only when the last scrape could read stock (`status=ok`). False = a Signal here cannot measure sales. `null` = not probed yet.
- Competitor `score` — similarity ranking, not market share.
- Audience row tiers (`revenueTier`, `sizeTier`, `productTier`, `adSpendTier`, `trustpilotTier`) — coarse buckets of modeled values; do not back-convert to a number. `matchedApps` is only the apps that were filtered on, not the stack.
- `emailCoveragePct` — share of the segment with at least one verified email in our index, not deliverability.
- Lookalike `score` — profile overlap (max 10), not similarity of revenue.
- Empty / zero / null — no observation.
