StoreRadar API
Search, enrich and export a database of about 2.9M Shopify stores: the technologies and apps each one runs, its storefront metrics, and its published contact details.
Prefer a panel to a request? The Chrome extension shows the store record for whatever Shopify store is in your active tab.
Authentication
Every request needs an API token in the Authorization header.
Authorization: Bearer YOUR_API_TOKEN
Authorization: token YOUR_API_TOKEN is accepted too, so older
integrations keep working.
Two kinds of token
A token you create yourself on the API tokens page can call every endpoint on this page.
The token issued automatically for MCP and OAuth connections can only READ. It cannot create exports or download the database. Those credentials are held by third-party AI platforms, so their reach is deliberately smaller.
Call GET /health to see which one you are holding.
Base URL
https://www.storeradar.io/api/v1
Use the www host exactly as written. The bare domain redirects, and a
redirect drops the body of a POST.
What each plan returns
| Plan | Search and lookup | Exports |
|---|---|---|
| Free account | Anonymized domains and contacts. Counts under 25 report as a range. | No |
| One-time pass | Same anonymized rows as the free tier, plus the valid-email filter. | Yes, scoped exports and the full database, including the verified-email columns |
| Annual plan | Real domains, contacts, named people and exact counts. | Yes, including the verified-email columns |
Machine-readable
- OpenAPI 3.1 spec, for agent frameworks and client generators.
- llms.txt, a short index for language models.
- The same data is available over the Model Context Protocol. One token works on both.
Authentication
Authorization: Bearer $STORERADAR_API_KEY
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/health"{
"status": "ok",
"capabilities": {
"unmasked": false,
"verified": false,
"exports": true
},
"documentation_url": "https://www.storeradar.io/api/docs"
}GET /health
Confirms the token works and reports what it is allowed to do. It costs nothing against your rate limit, so call it first rather than discovering your tier from a refusal halfway through a job.
unmasked: real domains and contacts are returned.verified: the verified-email filter is available.exports: this token may create exports and download the database.-
plan:free,csv_passorannual. A label for what the account holds, useful for wording an upgrade prompt. Readunmaskedandverifiedto know what the data looks like; the label grants nothing on its own. user_id: the token's user. Stable, so it works as an analytics id.
GET /health
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/health"{
"status": "ok",
"user_id": 4211,
"capabilities": {
"unmasked": false,
"verified": false,
"exports": false,
"plan": "free"
},
"documentation_url": "https://www.storeradar.io/api/docs"
}GET /stores/search
A page of up to 20 stores matching your filters. Use
/filter_options to learn the valid
values, and /stores/count to size the segment
first.
Pagination
Follow next_cursor. The cursor is opaque and carries the original
filters and sort, so it wins over any filters you send alongside it. Send it on
its own.
One query reaches at most about 520
rows. When you hit that edge you get has_more: true and NO
next_cursor. That pair means the window ran out, not the segment. To
take a whole segment, create an export instead of paging.
Masked and unmasked rows
Free accounts receive rows with the domain and contact values anonymized, and no
named people. The annual plan returns the real values.
Every row says which it is through masked.
Below 25 matches, a free account receives NO rows at all, only the count. A handful of anonymized rows plus their technology fingerprint would identify a single store.
Sorting
sort accepts:
storeradar_score, seo_lite_score, product_count, collections_count, articles_count, pages_count, first_seen_at, last_detected_at, domain_created_at, domain_expires_at, search_etv, search_etv_change_3m_pct, review_count, review_rating, review_count_change_30d.
sort_direction is asc or desc, defaulting
to desc. Rows with no value in the sort column are left out, because
a row that lacks the ranking value cannot be ranked by it.
GET /stores/search
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/stores/search?countries=US&product_count_min=50&sort=storeradar_score"{
"stores": [
{
"domain": "••••••.com",
"masked": true,
"storeradar_score": 52,
"industry": "Apparel & Accessories",
"industry_confidence": "high",
"industry_niche": "Womenswear",
"country_codes": ["US"],
"product_count": 412,
"technologies": ["Klaviyo", "Google Analytics"],
"apps": ["Judge.me"],
"emails": ["s***t@e******.com"],
"social_media": ["instagram", "facebook"],
"last_detected_at": "2026-09-17T04:12:09.000Z"
}
],
"total_count": "about 61,000",
"estimated_count": 61000,
"count_is_estimate": true,
"has_more": true,
"next_cursor": "eyJvIjoyMCwi...",
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual"
}{
"stores": [
{
"domain": "shop.examplebrand.com",
"masked": false,
"storeradar_score": 52,
"industry": "Apparel & Accessories",
"industry_confidence": "high",
"industry_niche": "Womenswear",
"country_codes": ["US"],
"product_count": 412,
"technologies": ["Klaviyo", "Google Analytics"],
"apps": ["Judge.me"],
"emails": ["support@examplebrand.com"],
"social_media": {"instagram": "examplebrand", "facebook": "examplebrand"},
"last_detected_at": "2026-09-17T04:12:09.000Z"
}
],
"total_count": "about 61,000",
"estimated_count": 61000,
"count_is_estimate": true,
"has_more": true,
"next_cursor": "eyJvIjoyMCwi..."
}{
"stores": ["..."],
"total_count": "about 61,000",
"estimated_count": 61000,
"count_is_estimate": true,
"has_more": true
}GET /stores/count
How many stores match a filter set. It returns no store rows, so it is the cheapest way to size a segment before you search or export it.
Counts are strings
count is ALWAYS a string label, never a number. It can be an exact
figure, a privacy range such as "fewer than 25", an estimate such as
"about 60,000", the cap "10000+", or "unknown"
when the count could not be computed in time.
Above 10,000 stores the count is an
estimate from a 2% sample, for every plan. It is rounded to two significant
figures and is about 7% accurate. estimated_count carries the integer and
count_is_estimate is true. Use it to size a segment, not as a number
of rows to page through. When no estimate is available, the count stays "10000+".
exact_count carries the integer, and it is present ONLY when the
count is exact. Its absence is meaningful: it says the label is not a number.
Search uses the same pair under the names total_count and
exact_total_count.
Free accounts see the range for every result below 25, including a result of zero. That is deliberate: if zero answered differently from one, narrowing a filter until the answer changed would confirm that a specific store exists.
The one-time file offer
For a free account, a count and the first search page carry list_offer:
the matching stores, with real domains and contacts, as a one-time file. It is the
exact segment, priced by its size, or the full CSV when the segment is too small to
sell alone or close to the whole database. checkout_url opens an order
page that needs no account. rows counts live stores only, so it can be
lower than count. A live CSV pass already includes every store, so a pass
holder gets no offer.
GET /stores/count
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/stores/count?countries=US&technologies=42"{
"count": "8432",
"exact_count": 8432
}{
"count": "about 61,000",
"estimated_count": 61000,
"count_is_estimate": true
}{
"count": "3412",
"exact_count": 3412,
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual",
"upgrade_message": "United States store list: 3,104 stores with real domains and contacts, as a one-time $29 file. No account needed. StoreRadar Annual unmasks every search here.",
"list_offer": {
"label": "United States store list",
"rows": 3104,
"price_cents": 2900,
"price": "$29",
"checkout_url": "https://www.storeradar.io/lists/discover--0123456789abcdef/order?quote=...&src=api"
}
}{
"count": "fewer than 25",
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual",
"upgrade_message": "Full Shopify stores CSV: 2,978,560 stores with real domains and contacts, as a one-time $149 file. No account needed. StoreRadar Annual unmasks every search here.",
"list_offer": {
"label": "Full Shopify stores CSV",
"rows": 2978560,
"price_cents": 14900,
"price": "$149",
"checkout_url": "https://www.storeradar.io/new-shopify-stores/buy?src=api"
}
}{
"count": "fewer than 25",
"ignored_filters": ["has_email=valid"],
"ignored_filters_message": "These filters require a CSV pass or StoreRadar Annual and were NOT applied: has_email=valid. The results below are therefore not narrowed by them."
}GET /stores/describe
What a segment looks like, estimated from a 2% random sample of
every store, drawn again each night. It takes the same filters as
/stores/count and returns no store rows. The MCP tool
describe_segment returns the same body.
Shares are adoption rates
Each list (industries, niches, countries, languages, themes, Shopify apps, technologies)
gives its most common values with share_pct: the share of the segment's stores,
in whole percent. For an app, a theme or a technology, that share is its adoption rate.
top sets the list length, 1 to 20
(default 10). A value needs
5 sampled stores to be listed.
size is a string label such as "about 48,000", and
estimated_size carries the integer. basis.share_margin_pct is the
widest 95% margin of any share, in percentage points.
Small segments
With fewer than 50 sampled stores,
described is false and the body holds only the size label, for example
"fewer than about 2,500". Zero gets the same label, so the answer never confirms
that a store exists. Use /stores/count for the size of a small segment.
GET /stores/describe
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/stores/describe?countries=DE&top=3"{
"size": "about 48,000",
"estimated_size": 48000,
"described": true,
"themes": [{"name": "Dawn", "share_pct": 21}, {"name": "Horizon", "share_pct": 9}, {"name": "Prestige", "share_pct": 4}],
"shopify_apps": [{"name": "Klaviyo", "id": 42, "share_pct": 18}],
"with_email_pct": 71,
"with_phone_pct": 38,
"with_blog_pct": 27,
"product_count": {"lower_quartile": 20, "median": 85, "upper_quartile": 340, "known_pct": 96},
"new_stores_by_month": [{"month": "2026-10", "new_stores": "about 1,200", "estimated_new_stores": 1200}],
"basis": {"method": "Estimated from a 2% random sample of every store StoreRadar tracks, drawn again each night. Counts are scaled up from the sample.", "sample_percent": 2, "sample_rows": 960, "share_margin_pct": 4}
}{
"size": "fewer than about 2,500",
"described": false,
"message": "Too few stores to describe: fewer than 50 of them are in the 2% sample, so every share would be noise. count_stores gives this segment's size.",
"basis": {"method": "Estimated from a 2% random sample of every store StoreRadar tracks, drawn again each night. Counts are scaled up from the sample.", "sample_percent": 2}
}GET /filter_options
The valid values for each filter, read from the same caches the website's filter UI uses, so the two never disagree.
Call it with no parameters to list the categories, then again with
?category=. Technologies and apps return {name, id}
pairs: pass the id into the technologies or
shopify_apps filter, not the name. Add ?q= to narrow
the long lists.
Available to every plan. No per-store data is involved.
GET /filter_options
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/filter_options?category=technologies&q=klaviyo"{
"category": "technologies",
"options": [
{"name": "Klaviyo", "id": 42}
]
}GET /stores
One store by domain. Pass a bare hostname in ?domain=. The store
arrives inside a store key, masked according to your plan.
Domains we have never seen
If the domain is not in the database, we queue it for analysis and answer
202. Poll the same URL. Within a minute or two you get either the
store or a 404.
A 404 after a crawl is final. It means we looked and the domain does
not serve a Shopify storefront we can publish. Polling it again will not change
the answer. Every 404 carries crawlable: false
for this final answer.
New-domain analysis is capped at 10 domains per day, and there is a shared daily ceiling across all callers. Accounts younger than 24 hours get 2 domains a day. Looking up domains that are already in the database is unaffected by any of these.
Lookup without a crawl
Add crawl=false to answer from the database only. A known store
still returns 200. A domain we do not have returns 404
at once with crawlable: true, and nothing is queued or charged
against your daily crawl cap. Repeat the call without the parameter when you
do want the crawl. Only the literal string false turns the crawl
off.
The domain you typed
A free key sees the domain it typed, up to
50 domains a day and
10 a minute: the limits of the free
app and theme detector. The MCP get_store tool spends the same budget.
Past the limit, or when the store is saved under another domain, the domain is
masked. domain_note says which, in one line. Contacts stay masked.
Named contacts
This is the only endpoint that returns people, the named contacts we
have for a store with their role and email. It is present for annual subscribers
only, and the key is absent entirely otherwise.
GET /stores
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/stores?domain=examplebrand.com"{
"store": {
"domain": "examplebrand.com",
"masked": false,
"storeradar_score": 52,
"seo_lite_score": 90,
"industry": "Apparel & Accessories",
"industry_confidence": "high",
"industry_niche": "Womenswear",
"emails": ["support@examplebrand.com"],
"phone_numbers": ["+1 415 555 0134"],
"technologies": ["Klaviyo"],
"apps": ["Judge.me"],
"people": [
{
"full_name": "Ada Lovelace",
"position": "Head of Ecommerce",
"email": "ada@examplebrand.com",
"seniority": "executive",
"department": "executive",
"linkedin": "https://www.linkedin.com/in/example"
}
],
"first_seen_at": "2024-03-02T11:04:51.000Z",
"last_detected_at": "2026-09-17T04:12:09.000Z"
}
}{
"store": {
"domain": "examplebrand.com",
"domain_note": "Shown because you typed this domain. This account sees up to 50 typed domains a day; contacts stay masked.",
"masked": true,
"storeradar_score": 52,
"seo_lite_score": 90,
"industry": "Apparel & Accessories",
"industry_confidence": "high",
"industry_niche": "Womenswear",
"emails": ["*******@***.com"],
"phone_numbers": ["+1 4** *** **34"],
"technologies": ["Klaviyo"],
"apps": ["Judge.me"],
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual"
}
}{
"status": "processing",
"message": "We are analyzing that domain. Poll this endpoint again in a minute."
}curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/stores?domain=examplebrand.com&crawl=false"{
"error": "not_found",
"message": "No store found for that domain.",
"crawlable": true
}Exports
Two products. POST /exports generates a CSV of one filtered segment.
GET /exports/full hands you the whole database as a nightly file.
Both need an active pass or annual plan, AND a token you created yourself. Tokens issued automatically for MCP or OAuth are read-only.
POST /exports
Send the same filters the search endpoint accepts. They have to narrow the scope:
an empty filter set is refused, because the whole database is what
/exports/full is for.
Filters your plan does not include are dropped, exactly as they are on search
and count. The response tells you which: filters_applied is the
scope that actually ran, and ignored_filters names anything
removed. Read them before you describe the file to anyone.
One export runs per account at a time. A second request while one is running
returns 409 with the export_id of the one already
going, so poll that instead of retrying.
GET /exports/:id
Always 200 while the export exists. Branch on status,
not on the HTTP code. Polling is metered on its own budget, so a poll every
few seconds costs you nothing you need for store lookups:
| status | Meaning |
|---|---|
queued | Accepted, not started. |
processing | Running. A large scope takes 20 to 40 minutes. |
completed | Ready. download_url is present. |
no_rows | Finished and matched nothing. There is no file. |
failed | Did not finish. Create it again. |
entitlement_lapsed | Your access ended while it was running. |
purged | Finished, but the file passed its retention window. |
download_url is a short-lived signed link in the body, not a
redirect. Fetch it WITHOUT your Authorization header: the link carries its own
signature and storage rejects a request that sends both.
truncated: true means the scope hit the
2,000,000-row ceiling,
so the file is complete-looking but short. Narrow the filters and run it again.
Files are kept for 48 hours. After that the
record stays and the file is gone, and a poll returns 410. Create the
export again for a fresh copy.
GET /exports/full
A signed link to the current nightly export of the whole database.
variant names the file you received. Annual subscribers and live
one-time passes get verified, with the email verification columns.
On a night whose verified file is missing you get basic, the same
file without those columns.
If the night's file is still being built you get 503 with
error: "generating" and a Retry-After. That is a real
state, not a failure. Try again in an hour.
POST /exports
curl -X POST \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filters":{"countries":["US"],"technologies":[42]}}' \
"https://www.storeradar.io/api/v1/exports"{
"id": 8812,
"status": "queued",
"created_at": "2026-09-19T09:14:22Z",
"completed_at": null,
"row_count": null,
"filters_applied": {"countries": ["US"]},
"truncated": false,
"ignored_filters": ["has_email=valid"],
"ignored_filters_message": "These filters require a CSV pass or StoreRadar Annual and were NOT applied: has_email=valid. The results below are therefore not narrowed by them."
}GET /exports/:id
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/exports/8812"{
"id": 8812,
"status": "completed",
"created_at": "2026-09-19T09:14:22Z",
"completed_at": "2026-09-19T09:31:07Z",
"row_count": 18432,
"truncated": false,
"file_expires_at": "2026-09-21T09:31:07Z",
"download_url": "https://files.storeradar.io/..."
}{
"error": "export_in_progress",
"message": "An export is already running for this account. Poll it instead of starting another.",
"export_id": 8812
}GET /exports/full
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/exports/full"{
"download_url": "https://files.storeradar.io/...",
"expires_at": "2026-09-26T09:14:22Z",
"variant": "verified",
"record_count": 2531884,
"generated_at": "2026-09-19T03:11:00Z"
}Store fields
Every endpoint that returns a store returns these fields, in this shape. The table is generated from the serializer itself, so a field cannot be listed here without being produced, and the pipeline column names what fills it.
| Field | Type | Description |
|---|---|---|
domain |
string | Store domain. Masked callers receive an anonymized form. Filled by Shopify::StorePersister. |
masked |
boolean | Whether identity and contact values are anonymized for this caller. Filled by derived. |
storeradar_score |
integer or null | Lead Score, 0-100. Its five factors add up to 70, so no store scores higher. Filled by Store scoring callback. |
seo_lite_score |
integer or null | SEO health score, 0-100: 100 minus a penalty for each SEO issue found. Null when the store has no audit or the crawl saw no storefront. Filled by SeoLite::Scoring::Aggregator. |
industry |
string or null | Industry: one of 21 Shopify product verticals or General store. Filled by Industry::Sweep (TypeSafe Jev). |
industry_confidence |
string or null | high or low; null when the store has no industry. Filled by Industry::Sweep (TypeSafe Jev). |
industry_niche |
string or null | Niche inside the industry, e.g. Skincare under Health & Beauty; null when Jev was not sure. Filled by Industry::NicheSweep (TypeSafe Jev). |
country_codes |
array of string | ISO country codes the store ships to or targets. Filled by Shopify::Enricher. |
languages |
array of string | Storefront languages. Filled by Shopify::Enricher. |
product_count |
integer or null | Products found in the sitemap. Filled by Signal::SitemapCollector. |
collections_count |
integer or null | Collections found in the sitemap. Filled by Signal::SitemapCollector. |
articles_count |
integer or null | Blog articles found in the sitemap. Filled by Signal::SitemapCollector. |
pages_count |
integer or null | Content pages found in the sitemap. Filled by Signal::SitemapCollector. |
has_blog |
boolean or null | Whether the storefront publishes a blog. Filled by Signal::SitemapCollector. |
password_protected |
boolean | Whether the storefront is behind a password now. Filled by Shopify::StorePersister. |
password_first_seen_at |
string or null | RFC3339 timestamp of the first crawl that found a password page. Kept after the store opens. Filled by Shopify::StorePersister. |
theme |
object or null | {name, version} of the detected Shopify theme. A masked caller receives •••••• for a name that contains the store's own brand. Filled by Shopify::StorePersister. |
technologies |
array of string | Detected third-party technologies. Filled by TechnologyDetection. |
apps |
array of string | Detected Shopify apps. Filled by Shopify app detection. |
emails |
array of string | Contact emails. Starred for masked callers. Filled by LegalContact::Orchestrator. |
phone_numbers |
array of string | Contact phone numbers. Starred for masked callers. Filled by LegalContact::Orchestrator. |
social_media |
object, array or null | Platform to handle map. Masked callers receive platform names only. Filled by Shopify::Enricher. |
first_seen_at |
string or null | RFC3339 timestamp of the first detection. Filled by Shopify::StorePersister. |
last_detected_at |
string or null | RFC3339 timestamp of the most recent successful crawl. Filled by Shopify::StorePersister. |
domain_created_at |
string or null | RFC3339 domain registration date from WHOIS. Filled by DomainIntel::Orchestrator. |
domain_expires_at |
string or null | RFC3339 domain expiry date from WHOIS. Filled by DomainIntel::Orchestrator. |
search_traffic |
search traffic object | Estimated Google organic search traffic (ETV) with 3- and 12-month change, Google scope and month, for live .com/.fr/.co.uk/.com.au/.ca/.de stores. Annual plan only: other callers receive status requires_annual. Not visits, not sales. Filled by SearchTraffic::Ingest. |
reviews |
reviews object | Beta. Customer-review aggregate from the store's own review app (Judge.me, Okendo; Loox, Stamped and Yotpo to follow): provider, count, mean rating, scope (store total or one sampled product), public link, and 30-day and 90-day change from our own snapshots. Annual plan only: other callers receive status requires_annual. Filled by Reviews::Reader. |
people
Returned by GET /stores only, and only for
annual subscribers. The key is absent for every other caller.
| Field | Type | Description |
|---|---|---|
first_name |
string or null | Contact's first name. |
last_name |
string or null | Contact's last name. |
full_name |
string or null | Contact's full name. |
position |
string or null | Job title as published. |
email |
string or null | Contact email address. |
seniority |
string or null | junior, senior or executive. |
department |
string or null | Detected department. |
linkedin |
string or null | LinkedIn profile URL. |
twitter |
string or null | X/Twitter handle. |
What masking does to each field
domainbecomes dots plus the extension. The registrable part never survives.emailsandphone_numberskeep their shape and lose their characters.social_mediabecomes a list of platform names with no handles.peopleis not returned at all.- Everything else is identical for every plan.
Timestamps
All timestamps are RFC3339 with milliseconds, in UTC.
{
"first_seen_at": "2024-03-02T11:04:51.000Z",
"last_detected_at": "2026-09-17T04:12:09.000Z",
"domain_created_at": "2019-07-14T00:00:00.000Z",
"domain_expires_at": "2027-07-14T00:00:00.000Z"
}Nulls
A null means we have not detected the value, not that it is zero. A store whose
sitemap we could not read reports null for the counts rather than 0.
Status codes
| Code | When |
|---|---|
| 200 | Success. Export polls use this in every state. |
| 202 | Accepted. Only two cases: an export was queued, or a domain was queued for analysis. |
| 401 | Missing, unknown or expired token. |
| 403 | Your plan or your token does not cover this. The body carries an upgrade link. |
| 404 | No store for that domain, or no export with that id. Final. |
| 409 | An export is already running. The body carries its id. |
| 410 | The export file passed its retention window. Create it again. |
| 422 | Bad domain, bad parameters, or a cursor we will not accept. |
| 429 | Rate limit, daily limit, or the new-domain crawl cap. See Retry-After. |
| 503 | The nightly database file is still being built. |
Error codes
Every response outside the 2xx range uses the same two keys:
error, a stable machine-readable code, and message,
text for a human. Branch on error. Some cases add one extra key,
never removing these two.
| error | Meaning |
|---|---|
unauthorized | The token is missing, unknown or expired. |
forbidden | Your plan or token type does not include this. Carries upgrade_url. |
invalid_domain | The domain parameter could not be parsed. |
invalid_params | A parameter was missing or unusable. |
invalid_cursor | The pagination cursor was tampered with or points outside the window. |
rate_limited | Per-minute budget spent. Carries retry_after. |
daily_limit_reached | Daily budget spent. Resets at UTC midnight. |
crawl_cap_reached | Daily limit on analyzing NEW domains. Existing stores still work. |
not_found | No store for that domain, or no export with that id. |
export_in_progress | One export per account at a time. Carries export_id. |
export_purged | The file passed its retention window. |
entitlement_lapsed | Access ended while the export was running. |
no_rows | The export matched nothing. |
generating | The nightly database file is not ready yet. |
server_error | Something broke on our side. We are alerted. |
Rate limits
Budgets are per endpoint and per account, shared with the MCP interface: spending
a search there spends it here. Annual subscribers get
5 times the figures below. On top of
these, one network address gets
120 requests a minute across
the whole API; that answer carries "limiter": "per_ip".
| Endpoint | Per minute | Per day |
|---|---|---|
/stores/search | 20 | 1000 |
/stores/count | 30 | 2000 |
/stores/describe | 10 | 500 |
/filter_options | 60 | 3000 |
/stores, /exports polls | 30 | 500 |
/health | Not metered. | |
Budget windows are aligned to the clock, so Retry-After is the real
number of seconds until the window resets rather than an estimate. On a daily
limit it can be large; treat it as advisory and retry after UTC midnight.
Error shape
{
"error": "unauthorized",
"message": "A valid API token is required.",
"documentation_url": "https://www.storeradar.io/api/docs"
}{
"error": "forbidden",
"message": "Scoped exports require an active StoreRadar pass.",
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual",
"upgrade_message": "Real domains and contacts come in a one-time file, no account needed: a list of one segment, or the full CSV of every store. StoreRadar Annual unmasks every search here."
}{
"error": "rate_limited",
"message": "Too many requests. Slow down and retry shortly.",
"limiter": "per_minute",
"retry_after": 37
}{
"error": "invalid_domain",
"message": "That does not look like a valid domain. Send a bare hostname, for example example.com."
}Changelog
November 2, 2026: describe a segment
New endpoint GET /stores/describe, and the MCP tool
describe_segment. It estimates a segment from the
2% sample: its size, its most common industries, countries,
themes, apps and technologies with their share of the segment, and new stores per month.
It returns no store rows.
October 27, 2026: the StoreRadar Score drops the migration factor
storeradar_score no longer uses the migration factor (the age gap
between the domain and the Shopify store). The field keeps its name and its 0 to
100 scale. Its five remaining factors add up to 70, so no store scores higher.
Each stored score loses its migration points, so many values go down by up to
30 points. A storeradar_score_min filter keeps its raw value: about
the top 18% of live stores score 40 or more.
October 27, 2026: the SEO score starts at 100
seo_lite_score now starts at 100 and loses points for each SEO issue
found. It used to start at 50 with one +6 bonus for a sitemap, so no store scored
above about 56. The checks that read sitemap data are gone (the sitemap bonus, and
the unlinked blog, unlinked collections and product collection context checks),
because the crawl collects that data on some paths only. Title and meta
description length no longer count. A canonical that differs from the store host
only by www. is no longer flagged. Stored scores are recomputed from
the stored checks, with no new crawl, so most values go up: the median moves from
about 37 to about 90. A homepage with no canonical tag means the crawl did not see
the storefront, so seo_lite_score is now null for these
stores until the next crawl. Saved seo_lite_score filters keep their
values.
October 2026: theme pricing in the StoreRadar Score
A crawler fault read every Shopify Theme Store theme as paid at $329, Dawn
included. Free themes now read as free, paid themes carry their real price,
and a theme whose price could not be read is unknown. The theme factor of
storeradar_score is recomputed: a store on a free theme gets 5
points instead of 25, so about 1.5 million scores drop by 20 points. The
scoring rules did not change.
September 2026: reviews (beta)
Every store now carries a reviews object: the customer-review
aggregate its own review app shows (Judge.me and Okendo first), with provider,
count, mean rating, scope, public link and 30-day and 90-day change. Annual plan
only; other callers receive requires_annual. Beta: numbers can be one
product's (scope: sampled) and no CSV carries them yet.
September 2026: v1 rebuilt
GET /api/v1/stores ran on a credits system that no longer exists.
The endpoint kept its URL and its store envelope; everything inside
it changed. If you built against the old payload, read this list.
-
Credits are gone. Lookups no longer charge anything and no
longer create a lead record. The
402 Payment Requiredresponse no longer exists. -
The payload follows your plan. It used to return real emails
and named contacts to any valid token. Free accounts now receive anonymized
values, and
peopleis returned to annual subscribers only. -
About twenty fields were removed, including
id,name,lead_score,crawl_state,alive_status,meta_title,meta_description,locale,currency,pixelsandlead. The field table is the complete list of what remains. - Emails, phones, apps and technologies are arrays of strings. They used to be arrays of objects. The per-email verification columns are not returned by this endpoint at all; they ship in the verified CSV export.
- Timestamps are RFC3339. They used to render in a format that was not parseable as a date-time.
-
The 202 body no longer carries
job_id. It was an internal queue identifier with nothing to do. - Expired tokens are rejected. A token past its expiry date used to keep working. Check yours on the API tokens page.
-
Error bodies are machine-readable.
errorused to be a sentence; it is now a stable code from a closed list.
Added in the same release
GET /health,GET /stores/search,GET /stores/count,GET /filter_options.POST /exports,GET /exports,GET /exports/:id,GET /exports/full.- An OpenAPI 3.1 spec and an llms.txt index.
Before and after
{
"store": {
"id": 12345,
"canonical_host": "examplebrand.com",
"name": "Example Brand",
"lead_score": 71,
"crawl_state": "completed",
"emails": [
{"value": "support@examplebrand.com", "verified": true, "verification_score": 98}
],
"people": [{"email_value": "ada@examplebrand.com"}],
"lead": {"revealed_at": "2026-01-04T10:00:00Z"}
}
}{
"store": {
"domain": "••••••.com",
"masked": true,
"storeradar_score": 78,
"emails": ["s***t@e******.com"],
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual"
}
}