# Ads.txt & App-ads.txt Validator + sellers.json Check (`capable_santolina/ads-txt-auditor`) Actor

Validate ads.txt or app-ads.txt for any list of domains against the IAB Tech Lab 1.1 spec: syntax errors, duplicates, DIRECT/RESELLER conflicts, redirect and content-type problems, plus seller ID checks against exchanges' public sellers.json. Respects robots.txt; no login or proxies.

- **URL**: https://apify.com/capable_santolina/ads-txt-auditor.md
- **Developed by:** [Doyle Pounders](https://apify.com/capable_santolina) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 domain auditeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Ads.txt & App-ads.txt Auditor with sellers.json Cross-check

Check any list of publisher or app-developer domains for **ads.txt / app-ads.txt problems that cost ad revenue**: syntax errors, invalid DIRECT/RESELLER values, duplicates, conflicting relationships, wrong Google IDs, bad redirects, wrong content type. It also checks each seller account ID against the ad system's **public sellers.json**: is the ID listed at all, and does DIRECT/RESELLER match the seller type?

- Follows the **IAB Tech Lab ads.txt 1.1** spec ([spec page](https://iabtechlab.com/ads-txt/), [PDF](https://iabtechlab.com/wp-content/uploads/2022/04/Ads.txt-1.1.pdf)) and **sellers.json 1.0** ([spec page](https://iabtechlab.com/sellers-json/)).
- **Respects robots.txt** (RFC 9309) on every host it contacts, including redirect targets and sellers.json hosts. A disallowed file is reported as `blocked_by_robots` and is never fetched or charged.
- One row per domain with a heuristic **health score**, counts, findings with **line numbers**, and a sellers.json summary. Every finding for every domain also goes to a `FINDINGS.csv` file.
- No login, no proxies, no personal data. Seller names and contact emails from sellers.json are **never stored or output**.

> Independent tool. Not affiliated with or endorsed by IAB Tech Lab, Google, or any ad exchange. "ads.txt" and "sellers.json" are IAB Tech Lab specifications; product and company names belong to their owners.

> Current as of 2026-10-26. Behaviour, limits and data-source rules were checked against the sources linked below on that date.

### Who it's for

- **Publishers and app developers:** catch broken lines before they block demand. Google's [Ad Manager help](https://support.google.com/admanager/answer/7673979) says an ads.txt "will be ignored by crawlers that respect the robots.txt standard" when robots.txt disallows it. This Actor sees it the same way.
- **Ad ops and monetisation agencies:** audit every client domain in one run, then re-run on a schedule.
- **SSPs, exchanges and supply-path analysts:** see which DIRECT lines point at accounts that sellers.json lists as INTERMEDIARY, or at IDs that aren't listed at all.

### Real run (2026-10-27, 8 inputs, `maxSellersJsonMb: 150`)

Input (`demo/INPUT.json`):

```json
{
  "domains": [
    "nytimes.com",
    "cnn.com",
    "washingtonpost.com",
    "forbes.com",
    "bbc.com",
    "wikipedia.org",
    "example.com",
    "localhost"
  ],
  "checkSellersJson": true,
  "maxSellersJsonMb": 150
}
```

| Domain | File status | Health (heuristic) | Records | Errors | Warnings | sellers.json match % |
|---|---|---|---|---|---|---|
| nytimes.com | valid | 93 | 30 | 0 | 7 | 90 |
| cnn.com | valid | 70 | 855 | 0 | 114 | 95.7 |
| washingtonpost.com | has_errors | 77 | 203 | 1 | 18 | 95.8 |
| forbes.com | valid | 80 | 88 | 0 | 20 | 83.7 |
| bbc.com | valid | 95 | 54 | 0 | 5 | 96.3 |
| wikipedia.org | not_found | – | – | – | – | – |
| example.com | not_found | – | – | – | – | – |
| localhost | invalid_input | – | – | – | – | – |

The run took 24.5 s and made 116 HTTP requests (about 15 s of that was waiting on aps.amazon.com's HTTP 503 retries). It streamed 12 sellers.json files, including Google's (101.73 MB, 954,129 sellers). indexexchange.com's sellers.json is disallowed by its robots.txt (`blocked_by_robots`), and aps.amazon.com's robots.txt returned HTTP 503 (`robots_unreachable`). Both were skipped, as RFC 9309 requires. yieldmo.com answered HTTP 202 with a body that isn't JSON (`invalid_json`).

Example findings from the same run (`FINDINGS.csv`, washingtonpost.com):

| Line | Severity | Code | Message | Line content |
|---|---|---|---|---|
| 68 | error | `INVALID_RELATIONSHIP` | Field 3 must be DIRECT or RESELLER (got "DIRECT/RESELLER"). | `conversantmedia.com, 101272, DIRECT/RESELLER, 03113cd04947736d` |
| 100 | warning | `GOOGLE_CERT_ID` | Google's certification authority ID is f08c47fec0942fa0. | `google.com, pub-1493799638656017, RESELLER, f08c47fec0942fa` |
| 100 | warning | `CONFLICTING_RELATIONSHIP` | The same ad system + account ID is listed as both DIRECT and RESELLER. | `google.com, pub-1493799638656017, RESELLER, f08c47fec0942fa` |

### Output

One dataset row per input. This is the full `bbc.com` row from the run above, unedited:

```json
{
  "domain": "bbc.com",
  "input": "bbc.com",
  "fileType": "ads.txt",
  "status": "ok",
  "fileStatus": "valid",
  "healthScore": 95,
  "errors": 0,
  "warnings": 5,
  "infos": 0,
  "totalLines": 82,
  "blankLines": 23,
  "commentLines": 3,
  "recordCount": 54,
  "directCount": 39,
  "resellerCount": 15,
  "duplicateCount": 0,
  "uniqueAdSystems": 25,
  "placeholder": false,
  "findingCodes": {
    "SELLER_TYPE_MISMATCH": 4,
    "SELLER_ID_NOT_IN_SELLERS_JSON": 1
  },
  "variables": {
    "contact": true,
    "ownerDomain": "bbc.com",
    "managerDomains": [],
    "subdomains": [],
    "inventoryPartnerDomains": []
  },
  "topAdSystems": [
    {
      "domain": "openx.com",
      "records": 7
    },
    {
      "domain": "pubmatic.com",
      "records": 5
    },
    {
      "domain": "triplelift.com",
      "records": 5
    },
    {
      "domain": "indexexchange.com",
      "records": 4
    },
    {
      "domain": "tritondigital.com",
      "records": 4
    },
    {
      "domain": "rubiconproject.com",
      "records": 3
    },
    {
      "domain": "appnexus.com",
      "records": 2
    },
    {
      "domain": "aps.amazon.com",
      "records": 2
    },
    {
      "domain": "liveintent.com",
      "records": 2
    },
    {
      "domain": "taboola.com",
      "records": 2
    }
  ],
  "sellersJson": {
    "recordsChecked": 27,
    "found": 26,
    "notFound": 1,
    "typeMismatch": 4,
    "confidential": 0,
    "domainDiffers": 0,
    "adSystemsChecked": [
      "appnexus.com",
      "google.com",
      "kargo.com",
      "media.net",
      "openx.com",
      "pubmatic.com",
      "rubiconproject.com",
      "teads.tv",
      "triplelift.com",
      "xandr.com"
    ],
    "adSystemsUnavailable": [
      {
        "adSystem": "aps.amazon.com",
        "status": "robots_unreachable"
      },
      {
        "adSystem": "indexexchange.com",
        "status": "blocked_by_robots"
      },
      {
        "adSystem": "yieldmo.com",
        "status": "invalid_json"
      }
    ],
    "adSystemsSkipped": 12,
    "matchRatePct": 96.3
  },
  "findings": [
    {
      "line": 27,
      "severity": "warning",
      "code": "SELLER_TYPE_MISMATCH",
      "message": "Listed as DIRECT, but sellers.json says INTERMEDIARY.",
      "content": "openx.com, 540222416, DIRECT",
      "source": "sellers.json"
    },
    {
      "line": 32,
      "severity": "warning",
      "code": "SELLER_TYPE_MISMATCH",
      "message": "Listed as DIRECT, but sellers.json says INTERMEDIARY.",
      "content": "pubmatic.com, 157339, DIRECT",
      "source": "sellers.json"
    },
    {
      "line": 36,
      "severity": "warning",
      "code": "SELLER_TYPE_MISMATCH",
      "message": "Listed as DIRECT, but sellers.json says INTERMEDIARY.",
      "content": "rubiconproject.com, 17264, DIRECT",
      "source": "sellers.json"
    },
    {
      "line": 50,
      "severity": "warning",
      "code": "SELLER_TYPE_MISMATCH",
      "message": "Listed as DIRECT, but sellers.json says INTERMEDIARY.",
      "content": "triplelift.com, 918-EB, DIRECT",
      "source": "sellers.json"
    },
    {
      "line": 52,
      "severity": "warning",
      "code": "SELLER_ID_NOT_IN_SELLERS_JSON",
      "message": "Account ID not found in triplelift.com's sellers.json.",
      "content": "triplelift.com, 14807-EB, DIRECT",
      "source": "sellers.json"
    }
  ],
  "findingsTruncated": false,
  "note": null,
  "http": {
    "requestedUrl": "https://bbc.com/ads.txt",
    "finalUrl": "https://www.bbc.com/ads.txt",
    "redirectChain": [
      "https://bbc.com/ads.txt",
      "https://www.bbc.com/ads.txt"
    ],
    "statusCode": 200,
    "contentType": "text/plain",
    "bytes": 3634
  },
  "checkedAt": "2026-10-27T03:17:43.159Z",
  "elapsedMs": 350
}
```

A domain without the file gives a free row:

```json
{
  "domain": "wikipedia.org",
  "input": "wikipedia.org",
  "fileType": "ads.txt",
  "status": "not_found",
  "fileStatus": "not_found",
  "note": "No ads.txt published (HTTP 404).",
  "http": {
    "requestedUrl": "https://wikipedia.org/ads.txt",
    "finalUrl": "https://en.wikipedia.org/ads.txt",
    "redirectChain": [
      "https://wikipedia.org/ads.txt",
      "https://www.wikipedia.org/ads.txt",
      "https://en.wikipedia.org/ads.txt"
    ],
    "statusCode": 404,
    "contentType": "text/html; charset=utf-8"
  },
  "checkedAt": "2026-10-27T02:48:07.532Z",
  "elapsedMs": 139
}
```

`status` is `ok` when a text file was retrieved and parsed (charged). Other values are free: `not_found` (404/410), `restricted` (401/403, often bot protection, which is never bypassed), `blocked_by_robots`, `robots_unreachable` (robots.txt returned 5xx, so RFC 9309 says skip), `redirect_violation`, `html_instead_of_text`, `unreachable`, `http_error`, `too_large`, `invalid_input` and `skipped` (not processed because your Max cost per run was reached). `fileStatus` is `valid` (no errors), `has_errors` or `invalid_content_type` for charged rows.

`sellersJson.adSystemsUnavailable` lists each ad system whose sellers.json could not be used, with its status: `not_found`, `http_error`, `blocked_by_robots`, `robots_unreachable`, `redirect_refused` (it redirected to an invalid or non-public URL, which is never followed), `too_large`, `invalid_json` or `error`.

Key-value store:

- `FINDINGS.csv`: every finding, one line each: domain, line, severity, code, message, line content.
- `SUMMARY`: status counts, per-domain scores, top finding codes and each sellers.json file's status and size.

### What is checked

**Errors:** HTML instead of text, wrong Content-Type (must be `text/plain`), fewer than 3 fields, URL or invalid domain in field 1, missing account ID or one with whitespace, relationship that isn't DIRECT/RESELLER, SUBDOMAIN not under the root domain, empty variables, a file with no records (ads.txt 1.1 asks for the `placeholder.example.com, placeholder, DIRECT, placeholder` line instead).

**Warnings:** duplicates, the same account as both DIRECT and RESELLER, more than 4 fields, Google lines that use `googlesyndication.com`/`doubleclick.net` instead of `google.com`, Google IDs not in `pub-` + 16-digit form, Google certification ID not `f08c47fec0942fa0` ([Google AdSense help](https://support.google.com/adsense/answer/12171612)), unknown variables, several OWNERDOMAINs or global MANAGERDOMAINs, invalid UTF-8.

**sellers.json cross-check:** `SELLER_ID_NOT_IN_SELLERS_JSON` and `SELLER_TYPE_MISMATCH` (warnings: DIRECT vs INTERMEDIARY, RESELLER vs PUBLISHER). Info: `SELLER_DOMAIN_DIFFERS` (DIRECT seller's domain ≠ OWNERDOMAIN/root, normal for multi-site groups) and `SELLER_CONFIDENTIAL`.

**Info:** placeholder record, no OWNERDOMAIN, UTF-8 BOM, lower-case variable names.

**Redirects:** any number of redirects are allowed inside the root domain, plus at most one hop to another domain. A second hop after leaving the root domain gives `redirect_violation`, as the IAB spec requires. Google's own crawler is stricter: it ignores the file after a second redirect even within the same domain ([Ad Manager help](https://support.google.com/admanager/answer/7673979)). Check `http.redirectChain` in each row if you need Google's stricter rule.

**Safety:** the Actor only fetches public http(s) URLs on ports 80/443. Redirects to other schemes (file:, data:, javascript:), malformed URLs, IP addresses, localhost, and names that resolve to private, loopback or link-local addresses are refused, and the row gets a free status instead of failing the run. The DNS check runs before each fetch, so it does not cover DNS rebinding between the check and the request.

**Health score** (our heuristic, not an IAB metric): 100 − min(60, 5 × errors) − min(30, warnings), and −20 for the wrong content type.

### Input

| Field | Default | Notes |
|---|---|---|
| `domains` | `["nytimes.com"]` | Domains or URLs. Each is reduced to its root domain (`www.bbc.co.uk/news` → `bbc.co.uk`). IPs, localhost and unknown TLDs are rejected. |
| `fileType` | `ads.txt` | Or `app-ads.txt` for app developers' sites. |
| `checkSellersJson` | `true` | Cross-check against sellers.json. |
| `sellersJsonMaxAdSystems` | 15 | The most-listed ad systems are checked first. |
| `maxSellersJsonMb` | 25 | Google's file is ~100 MB and is skipped by default. Set 150 to include it (adds ~10–15 s, est. from local tests). |
| `includeRecords` | `false` | Add every parsed record to each row. |
| `maxFindingsPerDomain` | 200 | The CSV always has all of them. |
| `requestTimeoutSecs` / `maxConcurrency` | 20 / 5 | Each domain is a different site, so concurrency doesn't increase load on any single site. |
| `contactInfo` | – | Optional. Appended to the User-Agent as `run-by:`. |

Empty input `{}` runs the default domain instead of failing.

### How it behaves

- User-Agent: `AdsTxtAuditorBot/0.1.0 (+https://apify.com/capable_santolina/ads-txt-auditor; pounderswork@mail.grokbot.com) apify-sdk/3 node/<version>`. The robots.txt product token is `AdsTxtAuditorBot`.
- One request per file, plus one robots.txt per host (cached). HTTPS is tried first, then HTTP. 429/503 responses are retried twice, honouring `Retry-After` up to 120 s.
- sellers.json files are **streamed**: only the IDs that appear in the audited files are kept. Peak memory with Google's 100 MB file was 139 MB RSS in a local test.
- Results show what each site served at that moment (`checkedAt` is UTC). CDN caches can lag behind recent edits.

### Pricing (pay per event)

`domain-audited` once per `status: ok` row and `sellers-json-check` once per domain where at least one record was cross-checked. All other rows are free. Run cost is capped by your **Max cost per run**: the Actor stops starting new domains when the next one wouldn't fit, and returns those domains as free `skipped` rows. Nothing is dropped. Prices are set in Console; see `PRICING.md`.

### Legal and data notes

- ads.txt and sellers.json are public files meant for automated crawling. IAB Tech Lab describes sellers.json as something that "enables buyers to verify the entities who are direct sellers, or intermediaries" and links an open-source ads.txt crawler for engineers ([sellers.json](https://iabtechlab.com/sellers-json/), [ads.txt](https://iabtechlab.com/ads-txt/)).
- robots.txt is honoured for every host. Bot walls (401/403/challenge pages) are reported, never bypassed. No proxies, no login.
- sellers.json `name` fields can identify individuals, so they are never stored. The `contact_email` field is never stored either. Only `seller_type`, `is_confidential` and `domain` are used, in memory only.
- Audit domains you own, manage or have a business reason to check. Some sites' own terms restrict commercial crawling even where robots.txt allows it. Respect those.
- Questions or takedown requests: pounderswork@mail.grokbot.com.
- The output is QA data, not proof of fraud or of contractual authorisation. Verify findings with the parties involved.

# Actor input Schema

## `domains` (type: `array`):

Publisher or app-developer domains to audit. Plain domains or URLs are accepted; each is reduced to its root domain (where the spec says the file must live). Duplicates are removed.

## `fileType` (type: `string`):

Which file to fetch from each domain's root.

## `checkSellersJson` (type: `boolean`):

Download each referenced ad system's public sellers.json (streamed, only matching IDs kept) and check that seller IDs exist and that DIRECT/RESELLER matches the seller type. Adds a separate charge per domain where the check ran.

## `sellersJsonMaxAdSystems` (type: `integer`):

Only the most frequently listed ad systems are checked, to keep runs fast. Each sellers.json is downloaded once per run.

## `maxSellersJsonMb` (type: `integer`):

Skip sellers.json files bigger than this. Default 25 MB covers most exchanges but skips Google's file (about 100 MB, which adds roughly 10-15 s and 100 MB of download to the run). Set 150 to include Google.

## `includeRecords` (type: `boolean`):

Add every parsed record (ad system, account ID, relationship, cert ID) to each dataset row. Can make rows large.

## `maxFindingsPerDomain` (type: `integer`):

Cap on findings listed inside each dataset row. The full list is always in the FINDINGS.csv key-value record.

## `requestTimeoutSecs` (type: `integer`):

Timeout per HTTP request.

## `maxConcurrency` (type: `integer`):

How many domains are fetched in parallel. Each domain is a different site, so this does not increase load on any single site.

## `contactInfo` (type: `string`):

Optional email or URL appended to the User-Agent as 'run-by', so site owners can reach whoever ran the audit. The Actor's own contact is always included.

## Actor input object example

```json
{
  "domains": [
    "nytimes.com",
    "cnn.com",
    "bbc.com"
  ],
  "fileType": "ads.txt",
  "checkSellersJson": true,
  "sellersJsonMaxAdSystems": 15,
  "maxSellersJsonMb": 25,
  "includeRecords": false,
  "maxFindingsPerDomain": 200,
  "requestTimeoutSecs": 20,
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

All dataset rows (one per input). Opens on the 'overview' view; switch views in Console.

## `summary` (type: `string`):

JSON run summary: counts by file status, per-domain health scores, top finding codes, sellers.json fetch status per ad system, request count and the User-Agent used.

## `findingsCsv` (type: `string`):

One row per finding (domain, line, severity, code, message, content) for spreadsheets.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "domains": [
        "nytimes.com",
        "cnn.com",
        "bbc.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("capable_santolina/ads-txt-auditor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "domains": [
        "nytimes.com",
        "cnn.com",
        "bbc.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("capable_santolina/ads-txt-auditor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "nytimes.com",
    "cnn.com",
    "bbc.com"
  ]
}' |
apify call capable_santolina/ads-txt-auditor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,capable_santolina/ads-txt-auditor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/LCR2hUi8u2PSNibLY/builds/MKcHyUpBS4yqDrq2D/openapi.json
