UNDERCART API
Query enriched Shopify store data — revenue estimates, technology stacks, ad presence, and more — over HTTPS with a single API-key header. Every response is JSON.
Pointing an AI agent at this? Skip the HTTP — add Undercart as an MCP connector and ask in natural language.MCP docs →Overview
The Undercart REST API is served under the base URL below. All endpoints require authentication and return a consistent JSON envelope. Endpoints that expose a store's data count against your plan's monthly store lookups, and listing or searching stores against its monthly searches.
Base URL: https://undercart.co/api/v1
Authentication
Authenticate every request with an API key in the X-API-Key header. Create and manage keys from the dashboard (Settings → API keys); the full key is shown once at creation and stored only as a hash. Keys look like sk_live_…. A logged-in browser session cookie also authenticates requests to the same endpoints.
curl https://undercart.co/api/v1/me \ -H "X-API-Key: sk_live_9f2a…"
Response format
Every endpoint returns the same envelope. On success, data holds the payload; on failure, error holds a human-readable message. List endpoints add a meta object with pagination details.
{
"success": true,
"data": { /* endpoint payload */ },
"meta": { // list endpoints only
"limit": 50,
"total": 681240,
"totalIsApproximate": true, // true for the unfiltered catalog count
"totalIsLowerBound": false, // true when a filtered count stopped at its cap
"nextCursor": "eyJ2Ijo…", // pass back as ?cursor= for the next page
"prevCursor": null
}
}Counts are bounded so a broad filter never holds up the response: the unfiltered catalog is an estimate (totalIsApproximate), and a filtered count stops at 1,000 — when totalIsLowerBound is true, read total as “1,000+”.
List endpoints use keyset (cursor) pagination: read meta.nextCursor and pass it back as ?cursor= with dir=next (or prev). A null cursor means there are no more pages in that direction.
Errors & rate limits
Errors use standard HTTP status codes with success: false and an error message.
| Parameter | Type | Description |
|---|---|---|
| 400 | Bad Request | Invalid or missing query/body parameters. |
| 401 | Unauthorized | Missing or invalid API key / session. |
| 403 | Forbidden | Authenticated but lacking permission (admin-only route). |
| 404 | Not Found | No matching store. |
| 429 | Too Many Requests | Monthly store-lookup or search allowance (or, for export, the monthly export-row allowance) reached; the message includes the plan cap, the reset date and the upgrade link. |
| 500 | Server Error | Unexpected error. |
Endpoints that return one store's data (by domain, by ID, lookup, tech stack, revenue history) charge a store lookup once per distinct store per calendar month (UTC): asking again for a store you already looked up this month is free, whichever endpoint you use. Listing and searching stores (GET /api/v1/stores) charges a search per request, every page included; repeating an identical request that month is free. On the free plan (25 store lookups and 50 searches a month), a lookup or a search made with an API key uses 5 of them, where the same thing in the web app uses one; Unlimited ($29/mo) has no caps. Bulk export (paid plans) uses neither; it has its own monthly row allowance (see Bulk export). Past a cap, the endpoint returns 429 until the start of the next month; the body carries a machine-readable quota object (used, cap, tier, resetsAt, plus meter, "stores" or "searches", and the credits the request needed).
List / search stores
/api/v1/storesSearch and filter the store catalog. Returns a page of store summaries. By default the full catalog is in scope (including dormant, not-yet-enriched discovery rows); pass enriched=true to restrict to fully enriched stores.
| Parameter | Type | Description |
|---|---|---|
| q | string | Case-insensitive match on domain or store name. A pasted URL or hostname is reduced to the bare domain first. |
| techStack | string | Comma-separated app/tech names (case-insensitive, e.g. klaviyo,gorgias); matches stores running any of them. Call once per app to require several. |
| sort | enum | estimatedAnnualRevenue | productCount | lastCrawledAt | firstSeenAt | name | domain (default: estimatedAnnualRevenue — stores not yet analyzed rank by their preliminary estimate). |
| order | enum | asc | desc (default: desc). |
| cursor / dir | string | Keyset pagination cursor and direction (next | prev). |
| limit | number | Page size, 1–100 (default: 50). |
Every filter from the Store Explorer's “Add filter” menu, under the same name as in the explorer's URLs. All filters AND together.
| Parameter | Type | Description |
|---|---|---|
| description | string | Description keywords. Matches the store's own description. Every keyword must appear, in any order. Use "quotes" for a phrase. |
| country | string | Country. An ISO country code, e.g. "US". |
| category | string | Category. Exact stored value, e.g. "Beauty & Cosmetics". |
| currency | string | Currency. Exact stored value, e.g. "USD". The store's own checkout currency, from /meta.json. |
| revenueMin / revenueMax | number | Est. annual revenue bounds, inclusive (USD). Stores we haven't analyzed yet are included by their popularity-based estimate. |
| monthlyRevenueMin / monthlyRevenueMax | number | Est. monthly revenue bounds, inclusive (USD). The same estimate as annual revenue, as a monthly run-rate. Stores we haven't analyzed yet are included by their popularity-based estimate. |
| productsMin / productsMax | number | Product count bounds, inclusive (count). Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. |
| priceMin / priceMax | number | Avg product price bounds, inclusive (USD). Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. |
| reviewsMin / reviewsMax | number | Total reviews bounds, inclusive (count). Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. |
| reviewRateMin / reviewRateMax | number | Review velocity bounds, inclusive (rate). Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. Observed new reviews per day between the last two comparable crawls. |
| onSaleMin / onSaleMax | number | Catalog on sale bounds, inclusive (percent, 0–100). Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. Share of the catalog currently discounted. |
| theme | string | Theme. Exact stored value, e.g. "Dawn". The base Theme Store theme, even where the store renamed its copy. |
| hiring | boolean | true — only stores matching "Hiring now". Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. Has a careers page — the cheapest proxy for a team that is growing. |
| localesMin / localesMax | number | Languages bounds, inclusive (count). Distinct languages the store publishes its pages in (hreflang) — international expansion. |
| rankMin / rankMax | number | Traffic rank bounds, inclusive (rank; 1 = most popular). Popularity rank blending Tranco with HTTP Archive/CrUX. Lower is more popular; stores in neither are unranked. |
| trafficTrend | string | Traffic trend. One of: rising (Rising), steady (Steady), falling (Falling). Chrome UX Report: the last 3 months' worldwide rank against the same months a year earlier. Only a whole-bucket move (e.g. Top 50k → Top 10k) counts. |
| topMarket | string | Top market. An ISO country code, e.g. "US". One of the store's top 3 countries for Chrome traffic, relative to each country's size. Where its visitors are, not where it is based. |
| emailsMin / emailsMax | number | Emails per week bounds, inclusive (rate). Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. Marketing emails we captured per week, averaged since we subscribed or over the last 30 days, whichever is shorter. |
| enriched | boolean | true — only stores matching "Fully enriched". Only stores we've crawled ourselves, with revenue, ads, social and catalog data. The rest carry just what HTTP Archive saw: tech stack, description, country and traffic rank. |
| trackingEmail | boolean | true — only stores matching "We capture their email". Only known for stores we've researched in depth — a few thousand of the ~700k in the catalog. Subscribed to their newsletter AND receiving mail — email intelligence is flowing. |
curl https://undercart.co/api/v1/stores \
-H "X-API-Key: sk_live_9f2a…" \
-G -d "revenueMin=1000000" \
-d "techStack=klaviyo" \
-d "sort=estimatedAnnualRevenue" \
-d "limit=2"
# → 200 OK
{
"success": true,
"data": [
{
"id": "clw…",
"domain": "maplestone.co",
"name": "Maple & Stone",
"country": "US",
"category": "Home & Living",
"estimatedAnnualRevenue": 11400000,
"estimatedMonthlyRevenue": 950000,
"revenueConfidence": 0.72,
"revenueTier": "high",
"revenueBand": null,
"categoryGuessed": false,
"productCount": 1284,
"lastCrawledAt": "2026-06-29T02:00:00.000Z",
"techCount": 37,
"topTech": ["Shopify Plus", "Klaviyo", "Gorgias", "Recharge"]
}
],
"meta": { "limit": 2, "total": 4120, "nextCursor": "eyJ2Ijo…" }
}The list includes stores we haven't analyzed yet. For those, estimatedAnnualRevenue is null and revenueBandcarries a preliminary annual range estimated from the site's popularity (e.g. "$100K–$1M"); it is null once a full estimate exists. categoryGuessed is truewhen the category was inferred from the store's homepage (description and title) rather than from analyzing the store.
Get store by domain
/api/v1/stores/by-domain?domain=<domain>Returns the full enriched profile for a single known store, keyed by domain — revenue, plan, catalog stats, active tech stack, ad presence, social profiles, and traffic (rank, year-on-year trend and top markets from the Chrome UX Report). A URL or www.host is accepted, and a hostname merged into another store (a duplicate listing, or a brand's account./checkout. host) answers with that store. Responds 404 if the domain is not in the catalog.
curl "https://undercart.co/api/v1/stores/by-domain?domain=maplestone.co" \
-H "X-API-Key: sk_live_9f2a…"
# → 200 OK
{
"success": true,
"data": {
"id": "clw…",
"domain": "maplestone.co",
"name": "Maple & Stone",
"category": "Home & Living",
"country": "US",
"foundedYear": 2019,
"shopifyPlan": "Shopify Plus",
"estimatedAnnualRevenue": 11400000,
"revenueConfidence": 0.72,
"revenueBand": null,
"redirectsToDomain": null,
"productCount": 1284,
"avgProductPrice": 68.5,
"currency": "USD",
"trafficRank": "#48,210",
"trafficTrend": "rising",
"trafficTrendDetail": "Top 50K in Chrome traffic, up from Top 100K a year earlier",
"topMarkets": {
"month": "Aug 2026",
"markets": [{ "country": "CA", "rank": "Top 10K" }]
},
"techStack": [
{ "techName": "Klaviyo", "techCategory": "Email & SMS" }
],
"adPresence": [
{ "platform": "meta", "isActive": true, "lastCheckedAt": "2026-06-29T…" }
],
"socialProfiles": [
{ "platform": "instagram", "profileUrl": "https://…", "followers": 84000 }
]
}
}A store with no published estimate — not yet analyzed, no product catalog found, or judged not to be a store — has a null estimatedAnnualRevenue and a revenueBand (a preliminary annual range from its popularity) instead. redirectsToDomainis set when the store's homepage now lands on another site: its data was read there, so look that domain up instead. trafficRank is a position ("#48,210") or, where only a Chrome UX Report bucket is known, the bucket ("Top 50K"); trafficTrend is rising, steady, falling or null.
Look up or enqueue a crawl
/api/v1/stores/lookupLook up a store by domain. If it is already known, the full profile is returned immediately (200). If it is not yet in the catalog, a discovery/enrichment job is enqueued and the endpoint responds 202 Accepted with a job id.
curl -X POST https://undercart.co/api/v1/stores/lookup \
-H "X-API-Key: sk_live_9f2a…" \
-H "Content-Type: application/json" \
-d '{ "domain": "newstore.com" }'
# → 202 Accepted (store was not yet known)
{
"success": true,
"data": { "jobId": "123", "domain": "newstore.com", "status": "queued" }
}Get store by ID
/api/v1/stores/{storeId}Returns the complete store record by its Undercart ID, including the full tech stack, recent revenue history, ad presence, social profiles, and any traffic data. Like the other store endpoints it carries revenueBand when there is no published estimate; POST /stores/lookup returns the same record for a known store.
Get store tech stack
/api/v1/stores/{storeId}/techReturns the store's detected technologies grouped by category (email/SMS, support, subscriptions, analytics, etc.), each with version, first/last-seen timestamps, and whether it is currently active. Metered like the other single-store endpoints.
Get revenue history
/api/v1/stores/{storeId}/revenueReturns the store's historical revenue estimates, most recent first. Each row's method names the signals it blended, e.g. composite(traffic,product,social), with a suffix when our revenue check corrected it. While a store publishes no estimate (see revenueBand) its history is empty.
| Parameter | Type | Description |
|---|---|---|
| limit | number | Number of estimates to return, 1–100 (default: 30). |
| method | enum | Rows whose method starts with: composite | traffic | tech_stack | product | ad_spend | social. Current estimates are all composite; the rest are legacy single-signal rows. |
Bulk export
/api/v1/stores/exportStreams a filtered result set for offline use. Accepts every filter the list endpoint does, plus sort and order, with two differences in naming: the domain/name search is search (not q), and technologies are repeated tech (running now), techAdded and techRemoved params (changed within the last days, default 30, max 365) instead of techStack. Choose the output with format=csv (default) or format=json, and pick columns with a comma-separated columns= list from: domain, name, category, country, currency, shopifyPlan, estimatedAnnualRevenue, estimatedMonthlyRevenue, revenueTier, revenueConfidence, productCount, avgProductPrice, totalReviews, foundedYear, isActive, lastCrawledAt, description, revenueBand, trafficRank, trafficTrend, revenue. revenueBand is the preliminary range for stores without a published estimate (their revenue cells are empty). lastCrawledAt is admin-only and is omitted for everyone else, whether or not it is named. revenue is the estimate where one is published and the preliminary range otherwise. Set how many rows with limit (the top N in the chosen sort; default and maximum 1,000). Export needs the paid plan, which includes 3,000 rows a month; past that the endpoint returns 429until the start of the next month (UTC), and an export that would cross it is cut short at what's left.
curl "https://undercart.co/api/v1/stores/export?format=json&limit=500&revenueMin=1000000" \ -H "X-API-Key: sk_live_9f2a…" -o stores.json
Identity
/api/v1/meReturns the authenticated caller's id and role. Useful for verifying a key works.
{ "success": true, "data": { "id": "usr_…", "role": "user" } }