Stoqlab tells you who is allowed to sell ads in a mobile app, and whether those declarations
hold up. For every tracked iOS and Google Play app we read the developer's app-ads.txt,
fetch the sellers.json of every ad system listed there, cross-check the two and build the
declared supply tree: app → ad system (DIRECT / RESELLER) → seller → intermediaries → further hops.
We crawl daily and keep history, so you can also see what changed and when.
- Base URL:
https://api.stoqlab.com/v1 - Format: JSON (UTF-8). Read-only (
GET), except your own saved lists (/lists, §3.16), domain checks (POST /checks, §3.17), alert rules and webhooks ("Alerts", "Webhooks"), and the two lookups that take a body (POST /verify/seats,POST /schain/validate, §3.18–3.19) - Machine-readable spec:
openapi.yaml(OpenAPI 3.1). Import it into Postman, Insomnia or a code generator.
1. Authentication#
- Sign in at https://app.stoqlab.com → API keys → create a token.
- Copy it straight away; it is shown only once. It looks like
12|Xk3p…— send the whole string, including the number and the|. - Send it on every request:
export STOQLAB_TOKEN='12|Xk3p...'
curl -s https://api.stoqlab.com/v1/apps?q=puzzle \
-H "Authorization: Bearer $STOQLAB_TOKEN" \
-H "Accept: application/json"
Treat the token like a password: keep it server-side, never in a mobile app or a public repo. Revoke it from the dashboard if it leaks; the revoked token stops working immediately.
Expiry. You choose when a key expires as you create it: 7, 30 or 90 days (the default), 1 year, a custom date (the key stops at the end of that day, UTC; at most 2 years ahead) or no expiry. "MCP only" keys always expire, within 90 days. A workspace can cap the lifetime of new keys (then "no expiry" is not offered). The API keys page shows, for every key, when it was created and last used, when it expires and its status (Active, Expires soon — within 7 days — or Expired). Rotate gives a key a new secret with the same access and lifetime and revokes the old secret at once; expired keys can be deleted one by one or all together.
An expired key answers 401 with the usual unauthenticated code plus error.reason: "token_expired" and error.expired_at (UTC), so a client can tell "renew the key" apart from
"wrong key" (§5):
{ "error": { "code": "unauthenticated", "reason": "token_expired", "expired_at": "2026-12-31T23:59:59Z",
"message": "This API key expired on 2026-12-31 23:59 UTC. Create a new key on the dashboard (API keys) and update your client." } }
Key types (abilities). Pick one when you create the key:
| Type | Abilities | Can |
|---|---|---|
| API, read-only (default) | read |
Every GET of this API, exports, and the MCP server (§8) |
| API, read + manage lists and checks | read, lists:write |
The above, plus POST /lists, PATCH/DELETE /lists/{id}, POST /lists/{id}/apps, POST /lists/{id}/refresh, and POST /checks (it makes us crawl a host) |
| MCP only | mcp |
The MCP server only — no REST, no exports, no changes. Always expires, within 90 days |
A key without the needed ability gets 403 forbidden with error.required_abilities
(e.g. ["lists:write"]). Keys created before these types existed are read-only (read).
2. Rate limits#
- Per account, by plan: 30 requests per minute on Explorer (free), 120 on Professional, 600 on
Business, 3,000 on Enterprise Data, shared by every key of the account (REST and MCP). The plan is
your team's (§2c); each member of a team gets this rate. Your current limit is shown on the
dashboard's Plan & usage page, in
GET /v1/meand inX-RateLimit-Limit. - In addition, 120 requests per minute per client IP address, summed over every account and key used from that address. This one is counted before the token is checked, so requests with a missing or wrong token count too.
- 30 failed authentications (
401) per minute per IP: after that, every request from the address gets429until the minute is over, valid token or not. - Every response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed per minute |
X-RateLimit-Remaining |
Requests left in the current minute |
- When you go over, you get
429 rate_limitedplusRetry-After(seconds to wait) andX-RateLimit-Reset(Unix time the window resets). Wait that long, then retry. - For bulk jobs, pace yourself at ~1 request/second and back off exponentially on
429and5xx.
Daily data quota (rows per UTC day). Every team also has a number of data rows per day: Explorer 5,000, Professional 50,000, Business 250,000, Enterprise Data 2,000,000 (§2b). It counts every row your team receives, whatever the surface:
- REST: each item of a list's
datais one row, one object (/apps/{app},/sellers/…) is one row, a supply tree counts its lines; - exports (
format=csv,format=ndjson,/exports, live CSV URLs): the rows written, charged when the export starts (it is cut to what is left); - MCP: the rows of each tool result;
- the dashboard: the rows of its list pages and its CSV downloads.
Your own objects are not counted (/me, list metadata, alert rules, webhooks, domain checks,
dataset links, schain validation). An API key can also get its own daily row limit below the
team's (dashboard → API keys). Metered responses carry:
| Header | Meaning |
|---|---|
X-RateLimit-Rows-Limit |
Rows per UTC day for this key (the team's quota, or the key's own limit if lower) |
X-RateLimit-Rows-Remaining |
Rows left today |
X-RateLimit-Rows-Reset |
Unix time the quota resets (00:00 UTC) |
When the quota is used up, list and detail requests answer 429 row_quota_exceeded (or
key_row_quota_exceeded for a key's own limit) with Retry-After until midnight UTC; MCP tools
answer a row_quota_exceeded: tool error. GET /v1/me shows limits.rows_per_day,
rows_used_today and rows_remaining_today. Admins have no quota.
2b. Plans: what each key can call#
Every key acts with the plan of its team (§2c), and the API and the MCP server (§8) enforce the same
plan features and limits as the dashboard (admins have everything). A route outside your plan
answers 403 plan_required; the plan is checked before the query parameters (§5):
{ "error": { "code": "plan_required", "feature": "tree", "plan": "pro", "required_plan": "pro",
"current_plan": "free", "upgrade_url": "https://stoqlab.com/contact?plan=pro&source=api-tree",
"message": "Supply tree: not included in your plan (Explorer). Part of the Professional plan and up. Contact us to upgrade: https://stoqlab.com/contact?plan=pro&source=api-tree" } }
| Plan feature | Explorer (free) | Professional | Business | Enterprise Data | Endpoints (REST) · MCP tools |
|---|---|---|---|---|---|
| — (every plan) | ✓ | ✓ | ✓ | ✓ | /apps, /apps/{app} (+ related, countries, privacy), /networks/{d}, /networks/{d}/apps, /networks/{d}/sellers, /sellers/…, /developers/…, /lists…, /exports, /checks…, /domains/{d}/audit · search_apps, get_app, apps_by_network, get_seller, audit_domain |
tree |
— | ✓ | ✓ | ✓ | /apps/{app}/supply-tree, /apps/{app}/direct-sellers · get_supply_tree |
history |
— | ✓ | ✓ | ✓ | /apps/{app}/history, /apps/{app}/appads-history, /apps/{app}/stale-lines, /changes; the 1/7/30-day changes in /apps/{app} meta.growth · recent_changes, the growth changes of get_app |
trends |
— | — | ✓ | ✓ | /network-share, /networks/{d}/share, /leaderboards/growth, /networks/{d}/stale-sellers · network_share, growth_leaderboard |
alerts |
— | ✓ | ✓ | ✓ | /alerts/…, /webhooks… · recent_alerts |
countries |
— | ✓ | ✓ | ✓ | /apps/{app}/availability, /countries…, /charts/… |
ctv |
— | ✓ | ✓ | ✓ | /ctv/apps… |
web |
— | — | ✓ | ✓ | /sites/…, /networks/{d}/sites |
datasets |
— | — | — | ✓ | /datasets… |
prospecting |
— | — | ✓ | ✓ | /prospects, /onboarding/{id}, alert rules with subject_type onboarding · find_publishers |
schain |
— | ✓ | ✓ | ✓ | POST /schain/validate · validate_schain (the web page stoqlab.com/tools/schain-validator is free for everyone) |
verification_api |
— | — | ✓ | ✓ | /verify/seat, POST /verify/seats · verify_seats |
bulk_api |
— | — | add-on | ✓ | GET /apps with per_page > 100 (up to 1,000, light fields), format=ndjson, page / cursor walks deeper than 10,000 rows on any list (§2d) |
Without history, GET /apps/{app} keeps its shape: the delta_* / per_day_* values of
meta.growth are null and meta.plan_gated is {"growth": "history"}. Without tree, the
supply path details (§4c: chain_depth, parts, counts) are null and meta.plan_gated adds
"supply_path": "tree"; the score itself is returned to every plan. Likewise data.ad_formats.formats[].via
(the networks behind each inferred format) is null without tree and meta.plan_gated adds
"ad_formats": "tree"; the formats themselves and data.audience_by_country are returned to every plan.
Numeric limits per plan, the same on the dashboard, REST and MCP:
| Limit | Explorer | Professional | Business | Enterprise Data | Where |
|---|---|---|---|---|---|
| Requests per minute (account, REST + MCP) | 30 | 120 | 600 | 3,000 | §2 |
| Saved lists · apps per list | 10 · 5,000 | 100 · 50,000 | 100 · 50,000 | 100 · 50,000 | §3.16 (422 over it) |
| Data rows per UTC day (REST, MCP, exports, dashboard together) | 5,000 | 50,000 | 250,000 | 2,000,000 | §2 (429 row_quota_exceeded) |
Rows per bulk export (/exports, /lists/{id}/apps?format=) |
5,000 | 50,000 | 250,000 | 1,000,000 | §3.16 |
Rows per format=csv list export |
10,000 | 10,000 | 10,000 | 10,000 | §3.12 |
Export rows per UTC day (bulk and format=csv together) |
5,000 | 50,000 | 250,000 | 2,000,000 | 429 export_quota_exceeded |
Rows per page · walk depth (page / cursor) |
100 · 10,000 | 100 · 10,000 | 100 · 10,000 | 1,000 · none | §2d (403 plan_required, feature bulk_api) |
| Alert rules · webhook endpoints | — | 50 · 2 | 500 · 10 | 5,000 · 50 | 422 over it |
Seats per POST /verify/seats call |
— | — | 100 | 1,000 | §3.18 (422 over it; MCP verify_seats ≤ 50) |
Exports count against the data quota too, so the export limits never exceed it (lowered in October 2026 them from 10,000 / 100,000 / 1,000,000 rows per export and 25,000 / 1M / 5M rows per day).
Plan-independent limits: per_page ≤ 100 (bulk_api: 1,000 on /apps), 30 domain checks per hour, 10 exports per minute,
10 list changes per hour, /apps/{app}/history ranges ≤ 400 days. Lists, the export quota, list
changes and domain checks count per team (§2c).
Breaking change for Explorer (free) keys, 2026-10-05. Until this date the API and MCP served the supply tree, history, change feed and trend endpoints to every plan, while the dashboard already gated them. They now answer
403 plan_required(MCP: aplan_required:tool error) to plans without the feature,format=csvlist exports count against the daily export quota, andmeta.growthchanges needhistory. Response shapes are unchanged for plans that include the feature.
2c. Teams and GET /v1/me#
Plans belong to teams. Every account has a team (its own personal team, or the teams it was invited to; the dashboard's Team page switches between them). What a team shares:
- the plan, its features and its seats (Explorer 1, Professional 3, Business 10, Enterprise Data unlimited);
- saved lists, alert rules, alerts (and their read state), webhook endpoints and domain checks: every
member sees and edits them; deleting one (or disabling a webhook) needs the team owner or an admin,
or the member who created it (
403 forbiddenotherwise); - the daily export quota, the list count and list-change budget, and the domain checks per hour.
API keys stay personal and act in the team that was current when the key was created, as long as
you are a member of it (if you leave, the key follows your current team). Ids of another team's
objects answer 404, like ids that do not exist.
GET /v1/me tells which account, team, plan and limits a key has:
curl -s https://api.stoqlab.com/v1/me -H "Authorization: Bearer $STOQLAB_TOKEN"
{ "data": {
"user": { "id": 12, "name": "Maria Popescu", "email": "maria@acme-ads.com", "admin": false },
"team": { "id": 7, "name": "Acme Ad Ops", "role": "owner", "personal": false, "seats": 3, "seats_used": 2 },
"plan": { "key": "pro", "name": "Professional", "features": ["tree", "history", "alerts", "countries", "ctv"], "expires_at": null },
"limits": { "api_per_minute": 120, "export_rows": 50000, "export_rows_per_day": 50000,
"export_rows_remaining_today": 48200, "lists": 100, "lists_used": 4, "list_apps": 50000,
"alert_rules": 50, "webhook_endpoints": 2, "checks_per_hour": 30,
"rows_per_day": 50000, "rows_used_today": 1800, "rows_remaining_today": 48200, "key_rows_per_day": null,
"max_per_page": 100, "max_depth_rows": 10000 },
"token": { "name": "ci", "abilities": ["read"], "rows_per_day": null, "expires_at": "2027-01-03T10:00:00Z" } } }
2d. Bulk access and acceptable use#
The data is licensed for your internal use and for work you deliver to your own clients (Terms of Service, section 4). Copying the dataset, or a substantial part of it, and republishing, reselling or using it to build a competing dataset needs a data licence. The daily data quota (§2) is sized for that use: one full saved list a day on Professional, about half the app catalogue on Business.
Bulk API & data licence (plan feature bulk_api: Enterprise Data, or an add-on on request for
Business) unlocks:
GET /appspages of up to 1,000 rows with a lightfields=list (§3.1b; other plans: up to 100);format=ndjsononGET /apps(format=csvworks on every plan, within its export quota);- page and cursor walks deeper than 10,000 rows from the start of a list. Without it,
page×per_pagebeyond 10,000 on any list, or a cursor walk past 10,000 rows on/apps, answers403 plan_required(error.feature: "bulk_api"). Narrow the filters (store,category,country, …) to read further.
Do not spread a job over several accounts, keys or addresses to get around a quota: usage is
watched per team, and such patterns are flagged. Every export carries an export id
(X-Export-Id, X-Export-Provenance, and the id in the file name; §3.12) that traces a file
back to the team that downloaded it.
3. Endpoints#
| Method & path | What you get |
|---|---|
GET /me |
The key's account, team, plan, limits and abilities (§2c) |
GET /apps?q=&store=&ads=&country=&availability=&min_audience_share=&sort=&fields=&cursor=&format= |
Search and list tracked apps: by name, store, "shows ads", storefront (country) availability and audience, installs, category, ad system…; pick the fields; page with a cursor; or stream every match — CSV, NDJSON (§3.1, §3.1a, §3.1b) |
GET /apps/{store}/{store_id} |
App detail (incl. icon, exact installs, reviews, version, size, legal entity, store listing details, privacy, in-app purchases, category rank, related apps, per-country metrics, estimated audience by country, inferred ad formats), its app-ads.txt summary, line counts per check_status, 1/7/30-day growth in meta.growth |
GET /apps/{store}/{store_id}/history?from=&to= |
history · Daily store metrics (installs, ratings, reviews, star histogram, price, IAP range, version, size, iOS storefront) with the change since the previous snapshot |
GET /apps/{store}/{store_id}/related?relation= |
Similar and same-developer apps the store lists next to this one, with icons |
GET /apps/{store}/{store_id}/countries |
Per-country availability; for iOS also ratings, score and price per storefront |
GET /apps/{store}/{store_id}/privacy |
App Store privacy labels or the Google Play Data safety summary |
GET /apps/{store}/{store_id}/supply-tree?view= |
tree · The nested declared supply tree; view=forward = developer → direct sellers → resellers (§3.3a) |
GET /apps/{store}/{store_id}/direct-sellers?sort=&order=&q= |
tree · The ad systems with a DIRECT line (direct SSPs): accounts, sellers.json status, resellers under them (§3.3b) |
GET /apps/{store}/{store_id}/appads-history |
history · Every distinct version of the app-ads.txt |
GET /apps/{store}/{store_id}/stale-lines |
history · The app's app-ads.txt lines whose seller is not in the exchange's sellers.json |
GET /networks/{domain} |
One ad system: sellers.json status + how many apps declare it |
GET /networks/{domain}/apps |
Apps declaring the ad system (DIRECT / RESELLER / one seller id) — CSV |
GET /networks/{domain}/sellers |
The ad system's sellers.json entries + summary — CSV |
GET /networks/{domain}/stale-sellers |
trends · Declared seller ids missing from its sellers.json, with affected apps — CSV |
GET /sellers/{domain}/{seller_id} |
One sellers.json entry + its change history |
GET /sellers/{domain}/{seller_id}/apps |
Apps declaring that seller account (also for removed / unknown ids) — CSV |
GET /developers/{domain} |
Developer domain: store accounts, company legal entities, its app-ads.txt, app count |
GET /developers/{domain}/apps |
Apps of the developer domain — CSV |
GET /changes |
history · Change feed: app-ads.txt, sellers.json and store changes, cursor-based |
GET /leaderboards/growth?window=&sort=&category=&contains_ads=&min_installs= |
trends · Fastest growing Google Play apps by exact installs over 1/7/30 days — CSV |
GET /network-share?sort=&limit= |
trends · Ad systems by share of tracked app-ads.txt files / apps / Android installs declaring them (DIRECT vs RESELLER) — CSV |
GET /networks/{domain}/share?weeks= |
trends · Weekly trend of one ad system's declared share |
GET /lists · POST /lists |
Your saved app lists; create one from filters (refreshed daily) or from hand-picked apps (§3.16) |
GET /lists/{id} · PATCH /lists/{id} · DELETE /lists/{id} |
One list: read, rename / change filters, delete |
GET /lists/{id}/apps?change=added|removed |
The list's apps, or what the latest refresh added / removed — CSV, JSON Lines, bundle list |
POST /lists/{id}/apps · POST /lists/{id}/refresh |
Add / remove apps of a hand-picked list · re-compute a filter list now |
GET /exports?list= or GET /exports?<filters> |
Bulk export of a list or of an ad-hoc filter: CSV, JSON Lines or a DSP bundle list |
POST /checks · GET /checks/{id} |
Check any developer domain's app-ads.txt now (needs lists:write), then follow the request (§3.17) |
GET /domains/{domain}/audit |
The app-ads.txt audit of a domain from data already collected — no crawl (§3.17) |
GET /datasets · GET /datasets/{date}/{file} |
Daily dataset files (CSV.gz / Parquet) with rows, bytes, sha256 and signed download links — Enterprise Data plan ("Datasets" below) |
GET /ctv/apps?q=&store=&status=&appads= · GET /ctv/apps/{store}/{store_id} |
Connected-TV apps (today: Apple TV / tvOS) with their app-ads.txt status, declared ad systems and supply-tree summary — Professional plan and up ("Connected TV" below) |
GET /sites/{domain} · GET /sites/{domain}/adstxt-history · GET /networks/{domain}/sites |
Website ads.txt: a site's file, its lines with the sellers.json cross-check and its change history; websites declaring an ad system (cursor) — Business plan and up ("Websites (ads.txt)" below) |
GET /apps/{store}/{store_id}/availability |
Availability per tracked storefront: status, first / last seen, last check, flips, iOS ratings per storefront — Professional plan and up ("Countries and charts" below) |
GET /countries?store= · GET /countries/{cc}/delistings?since=&from=&store=&limit= |
Availability counts and 7-day delistings per country; apps delisted in one storefront (cursor) — Professional plan and up |
GET /charts/{store}/{cc}/{chart}?genre=&date= |
App Store top free / top paid per storefront and genre, with the rank change since the previous day — Professional plan and up |
GET /alerts/rules · POST /alerts/rules · GET/PATCH/DELETE /alerts/rules/{id} |
Your watch rules: an app, a developer domain, an ad system or a saved list, and the changes to be alerted about — Professional plan and up ("Alerts" below) |
GET /alerts/events?since=&limit=&rule_id=&type=&unread= |
Your alerts, newest first, or after a cursor (oldest first) — Professional plan and up |
GET /webhooks · POST /webhooks · DELETE /webhooks/{id} |
Your webhook endpoints; every alert is POSTed to them, signed ("Webhooks" below) |
GET /prospects?ad_system=&competitors=&… |
prospecting · Publishers whose app-ads.txt declares your competitors but not your ad system, grouped by company domain, most reach first ("Prospecting" below) — CSV with HubSpot column names |
GET /onboarding/{id}?state= |
prospecting · Your onboarding monitor: per target publisher, is your line live, DIRECT / RESELLER, with your seller ID, listed in your sellers.json, since when ("Onboarding monitor" below) |
GET /verify/seat?store_id=|host=&ad_system=&seller_id=&relationship= · POST /verify/seats |
verification_api · Is this seller authorised for this app? app-ads.txt line, sellers.json status, supply verification, freshness and a verdict; batch of up to 100 / 1,000 (§3.18) |
POST /schain/validate |
schain · Validate an OpenRTB SupplyChain object node by node against sellers.json and the app's app-ads.txt (§3.19) |
GET /live/{token}.csv |
A live CSV URL: the current CSV of a saved list or export, no API key (the URL is the credential; "Live CSV URLs" below) |
"CSV" = also available as a streamed CSV file with format=csv (§3.12). Bold = plan feature the
endpoint needs (§2b); the round-12 endpoints at the end name their plan in the description.
Identifying an app — store is ios or android. store_id is:
- iOS: the numeric App Store ID — the number after
idinhttps://apps.apple.com/app/id1234567890. - Android: the package name — the
id=value inhttps://play.google.com/store/apps/details?id=com.example.puzzle.
3.1 Search apps#
curl -s "https://api.stoqlab.com/v1/apps?q=puzzle&store=android&per_page=10" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
q matches the beginning of words in the app name (every word must match: block puz finds
"Block Puzzle"; words shorter than 2 characters are ignored), or exactly a store ID / bundle ID; an App Store
or Google Play link finds that app. Results are sorted by number
of ratings (most popular first). Response: data[] (apps), links (first, last, prev, next)
and meta (current_page, last_page, per_page, total, …).
Filters (all optional, combinable with q and store):
| Parameter | Values | Meaning |
|---|---|---|
contains_ads |
yes / no (also true / false, 1 / 0) |
The store's own flag: Google Play's "Contains ads" label; on the App Store the age rating's "Contains: Advertising" (read from the product page). Apps whose flag is not known yet (contains_ads: null, mostly iOS apps whose product page has not been read) match neither value. |
has_appads |
yes / no |
yes: the developer publishes an app-ads.txt that we found, with at least one line (authorised sellers). no: none known, not found, or empty. A separate signal from contains_ads: an app can show ads without an app-ads.txt. |
Any other value is a 422. GET /v1/apps?store=ios&contains_ads=yes&has_appads=no = iOS apps
that say they show ads but publish no app-ads.txt.
{
"data": [
{
"store": "android",
"store_id": "com.example.puzzle",
"bundle_id": "com.example.puzzle",
"name": "Example Puzzle Quest",
"icon_url": "https://play-lh.googleusercontent.com/AbC123exampleIcon",
"category": "GAME_PUZZLE",
"status": "active",
"rating": 4.6,
"rating_count": 182344,
"installs_min": 10000000,
"installs_exact": 12345678,
"reviews_count": 51200,
"contains_ads": true,
"developer": { "name": "Example Games Ltd", "domain": "examplegames.com" },
"appads": { "domain": "examplegames.com", "status": "found", "verification_score": 71, "path_score": 64 },
"appads_applicable": true,
"last_crawled_at": "2026-10-02T04:13:55+00:00",
"links": {
"self": "https://api.stoqlab.com/v1/apps/android/com.example.puzzle",
"store": "https://play.google.com/store/apps/details?id=com.example.puzzle"
}
}
],
"links": { "first": "…?page=1", "last": "…?page=4", "prev": null, "next": "…?page=2" },
"meta": { "current_page": 1, "last_page": 4, "per_page": 10, "total": 37, "from": 1, "to": 10, "path": "https://api.stoqlab.com/v1/apps" }
}
Paging through everything: keep requesting links.next until it is null — or, for long
lists, use a cursor and a short field list (§3.1b).
More filters (all optional and combinable with the ones above):
| Parameter | Values | Meaning |
|---|---|---|
ads |
yes / no (also true / false, 1 / 0) |
"Does the app monetise with ads?", from both signals: yes = the store's flag says it contains ads or its app-ads.txt was found with at least one authorised seller line (has_appads=yes); no = the flag says no ads and no such file. Apps with an unknown flag and no file match neither. On iOS the flag is known for only about 1 % of apps (it is read from the App Store product page, which Apple throttles), so app-ads.txt is the main iOS signal; on Google Play the flag is known for every fetched app. |
country |
ISO 3166-1 alpha-2, one or a comma list (DE, DE,AT,CH; at most 20) |
Apps available in that storefront (several = in any of them). See "Country availability" below. |
availability |
likely (default) / confirmed |
With country. How sure "available" must be (below). |
min_audience_share |
0–1 (e.g. 0.05) |
With country. The app's estimated audience share in those storefronts (summed over the list) is at least this: iOS apps with per-storefront ratings only (audience_by_country, §3.2). |
status |
active / delisted / unknown |
Store status. |
category |
text | Exact match of the app's category value as this API returns it. |
min_installs, max_installs |
integer | Google Play installs (exact when known, else the bucket floor). iOS apps have none and never match. |
min_rating, max_rating |
0–5 | Store rating. |
developer |
domain | The developer's website domain (its store accounts, plus apps whose app-ads.txt is served from it). |
ad_system (+ relationship, seller_id) |
domain (+ DIRECT / RESELLER, an account id) |
The app's app-ads.txt declares this ad system (as that relationship, with that seller account). |
appads_status |
found, missing, invalid, no_website, error, none |
Status of the app's app-ads.txt (none = no file known). |
has_errors |
true / false |
The app-ads.txt has lines with an error check_status (§4). |
sort |
popularity (default: most ratings first), installs, name, newest, updated, audience_share, id |
audience_share needs country and lists only apps with per-storefront ratings there (iOS), largest share first; id = the order apps were added (stable, for complete walks). |
Country availability — what we know. Store apps are offered in every storefront unless the developer restricts them, but we can only check a storefront for some apps:
- the per-storefront availability check (
GET /apps/{store}/{id}/availability) covers, since every app in its home storefront (the store data refresh asks the store there anyway: the US for most apps), apps with ads — an app-ads.txt found, or the store's "contains ads" flag — and the most popular apps in the 30 largest ad markets every 30 days (App Store: US, GB, DE, FR, JP, KR, CA, AU, BR, MX, IN, IT, ES, NL, SE, CH, NO, DK, AT, BE, PL, TR, SA, AE, TW, HK, SG, ID, TH, PH; Google Play: US, GB, DE, FR, IN, BR, JP, KR, ID, MX, as far as the Play budget allows), the most popular ones daily / weekly, and on Google Play every app a category page of that country lists; - on iOS, ratings are counted per storefront: an app with ratings in a storefront is sold there.
For "every app with ads available in DE", ads=yes&country=DE&availability=confirmed is the precise
list and availability=likely (the default) the complete one; the difference is the apps nobody has
checked in DE yet (mostly the long tail without an app-ads.txt, and Google Play apps outside the US
while the Play budget is small).
So availability has two levels:
availability |
An app matches when… |
|---|---|
likely (default) |
it is not known to be unavailable there: no check said "unavailable" and it is not delisted. Includes every confirmed app; for most apps it is the best available answer. |
confirmed |
a storefront check (or a Google Play category page of that country) says available there, or it has ratings in that storefront (iOS) and no check says unavailable; never a delisted app. Precise but covers only the checked apps. A check of any age counts: country_availability.checked_at says how old it is. |
With country, every app carries country_availability: country (the first of your countries
where it is confirmed, else the first where it is likely), status (confirmed / likely),
source (availability_check, store_listing — a Google Play category page of that country lists
it, storefront_ratings, store_page — Google Play served the app's page for that country at the last
store refresh but whether it is offered there was not read yet, so likely, never confirmed — or
no_restriction_known), audience_share
(its estimated share in your countries, 0–1; null when there is no per-storefront data — every
Google Play app and the iOS apps that are not checked per country) and checked_at (when
that evidence was last seen, ISO 8601; null for likely). Checks are kept whatever their age — ad
apps are re-checked every 30 days, a restriction is rare — so filter on checked_at yourself if you
need fresher evidence. meta.filters echoes what was
applied (availability included), meta.audience_as_of the day the audience shares were computed
(once a day from the per-storefront rating counts).
# Google Play apps that show ads and are (likely) available in Germany, most popular first
curl -s "https://api.stoqlab.com/v1/apps?country=DE&ads=yes&store=android" -H "Authorization: Bearer $STOQLAB_TOKEN"
# iOS apps with at least 5 % of their audience in Germany, Austria and Switzerland together
curl -s "https://api.stoqlab.com/v1/apps?country=DE,AT,CH&min_audience_share=0.05&sort=audience_share" -H "Authorization: Bearer $STOQLAB_TOKEN"
"country_availability": { "country": "DE", "status": "confirmed", "source": "availability_check", "audience_share": 0.0712, "checked_at": "2026-10-06T14:02:11+00:00" }
3.1a Choosing the fields (fields, include)#
Every app list, the app detail, /developers/{domain} and /networks/{domain} take fields= (a
comma list) to return only some fields — smaller responses, and on /apps and the app detail the
blocks you leave out are not even computed (an app detail with a few fields answers in about a
tenth of the time). Nested fields use dot paths: developer.name, appads.status; a block name
(developer) means all of its fields. Fields come back in the response's usual order, nested the
usual way ({"developer": {"name": …}}). An unknown field is a 422 whose message lists the
allowed ones. Without fields the response is exactly as before.
include= adds optional blocks that are not in the default response (they cost extra reads):
naming one in fields includes it too. Plan gates still apply per field: a field of a feature your
plan does not have is null (or without its gated part) and meta.plan_gated names it, as without
fields.
curl -s "https://api.stoqlab.com/v1/apps?store=android&ads=yes&fields=bundle_id,store,store_id,name,developer.name,installs,contains_ads,appads.status" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
curl -s "https://api.stoqlab.com/v1/apps/android/com.example.puzzle?fields=name,appads.status,appads.line_count&include=check_status_counts" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
GET /apps — store, store_id, bundle_id, name, icon_url, category, status, rating,
rating_count, installs_min, installs_exact, reviews_count, contains_ads, developer
(developer.name, developer.domain), appads (appads.domain, appads.status,
appads.verification_score, appads.path_score), appads_applicable, last_crawled_at, links
(links.self, links.store); with country: country_availability (country_availability.country,
country_availability.status, country_availability.source, country_availability.audience_share,
country_availability.checked_at).
Optional (include= or named in fields):
| Include | What |
|---|---|
installs |
One number: exact Google Play installs when known, else the bucket floor (null on iOS) |
appads_summary |
appads_summary.line_count, appads_summary.direct_count, appads_summary.reseller_count, appads_summary.fetched_at, appads_summary.changed_at of the app's app-ads.txt (null without one) |
audience_by_country |
iOS: the top 10 storefronts of the estimated audience, [{country, share, rating_count}] (audience_by_country.country, audience_by_country.share, audience_by_country.rating_count); null on Google Play and without per-storefront data |
ad_formats |
ad_formats.networks_declared, ad_formats.networks_mapped, ad_formats.formats — the formats the app's authorised networks sell, as in §3.2, with formats[].sizes[] (formats[].via and sizes[].via need the tree feature) |
per_page goes up to 1,000 when fields is given and neither audience_by_country nor
ad_formats is included (100 otherwise), with the bulk_api plan feature (§2d); other plans
page by up to 100 (403 plan_required above it).
GET /apps/{store}/{store_id} — store, store_id, bundle_id, name, icon_url, category,
category_id, release_date, rating, rating_count, installs_min, installs_text,
installs_exact, reviews_count, price, currency, contains_ads, ads_evidence, has_iap,
version, store_updated_at, size_bytes, developer_website, status, delisted_at,
last_crawled_at, developer (developer.name, developer.store, developer.store_developer_id,
developer.website, developer.domain, developer.legal_entity), legal_entity, appads
(appads.domain, appads.url, appads.status, appads.http_status, appads.status_reason,
appads.content_type, appads.content_hash, appads.line_count, appads.direct_count,
appads.reseller_count, appads.owner_domain, appads.manager_domain, appads.contact,
appads.parse_errors, appads.fetched_at, appads.changed_at, appads.verification_score,
appads.verified_at, appads.path_score), appads_applicable, store_details, screenshots,
privacy, in_app_purchases, events, category_rank, related, countries,
audience_by_country, ad_formats, links (links.self, links.supply_tree,
links.appads_history, links.history, links.related, links.countries, links.privacy,
links.store, links.website, links.privacy_policy, links.support). Optional: installs.
With fields, meta holds only the blocks named in include: check_status_counts (with
error_count / warning_count), verification, supply_tree, growth, supply_path; without
fields, meta is complete as before.
GET /developers/{domain}/apps — the GET /apps fields without country_availability and the
optional blocks except installs. GET /networks/{domain}/apps, GET /sellers/{domain}/{id}/apps
— the same plus declaration (declaration.relationships, declaration.lines, declaration.errors).
Their format=csv keeps its fixed columns (§3.12).
GET /developers/{domain} — domain, developers (developers.name, developers.store,
developers.store_developer_id, developers.website, developers.domain, developers.legal_entity;
applied to each account), legal_entities, appads (the fields of the app detail's appads),
apps_count, links (links.self, links.apps).
GET /networks/{domain} — domain, name, sellers_url, sellers_status, sellers_count,
sellers_hash, seen_in_files, fetched_at, links (links.self); meta is unchanged.
3.1b Complete lists: cursor pages, CSV and NDJSON#
Cursor pages. Add cursor= (empty for the first page) to GET /apps and follow
meta.next_cursor / links.next until it is null. The response is {data, links: {next}, meta: {per_page, count, next_cursor, total, filters, sort}}. Pages continue exactly after the last app of
the previous one in the sort order (ties broken by id), so nothing is skipped or repeated because
of OFFSET drift, and a page deep in the list is as fast as the first. Keep the same parameters while
paging: a cursor used with other filters or another sort is a 422. Apps that change while you
page (new ratings, new apps) may move; sort=id gives the most stable walk.
The cursor also carries (signed) how many rows the walk has read: without the
bulk_api feature a walk stops after 10,000 rows (403 plan_required), pages are at most
100 rows, and every page counts against your daily data quota (§2, §2d). Cursors issued before
October 2026 are refused with a 422: start the walk again with cursor=.
# every Google Play app with ads available in Germany, 1,000 per page (Bulk API; other plans: per_page=100)
url="https://api.stoqlab.com/v1/apps?country=DE&ads=yes&store=android&fields=bundle_id,name,installs&per_page=1000&cursor="
while [ -n "$url" ] && [ "$url" != "null" ]; do
page=$(curl -s "$url" -H "Authorization: Bearer $STOQLAB_TOKEN")
echo "$page" | jq -r '.data[] | [.bundle_id, .name, .installs] | @tsv'
url=$(echo "$page" | jq -r '.links.next')
done
Streams. format=csv (every plan) or format=ndjson (one JSON object per line; bulk_api, §2d) streams every matching app
in the sort order, with the same filters, after cursor when given. The columns (CSV, dot paths
as headers: developer.name) or keys (NDJSON, nested as in JSON) are the fields you ask for —
without fields, every default field. Like every list export (§3.12): at most 10,000 rows per
request (or your plan's rows per export, if lower), counted against the plan's daily export quota
(§3.16), 10 exports per minute. Headers: X-Total-Count (matching apps after the cursor),
X-Truncated, X-Row-Limit (lowered to what is left of today's quota), X-Export-Quota-Remaining,
and when truncated X-Next-Cursor: pass it as cursor to get the next rows.
curl -s -D headers.txt -o de-android-ads.csv \
"https://api.stoqlab.com/v1/apps?country=DE&ads=yes&store=android&fields=bundle_id,name,installs&format=csv" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
grep -i '^x-next-cursor' headers.txt # present when more rows match: add &cursor=<value>
For one big file at once (up to your plan's rows per export: 50,000 on Professional, 250,000 on
Business, 1,000,000 on Enterprise Data) use GET /exports with the same filters (§3.16), and for a
URL a spreadsheet can poll, a live CSV URL ("Live CSV URLs").
app-ads.txt applicability (additive). appads_applicable is false when the store listing says
the app contains no ads (contains_ads: false) and no valid app-ads.txt was found (appads is null or
appads.status is not found): an app-ads.txt only authorises ad sellers, so none is expected of such an app.
Show it as "Not applicable", not as missing or invalid. appads.status keeps the raw status of the developer's
file; a file that is found is always applicable. true in every other case, including contains_ads: null
(flag not known yet). The same field is in GET /v1/apps/{store}/{id}, every app list that uses this shape
(developers, lists) and the MCP get_app / app search results.
3.2 App detail#
curl -s https://api.stoqlab.com/v1/apps/android/com.example.puzzle \
-H "Authorization: Bearer $STOQLAB_TOKEN"
data has the store metadata, developer, and appads — the current state of the app-ads.txt
that covers this app (URL that answered, HTTP status, line counts, OWNERDOMAIN / MANAGERDOMAIN
variables, and parse_errors). contact is always null (withheld: personal data — the CONTACT=
variable is often a person's e-mail or phone; the key stays for backwards compatibility; e-mail
addresses quoted in parse_errors messages are masked). meta has the health check:
"meta": {
"check_status_counts": {
"ok": 111, "id_missing": 14, "direct_but_intermediary": 2, "reseller_but_publisher": 3,
"both": 4, "confidential": 2, "no_sellers_json": 6, "unchecked": 0
},
"error_count": 19,
"warning_count": 12,
"supply_tree": {
"total_edges": 171, "max_hop": 3, "by_hop": { "1": 142, "2": 26, "3": 3 },
"direct": 18, "reseller": 124, "ad_systems": 37,
"computed_at": "2026-10-02T03:20:05+00:00"
},
"verification": {
"score": 61, "lines": 142, "checked": 142, "confirmed": 87,
"direct": { "lines": 18, "confirmed": 9 }, "reseller": { "lines": 124, "confirmed": 78 },
"counts": {
"verified_owner": 9, "verified_reseller": 78, "domain_mismatch": 2, "seller_domain_missing": 3,
"intermediary_unknown": 18, "confidential": 2, "id_missing": 14, "direct_but_intermediary": 2,
"reseller_but_publisher": 3, "no_sellers_json": 6, "unchecked": 0
},
"cert_mismatch": 4,
"publisher_domains": ["example-puzzle.com"], "manager_domain": null,
"stored_score": 61, "verified_at": "2026-10-02T03:20:07+00:00"
}
}
error_count=id_missing+direct_but_intermediary+reseller_but_publisher.warning_count=both+confidential+no_sellers_json.verification= the same lines checked in both directions (§4b):scoreis the share (%) of checked lines confirmed both ways,domain_mismatchcounts DIRECT accounts that sellers.json attributes to another domain,cert_mismatchlines whose field 4 differs from the ad system's known TAG ID.data.appads.verification_score/verified_atare the workers' stored copy of the score.
Useful fields at a glance:
| Field | Meaning |
|---|---|
status |
active (in the store), delisted (gone from the store — see delisted_at), unknown (not checked yet) |
appads.status |
found, missing (404), invalid (served something that isn't app-ads.txt, e.g. an HTML page), no_website (store listing has no developer site), error (network/TLS/timeout) |
appads.status_reason |
Why the last check found no usable file (null when found). Publisher side: html_soft_404 (an HTML page is served at /app-ads.txt), html_wrapped (records inside an HTML page), content_type, empty, http_404, http_410. Not verified (our side, never the publisher's fault): html_challenge (bot challenge), http_401, http_403, http_429, http_5xx, http_other, timeout, connect, tls, redirect_refused, redirect_loop, too_large, blocked, no_host, network. A bot challenge is reported as error, and the last good copy of the file is kept. |
appads.parse_errors[].code |
bad_field_count, bad_relationship, bad_domain, duplicate, direct_and_reseller_same_id, too_many_lines (more than 100,000 declarations: the rest of the file is not stored; listed first) |
appads.content_hash |
SHA-256 of the file; compare with /appads-history to see when it changed |
Note: one app-ads.txt belongs to a developer domain, so every app of that developer shares it.
Store facts. data also carries everything public the stores show about the app.
Every key is always present; values are null (or empty lists) until the workers have fetched them.
| Field | Meaning |
|---|---|
icon_url |
Store icon (App Store mzstatic.com / Google Play play-lh.googleusercontent.com). Hotlink it next to the app name and link it to the store listing; do not re-host it. |
legal_entity |
{name, country} of the company behind the developer account (App Store seller, Google Play EU trader), also on developer.legal_entity. null when unknown or when the trader is an individual (a person's name is personal data). Developer e-mail, address and phone are never returned. |
store_details |
content_rating (the store's label), iOS age_rating / age_descriptors (e.g. "Contains: Advertising"), genres[], Play tags[], iOS languages[] / devices[] / min_os, editors_choice, Play teacher_approved, iOS has_external_purchases, iap_min / iap_max, description and release_notes as {hash, length} (the text itself is never stored), iOS ratings_histogram (1★→5★), Play header_image_url, video_url, privacy_policy_url, support_url, changed_at, page_fetched_at / page_storefront (App Store product page) |
screenshots |
Store CDN URLs of the first 5 phone / 3 tablet / 2 TV screenshots, plus count and hash of the whole set (a new hash = new creatives). TV screenshots mean an Apple TV build. |
privacy |
Same object as /privacy (§3.4c). |
in_app_purchases |
iOS: the top in-app purchases listed on the product page now (name, price, first_seen_on), with storefront and currency. |
events |
iOS in-app events (title, kind, start/end, state = upcoming / live / ended). |
category_rank |
iOS: #rank in the genre chart from the product page (any rank, source: product_page), else the best rank in the latest genre top chart (source: top_chart). null on Google Play. |
related |
The first 12 similar and same_developer apps with icons, counts, and href to page through all of them (§3.4c). |
countries |
Same rows as /countries (§3.4c), plus totals. |
audience_by_country |
Estimated geographic split of the audience (every plan). iOS: each App Store storefront counts its own ratings, so the share of a storefront in the ratings summed over the storefronts we check approximates where the users are (method: "ios_storefront_rating_share"). countries[] = the top 10 {country, share, rating_count} (share 0–1), other = {countries, share, rating_count} of the rest (or null), storefronts, rating_count (the sum), as_of, low_confidence (under 200 ratings or fewer than 3 storefronts), note. Ratings are not installs (rating habits differ by country) and unchecked storefronts are missing. Google Play publishes nothing per country: method: null, empty countries, and a note saying so (availability stays in countries). |
ad_formats |
Ad formats offered by the app's authorised networks, an inference, never a confirmed SDK integration (inferred: true, method: "inferred_from_app_ads_txt"). Every ad system in the app's app-ads.txt is mapped to the formats it sells (a curated table of ~80 mobile networks and exchanges). formats[] = {format, label, direct, networks, via} in display order; format ∈ banner, interstitial, rewarded, native, app_open, playable, offerwall, ctv; direct = at least one of those networks is declared DIRECT; networks = how many declared networks sell it; via = up to 12 of their domains, DIRECT first (tree plan feature: null otherwise, and meta.plan_gated.ad_formats = "tree"). Context: networks_declared, networks_mapped, contains_ads + contains_ads_source (google_play / app_store / null), has_iap. Sizes: every format has sizes[] = {size, name, device, networks, networks_unconfirmed, via}: the sizes its networks sell, from each network's own SDK docs (AdMob, AppLovin MAX, Unity Ads, LevelPlay, Meta Audience Network, Mintegral, Liftoff/Vungle, Pangle, InMobi, Chartboost, DT Exchange, Moloco, BidMachine, Start.io, Yandex, Verve/HyBid, Smaato, Ogury, Amazon); a network without documented sizes is assumed to sell the standard IAB banner sizes (320x50, 300x250, 728x90) and is counted in networks_unconfirmed. size = WxH (320x50, 320x100, 300x250, 728x90, 468x60, 320x480 …) or adaptive, fullscreen, flexible (native); device = phone | tablet | any; via gated like formats[].via; ctv has none. form_factor = {phone, tablet, source, devices} from the App Store's supported devices (Android: all null, Google Play publishes no tablet support): an iPhone-only app gets no tablet sizes, an iPad-only app gets tablet sizes first and no phone-only ones, otherwise tablet sizes come after the others. sizes_method = "network_docs_by_device_support". Never the sizes the app serves (that needs an SDK scan or ad-request sampling, not done). There is no ad_size= filter on GET /apps: it would need every candidate app's app-ads.txt systems per request. |
links.website / links.privacy_policy / links.support |
Developer links from the listing (http/https only). |
3.3 Supply tree#
curl -s https://api.stoqlab.com/v1/apps/android/com.example.puzzle/supply-tree \
-H "Authorization: Bearer $STOQLAB_TOKEN"
Each root edge (hop: 1) is one line of the app-ads.txt, cross-checked against sellers.json.
When that seller is an intermediary whose domain is itself an ad system, we follow it and
add children (hop 2, 3 …, up to depth 4; loops are cut). line_no is the app-ads.txt line
(for children: the line their branch starts from). Roots are in file line order; children are
sorted by ad system domain, account id, relationship (node ids are not stable across rebuilds).
The tree belongs to the developer's app-ads.txt file, so every app of that developer returns
the same tree. total_edges and max_hop always describe the whole tree.
Big files. One response holds at most 2,000 root lines (each with all its deeper hops).
Above that the response says "truncated": true, roots gives total / returned / page /
last_page, ad_systems lists every ad system of the file with its line count and an href
that fetches only that system (?ad_system=google.com), and links.next pages through the
lines in file order (?page=2). Small files come back whole with "truncated": false.
{
"id": 90212,
"hop": 1,
"line_no": 4,
"ad_system": { "domain": "exchange-two.com", "name": "Exchange Two" },
"account_id": "88412",
"relationship": "RESELLER",
"seller": { "type": "INTERMEDIARY", "name": "Mediation Partner Inc", "domain": "mediationpartner.com" },
"check_status": "ok",
"children": [
{
"id": 7781,
"hop": 2,
"line_no": 4,
"ad_system": { "domain": "mediationpartner.com", "name": "Mediation Partner" },
"account_id": "mp-31877",
"relationship": "RESELLER",
"seller": { "type": "INTERMEDIARY", "name": "Regional Reseller GmbH", "domain": "regionalreseller.de" },
"check_status": "ok",
"children": []
}
]
}
seller is null when no sellers.json entry matched; check_status tells you why.
Hop-1 nodes also carry verification (both directions, §4b), cert_authority_id (field 4 as
written) and cert_check (match, mismatch, absent, unknown against the ad system's TAG
ID); deeper nodes have null there. Filters: ?verification=domain_mismatch (any §4b value) and
?cert_check=mismatch keep only those root lines (with their branches); links keep the filter.
The tree is the declared path (what the files say), not observed bid traffic. id is the
app-ads.txt line ID at hop 1 and the edge ID deeper (unique per hop); both change when the file
or the tree is rebuilt — don't store them. Right after a file changes, roots appear before the
rebuild finishes: computed_at is null and there are no children yet.
Flatten it with jq:
curl -s .../supply-tree -H "Authorization: Bearer $STOQLAB_TOKEN" \
| jq -r '.data.edges | recurse(.[].children) | .[] | [.hop, .ad_system.domain, .account_id, .relationship, .check_status] | @tsv'
3.3a Supply tree, forward view (?view=forward)#
The default view above starts from each app-ads.txt line and follows its account toward the
publisher. Buyers and sellers read a supply chain the other way round, from the inventory
outwards: developer → direct sellers → resellers. ?view=forward returns that reading
(additive, since 2026-10-06; same plan gate tree; the default response is unchanged).
curl -s "https://api.stoqlab.com/v1/apps/android/com.example.puzzle/supply-tree?view=forward" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
root: the developer (name,domain), the app-ads.txt host and the publisher's own domains.direct_sellers: the DIRECT lines (level: 1), the ones with the most resellers under them first, then file order. A PUBLISHER seller with the developer's domain is the developer's own account (verification: verified_owner).- Each node's
childrenare the resellers of that line (level2, 3 …): a RESELLER line hangs under the line its account leads to in sellers.json —match.by: "sellers_chain": walking the stored sellers.json hops from the account reaches the account declared on the parent line;match.vialists the sellers.json entries walked in between that the file does not declare (nearest the parent first);match.by: "seller_domain": the account is an INTERMEDIARY (or BOTH) whose sellers.jsondomainis the parent line's ad system (e.g. pubnative.net account X says INTERMEDIARYstart.io→ it hangs under thestart.ioDIRECT line). A direct seller is preferred; otherwise another reseller of that domain, and the line branches one level deeper.
unmatched_resellers: RESELLER lines that cannot be placed, withunmatched.reason:seller_not_listed(account not in the ad system's sellers.json),no_sellers_json,unchecked,confidential,publisher_account(sellers.json says PUBLISHER, not an intermediary),no_domain(intermediary without a domain),not_a_direct_seller(its domain is not an ad system of this file;unmatched.intermediarynames it),circular.- Every node:
line_no,level,ad_system,account_id,relationship,seller,check_status,verification,cert_authority_id/cert_check,parent_line_no,resellers(lines below, all levels),children_total. summary: the whole file — lines, direct (DIRECT lines),direct_ad_systems(distinct ad systems among them, additive since 2026-10-08), reseller, matched (by method), unmatched (by reason), deepest level.- Paging: whole branches per page (direct sellers first, then the unmatched resellers), at most
2,000 lines per page; a bigger branch comes alone on its page.
branchesgivestotal/page/last_page,links.nextthe next page.ad_system=keeps the branches whose top line is of that ad system;verification=/cert_check=keep the matching lines and the lines above them.
{
"line_no": 1, "level": 1, "relationship": "DIRECT",
"ad_system": { "domain": "start.io", "name": "Start.io" }, "account_id": "1001",
"seller": { "type": "PUBLISHER", "name": "Mergeworks Games", "domain": "mergeworks.example" },
"verification": "verified_owner", "resellers": 2, "children_total": 1,
"children": [
{
"line_no": 5, "level": 2, "relationship": "RESELLER", "parent_line_no": 1,
"ad_system": { "domain": "pubnative.net", "name": "PubNative" }, "account_id": "1007321",
"seller": { "type": "INTERMEDIARY", "name": "Start.io", "domain": "start.io" },
"match": { "by": "sellers_chain", "via": [] }, "resellers": 1,
"children": [ { "line_no": 7, "level": 3, "ad_system": { "domain": "verve.com" }, "…": "…" } ]
}
]
}
Grouped by ad system (&group=ad_system)
A file often declares several accounts of one ad system — six DIRECT freewheel.tv lines, one per
account — which read as identical rows. ?view=forward&group=ad_system (opt-in, since 2026-10-08;
without it the shape above is unchanged) returns one ForwardGroup per ad system and
relationship under the same parent, in direct_sellers and unmatched_resellers:
ad_system,relationship,level,lead_line_no(its first account),account_count;accounts: the lines of the group (most resellers first), each a full node as above with its owncheck_status,verification,resellers; theirchildrenare empty (children_total0);status_summary: accounts per status dot —ok(confirmed both ways),warning(needs a check, incl. an unexpected TAG ID),error,neutral(not checked);resellers: lines below all its accounts (all levels);children/children_total: the lines under all its accounts, merged and grouped the same way. Which account a reseller buys from stays in each account'sparent_line_no.
Direct groups come with the most resellers first; branches and the pages count groups. A group of
one line is still a ForwardGroup (account_count: 1), so the shape is uniform.
{
"ad_system": { "domain": "freewheel.tv", "name": "FreeWheel" }, "relationship": "DIRECT", "level": 1,
"lead_line_no": 1, "account_count": 6, "resellers": 4,
"status_summary": { "ok": 4, "warning": 0, "error": 2, "neutral": 0 },
"accounts": [ { "line_no": 1, "account_id": "10101", "check_status": "ok", "verification": "verified_owner", "resellers": 3, "children": [], "…": "…" } ],
"children_total": 1,
"children": [
{ "ad_system": { "domain": "pubmatic.com" }, "relationship": "RESELLER", "level": 2, "account_count": 3,
"accounts": [ { "line_no": 15, "parent_line_no": 1, "…": "…" }, { "line_no": 17, "parent_line_no": 3, "…": "…" } ], "…": "…" }
]
}
3.3b Direct sellers (direct SSPs)#
GET /apps/{store}/{store_id}/direct-sellers (plan feature tree, additive since 2026-10-07) lists
the ad systems with at least one DIRECT line in the app's app-ads.txt — the networks the developer
sells its inventory through directly — one row per ad system, most resellers first:
direct_accountsandaccounts: the DIRECT accounts by where that ad system's own sellers.json puts them (publisher, of whichowner_confirmedalso carry one of the publisher's domains;both,intermediary,confidential,not_listed,no_sellers_json,unchecked), summed up insellers_json_status(confirmed_publisher,publisher,intermediary,not_listed,no_sellers_json,confidential,uncheckedwhen every account is in that bucket, elsemixed);resellers: the RESELLER lines branching under those accounts in the forward tree (all levels, same rules as §3.3a), andreseller_share, their part (0–1) of the file's RESELLER lines;first_line_noandline_nos(its DIRECT lines).
sort = resellers (default), accounts, domain, first_line or status (worst first); order
= asc / desc; q keeps ad systems whose domain or name contains the text; page / per_page
as everywhere. meta.summary counts the direct sellers and the file's DIRECT / RESELLER lines. The
dashboard's Supply tree tab shows the same list ("Direct SSPs", with a CSV) and MCP
get_supply_tree returns it with view: "direct".
curl -s "https://api.stoqlab.com/v1/apps/android/com.example.puzzle/direct-sellers?sort=resellers" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
{
"data": [
{
"ad_system": { "domain": "start.io", "name": "Start.io" },
"direct_accounts": 1,
"accounts": { "owner_confirmed": 1, "publisher": 1, "both": 0, "intermediary": 0, "confidential": 0,
"not_listed": 0, "no_sellers_json": 0, "unchecked": 0 },
"sellers_json_status": "confirmed_publisher",
"resellers": 4, "reseller_share": 0.3636, "first_line_no": 1, "line_nos": [1],
"links": { "network": "https://api.stoqlab.com/v1/networks/start.io" }
}
],
"links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
"meta": {
"current_page": 1, "per_page": 25, "total": 4, "…": "…",
"app": { "store": "android", "store_id": "com.example.puzzle", "name": "Example Puzzle Quest" },
"appads_domain": "examplegames.com", "computed_at": "2026-10-02T03:20:05+00:00",
"summary": { "direct_sellers": 4, "direct_lines": 4, "reseller_lines": 11, "resellers_placed": 7, "resellers_unmatched": 4 },
"sort": "resellers", "order": "desc"
}
}
3.4 app-ads.txt history#
curl -s "https://api.stoqlab.com/v1/apps/ios/1234567890/appads-history" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
One row per distinct file content, newest first: content_hash, line_count, first_seen_at,
last_seen_at, source. A new row appears only when the content actually changes.
source is crawl (downloaded by our crawler) or wayback (taken from an Internet Archive
capture, older than our own crawl; first_seen_at / last_seen_at are capture times). Archived
versions never appear in the change feed (§3.13).
3.4b Store metrics history and growth#
curl -s "https://api.stoqlab.com/v1/apps/android/com.example.puzzle/history?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
One row per day the app was fetched (top apps daily, the rest weekly or monthly), oldest first:
installs_exact, rating_count, reviews_count, score, ratings_histogram, price, IAP range,
version, size_bytes, … and delta = the change of each count since the previous row
(delta.days apart). Deltas can be negative — Google Play install and review counts do go
down. Default range: the last 90 days; at most 400 days per request. The app detail carries the
same 1/7/30-day growth in meta.growth.
Two rules:
- Apps outside our daily-refresh set (the most popular apps) get a row only when a metric changes, or weekly: a missing day means "unchanged".
- App Store ratings are counted per storefront. Each iOS row says which one (
storefront,us,gb, …;nullon Google Play). The deltas andmeta.growthonly compare rows of the same storefront: a row from the fallback storefront getsnulldeltas instead of a fake jump. For ratings across countries use/countries.
3.4c Related apps, countries, privacy#
# similar + same-developer apps, with icons; tracked=false = queued, not fetched yet
curl -s "https://api.stoqlab.com/v1/apps/ios/553834731/related?relation=similar&per_page=50" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
# availability per country; iOS: ratings / score / price per storefront
curl -s https://api.stoqlab.com/v1/apps/ios/553834731/countries -H "Authorization: Bearer $STOQLAB_TOKEN"
# App Store privacy labels / Google Play Data safety summary
curl -s https://api.stoqlab.com/v1/apps/ios/553834731/privacy -H "Authorization: Bearer $STOQLAB_TOKEN"
/related— paginated like every list (per_page≤ 100). Similar apps first; within a relation fetched apps first, most ratings first. An app not fetched yet hastracked: false, itsstore_idandlinks.store; its metrics arenull. Every listed app is queued for collection./countries— one row per checked country (top apps only: 30 App Store storefronts, 10 Google Play countries), most ratings first, at most 200 rows.available(null= never confirmed),source(where the row comes from:lookup,play_page,refresh,listing, orinferred— the US page was served at the app's last Google Play refresh before per-country recording began,availability_statusunknown,availablenull: listed, offer not checked;null= metrics only or before October 2026),meta.totals.checked(countries with a real answer) andmeta.totals.inferred(only that "page served" row). Google Play does not publish installs or ratings per country: an Android row says only whether the app is offered there. For iOSrating_count,score,price(in the storefront'scurrency, recorded from the App Store lookups of every storefront;nullonly for a storefront not looked up yet) andrating_count_delta_30d(two rows of the same country 30 days apart).meta.totals.rating_countsums the ratings over the available storefronts (≈ global iOS ratings). The per-country block (andcategory_rankof the app detail) is cached for up to 5 minutes./privacy— the developer's declarations as the store shows them, not verified by anyone. iOS (ios):tracks_users,used_to_track(data types "Used to Track You"),tracking_categories,linked/not_linked(purpose → data types, e.g."third_party_advertising": ["coarse_location", "device_id", "advertising_data"]),not_collected. Google Play (data_safety):shared/collected(categories the listing names, at most three) withshared_count/collected_count(all of them),encrypted_in_transit,deletion_request,no_data_sharedandshares_device_ids(the advertising ID;nullwhen the listing does not name every shared category). The full Data safety page is not read (robots.txt).
{
"data": {
"store": "android",
"privacy_policy_url": "https://www.examplegames.com/privacy",
"ios": null,
"data_safety": {
"shared": ["location", "personal_info", "device_ids"], "shared_count": 3,
"collected": ["location", "personal_info", "app_activity"], "collected_count": 6,
"encrypted_in_transit": true, "deletion_request": true,
"no_data_shared": false, "shares_device_ids": true
}
},
"meta": { "app": { "store": "android", "store_id": "com.example.puzzle", "name": "Example Puzzle Quest" } }
}
3.5 Network (ad system)#
curl -s https://api.stoqlab.com/v1/networks/adnetwork-one.com \
-H "Authorization: Bearer $STOQLAB_TOKEN"
data: the ad system's sellers_url, sellers_status (ok, missing, invalid, error,
unchecked), sellers_count, and seen_in_files. meta.coverage:
| Field | Meaning |
|---|---|
apps_total |
Tracked apps whose app-ads.txt declares this system |
apps_direct |
…with at least one DIRECT line |
apps_reseller |
…with at least one RESELLER line (an app can be in both) |
files_total / lines_total |
app-ads.txt files / lines mentioning it |
meta.check_status_counts shows how all those lines cross-check against this system's sellers.json —
a quick way to see how much stale inventory points at a network.
meta.direct_verification splits the apps that declare the system DIRECT by the owner check of
those accounts (§4b): apps_verified (an account's sellers.json domain is the app's own, and none
belongs to another domain), apps_mismatch (at least one DIRECT account belongs to another domain),
apps_unverified (neither), plus files / files_verified / files_mismatch. meta.tag_id is the
system's certification authority (TAG) ID (tag_id_source: sellers_json = its sellers.json
identifiers, majority = what most declarations carry in field 4; null when unknown).
3.6 Seller#
curl -s https://api.stoqlab.com/v1/sellers/adnetwork-one.com/5a1f00c9e2 \
-H "Authorization: Bearer $STOQLAB_TOKEN"
Returns the sellers.json entry (seller_type, name, domain, is_confidential,
is_passthrough), when we first/last saw it, removed_at if it has disappeared from the file,
and events (up to 200, newest first): added, removed, type_changed, name_changed,
domain_changed, confidential_changed, passthrough_changed (values 0 / 1) with old_value / new_value.
last_seen_at is refreshed at most once every 7 days for an entry that is still listed and
unchanged (a change of the entry rewrites it at once), so it can be up to 7 days older than our
last fetch of the file. "Still listed" is removed_at: null: removals are detected on every fetch.
IDs are matched case-insensitively and with ad-system rules applied (e.g. for google.com,
ca-pub-123 and pub-123 are the same seller). URL-encode IDs containing special characters.
meta.links.apps points at the apps that declare the seller (§3.8).
3.7 Apps declaring an ad system#
curl -s "https://api.stoqlab.com/v1/networks/google.com/apps?relationship=RESELLER&store=android&per_page=50" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
Same app objects as /apps, plus declaration — how the app's app-ads.txt declares the system:
"declaration": { "relationships": ["DIRECT", "RESELLER"], "lines": 14, "errors": 2 }
Filters (all optional): relationship (DIRECT / RESELLER), seller_id (one account at that
system, normalized like §3.6), direct_owner (verified / mismatch / unverified: apps by the DIRECT
owner check above), store, status (active / delisted / unknown), country
(apps known to be available in that storefront — tracked for top iOS apps), sort
(popularity = most ratings, default; installs; name; newest; updated).
3.8 Apps declaring one seller account#
curl -s https://api.stoqlab.com/v1/sellers/google.com/pub-1234567890/apps \
-H "Authorization: Bearer $STOQLAB_TOKEN"
"Which apps sell through seller S on network N?" Same filters as §3.7. It also works for IDs that
the network's sellers.json does not list: meta.seller is then null (never listed) or carries
removed_at (dropped from the file) — the apps returned are the ones still declaring a dead account.
3.9 sellers.json of an ad system#
curl -s "https://api.stoqlab.com/v1/networks/pubmatic.com/sellers?type=INTERMEDIARY" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
Entries ordered by seller ID with declared.direct / declared.reseller (app-ads.txt lines
declaring each). Filters: state (active default, removed, all), type, q (seller ID prefix
or exact seller domain). meta.summary: active / removed entries, counts per type, confidential,
passthrough, how many distinct account IDs tracked files declare (declared_accounts), and how many
of those the file does not list (stale_accounts).
3.10 Stale-seller report#
# per network: declared ids missing from its sellers.json, most affected apps first
curl -s "https://api.stoqlab.com/v1/networks/google.com/stale-sellers?state=removed&min_days=30" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
# per app: its lines whose seller is missing
curl -s https://api.stoqlab.com/v1/apps/android/com.example.puzzle/stale-lines \
-H "Authorization: Bearer $STOQLAB_TOKEN"
A seller account is stale when an app-ads.txt declares it (DIRECT or RESELLER) but the ad
system's own sellers.json does not list it: state: "removed" (it was listed; removed_at and the
last known name/type are returned) or state: "never_listed" (typo, or never published). Each
network row has lines, direct, reseller, files and apps; meta.summary totals them.
Filters: relationship, state (any / removed / never_listed), min_days (removed at least
N days ago), sort (apps, lines, removed_at, account_id). Networks without a usable
sellers.json have nothing to compare against and return an empty list (their lines show up as
no_sellers_json).
3.11 Developers#
curl -s https://api.stoqlab.com/v1/developers/examplegames.com -H "Authorization: Bearer $STOQLAB_TOKEN"
curl -s https://api.stoqlab.com/v1/developers/examplegames.com/apps -H "Authorization: Bearer $STOQLAB_TOKEN"
A developer is identified by its website domain. The detail lists the store developer accounts
on that domain (each with its legal_entity), legal_entities (the distinct companies behind them;
individual traders are never named, contact details are never returned), the app-ads.txt served
from it and apps_count; /apps lists those apps (filters as
§3.7 without relationship) — including apps of other accounts whose app-ads.txt points at the
domain.
3.12 CSV exports#
Every list marked "CSV" in the table above takes format=csv and streams the whole filtered
list (not one page) as a CSV file, up to 10,000 rows (or your plan's rows per export, if lower).
The rows count against your plan's daily export quota (§2b; since 2026-10-05), and the response
carries X-Row-Limit and, on plans with a quota, X-Export-Quota-Remaining:
curl -s -o google-resellers.csv -D - \
"https://api.stoqlab.com/v1/networks/google.com/apps?relationship=RESELLER&format=csv" \
-H "Authorization: Bearer $STOQLAB_TOKEN" | grep -i '^x-'
# X-Total-Count: 18233
# X-Truncated: true
X-Total-Count is the number of matching rows, X-Truncated: true means the file stopped at the
cap — narrow the filters (e.g. per store) to get the rest. Text cells starting with =, +, -
or @ are prefixed with ' so spreadsheets do not run them as formulas. Exports have their own
limit of 10 per minute per account (on top of §2); over it you get 429 rate_limited.
/network-share?format=csv is the exception: it is the same ranking as the JSON (at most limit ≤ 200
ad systems), so it is not counted against the quota or the export limit and carries only
X-Total-Count / X-Truncated.
Export provenance. Every export (format=csv / ndjson, /exports, live CSV URLs,
dashboard downloads) gets an id: the response carries X-Export-Id and
X-Export-Provenance: export=<id>; team=t_<tag>; at=<UTC time>, and the id is part of the file name
(stoqlab-apps-20261007-x7k2m9qa1b.csv). The rows count against the daily data quota as well
(§2). Add provenance=1 to also get a first line in the file:
# stoqlab export <id> · team t_<tag> · <time> · licensed under https://stoqlab.com/terms (CSV and
txt bundle lists) or {"_provenance": {"export_id", "team", "generated_at", "source", "terms"}}
(JSON Lines / NDJSON). It is off by default because a comment line breaks most CSV readers
(Google Sheets IMPORTDATA, Excel) and an extra object breaks JSON Lines consumers: skip it
(comment='#' in pandas, or the object with _provenance) if you turn it on.
3.13 Change feed#
# everything from the last 7 days, oldest first
curl -s https://api.stoqlab.com/v1/changes -H "Authorization: Bearer $STOQLAB_TOKEN"
# only sellers.json and app-ads.txt changes since a date
curl -s "https://api.stoqlab.com/v1/changes?types=sellers,appads&from=2026-10-01T00:00:00Z" -H "Authorization: Bearer $STOQLAB_TOKEN"
# only what happens from now on
curl -s "https://api.stoqlab.com/v1/changes?since=now" -H "Authorization: Bearer $STOQLAB_TOKEN"
type |
types= group |
What happened |
|---|---|---|
appads.new_file |
appads |
First version of an app-ads.txt we stored |
appads.changed |
appads |
New content; diff.added / diff.removed list the declarations (up to 50 each, counts complete) |
appads.status_changed |
appads |
The file went found → missing / invalid / error (or back); http_status included |
seller.added / seller.removed |
sellers |
An entry appeared in / disappeared from a sellers.json |
seller.changed |
sellers |
change.field (seller_type, name, domain, is_confidential, is_passthrough) with old / new (flags as 0 / 1) |
app.new |
store |
An app was added to tracking |
app.delisted / app.relisted |
store |
The app left / came back to its store (per day) |
app.availability_changed |
store |
Availability in one storefront flipped (availability.country) |
How to consume it. Each response has meta.next_cursor and links.next (= the same request with
since=<cursor>). Keep following links.next while meta.has_more is true; when it is false
you are up to date — store the cursor and poll again later (every few minutes is plenty). The cursor
is opaque; it never goes backwards and no event is skipped or repeated. Use the same types for the
whole life of a cursor.
import os, time, requests
s = requests.Session(); s.headers["Authorization"] = f"Bearer {os.environ['STOQLAB_TOKEN']}"
cursor = None # load the last one you saved
while True:
params = {"since": cursor} if cursor else {}
body = s.get("https://api.stoqlab.com/v1/changes", params=params, timeout=30).json()
for event in body["data"]:
print(event["happened_at"], event["type"], event["id"])
cursor = body["meta"]["next_cursor"] # save it
if not body["meta"]["has_more"]:
time.sleep(300)
Freshness. Events are served once they are 2 minutes old. Delistings / relistings are decided
per day (UTC) and appear once that day is complete (meta.store_settled_through). A file that
goes back to an earlier content is not reported as appads.changed.
3.14 Growth leaderboards (Google Play)#
Fastest growing Android apps by exact install counts (Play realInstalls), recomputed once a day
for the last complete UTC day (meta.as_of, available ~6 hours after midnight UTC).
# top gainers over 7 days, absolute
curl -s "https://api.stoqlab.com/v1/leaderboards/growth?window=7" -H "Authorization: Bearer $STOQLAB_TOKEN"
# highest % growth over 30 days among puzzle games with ads and at least 100k installs
curl -s "https://api.stoqlab.com/v1/leaderboards/growth?window=30&sort=pct&category=GAME_PUZZLE&contains_ads=true&min_installs=100000" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
| Parameter | Values |
|---|---|
window |
1, 7 (default), 30 days |
sort |
abs (installs gained, default) or pct (growth in %, only apps with ≥ 1,000 installs at the window start) |
category |
Play category id (GAME_PUZZLE, TOOLS, …) |
contains_ads |
true / false (Play's "Contains ads") |
min_installs |
minimum installs at the end of the window |
Each entry has installs (end value, end_on), start_installs (start_on), delta and pct. Only
apps that grew are listed. Apps fetched weekly or monthly appear when they have a snapshot within
3 days of both ends of the window (same rule as meta.growth of the app detail).
Sparse history. Install history started recently. meta.coverage tells you whether the window
is fully covered (window_complete), since when installs are collected (history_since), how many
apps have a value for this window, and a ready-to-show note. A 30-day leaderboard is empty until
30 days of history exist; before the very first computation data is [] and meta.as_of is null.
3.15 Declared network share#
How much of the tracked app-ads.txt universe declares each ad system, from a daily snapshot (UTC).
# top 50 ad systems by files declaring them, with DIRECT/RESELLER split and 7-day change
curl -s "https://api.stoqlab.com/v1/network-share" -H "Authorization: Bearer $STOQLAB_TOKEN"
# ranked by Android installs behind the files
curl -s "https://api.stoqlab.com/v1/network-share?sort=installs_any&limit=100" -H "Authorization: Bearer $STOQLAB_TOKEN"
# weekly trend of one network over the last 26 weeks
curl -s "https://api.stoqlab.com/v1/networks/unity3d.com/share" -H "Authorization: Bearer $STOQLAB_TOKEN"
Every row has files, apps and android_installs, each split into direct, reseller, any
(at least one line of either kind) with the matching *_pct = share of meta.totals:
- files — current app-ads.txt files (found, or a failed re-check with the last good copy kept) that at least one tracked, not delisted app links to;
- apps — those apps (iOS and Google Play);
- android_installs — the sum of their exact Google Play installs.
change_7d is the change in percentage points against the latest snapshot at least 7 days older
(meta.compare_day). The points are measured on a fixed cohort (method: "fixed_cohort"):
the cohort_files app-ads.txt files we had on both days (a body crawled by the end of compare_day, no
status flip since, still in scope today), comparing how many of those same files declare the system now
and did then (rewound with the line history). Files found since compare_day change meta.totals but not
the points, so a growing crawl no longer shows as every network losing share. files_*_pct_points are
null when the day has no cohort for that comparison or it has fewer than 30 files. files_any stays
the raw difference of the two days' counts (it includes files found since). Ad systems declared in fewer than meta.min_files (2) files are not tracked
per day: in the weekly trend those weeks have below_threshold: true and null counts. History
starts with the first snapshot (meta.coverage.since); earlier weeks are simply absent.
3.16 Lists and exports#
Saved app lists are private to your account: another account's list id answers 404, like
one that does not exist. Two kinds:
- filter list — a saved set of filters. Its apps are re-computed once a day (and when you
change the filters or call
/refresh); each refresh records which apps came in and which dropped out, so placement lists stay current without re-checking them by hand. - hand-picked list (
kind: manual) — exactly the apps you add (store ids, bundle ids or store URLs).
# a filter list: Android puzzle apps with ads, 100k+ installs, selling AppLovin inventory DIRECT,
# with a clean app-ads.txt
curl -s -X POST https://api.stoqlab.com/v1/lists \
-H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "Puzzle · AppLovin direct", "filters": {"store": "android", "category": "Puzzle",
"contains_ads": true, "min_installs": 100000, "ad_system": "applovin.com",
"relationship": "DIRECT", "has_errors": false}}'
# a hand-picked list (unknown references come back in meta.missing)
curl -s -X POST https://api.stoqlab.com/v1/lists \
-H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "Q4 include list", "apps": ["com.example.puzzle", "ios/1234567890",
"https://play.google.com/store/apps/details?id=com.example.run"]}'
# what changed in the latest refresh
curl -s "https://api.stoqlab.com/v1/lists/12/apps?change=added" -H "Authorization: Bearer $STOQLAB_TOKEN"
curl -s "https://api.stoqlab.com/v1/lists/12/apps?change=removed" -H "Authorization: Bearer $STOQLAB_TOKEN"
# edit a hand-picked list
curl -s -X POST https://api.stoqlab.com/v1/lists/13/apps \
-H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"add": ["android/com.example.new"], "remove": ["ios/1234567890"]}'
Filters (all optional; a filter list needs at least one). The same names work as query
parameters of GET /exports:
| Filter | Meaning |
|---|---|
store |
ios or android |
category |
Store category exactly as listed (Puzzle, Games, …) |
country |
Available in this storefront (ISO alpha-2; a comma list = any of them). Availability is checked for top apps only (§3.1 "Country availability") |
availability |
With country: confirmed (the default here, as before: checked available, or rated in that storefront) or likely (not known to be unavailable; the default of GET /apps) |
min_audience_share |
With country: estimated audience share 0–1 in those storefronts (iOS apps with per-storefront ratings) |
contains_ads |
true / false — the store listing's "contains ads" |
ads |
true: the listing says it contains ads or its app-ads.txt lists authorised sellers; false: the listing says no ads and no such file (§3.1) |
min_installs, max_installs |
Google Play installs (exact when known, else the bucket floor); iOS apps have none and never match |
min_rating, max_rating |
Store rating, 0–5 |
developer |
Developer domain: its store accounts, plus apps whose app-ads.txt is served from it |
ad_system |
The app's app-ads.txt declares this ad system… |
relationship |
…as DIRECT or RESELLER (needs ad_system) |
seller_id |
…with this account id (normalized like /sellers/{domain}/{seller_id}; needs ad_system) |
appads_status |
found, missing, invalid, no_website, error, or none (no app-ads.txt known) |
has_errors |
true: the app-ads.txt has lines with an error check_status (§4); false: none |
delisted |
true: delisted apps only; false: apps that are not delisted |
Booleans accept true/false, 1/0, yes/no. Domains are normalized (https://www.Example.com/
→ example.com).
A list carries app_count, truncated (more apps matched than a list keeps — 50,000, the most
rated first) and last_refresh: seq (refresh number), at, added, removed, error. The
first membership of a list is its baseline (added = removed = 0); for a hand-picked list every
edit counts as a refresh. Each app of /lists/{id}/apps has list.added_at, list.new (added by
the latest refresh) and list.removed (only with change=removed). Sort with sort= (§3.7 values
plus added); filter with store= and status=. POST /lists/{id}/refresh and a PATCH that
changes a list's filters re-compute it at most once per 10 minutes per list (429 with
Retry-After otherwise; nothing is saved). Creating lists and changing filters share a budget of
10 per hour per account (429). POST /lists/{id}/apps takes up to 1,000 apps per call.
Changing lists needs a key with lists:write (§1).
| Plan | Lists per account | Apps kept per list |
|---|---|---|
| free | 10 | 5,000 |
| pro | 100 | 50,000 |
| business | 100 | 50,000 |
Exports stream the whole selection — a list (GET /exports?list=12 or
GET /lists/12/apps?format=…) or an ad-hoc filter (GET /exports?store=ios&contains_ads=true):
format |
File |
|---|---|
csv (default) |
CSV with a header row; cells starting with =, +, -, @ are prefixed with ' |
jsonl |
JSON Lines: one JSON object per app, typed values |
txt |
One store id per line (iOS numeric id, Android package) — the bundle list DSPs take |
columns=store_id,name,… picks the columns (CSV and JSON Lines, in the order given) from:
store, store_id, bundle_id, name, developer, developer_domain, category, status,
rating, rating_count, installs_min, installs_exact, reviews_count, contains_ads, has_iap,
price, currency, release_date, version, content_rating, icon_url, appads_domain,
appads_status, store_url, delisted_at, last_crawled_at, and for lists list_added_at, list_new.
Developer contact details are never exportable. Rows per export and per day depend on your plan:
| Plan | Rows per export | Rows per day (UTC) |
|---|---|---|
| free | 5,000 | 5,000 |
| pro | 50,000 | 50,000 |
| business | 250,000 | 250,000 |
| enterprise | 1,000,000 | 2,000,000 (the daily data quota, §2) |
The response says X-Total-Count (matching apps), X-Truncated, X-Row-Limit (lowered to what
is left of today's quota) and X-Export-Quota-Remaining. On the free plan an ad-hoc export needs
at least one filter (422 otherwise; list exports are fine). Exports share the limit of 10 per
minute per account with §3.12, and one export runs at a time per account (429 export_in_progress); when the server's export slots are all busy you get 429 exports_busy
(retry after a minute). 429 export_quota_exceeded means today's rows are used up (resets at
00:00 UTC).
curl -s -o placements.txt "https://api.stoqlab.com/v1/exports?list=12&format=txt" -H "Authorization: Bearer $STOQLAB_TOKEN"
curl -s -o ios-ads.jsonl "https://api.stoqlab.com/v1/exports?store=ios&contains_ads=true&format=jsonl&columns=store_id,name,rating_count,appads_status" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
The dashboard (Lists) does the same: preview filters, save them, see the New / Removed tabs of the latest refresh and export with a column picker.
3.17 Domain checker#
Check any developer domain — tracked apps or not — the way our crawler does, now: the
app-ads.txt file, its parse errors and every line cross-checked against the sellers.json of its ad
system. Send a domain or a URL; it is reduced to the host we crawl (no www. / m., at most two
labels before the public suffix: https://cdn.games.studio.co.uk/x → games.studio.co.uk), and
that host's /app-ads.txt is read, then its registrable domain's, over HTTPS then HTTP.
# ask for a check (202 + the request; the workers pick it up within about a minute)
curl -s -X POST https://api.stoqlab.com/v1/checks \
-H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"domain": "https://www.example-studio.com"}'
# follow it: status pending → running → done (or failed); result is filled once done
curl -s https://api.stoqlab.com/v1/checks/123 -H "Authorization: Bearer $STOQLAB_TOKEN"
# the stored audit of a domain, without a new check
curl -s https://api.stoqlab.com/v1/domains/example-studio.com/audit -H "Authorization: Bearer $STOQLAB_TOKEN"
POST /checksanswers202with the request (id,domain,status,message,requested_at,started_at,finished_at,created,links.self,links.audit) and aLocationheader. A request of your account already pending or running for the host is returned instead of a new one (created: false). A host checked less than 10 minutes ago is not checked again:200withfresh: true,id: nulland theresult. Creating checks needs a key withlists:write(§1); following them and audits needread.GET /checks/{id}— only requests made by your account (another id answers404).resultisnulluntilstatusisdone.failedmeans the check could not be completed on our side (messagesays so); ask again later.GET /domains/{domain}/audit—404when we have never checked the host,422when the domain is refused (see below).- Limits: 30 new checks per hour per account (
429 check_rate_limited, withRetry-After) and a server-wide queue cap (429 checks_busy: retry after a minute). Refused input (422 validation_failedwith the reason): IP addresses,localhostand other single-label names, names over 253 characters, names without a public suffix (.example,.local), invalid host names (internationalized domains in theirxn--form are fine), and store, social and link-in-bio hosts (play.google.com,apps.apple.com,facebook.com,linktr.ee, …): app-ads.txt lives on the developer's own website.
The result (result of a check, data of an audit):
| Field | Meaning |
|---|---|
domain |
The host checked |
appads |
status (found, missing, invalid, error), status_label, status_reason + reason (plain words, §3.2 appads.status_reason), verified (false = we could not verify the file: bot challenge, timeout, 403, … — our side, never the publisher's fault; the last copy we read, if any, is what the rest describes), publisher_side, url, http_status, content_type, fetched_at, changed_at, line_count, direct_count, reseller_count, owner_domain, manager_domain, parse_error_count, parse_errors (first 50: line_no, code, message) |
summary |
{tone, text}: one plain sentence; tone = ok, error (the file has a problem), warning, neutral (could not verify) |
cross_check |
lines, ok, errors, warnings, unchecked (no sellers.json data for that ad system yet) and counts per check_status (§4) |
problem_lines |
The first 100 lines with an error or warning check_status: line_no, ad_system, account_id, relationship, check_status, label, severity, seller (type, name, domain, removed) or null; problem_lines_truncated |
verification |
Both directions (§4b): score, confirmed, checked, direct / reseller (lines, confirmed), counts per verification value, cert_mismatch, publisher_domains (the host, OWNERDOMAIN and the developer websites of tracked apps using the file), and flagged_lines (first 20: domain_mismatch, seller_domain_missing or a TAG ID mismatch — line_no, ad_system, account_id, relationship, verification, label, seller_domain, cert_authority_id, cert_check) |
ad_systems |
count and items (≤ 200, most lines first): domain, name, direct, reseller, lines, failing (lines with an error check_status) |
apps |
Tracked apps whose app-ads.txt this is: count and top (≤ 20, most installs then ratings): store, store_id, name, icon_url, status, installs, rating_count |
events |
The file's last 10 history events (first, content_changed, status_changed, fetch_error = could not verify, recovered) with reason, reason_label, line_count, http_status, happened_at |
A check never fetches a sellers.json: lines of an ad system whose sellers.json we have not read yet
stay unchecked until our regular sellers.json crawl gets there. The dashboard has the same checker
(Check), and MCP clients get the stored audit with the audit_domain tool (§8).
3.18 Seat verification#
Business plan and up (plan feature verification_api). For a buyer: is this seller account
allowed to sell this app's inventory? One seat is (app, ad system, seller ID[, relationship]); the
app is a store ID or bundle ID (optionally with store) or the app-ads.txt host. Stored data only,
nothing is crawled.
# one seat
curl -s "https://api.stoqlab.com/v1/verify/seat?store=android&store_id=com.example.puzzle&ad_system=google.com&seller_id=pub-1234567890&relationship=DIRECT" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
# up to 100 seats per call (Enterprise Data: 1,000), answered in the order sent
curl -s -X POST https://api.stoqlab.com/v1/verify/seats \
-H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"items": [
{"store": "android", "store_id": "com.example.puzzle", "ad_system": "google.com", "seller_id": "pub-1234567890", "relationship": "DIRECT"},
{"host": "example-studio.com", "ad_system": "pubmatic.com", "seller_id": "156439", "relationship": "RESELLER"}
]}'
Each result (data, or each item of data for the batch, whose meta has count, limit and a
count per verdict):
| Field | Meaning |
|---|---|
verdict |
authorised — the app-ads.txt declares the seat (with the relationship you asked for) and sellers.json does not contradict it. unauthorised — not declared, declared only with the other relationship, or contradicted: the seller ID is not in the ad system's sellers.json (or was removed), a DIRECT line names an intermediary's account, a RESELLER line a publisher's. unverifiable — we cannot read the app's authorisation: unknown or ambiguous app, no app-ads.txt known, or a file that is missing / invalid / unreachable with no stored copy. |
reasons |
[{code, severity, message}], errors first, then warnings (domain_mismatch, confidential, no_sellers_json, seller_domain_missing, intermediary_unknown, cert_mismatch, appads_stale, sellers_stale, appads_fetch_error), then ok / info (declared, verified_owner, verified_reseller). Warnings never change an authorised verdict. |
app, appads, ad_system |
What was looked up: the app, its app-ads.txt (domain, url, status, fetched_at, changed_at) and the ad system (known, sellers_status, sellers_fetched_at). |
declaration |
declared, the relationship used, every relationships declared for this seller, the matching lines (line_no, account_id as written, relationship, cert_authority_id), since and since_exact (true: the line appeared then, from the line history; false: present at least since our first copy of the file). |
seller |
The sellers.json entry, removed ones included: listed, seller_type, name, name_withheld, domain, is_confidential, is_passthrough, first_seen_at, removed_at; null when we hold no sellers.json for the ad system. Names: an INTERMEDIARY / BOTH account's name is returned; a PUBLISHER account's name only when it carries a company form (Ltd, Inc, GmbH, …), otherwise name: null, name_withheld: true (it may be a person's name). |
verification |
The line's supply verification in both directions (§4b): value, label, description, check_status, confirmed, cert_check. |
staleness |
appads_fetched_at, appads_age_days, appads_stale (over 35 days), sellers_fetched_at, sellers_age_days, sellers_stale (over 14 days), stale. |
Input errors are 422 validation_failed (items.3.ad_system, …), including more items than the
plan allows. The batch needs only the read ability: it changes nothing. Dashboard: Seat check
(a box with one seat per line). MCP: verify_seats (§8).
3.19 schain validation#
Professional plan and up (plan feature schain); the same check is free, one chain at a time,
at stoqlab.com/tools/schain-validator. Send an OpenRTB
SupplyChain object — or a JSON string of it, or a bid request holding it in source.schain (2.6) or
source.ext.schain (2.5) — and optionally the app (store_id / store), the app-ads.txt host or a
website (site) to compare it with. Without them, a bid request's app.bundle or site.domain is
used. At most 20,000 bytes and 20 nodes; stored data only.
curl -s -X POST https://api.stoqlab.com/v1/schain/validate \
-H "Authorization: Bearer $STOQLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"store_id": "com.example.puzzle",
"schain": {"complete": 1, "ver": "1.0", "nodes": [
{"asi": "google.com", "sid": "pub-1234567890", "hp": 1},
{"asi": "pubmatic.com", "sid": "156439", "hp": 1, "rid": "req-42"}]}}'
Per node (nodes[]: index, role publisher / intermediary, asi, sid, hp, rid, name, domain,
seller, checks, verdict), each check is {check, status: pass|warn|fail|info, message}:
| Check | Pass when |
|---|---|
syntax |
OpenRTB 2.6: asi, sid are non-empty strings, hp is the integer 0 or 1, rid / name / domain strings, ext an object; sid ≤ 64 characters (warning); asi a bare domain |
sellers_json |
We hold a sellers.json for the asi (warn when the ad system is unknown to us, fail when it publishes none) |
seller |
The sid is listed today, with a seller_type that fits its position: node 0 PUBLISHER or BOTH (the publisher's own account), later nodes INTERMEDIARY or BOTH; confidential = warn |
appads |
With an app / host / site: node 0 is a DIRECT line of its app-ads.txt (ads.txt), every later node a RESELLER line (warn for the other relationship, fail when absent) |
link, domain, duplicate |
Informational: the seller of node n is the ad system of node n − 1; the node's domain matches sellers.json; the same asi + sid twice |
errors lists problems of the object itself (complete / ver / nodes missing or of the wrong type);
complete checks the flag: complete: 1 fails when node 0 is an intermediary's account or is not
authorised by the app. verdict is invalid when anything fails, warning when something warns,
else valid; summary says it in one sentence. MCP: validate_schain (§8).
Datasets (Enterprise Data plan)#
Every day shortly after midnight UTC a full snapshot of the data is written as files: one file per
dataset and format, plus a manifest.json. The last 14 days are kept (older days are deleted).
Needs the Enterprise Data plan (plan feature datasets); other plans get 403 plan_required.
Any key with read works.
| Dataset | One row per | Columns (main) |
|---|---|---|
apps |
tracked app (also delisted ones) | app_id, store, store_id, bundle_id, name, developer_id, developer_name, developer_website, category, category_id, installs_min, installs_exact, rating, rating_count, reviews_count, price, currency, contains_ads, has_iap, status, delisted_at, appads_file_id, appads_host, appads_status, last_crawled_at |
appads_files |
app-ads.txt host | file_id, host, url, status, status_reason, http_status, line_count, direct_count, reseller_count, owner_domain, manager_domain, content_hash, fetched_at, changed_at |
appads_lines |
current declaration of a file | file_id, host, line_no, ad_system_domain, account_id, account_id_norm, relationship, cert_authority_id, check_status (§4) |
ad_systems |
ad system | ad_system_id, domain, name, sellers_url, sellers_status, sellers_count, seen_in_files, fetched_at |
sellers |
current sellers.json entry (removed ones are left out) | ad_system_domain, seller_id, seller_id_norm, seller_type, name, domain, is_confidential, is_passthrough, first_seen_at, last_seen_at |
changes |
event of the previous UTC day | kind (appads_line line added/removed, seller sellers.json change, app store identity change), happened_at, event, host, app_id, store, store_id, field, ad_system_domain, account_id, relationship, cert_authority_id, seller_id, old_value, new_value |
Formats. {dataset}.csv.gz: gzip-compressed CSV, UTF-8, header row, RFC 4180 quoting, empty
field = null, flags as 1/0, timestamps YYYY-MM-DDTHH:MM:SSZ (UTC); text cells starting with =, +, -, @,
tab or CR are prefixed with ' so spreadsheets do not run them as formulas. {dataset}.parquet: typed
columns (int64, double, bool, string, date, timestamp UTC), exact values, zstd compression. The manifest lists every
file with dataset, format, rows, bytes, sha256 and columns (name, type), plus
changes_window (from/to) and generated_at. Each dataset is read in batches while the crawlers
keep running, so a file is not a single-instant snapshot: a host crawled during the export can appear
with its new status in appads_files and its old lines in appads_lines.
# the days and their files, each with a signed download link valid for 60 minutes
curl -s https://api.stoqlab.com/v1/datasets -H "Authorization: Bearer $STOQLAB_TOKEN"
# one file: 302 to a signed URL (-L follows it; the signed URL needs no token)
curl -sL -o apps.parquet https://api.stoqlab.com/v1/datasets/2026-10-14/apps.parquet \
-H "Authorization: Bearer $STOQLAB_TOKEN"
# the signed URL as JSON instead of a redirect
curl -s "https://api.stoqlab.com/v1/datasets/2026-10-14/apps.csv.gz?redirect=false" -H "Authorization: Bearer $STOQLAB_TOKEN"
# verify
sha256sum apps.parquet
GET /datasets?date=YYYY-MM-DD—data[]= days, newest first:date,generated_at,changes_window,formats,bytes,manifest_url,files[](dataset,format,file,rows,bytes,sha256,content_type,columns,url,download_url,download_expires_at);meta.days,meta.url_expires_minutes.GET /datasets/{date}/{file}—302todownload_url;?redirect=falseanswers{"data": {…file, download_url, download_expires_at}}.{file}is a name from the manifest ormanifest.json; anything else is404 not_found(also a day that was rotated out).download_url(/v1/datasets/{date}/{file}/download?expires=…&u=…&signature=…) streams the file (Content-Length,X-Checksum-Sha256,X-Row-Count). It carries its own credential — treat it as a secret until it expires. It stops working afterdownload_expires_at(403 url_expired), when it was altered (403 invalid_signature), or when the account loses the plan (403 plan_required) or is disabled. At most a few downloads run at once server-wide:429 downloads_busy= retry afterRetry-Afterseconds.
The dashboard page Datasets lists the same days with download buttons.
Connected TV (Professional plan and up)#
Apps that run on TV devices, with the app-ads.txt their developer website declares. Needs the
Professional plan or higher (plan feature ctv); other plans get 403 plan_required. Any key
with read works.
What is covered. Today store is always tvos: App Store apps whose listing names Apple TV
among their devices (the product page's Compatibility list, or the lookup's Apple TV devices and
screenshots). store_id is the App Store ID, and the app-ads.txt is the one of that app's developer
website — the same file as GET /apps/ios/{store_id}. An app appears once its App Store product page
has been read (popular apps first), and the list is refreshed daily. Roku, Amazon Fire TV, Samsung,
LG and Vizio store listings are not collected: their website terms forbid automated access (Vizio has
no public store website). Google TV / Android TV apps are not marked yet: the Play data we collect does
not say whether an app runs on TV.
# search by name, developer, domain, store ID or bundle ID
curl -s "https://api.stoqlab.com/v1/ctv/apps?q=weather" -H "Authorization: Bearer $STOQLAB_TOKEN"
# CTV apps whose developer website has no usable app-ads.txt
curl -s "https://api.stoqlab.com/v1/ctv/apps?appads=missing" -H "Authorization: Bearer $STOQLAB_TOKEN"
# one app: app-ads.txt, supply-tree summary, declared ad systems
curl -s https://api.stoqlab.com/v1/ctv/apps/tvos/363590051 -H "Authorization: Bearer $STOQLAB_TOKEN"
GET /ctv/apps— filters:q(substring of name, developer or developer domain; exact store ID or bundle ID),store(tvos),status(active|delisted|unknown),appads(the file's statusfound|missing|invalid|no_website|error, ornone= no file linked yet),page,per_page. Most ratings first. Each item:store,store_id,bundle_id,name,developer{name,domain,website},category,rating,rating_count,status,source(derived= found from App Store data),appads{domain,status,status_reason,line_count,direct_count,reseller_count,fetched_at} ornull,app{store,store_id} (the tracked app it comes from) ornull,first_seen_at,synced_at,links{self,store,app,supply_tree}. Pagination as everywhere (links,meta).GET /ctv/apps/{store}/{store_id}—data= the item above;meta.supply_tree= the summary of §3.3 (total_edges,max_hop,by_hop,direct,reseller,ad_systems,computed_at),meta.ad_systems= up to 50 ad systems of the file (domain,name,lines, most lines first),meta.ad_system_count. The full nested tree islinks.supply_tree(GET /apps/ios/{store_id}/supply-tree). Unknown app:404 not_found.
The dashboard page CTV has the same search, filters and detail pages.
Websites (ads.txt) (Business plan and up)#
The ads.txt of websites, read like app-ads.txt (same format, same parser, same redirect rules) and
cross-checked against the same sellers.json data: a declaration has the same check_status whether an
app-ads.txt or an ads.txt makes it. Needs the Business plan or higher (plan feature web); other
plans get 403 plan_required. Any key with read works.
What is covered. The 100,000 most popular websites of a public top-sites ranking (the top 10,000
are checked daily, the rest weekly), every developer domain and app-ads.txt host we track (monthly),
and the domains users check with the domain checker (weekly). {domain} is the site's registrable
domain (https://www.news.example.co.uk/x → example.co.uk); a tracked subdomain is matched first.
The file is https://{domain}/ads.txt (then http://). A site that answered with a bot challenge, a
401/403, a 429 (its Retry-After is honoured) or a timeout is error = "could not verify", never
the site's fault; the last good copy and its lines are kept. The ranking position itself is not
published: popularity is top_10k, top_100k or null.
# one site: its ads.txt, lines with the cross-check (paginated), counts per check_status
curl -s "https://api.stoqlab.com/v1/sites/example.com?check_status=id_missing" -H "Authorization: Bearer $STOQLAB_TOKEN"
# status flips and content changes, newest first, with declarations added / removed
curl -s https://api.stoqlab.com/v1/sites/example.com/adstxt-history -H "Authorization: Bearer $STOQLAB_TOKEN"
# websites whose ads.txt declares an ad system (follow links.next / meta.next_cursor)
curl -s "https://api.stoqlab.com/v1/networks/pubmatic.com/sites?relationship=DIRECT&limit=100" -H "Authorization: Bearer $STOQLAB_TOKEN"
GET /sites/{domain}— query:check_status,ad_system(filter the lines),page,per_page.data=domain,popularity,source(top_sites|developer|domain_checker),last_checked_at,next_check_at,adstxt(nulluntil the first check) {status(found|missing|invalid|error),status_reason(the app-ads.txt codes, e.g.html_soft_404,html_challenge,http_404),reason(plain words),publisher_side,url,http_status,content_type,content_hash,line_count,direct_count,reseller_count,owner_domain,manager_domain,contact(alwaysnull: withheld, personal data),fetched_at,changed_at,parse_errors(≤ 50 {line_no,code,message}),check_status_counts},lines[{line_no,ad_system,ad_system_name,account_id,relationship,cert_authority_id,check_status}].links/metapaginate the lines;meta.links.history. Untracked site:404 not_found(check it withPOST /checks: checked domains join the website crawl).GET /sites/{domain}/adstxt-history?limit=(≤ 200, default 50) —data[{event(first|content_changed|status_changed),old_status,new_status,status_reason,content_hash,line_count,added_count,removed_count,happened_at}], newest first. Only the latest body is read; earlier bodies are not stored (the counts are distinct declarations added / removed). A failed check (error) is not a change.GET /networks/{domain}/sites?relationship=&cursor=&limit=— websites whose current ads.txt declares the ad system, in a stable order. Each item:domain,popularity,declares{direct,reseller,lines},adstxt_status,adstxt_line_count,fetched_at,links.self.meta:ad_system,totals{sites,direct,reseller} (cached 10 min),next_cursor,has_more,limit;links.next.relationship=DIRECTorRESELLER; an invalid cursor is422.
The dashboard page Websites has the same lookup, the lines and the history; each network page shows how many websites declare it.
Countries and charts (Professional plan and up)#
Whether the stores still offer an app in each country, when that changed, and the App Store top
charts per storefront. Needs the Professional plan or higher (plan feature countries); other
plans get 403 plan_required. Any key with read works.
What is covered. App Store: the most popular apps (tier A: charting, or 50,000+ ratings) are
checked daily in the 30 largest storefronts and weekly in every other storefront (174 in
all); apps with ads (an app-ads.txt found, or "contains ads": tier B) and tier A every 30 days in
the 30 largest ad markets (US, GB, DE, FR, JP, KR, CA, AU, BR, MX, IN, IT, ES, NL, SE,
CH, NO, DK, AT, BE, PL, TR, SA, AE, TW, HK, SG, ID, TH, PH). Google Play: tier-A apps weekly and apps
with an app-ads.txt every 30 days in 10 countries (US, GB, DE, FR, IN, BR, JP, KR, ID, MX), within a
small share of the Google Play budget, so the 30-day round of the app-ads.txt apps takes longer while
that budget is small. On both stores every app is also checked in its home storefront whenever its
store data is refreshed (no extra request), and a Google Play category page of a country confirms
the apps it lists there. The App Store checks use the part of the lookup budget the store refresh
leaves (between 10 % and 45 %), so when more apps qualify the rotation stretches; last_checked_at
always says how fresh a row is and source where it comes from (lookup, play_page, refresh,
listing, inferred: Google Play served the US page at the last refresh, offer not read, status
unknown; replaced by the next real answer, never a delisting). A storefront becomes unavailable after the app is missing there on
two checks on different days; until then it keeps its previous status and pending_misses counts
the misses. An app is delisted in a country when it was available there and became
unavailable (one event); a storefront where the app was never offered is not a delisting.
# one app in every tracked storefront, unavailable ones first, with its flips
curl -s https://api.stoqlab.com/v1/apps/ios/389801252/availability -H "Authorization: Bearer $STOQLAB_TOKEN"
# per-country counts (latest daily summary) and delistings / relistings of the last 7 days
curl -s "https://api.stoqlab.com/v1/countries?store=ios" -H "Authorization: Bearer $STOQLAB_TOKEN"
# apps delisted in India, oldest first; then poll with meta.next_cursor
curl -s "https://api.stoqlab.com/v1/countries/in/delistings?from=2026-10-01T00:00:00Z&limit=100" -H "Authorization: Bearer $STOQLAB_TOKEN"
curl -s "https://api.stoqlab.com/v1/countries/in/delistings?since=$CURSOR" -H "Authorization: Bearer $STOQLAB_TOKEN"
# today's top free games in Germany, with the movement since yesterday
curl -s "https://api.stoqlab.com/v1/charts/ios/de/top-free?genre=6014" -H "Authorization: Bearer $STOQLAB_TOKEN"
GET /apps/{store}/{store_id}/availability—data= one row per tracked storefront:country(upper case),name,status(available|unavailable|unknown),pending_misses,first_seen_on(first check that found the app there, since tracking began;nullwhen unknown),last_seen_at,last_checked_at,changed_at(last status change),flips, and for iOSrating_count(the App Store counts ratings per storefront),rating_count_delta_30d,score,price,currency,metrics_on. Unavailable rows first.events= the app's flips, newest first (≤ 200:country,old_status,new_status,happened_at).meta.totals=storefronts,available,unavailable,unknown. (GET /apps/{store}/{store_id}/countries, on every plan, keeps returning the per-storefront metrics without the history.)GET /countries?store=—data= one row per country with data:country,name,available,unavailable,unknown(app × storefront pairs, from the daily summary),delisted_7d,relisted_7d(events of the last 7 days),href.meta:as_of,source(daily_summary, orlivebefore the first summary),totals,store.GET /countries/{cc}/delistings?since=&from=&store=&limit=— delistings in one storefront, oldest first in (happened_at, event) order. Start withfrom(ISO 8601; default 30 days ago), then passmeta.next_cursorassince(fromandsincetogether are422).has_more: falsemeans you are up to date: poll later with the same cursor. Events younger than 10 minutes (meta.settle_seconds) are held back so nothing is skipped. Each item:country,happened_at,old_status,new_status(unavailable),current_status(the latest check:availableagain, or stillunavailable),globally_delisted(removed from the store altogether),app{store,store_id,name,developer,rating_count,installs_min,href}.limit≤ 500 (default 100).GET /charts/{store}/{cc}/{chart}?genre=&date=—store=ios;chart=top-free|top-paid;genre=all(default: the App Store RSS feed, 100 ranks, every storefront) or an Apple genre id (6014Games,7012Puzzle, … the 25 ranks of the App Store chart page, the 30 largest storefronts);date=YYYY-MM-DD(UTC; default the latest capture).data= the ranked apps:rank,previous_rank,change(positive = moved up;nullwhen new or nothing to compare),is_new,app{store,store_id,name,developer,category,rating,rating_count,status,href}.meta:country,chart,genre,genre_name,date,previous_date(the capture compared with, at most 3 days earlier, elsenull),source(app_store_rss_v2|app_store_web_charts),entries,new_entries,dropped(apps of the previous capture no longer in the chart:previous_rank,app). No capture for that day:404 not_found. Not available: top grossing (Apple no longer publishes it in a robots.txt-allowed feed) and Google Play charts (/charts/android/…is404): Google Play serves its ranked charts only from an endpoint its robots.txt disallows, and Stoqlab honours robots.txt.
The dashboard pages Countries (every storefront, then per country: delisted in the last 7 / 30 days, available elsewhere but not there), Charts and the app page's Countries tab show the same data.
Prospecting (Business plan and up)#
For SSPs and ad networks: the publishers that work with your competitors and not with you. Give your
ad system and one to ten competitors (domains as written in app-ads.txt); every current app-ads.txt
that names at least one competitor and does not name your ad system (in any relationship) is a
prospect. Files are grouped by company, their live apps counted, and the groups ranked by reach.
Plan feature prospecting; MCP tool find_publishers; dashboard Sales tools → Find publishers.
# publishers declaring AppLovin or Unity but not PubMatic, Google Play apps with 100k+ installs
curl -H "Authorization: Bearer $KEY" \
"https://api.stoqlab.com/v1/prospects?ad_system=pubmatic.com&competitors=applovin.com,unity3d.com&store=android&min_installs=100000"
# the same as a CSV for a CRM import (HubSpot company columns)
curl -H "Authorization: Bearer $KEY" -o prospects.csv \
"https://api.stoqlab.com/v1/prospects?ad_system=pubmatic.com&competitors=applovin.com,unity3d.com&format=csv"
| Parameter | Meaning |
|---|---|
ad_system |
Required. Your ad system domain. Files that declare it are left out (meta.totals.files_declaring_both). |
competitors |
Required. 1-10 competitor domains, comma-separated. |
relationship |
DIRECT or RESELLER: only files declaring a competitor that way. |
store, category, country, contains_ads |
App filters, as in §3.16 (country: available in that storefront). |
min_installs |
Google Play installs (exact when known, else the range floor) at least this. |
min_ratings |
Store rating count at least this. |
updated_within_days |
App updated in its store in the last N days (1-3650). |
owner_domain, manager_domain |
Only files declaring this OWNERDOMAIN / MANAGERDOMAIN. |
appads |
found: only files found at our last check (default: any current copy). |
group_by |
publisher (default: the app-ads.txt host), owner (OWNERDOMAIN) or manager (MANAGERDOMAIN); a file without that variable falls back to its host. |
sort |
installs (default: reach, then ratings), ratings, apps or domain. |
page, per_page |
Page-number pagination (§6). |
Each row of data: domain (the group key), name (developer name of the store account with the
most matching apps; public listing data, never contact details), group_by, hosts_count, hosts
(≤ 10 app-ads.txt hosts), owner_domains, manager_domains, apps {total, ios, android}
(live apps matching the filters), installs (Google Play), ratings, competitors (each competitor
the group declares, with its relationships), top_app {store, store_id, name, store_url},
categories (≤ 3, most apps first). meta: pagination, ad_system, competitors, criteria (the
normalized query), totals {groups, hosts, apps, installs, ratings,
files_declaring_competitors, files_declaring_both}, definitions. Unknown ad systems are 422.
format=csv returns every group (one row per company, most reach first), at most your plan's rows
per export and counted against the daily export quota (§2b; headers as in §3.12). Columns: Company name, Company domain name, Website URL, Number of apps, iOS apps, Android apps, Total installs, Total ratings, Competitor networks, Competitors DIRECT, Competitors RESELLER,
Owner domain, Manager domain, app-ads.txt hosts, Top app, Top app store URL, Categories,
Missing ad system. The first two map to HubSpot's company name and domain properties as they are.
Onboarding monitor (Business plan and up)#
Follow the publishers you are onboarding. A monitor is an alert rule with subject_type
onboarding: subject is your ad system, the targets are publisher domains (hosts, the
app-ads.txt hosts) and / or the apps of a saved list (list_id; each app counts through its
app-ads.txt host), and seller_ids are your own account ids (optional). Create, change and delete it
with POST / PATCH / DELETE /alerts/rules ("Alerts" below); read its state here. Plan feature
prospecting (creating one with another plan is 403 plan_required).
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
https://api.stoqlab.com/v1/alerts/rules -d '{"name": "Q4 onboarding", "subject_type": "onboarding",
"subject": "pubmatic.com", "hosts": ["studio.example", "games.example"], "seller_ids": ["12345"],
"event_types": ["onboarding.line_added", "onboarding.line_removed", "onboarding.mismatch"]}'
# the state of every target, problems first; ?state=problem for the ones that need attention
curl -H "Authorization: Bearer $KEY" "https://api.stoqlab.com/v1/onboarding/42"
GET /onboarding/{id}?state=&page=&per_page= — one row per target host (at most 2,000 per monitor,
meta.truncated): host, source (host and / or list), appads_status, checked_at, apps
{live, in_list, examples (≤ 3)}, state, relationships, lines (your lines in that file:
account_id, relationship, check_status, own_seller_id), own_seller_id (one of your ids is
there; null without seller ids), sellers_json (ok, not_listed, type_mismatch or
unchecked: the cross-check against your own sellers.json), live_since, live_since_exact,
days_live, removals, last_removed_at, needs_attention.
state |
Meaning |
|---|---|
live |
Your ad system is declared in the target's app-ads.txt. |
missing |
The file is there, without a line of yours. |
removed |
No line of yours now, but one was removed before (removals, last_removed_at). |
no_file |
No app-ads.txt known for the host (not crawled, missing, invalid or no website). |
problem (filter only) |
Live, but not one of your seller ids, or not confirmed by your sellers.json. |
live_since is when the oldest current line of yours appeared; when it was already there before our
line history starts, live_since_exact is false and the date is our first read of the file (a
lower bound). meta: pagination, rule, ad_system, seller_ids, summary {targets, live,
removed, missing, no_file, direct, mismatch}, truncated, tracking_since. Another
account's monitor (or a rule that is not a monitor) is 404.
Alerts (Professional plan and up)#
Watch rules tell Stoqlab what to alert you about. Every 5 minutes the workers compare what changed
since the previous pass with every active rule; each matching change becomes one alert per account
(two rules matching the same change give one alert that lists both rule_ids). Alerts appear on the
dashboard (Alerts, with an unread badge), in GET /alerts/events, in the MCP tool
recent_alerts, and are POSTed to your webhook endpoints ("Webhooks" below). A rule only sees changes
that happen after it was created. Without the plan feature every endpoint below answers
403 plan_required. Writes need a key with lists:write.
Subjects (subject_type + subject):
subject_type |
subject |
Event types it can ask for |
|---|---|---|
app |
ios/123456789, android/com.example.game or a store URL of a tracked app |
all appads.* (about the app's developer domain) and all app.* |
host |
a developer domain whose app-ads.txt we crawl (example.com, a URL is reduced to its crawl host) |
appads.lines_changed, appads.status_changed, appads.stale_lines |
network |
an ad system domain as written in app-ads.txt (applovin.com; must be known) |
seller.removed, seller.changed |
list |
send list_id: one of your saved lists (members are re-read after each list refresh) |
appads.lines_changed, appads.status_changed, app.* |
onboarding |
your ad system domain, plus hosts and / or list_id (targets) and optional seller_ids — the onboarding monitor (Business plan and up, "Onboarding monitor" above) |
onboarding.line_added, onboarding.line_removed, onboarding.mismatch, appads.status_changed |
Event types and their data:
type |
When | data |
|---|---|---|
appads.lines_changed |
A new app-ads.txt body added or removed declarations (comments / order alone are not a change) | host, added_count, removed_count, added / removed (≤ 50 each: ad_system, account_id, relationship, cert_authority_id), truncated, content_hash |
appads.status_changed |
The file's status flipped between found, missing, invalid, no_website (fetch errors on our side are not flips) |
host, old_status, new_status, reason, http_status |
appads.stale_lines |
Declarations whose seller ID is missing from the ad system's sellers.json (id_missing) appeared since the previous check (hourly; the first check is the baseline) |
host, new_count, stale_total, lines (≤ 50: ad_system, account_id, relationship), truncated |
seller.removed |
Sellers disappeared from the ad system's sellers.json (one alert per sellers.json crawl) | ad_system, count, sellers (≤ 50: seller_id, name, domain, seller_type), truncated |
seller.changed |
A listed seller's type, is_confidential / is_passthrough flag or domain changed |
ad_system, count, changes (≤ 50: seller_id, name, domain, seller_type, field, old, new), truncated |
app.delisted · app.relisted |
The app left its store / came back | app {store, store_id, name}, old_status, new_status |
app.identity_changed |
The app's name, developer account or developer website changed | app, field (name | developer | developer_website), old, new (developer: {name, domain}) |
onboarding.line_added · onboarding.line_removed |
A new body of a target's app-ads.txt added / removed lines of your ad system (one alert per monitor) | host, ad_system, count, lines (≤ 50: account_id, relationship, own_seller_id; added lines also check_status) |
onboarding.mismatch |
An added line of yours is not one of your seller_ids, its seller id is not in your sellers.json, or its relationship contradicts the seller type |
the line_added data plus reasons (other_seller_id, not_in_sellers_json, type_mismatch) |
For app and list rules, app-ads.txt alerts also carry apps (≤ 10 watched apps using that file)
and apps_matched.
POST /alerts/rules{name?, subject_type, subject | list_id, event_types: [...] (or a comma list), webhooks?: true, enabled?: true}(onboarding monitors alsohosts,seller_ids: arrays or comma lists, ≤ 500 / ≤ 50) →201+ the rule (Locationheader). Every rule carriesoptions:{hosts, seller_ids}for an onboarding monitor,nullotherwise.namedefaults to the subject.webhooks: falsekeeps the rule's alerts in the feed only. Validation errors are422witherror.details.PATCH /alerts/rules/{id}— any of the same fields;enabled: falsepauses the rule.DELETE /alerts/rules/{id}→204; its past alerts stay in the feed (rule_idthen points to no rule).GET /alerts/rules— paginated like the lists;meta.max_rules(Professional 50, Business 500, Enterprise 5,000) andmeta.event_types(valid types per subject).GET /alerts/events— withoutsince: the latestlimitalerts (default 50, ≤ 200), newest first, andmeta.next_cursor= the newest id. Withsince=<next_cursor>: alerts after it, oldest first; repeat withmeta.next_cursorwhilemeta.has_moreis true, then poll later with the same cursor. Filters:rule_id,type,unread=1. Each alert:id,type,title,rule_id,subject{type,value},occurred_at(when the change happened),created_at,read_at(set on the dashboard),data. Alerts are kept 90 days.
Rules and alerts of another account answer 404, exactly like ids that do not exist.
Webhooks#
Every alert of a rule with webhooks: true is sent as an HTTP POST to each enabled endpoint of the
account (optionally only some event_types). Endpoints: dashboard Alerts → Webhooks, or:
POST /webhooks{url, description?, event_types?}→201; the response is the only timedata.secretis shown (whsec_+ 48 hex characters). Store it like a password.GET /webhooks— your endpoints (enabled,disabled_reason,consecutive_failures,last_success_at,last_failure_at; never the secret);meta.max_endpoints(Professional 2, Business 10, Enterprise 50).DELETE /webhooks/{id}→204.- On the dashboard: enable / disable an endpoint, send a test event (
webhook.test, delivered within about a minute, one attempt) and see the last 50 deliveries with their HTTP status codes.
URL rules: https on port 443 only, a public DNS name (no IP address, no localhost or internal
names, no user name / password in the URL). Deliveries are made from Stoqlab's servers through a
guard that resolves the name and refuses private, loopback, link-local, metadata and other
non-public addresses at connection time; redirects are not followed (a 3xx is a failed
attempt).
Request: Content-Type: application/json, body ≤ 64 KiB (longer item lists are cut and
data.truncated is true), User-Agent: Stoqlab-Webhooks/1.0, and the headers:
| Header | Value |
|---|---|
Stoqlab-Event |
the event type, e.g. appads.lines_changed |
Stoqlab-Delivery |
a UUID, the same on every retry of this delivery (use it to de-duplicate) |
Stoqlab-Timestamp |
Unix time of this attempt (seconds) |
Stoqlab-Signature |
t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the endpoint secret> |
Body (the same on every attempt):
{
"id": 1842,
"type": "seller.removed",
"created_at": "2026-10-14T12:05:11Z",
"occurred_at": "2026-10-14T11:58:40Z",
"title": "applovin.com: 2 seller(s) removed from sellers.json",
"rule": { "id": 7, "name": "AppLovin sellers" },
"rule_ids": [7],
"subject": { "type": "network", "value": "applovin.com" },
"data": { "ad_system": "applovin.com", "count": 2, "truncated": false,
"sellers": [ { "seller_id": "a1b2", "name": "Example Games", "domain": "example.com", "seller_type": "PUBLISHER" } ] }
}
Verify every request before trusting it: recompute the HMAC over the raw body bytes (before any JSON parsing), compare in constant time, and reject timestamps older than 5 minutes (replays).
import hashlib, hmac, time
def verify(secret: str, signature_header: str, raw_body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
ts = int(parts.get("t", "0"))
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
function stoqlab_verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool {
parse_str(str_replace(',', '&', $header), $p);
$ts = (int) ($p['t'] ?? 0);
if (abs(time() - $ts) > $tolerance) return false;
return hash_equals(hash_hmac('sha256', $ts.'.'.$rawBody, $secret), (string) ($p['v1'] ?? ''));
}
// Node.js (express.raw({ type: "application/json" }) keeps the raw body)
const crypto = require("crypto");
function verify(secret, header, rawBody, tolerance = 300) {
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=", 2)));
const ts = Number(p.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex");
return p.v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}
Test vector: secret whsec_0123456789abcdef0123456789abcdef0123456789abcdef, timestamp 1760443200,
body {"id":1,"type":"app.delisted"} → v1=45a68aa26f02b08d44ffa3191485fa016c16a1d244dca185a5f52ccb6f043616.
Answer 2xx within 10 seconds (do the work afterwards). Anything else — another status, a
timeout, a refused connection — is retried after 1 min, 5 min, 30 min, 2 h and 6 h; after the
sixth failed attempt the delivery is marked failed. Deliveries to one endpoint are sent one at a
time, oldest first (not guaranteed in order across retries: use id / occurred_at). After 50
failed attempts in a row the endpoint is disabled (disabled_reason: "failures") and its waiting
deliveries are dropped; re-enable it on the dashboard once it is fixed. Delivery logs are kept 30 days.
Secret storage (why the secret is not hashed): the signature is an HMAC, so the sender needs the secret itself; Stoqlab keeps it in its database (never in logs, never returned after creation). Rotate it by creating a new endpoint and deleting the old one.
Alert channels#
Alerts can also be posted to chat or sent by email, without running a server: dashboard
Alerts → Email & chat (Email, Slack, Teams, Telegram). A channel receives the same events as a webhook (same rules, event_types filter,
retries after 1 min, 5 min, 30 min, 2 h and 6 h, disabled after 50 failures in a row), rendered for
the chat app, each with an Open in Stoqlab link to the alert feed:
| Channel | What you paste | Message format |
|---|---|---|
| Slack | an incoming webhook URL (https://hooks.slack.com/services/T…/B…/…) |
Block Kit: title, facts, up to 8 items, button |
| Microsoft Teams | the URL of a Teams Workflows webhook ("Post to a channel when a webhook request is received"; https://….environment.api.powerplatform.com/…, older flows https://prod-NN.<region>.logic.azure.com/…, retired connectors https://<tenant>.webhook.office.com/…) |
Adaptive Card 1.4 |
| Telegram | a bot token from @BotFather and the chat id (-100… for groups and channels) or @channelname; add the bot to the chat first |
HTML message |
| nothing: pick your own address or a team member's (confirmed addresses only) and the delivery: each alert, a daily digest or a weekly digest (Mondays) | branded email from noreply@stoqlab.com with an unsubscribe link |
- The webhook URL and the bot token are credentials: they are stored encrypted and never shown again (the dashboard shows the host and the first characters of the ids). To change one, add the channel again and delete the old one.
- Messages are only ever sent to the official hosts above (
hooks.slack.com,*.environment.api.powerplatform.com,*.logic.azure.com,*.webhook.office.com,api.telegram.org), over https, without following redirects. - Send test message posts a test within about a minute. A
400,401,403,404or410answer (wrong URL or token, chat not found, bot removed, channel archived) is not retried; the reason is shown on the channel page. - Email: "each alert" sends at most 6 emails per address per hour (more alerts are grouped
into the next email); digests arrive at 07:00 UTC. Every email has an unsubscribe link (one click,
also as a
List-Unsubscribeheader); the channel is then shown as unsubscribed and can be enabled again from the dashboard. - Channels count towards
meta.max_endpointstogether with webhooks (GET /webhooksreturnsmeta.endpoints_used); they are managed on the dashboard only and do not appear inGET /webhooks.
Live CSV URLs#
A live CSV URL serves the current CSV of a saved list, or of a saved export (filters, columns and sort), every time it is opened, with no API key: the URL itself is the credential. Create one from the Export… dialog of a list or of a filter (Create live CSV URL), manage them at Lists → Live CSV URLs.
=IMPORTDATA("https://api.stoqlab.com/v1/live/slc_…csv")
- Google Sheets: paste the formula in a cell (Sheets refreshes
IMPORTDATAabout once an hour). Excel: Data → From Web. Looker Studio, Airtable and scripts: any HTTP GET. GET /v1/live/{token}.csv→200 text/csv, the export headers (X-Total-Count,X-Truncated,X-Row-Limit,X-Export-Quota-Remaining). Unknown or revoked URL:404.- Every fetch is an export of the account that created the URL: at most your plan's export rows
and at most 50,000 rows per fetch, counted against the plan's daily export quota and the team's
daily data quota (
429 export_quota_exceeded/row_quota_exceededwhen used up, like every other export). One export of an account runs at a time (429 export_in_progress). Each fetch carriesX-Export-Id(§3.12). - Limits: 60 fetches per URL and hour, 120 per client address and minute (Google Sheets fetches from shared Google addresses) (
429+Retry-After); 3 URLs on Explorer, 25 on Professional, 100 on Business, 500 on Enterprise. - The URL is shown once. Rotate issues a new one (the old one stops working at once);
Revoke turns it off. Deleting a list deletes its URLs. A disabled account's URLs answer
404. - Columns are the export columns of §3.16 (never personal contact data); text cells starting with
=,+,-,@, a tab or a carriage return get a leading'so spreadsheets do not run them as formulas.
Export to CRM (HubSpot companies)#
On the dashboard, the Export… dialog of a list offers the list's developers as a HubSpot
companies import file: one row per developer (company), with the columns Company name,
Company domain name, Website URL, Description (HubSpot maps these by itself and de-duplicates
companies on the domain) and Stoqlab apps in list, Stoqlab stores, Stoqlab top app,
Stoqlab top app installs (min), Stoqlab app-ads.txt status, Stoqlab developer page (map them to
custom properties or skip them in the import wizard). Salesforce's Data Import Wizard maps the same
file to Account Name, Website and Description. Company facts only: no personal contact data. The apps
read count against the export quota.
Dataset delivery to your bucket#
On the Enterprise Data plan the daily dataset files can be pushed to your own S3-compatible
bucket (dashboard Datasets → Deliver to your bucket): Amazon S3, Cloudflare R2, Backblaze B2,
Google Cloud Storage (with HMAC interoperability keys, endpoint https://storage.googleapis.com)
and other S3-compatible stores.
- Once a new day is published, every file goes to
<prefix>/YYYY-MM-DD/<file>, withmanifest.jsonlast: when you see the manifest, the day is complete. Large files are sent as multipart uploads. - Give Stoqlab a key that can only write to that bucket:
s3:PutObject(pluss3:GetObjectands3:DeleteObjecton<prefix>/.stoqlab-connection-testfor the connection test; multipart uploads needs3:AbortMultipartUpload). The secret key is stored encrypted and never shown again. - Test connection writes, reads back and deletes
<prefix>/.stoqlab-connection-testwithin about a minute and shows the result (or the S3 error code, e.g.AccessDenied,SignatureDoesNotMatch). - A failed upload is retried after 15 minutes, then 30 minutes, 1 hour … up to every 6 hours, and the error is shown on the page. Only the newest day is pushed; files already in your bucket are never deleted.
- The endpoint must be a public
httpshost; uploads go through the same address guard as webhooks (no private or internal addresses, redirects are not followed).
4. check_status — what each value means#
Every app-ads.txt line and every supply-tree edge gets one of these. Severity: error means the
line is very likely wrong and is costing money or trust; warning means it can't be verified;
ok / unchecked are neutral.
| Value | Severity | Plain English | Publisher: what to do | Agency / buyer: what it means |
|---|---|---|---|---|
ok |
— | The ad system's sellers.json lists this account, and its type matches the line (DIRECT ↔ PUBLISHER, RESELLER ↔ INTERMEDIARY). | Nothing. | Declared path is consistent. |
id_missing |
error | The account ID is not in (or was removed from) the ad system's sellers.json. Most often a stale line: the account was closed, or the ID has a typo. | Check the ID with the network; remove the line if the partnership ended. Stale lines invite spoofing. | Treat inventory on this path as unverified; many DSPs filter it automatically. |
direct_but_intermediary |
error | The app says DIRECT (“I own this account”) but sellers.json says the account belongs to an intermediary (a reseller). | Change the line to RESELLER, or ask the network to fix the seller type if you really own the account. | The publisher is overstating its relationship; price the path as resold. |
reseller_but_publisher |
error | The app says RESELLER but sellers.json lists the account as a publisher — i.e. someone's own direct account. | If it is your own account, mark it DIRECT. If it belongs to another publisher, ask why it is in your file. | The "reseller" is actually a publisher account — a common sign of copy-pasted lines or arbitrage. |
both |
warning | sellers.json lists the account as BOTH publisher and intermediary, so the declared relationship can't be confirmed either way. | Usually fine; confirm the relationship with the partner. | Ambiguous; rely on other signals (schain, deal terms). |
confidential |
warning | The seller is marked confidential in sellers.json — its name and domain are hidden. | Nothing you can change; ask the partner if transparency matters to your buyers. | You can't see who is behind the account; some buyers discount or block confidential sellers. |
no_sellers_json |
warning | The ad system doesn't publish a usable sellers.json (missing, invalid or unreachable), so nothing can be verified. | Ask the network to publish one, or reconsider the partner. | No way to verify any path through this system. |
unchecked |
— | Not cross-checked yet (new line, or the sellers.json hasn't been fetched yet). Usually resolves within a day. | Wait for the next crawl. | Not yet known. |
DIRECT vs RESELLER, in one line: DIRECT means the publisher (or its parent company) controls the account at the ad system and gets paid directly; RESELLER means a third party sells the inventory on the publisher's behalf. sellers.json is the ad system's side of the same story — the checks above are simply "do both sides agree?".
4b. verification — both directions#
check_status asks one question: does the ad system's sellers.json list the declared account, with
a type that fits the line? verification adds the reverse one: does that sellers.json entry point
back? A DIRECT account should belong to the publisher; a RESELLER account to a real intermediary.
Domains are compared by registrable domain (Public Suffix List: games.studio.com → studio.com;
a.github.io and b.github.io are different owners), case-insensitively.
| Value | Plain English | What to do |
|---|---|---|
verified_owner |
Confirmed both ways: DIRECT, sellers.json lists a PUBLISHER/BOTH account whose domain is the publisher's (the app-ads.txt host, OWNERDOMAIN, or the developer website / domain of the apps using the file). |
Nothing. |
verified_reseller |
Confirmed both ways: RESELLER, sellers.json lists an INTERMEDIARY/BOTH account whose domain is an ad system Stoqlab tracks, the file's MANAGERDOMAIN, the line's own ad system, or the publisher. |
Nothing. |
domain_mismatch |
Red flag. DIRECT, but sellers.json says the account belongs to another domain: someone else's account (copied from another file, or a misrepresentation), a stale line — or a parent / holding company whose domain differs. | Publisher: check whose account it is; if it is a parent company, add OWNERDOMAIN= with the domain sellers.json uses. Buyer: treat as unverified. |
seller_domain_missing |
The account is listed (not confidential) but without a usable domain, so its owner can't be confirmed. |
Ask the network to add the domain. |
intermediary_unknown |
RESELLER account listed as an intermediary, but its domain is not an ad system we know. | Usually a small reseller; confirm the partner. |
confidential, id_missing, direct_but_intermediary, reseller_but_publisher, no_sellers_json, unchecked |
The forward check already decides (§4). | See §4. |
Score. confirmed = verified_owner + verified_reseller; score = confirmed / checked (lines
minus unchecked), in whole percent. Lines whose ad system has no sellers.json count as not
confirmed: nothing can confirm them.
TAG ID (field 4). Each ad system's certification authority ID is taken from its sellers.json
identifiers (TAG-ID), else from the value most of its declarations carry. A line with another
value gets cert_check: mismatch — a warning only (field 4 is optional and buyers rarely rely on it).
4c. Supply path score and chain depth#
Every app-ads.txt file (so every app using it) has a supply path score, 0–100, computed by the workers right after the supply verification (§4b), and a chain depth from its declared supply tree (§3.3). The formula is published at stoqlab.com/methodology#path-score:
| Part | Weight | Value in [0, 1] |
|---|---|---|
direct |
25 | DIRECT lines / lines |
verified |
30 | lines confirmed both ways (verified_owner + verified_reseller) / checked lines; 0 when none is checked |
sellers |
20 | 1 − (lines whose seller is not in sellers.json + lines whose network has no sellers.json) / lines |
resellers |
10 | 1 up to 5 distinct networks declared RESELLER, falling linearly to 0 at 50 |
depth |
10 | 1 − (average chain depth − 1) / 3 |
transparency |
5 | 1 − confidential sellers / lines |
score = round(Σ weight × part); null for a file without lines. Chain depth of a line = the deepest
hop reachable below it in the declared tree (1 = nothing behind the seller, at most 4); the file's
chain_depth has the max, the avg and a histogram (lines per depth).
Where it appears (additive fields):
GET /apps/{app}→meta.supply_path{score,band(high 70–100, medium 40–69, low 0–39),computed_at,version,chain_depth,parts,counts};data.appads.path_score.GET /apps,/networks/{d}/apps,/sellers/{d}/{id}/apps, list and developer app rows →appads.path_scorenext toappads.verification_score.GET /networks/{domain}→meta.supply_path{avg_score,band,files,apps,scored_apps,avg_depth,max_depth,computed_at}: the average over the apps whose app-ads.txt names the network, weighted by app, recomputed daily.- MCP
get_app→appads.supply_path; app rows ofsearch_apps/apps_by_network→appads.path_score. - Dashboard: a "Supply path score" card on the app overview, a column with a filter (
path=high|medium|low|none) and a sort (sort=path_desc|path_asc) on the apps list, the average on network pages.
Plans: the score (and the network average) is returned to every plan. The details — chain_depth,
parts, counts, a network's avg_depth / max_depth — come from the supply tree and need plan
feature tree (Professional and up); without it they are null and meta.plan_gated.supply_path is
"tree".
5. Errors#
Every non-2xx response has the same shape:
{ "error": { "code": "not_found", "message": "App android/com.example.unknown is not tracked." } }
Branch on code (stable); show message to humans (wording may change).
| HTTP | code |
When | What to do |
|---|---|---|---|
| 401 | unauthenticated |
Missing, malformed, revoked or expired token. An expired key adds error.reason: "token_expired" and error.expired_at |
Check the Authorization: Bearer … header and the token in the dashboard; on token_expired, create (or rotate) a key |
| 403 | forbidden |
Token not allowed to do this (error.required_abilities names the missing ability) |
Use a key of the right type (§1) |
| 404 | not_found |
App not tracked, ad system unknown, seller never listed, wrong path, or store not ios/android |
Check identifiers; app requests for untracked apps can be sent to support |
| 405 | method_not_allowed |
A method the endpoint does not take (everything is GET except lists, POST /checks, alert rules, webhooks, POST /verify/seats and POST /schain/validate) |
Use the documented method |
| 422 | validation_failed |
Bad query parameter (e.g. per_page > 100 — 1,000 on /apps with fields and bulk_api, store=windows, an invalid since cursor, an unknown field in fields, a cursor used with other filters) |
Fix the parameter; error.details lists messages per field |
| 429 | rate_limited |
Over the per-minute limit, a list re-computed less than 10 min ago, or the hourly list-change budget | Wait Retry-After seconds |
| 429 | export_in_progress / exports_busy / export_quota_exceeded |
Another export of yours is running / every export slot is busy / today's export rows are used up | Wait Retry-After seconds |
| 429 | row_quota_exceeded / key_row_quota_exceeded |
Your team's daily data quota (§2) / this key's own daily row limit is used up | Wait until 00:00 UTC (Retry-After, X-RateLimit-Rows-Reset); ask for the Bulk API & data licence (§2d) |
| 429 | check_rate_limited / checks_busy |
Over 30 domain checks this hour / too many checks waiting server-wide | Wait Retry-After seconds |
| 403 | plan_required |
The endpoint is part of a plan your account does not have (§2b): error.feature, error.plan = error.required_plan, error.current_plan, error.upgrade_url. Also per_page > 100, format=ndjson or a walk deeper than 10,000 rows without bulk_api (§2d) |
Contact us to upgrade |
| 403 | invalid_signature / url_expired |
A dataset download URL that was altered / is past download_expires_at |
Get a fresh URL from GET /datasets/{date}/{file} |
| 429 | downloads_busy |
Every dataset download slot is busy | Wait Retry-After seconds |
| 500 | server_error |
Bug on our side | Retry with exponential backoff; contact us if it persists |
Example 422:
{
"error": {
"code": "validation_failed",
"message": "The per page field must not be greater than 100.",
"details": { "per_page": ["The per page field must not be greater than 100."] }
}
}
6. Conventions#
- Timestamps: ISO 8601 in UTC (
2026-10-02T04:13:55+00:00; alerts, webhooks and key expiry use the equivalent2026-10-02T04:13:55Z); dates asYYYY-MM-DD. - Nulls: unknown fields are
null, never omitted. New fields may be added at any time — ignore ones you don't know. Removing or renaming a field means a new API version (/v2). - Pagination:
page(from 1) andper_page(1–100, default 25). Followlinks.next.GET /appsalso pages with a cursor (cursor=, up to 1,000 per page withfieldsandbulk_api, §3.1b). Withoutbulk_api, a list is read at most 10,000 rows deep (§2d). - Fields:
fields=/include=choose what an app list, app detail, developer or network returns (§3.1a). - Freshness: stores, app-ads.txt and sellers.json are crawled roughly daily. Check
last_crawled_at,appads.fetched_at, networkfetched_atand treecomputed_at. - Conditional requests: successful JSON
GETresponses carry anETag. Send it back inIf-None-Matchand an unchanged response comes back as304 Not Modifiedwith no body (CSV exports and errors have noETag). - Health:
GET https://api.stoqlab.com/healthz→{"ok": true}(no auth, outside/v1).
7. Recipes#
Bundles with ads available in a country (every one, as a file):
# 1. how many, and a first look (Google Play, Germany, shows ads, likely available)
curl -s "https://api.stoqlab.com/v1/apps?country=DE&ads=yes&store=android&fields=bundle_id,name,installs" \
-H "Authorization: Bearer $STOQLAB_TOKEN" | jq '.meta.total, .data[:3]'
# 2. the whole list as CSV (10,000 rows per request; continue with X-Next-Cursor)
curl -s -D h.txt -o de-ads.csv \
"https://api.stoqlab.com/v1/apps?country=DE&ads=yes&store=android&fields=bundle_id,name,installs&format=csv" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
next=$(grep -i '^x-next-cursor:' h.txt | cut -d' ' -f2 | tr -d '\r')
curl -s -o de-ads-2.csv \
"https://api.stoqlab.com/v1/apps?country=DE&ads=yes&store=android&fields=bundle_id,name,installs&format=csv&cursor=$next" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
# iOS: app-ads.txt is the main ads signal; only apps checked or rated in Germany
curl -s -o de-ios.ndjson \
"https://api.stoqlab.com/v1/apps?country=DE&availability=confirmed&ads=yes&store=ios&fields=bundle_id,store_id,name,country_availability&format=ndjson" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
# apps whose audience is in the country: at least 5 % of their ratings in Germany
curl -s "https://api.stoqlab.com/v1/apps?country=DE&min_audience_share=0.05&sort=audience_share&fields=bundle_id,name,country_availability.audience_share" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
ads=yes = the store's "contains ads" flag or an app-ads.txt with authorised sellers;
country without availability = "not known to be unavailable" (availability=confirmed keeps only
apps checked or rated in that storefront: precise; every app with an app-ads.txt is
checked in the 30 largest ad markets every 30 days and every app in its home storefront). The same
filters work in GET /exports (one file up to your plan's rows per export; there country means
confirmed unless you pass availability=likely), in saved lists and in live CSV URLs. MCP:
search_apps with country, ads and fields.
Find every stale line in an app (errors only):
curl -s https://api.stoqlab.com/v1/apps/android/com.example.puzzle/supply-tree \
-H "Authorization: Bearer $STOQLAB_TOKEN" \
| jq '[.data.edges[] | select(.check_status | IN("id_missing","direct_but_intermediary","reseller_but_publisher"))
| {ad_system: .ad_system.domain, account_id, relationship, check_status}]'
Was a reseller dropped by the network? — call /sellers/{domain}/{id} and look at removed_at
and the removed event; /sellers/{domain}/{id}/apps lists the apps still declaring it.
Every app still declaring dead Google accounts, as a spreadsheet:
curl -s -o stale-google.csv "https://api.stoqlab.com/v1/networks/google.com/stale-sellers?format=csv" \
-H "Authorization: Bearer $STOQLAB_TOKEN"
Get told when your partners change — poll /v1/changes?types=sellers,appads (§3.13) and filter
the events on the ad systems / domains you care about.
Did the app-ads.txt change this week? — compare appads.content_hash from the app detail with
the previous one you stored, or read /appads-history.
Python, all pages:
import os, time, requests
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['STOQLAB_TOKEN']}"
url = "https://api.stoqlab.com/v1/apps?q=puzzle&per_page=100"
while url:
r = s.get(url, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
body = r.json()
for app in body["data"]:
print(app["store"], app["store_id"], app["name"])
url = body["links"]["next"]
8. MCP server (AI assistants)#
The same data is available to Claude and other AI assistants through a remote
Model Context Protocol server — full guide: MCP.md.
- URL:
https://api.stoqlab.com/mcp(Streamable HTTP; protocol 2026-07-28 and theinitialize-based 2025-11-25 / 2025-06-18 / 2025-03-26). - Auth: an MCP only key (§1; read-only API keys work too), as
Authorization: Bearer <token>(no OAuth). - Rate limits: §2, shared with REST — every MCP message counts as one request.
- Plans: the same features as REST (§2b); a tool outside your plan returns a
plan_required:tool error. - Read-only tools:
search_apps,get_app,get_supply_tree,apps_by_network,get_seller,network_share,growth_leaderboard,recent_changes,audit_domain(the stored audit of §3.17; it never starts a check),recent_alerts(your own alerts, "Alerts" above),verify_seats(§3.18) andvalidate_schain(§3.19). They return the API's public fields only, bounded per call (about 40 KB) withnext_cursorpagination.
# Claude Code
claude mcp add --transport http stoqlab https://api.stoqlab.com/mcp \
--header "Authorization: Bearer $STOQLAB_TOKEN"
In Claude.ai / Desktop: Settings → Connectors → Add custom connector, URL above, request
header Authorization: Bearer <token> (organization Owners; beta).