StoreRadar API
Search, enrich and export a database of about 2.5 million Shopify stores: the technologies and apps each one runs, its storefront metrics, and its published contact details.
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. | Yes, scoped exports and the full database |
| 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.
GET /health
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/health"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.
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": 78,
"industry": "Apparel",
"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": "10000+",
"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": 78,
"industry": "Apparel",
"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": "10000+",
"has_more": true,
"next_cursor": "eyJvIjoyMCwi..."
}{
"stores": ["..."],
"total_count": "10000+",
"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", the cap
"10000+", or "unknown" when the count could not be
computed in time.
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.
GET /stores/count
curl \
-H "Authorization: Bearer $STORERADAR_API_KEY" \
"https://www.storeradar.io/api/v1/stores/count?countries=US&technologies=42"{
"count": "18432",
"exact_count": 18432
}{
"count": "fewer than 25",
"upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual",
"upgrade_message": "Upgrade to the StoreRadar annual plan to unmask domains and contacts."
}{
"count": "fewer than 25",
"ignored_filters": ["has_email=valid"],
"ignored_filters_message": "These filters require the StoreRadar annual plan and were NOT applied: has_email=valid. The results below are therefore not narrowed by them."
}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 here 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.
New-domain analysis is capped at 10 domains per day, and there is a shared daily ceiling across all callers. Looking up domains that are already in the database is unaffected by either.
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": 78,
"seo_lite_score": 64,
"industry": "Apparel",
"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": "••••••.com",
"masked": true,
"storeradar_score": 78,
"seo_lite_score": 64,
"industry": "Apparel",
"emails": ["s***t@e******.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."
}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 is verified for annual subscribers and verified
add-on holders, and basic otherwise. The basic file has the email
verification columns removed.
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 the StoreRadar annual plan 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": "basic",
"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 | Overall quality score, 0-100. Filled by Store scoring callback. |
seo_lite_score |
integer or null | SEO health score, 0-100. Filled by SeoLite::Scoring::Aggregator. |
industry |
string or null | Detected industry. Filled by Shopify::Enricher. |
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. 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. |
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.
| Endpoint | Per minute | Per day |
|---|---|---|
/stores/search | 20 | 1000 |
/stores/count | 30 | 2000 |
/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": "Upgrade to the StoreRadar annual plan to unmask domains and contacts."
}{
"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
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"
}
}