Skip to content
Stoqlab

Documentation

Stoqlab API v1 — guide

Stoqlab API v1 reference: authentication, rate limits, every endpoint for apps, app-ads.txt, sellers.json, supply trees, change feed, lists, exports and domain checks, with curl examples.

On this page

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#

  1. Sign in at https://app.stoqlab.com → API keys → create a token.
  2. Copy it straight away; it is shown only once. It looks like 12|Xk3p… — send the whole string, including the number and the |.
  3. 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/me and in X-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 gets 429 until 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_limited plus Retry-After (seconds to wait) and X-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 429 and 5xx.

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 data is 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: a plan_required: tool error) to plans without the feature, format=csv list exports count against the daily export quota, and meta.growth changes need history. 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 forbidden otherwise);
  • 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 /apps pages of up to 1,000 rows with a light fields= list (§3.1b; other plans: up to 100);
  • format=ndjson on GET /apps (format=csv works 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_page beyond 10,000 on any list, or a cursor walk past 10,000 rows on /apps, answers 403 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 id in https://apps.apple.com/app/id1234567890.
  • Android: the package name — the id= value in https://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): score is the share (%) of checked lines confirmed both ways, domain_mismatch counts DIRECT accounts that sellers.json attributes to another domain, cert_mismatch lines whose field 4 differs from the ad system's known TAG ID. data.appads.verification_score / verified_at are 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 children are the resellers of that line (level 2, 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.via lists 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.json domain is the parent line's ad system (e.g. pubnative.net account X says INTERMEDIARY start.io → it hangs under the start.io DIRECT 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, with unmatched.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.intermediary names 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. branches gives total / page / last_page, links.next the 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 own check_status, verification, resellers; their children are empty (children_total 0);
  • 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's parent_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_accounts and accounts: the DIRECT accounts by where that ad system's own sellers.json puts them (publisher, of which owner_confirmed also carry one of the publisher's domains; both, intermediary, confidential, not_listed, no_sellers_json, unchecked), summed up in sellers_json_status (confirmed_publisher, publisher, intermediary, not_listed, no_sellers_json, confidential, unchecked when every account is in that bucket, else mixed);
  • resellers: the RESELLER lines branching under those accounts in the forward tree (all levels, same rules as §3.3a), and reseller_share, their part (0–1) of the file's RESELLER lines;
  • first_line_no and line_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, …; null on Google Play). The deltas and meta.growth only compare rows of the same storefront: a row from the fallback storefront gets null deltas instead of a fake jump. For ratings across countries use /countries.
# 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 has tracked: false, its store_id and links.store; its metrics are null. 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, or inferred — the US page was served at the app's last Google Play refresh before per-country recording began, availability_status unknown, available null: listed, offer not checked; null = metrics only or before October 2026), meta.totals.checked (countries with a real answer) and meta.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 iOS rating_count, score, price (in the storefront's currency, recorded from the App Store lookups of every storefront; null only for a storefront not looked up yet) and rating_count_delta_30d (two rows of the same country 30 days apart). meta.totals.rating_count sums the ratings over the available storefronts (≈ global iOS ratings). The per-country block (and category_rank of 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) with shared_count / collected_count (all of them), encrypted_in_transit, deletion_request, no_data_shared and shares_device_ids (the advertising ID; null when 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 /checks answers 202 with the request (id, domain, status, message, requested_at, started_at, finished_at, created, links.self, links.audit) and a Location header. 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: 200 with fresh: true, id: null and the result. Creating checks needs a key with lists:write (§1); following them and audits need read.
  • GET /checks/{id} — only requests made by your account (another id answers 404). result is null until status is done. failed means the check could not be completed on our side (message says so); ask again later.
  • GET /domains/{domain}/audit — 404 when we have never checked the host, 422 when the domain is refused (see below).
  • Limits: 30 new checks per hour per account (429 check_rate_limited, with Retry-After) and a server-wide queue cap (429 checks_busy: retry after a minute). Refused input (422 validation_failed with the reason): IP addresses, localhost and other single-label names, names over 253 characters, names without a public suffix (.example, .local), invalid host names (internationalized domains in their xn-- 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} — 302 to download_url; ?redirect=false answers {"data": {…file, download_url, download_expires_at}}. {file} is a name from the manifest or manifest.json; anything else is 404 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 after download_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 after Retry-After seconds.

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 status found | missing | invalid | no_website | error, or none = 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} or null, app {store, store_id} (the tracked app it comes from) or null, 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 is links.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 (null until 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 (always null: 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 / meta paginate the lines; meta.links.history. Untracked site: 404 not_found (check it with POST /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 = DIRECT or RESELLER; an invalid cursor is 422.

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; null when unknown), last_seen_at, last_checked_at, changed_at (last status change), flips, and for iOS rating_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, or live before 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 with from (ISO 8601; default 30 days ago), then pass meta.next_cursor as since (from and since together are 422). has_more: false means 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: available again, or still unavailable), 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 (6014 Games, 7012 Puzzle, … 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; null when 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, else null), 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/… is 404): 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 also hosts, seller_ids: arrays or comma lists, ≤ 500 / ≤ 50) → 201 + the rule (Location header). Every rule carries options: {hosts, seller_ids} for an onboarding monitor, null otherwise. name defaults to the subject. webhooks: false keeps the rule's alerts in the feed only. Validation errors are 422 with error.details.
  • PATCH /alerts/rules/{id} — any of the same fields; enabled: false pauses the rule.
  • DELETE /alerts/rules/{id} → 204; its past alerts stay in the feed (rule_id then points to no rule).
  • GET /alerts/rules — paginated like the lists; meta.max_rules (Professional 50, Business 500, Enterprise 5,000) and meta.event_types (valid types per subject).
  • GET /alerts/events — without since: the latest limit alerts (default 50, ≤ 200), newest first, and meta.next_cursor = the newest id. With since=<next_cursor>: alerts after it, oldest first; repeat with meta.next_cursor while meta.has_more is 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 time data.secret is 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
Email 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, 404 or 410 answer (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-Unsubscribe header); the channel is then shown as unsubscribed and can be enabled again from the dashboard.
  • Channels count towards meta.max_endpoints together with webhooks (GET /webhooks returns meta.endpoints_used); they are managed on the dashboard only and do not appear in GET /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 IMPORTDATA about 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_exceeded when used up, like every other export). One export of an account runs at a time (429 export_in_progress). Each fetch carries X-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>, with manifest.json last: 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 (plus s3:GetObject and s3:DeleteObject on <prefix>/.stoqlab-connection-test for the connection test; multipart uploads need s3:AbortMultipartUpload). The secret key is stored encrypted and never shown again.
  • Test connection writes, reads back and deletes <prefix>/.stoqlab-connection-test within 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 https host; 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_score next to appads.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 of search_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 equivalent 2026-10-02T04:13:55Z); dates as YYYY-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) and per_page (1–100, default 25). Follow links.next. GET /apps also pages with a cursor (cursor=, up to 1,000 per page with fields and bulk_api, §3.1b). Without bulk_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, network fetched_at and tree computed_at.
  • Conditional requests: successful JSON GET responses carry an ETag. Send it back in If-None-Match and an unchanged response comes back as 304 Not Modified with no body (CSV exports and errors have no ETag).
  • 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 the initialize-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) and validate_schain (§3.19). They return the API's public fields only, bounded per call (about 40 KB) with next_cursor pagination.
# 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).

Want an API key?

Every plan starts with a short conversation and sales-assisted onboarding.