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.

Note: sign in to see your own token in the examples below. A free account can call every read endpoint.

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

PlanSearch and lookupExports
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

Authentication

Header format
Authorization: Bearer $STORERADAR_API_KEY
Check your token and tier
curl \
     -H "Authorization: Bearer $STORERADAR_API_KEY" \
     "https://www.storeradar.io/api/v1/health"
Response
{
  "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"

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"
Free account: anonymized
{
  "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"
}
Annual plan: the same store, unmasked
{
  "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..."
}
The end of the pagination window
{
  "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"
Exact count (annual plan)
{
  "count": "18432",
  "exact_count": 18432
}
Below the privacy floor: no exact_count key at all
{
  "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."
}
A filter your plan does not include is named back to you
{
  "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"
Response
{
  "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"
Annual plan
{
  "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"
  }
}
Free account: same store, no people key
{
  "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"
  }
}
Queued for analysis
{
  "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:

statusMeaning
queuedAccepted, not started.
processingRunning. A large scope takes 20 to 40 minutes.
completedReady. download_url is present.
no_rowsFinished and matched nothing. There is no file.
failedDid not finish. Create it again.
entitlement_lapsedYour access ended while it was running.
purgedFinished, 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"
202 Accepted
{
  "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"
Completed
{
  "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/..."
}
409 while one is already running
{
  "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"
Response
{
  "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.

FieldTypeDescription
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.

FieldTypeDescription
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

  • domain becomes dots plus the extension. The registrable part never survives.
  • emails and phone_numbers keep their shape and lose their characters.
  • social_media becomes a list of platform names with no handles.
  • people is 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

CodeWhen
200Success. Export polls use this in every state.
202Accepted. Only two cases: an export was queued, or a domain was queued for analysis.
401Missing, unknown or expired token.
403Your plan or your token does not cover this. The body carries an upgrade link.
404No store for that domain, or no export with that id. Final.
409An export is already running. The body carries its id.
410The export file passed its retention window. Create it again.
422Bad domain, bad parameters, or a cursor we will not accept.
429Rate limit, daily limit, or the new-domain crawl cap. See Retry-After.
503The 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.

errorMeaning
unauthorizedThe token is missing, unknown or expired.
forbiddenYour plan or token type does not include this. Carries upgrade_url.
invalid_domainThe domain parameter could not be parsed.
invalid_paramsA parameter was missing or unusable.
invalid_cursorThe pagination cursor was tampered with or points outside the window.
rate_limitedPer-minute budget spent. Carries retry_after.
daily_limit_reachedDaily budget spent. Resets at UTC midnight.
crawl_cap_reachedDaily limit on analyzing NEW domains. Existing stores still work.
not_foundNo store for that domain, or no export with that id.
export_in_progressOne export per account at a time. Carries export_id.
export_purgedThe file passed its retention window.
entitlement_lapsedAccess ended while the export was running.
no_rowsThe export matched nothing.
generatingThe nightly database file is not ready yet.
server_errorSomething 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.

EndpointPer minutePer day
/stores/search201000
/stores/count302000
/filter_options603000
/stores, /exports polls30500
/healthNot 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

401
{
  "error": "unauthorized",
  "message": "A valid API token is required.",
  "documentation_url": "https://www.storeradar.io/api/docs"
}
403 with the upgrade hook
{
  "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."
}
429 naming the limit it hit
{
  "error": "rate_limited",
  "message": "Too many requests. Slow down and retry shortly.",
  "limiter": "per_minute",
  "retry_after": 37
}
422
{
  "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 Required response 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 people is 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, pixels and lead. 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. error used 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

Old: any token, full contacts
{
  "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"}
  }
}
New: free account
{
  "store": {
    "domain": "••••••.com",
    "masked": true,
    "storeradar_score": 78,
    "emails": ["s***t@e******.com"],
    "upgrade_url": "https://www.storeradar.io/pricing?src=api#buy-annual"
  }
}