# Bulk Domain Checker: Availability, Expiry, WHOIS, DNS & DMARC (`tessellware/bulk-domain-checker`) Actor

Check DNS, SPF/DMARC email-auth grade, RDAP registration & expiry and availability for any domain in one row. Official DoH and RDAP only. Monitor mode.

- **URL**: https://apify.com/tessellware/bulk-domain-checker.md
- **Developed by:** [tessellware](https://apify.com/tessellware) (community)
- **Categories:** Developer tools, SEO tools, Lead generation
- **Features:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 domains

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

## Bulk Domain Checker: Availability, Expiry, WHOIS, DNS & DMARC

Bulk-check domain availability, expiry dates, WHOIS (RDAP) registration data, DNS records and email authentication in one run. Give it a list of domains and get **one row per domain** with its DNS records, an **email-authentication grade (A to F)** from SPF, DMARC and friends, **registration and expiry dates**, and whether the name looks **available**. Everything comes from official, public protocols (DNS-over-HTTPS and RDAP): no scraping, no API keys, no paid data sources. A **monitor mode** returns only the domains that changed since your last run.

You are **billed only for domain rows written to the dataset**. Invalid inputs, duplicates, unchanged domains in monitor mode, and domains for which no requested check returned usable data are never written and never billed. TLDs without an RDAP server in the IANA bootstrap (for example `.io` and `.de` today) get no registration or expiry data; their availability comes from DNS only and may be `unknown`.

Maintained by Tessellware.

### Quick start

1. Paste domains, URLs or email addresses into **Domains**. The prefilled example (`apify.com`, `github.com`, `example.com`) finishes in a few seconds.
2. Leave **Checks to run** empty for everything, or pick only the checks you need.
3. Click **Start**, then open the **Domains overview** table or download the dataset as JSON, CSV or Excel.

### How much does it cost to check domains in bulk?

You pay per domain row (see Pricing): $2.00 per 1,000 domains on the Free and Bronze tiers. Checking a 500-domain customer list once costs about $1.00; a nightly monitor over 500 domains pays only for the rows that changed. Set **Max domains output** (or the maximum cost per run in the Apify Console) to cap any run.

### What you can do with it

- **Email deliverability audit:** grade the SPF/DMARC setup of every sending domain you manage or sell to. Find domains with no DMARC, `p=none`, `+all`, multiple SPF records, or more than 10 SPF DNS lookups (which makes SPF fail).
- **Expiring-domain lists:** `rdap.expiresAt` and `rdap.daysToExpiry` for a whole portfolio, sorted to show what to renew first.
- **Portfolio and competitor monitoring:** schedule a run in monitor mode; you get a row only when nameservers, MX, SPF, DMARC, registrar, expiry, status flags or availability change, with a field-level `changes` list.
- **Lead-list hygiene:** drop dead domains (`nxdomain`), parked-looking domains with no MX, and domains that cannot receive or authenticate mail, before you send to them.
- **Domain research:** check whether a batch of candidate names is registered, with the caveats below.

### How it works

For every domain the Actor:

1. reduces the entry to its registrable domain using the Public Suffix List (`https://www.bbc.co.uk/news` becomes `bbc.co.uk`, `jane@mail.example.org` becomes `example.org`, `münchen.de` becomes `xn--mnchen-3ya.de`);
2. queries DNS over HTTPS using the Cloudflare JSON API, falling back to Google Public DNS on errors and SERVFAIL (NXDOMAIN is treated as a real answer);
3. finds the registry's RDAP server in the IANA bootstrap file (`data.iana.org/rdap/dns.json`, fetched once per run) and reads the domain's RDAP record;
4. builds the row. One failed check never fails the row or the run; the reason is listed in the row's `errors`.

Requests are paced (RDAP servers: at most 3 at a time per host, with retry and back-off on 429 and 5xx, honours Retry-After (up to 30 s); HTTP redirects are followed only to public https hosts, at most 3 hops) and identify themselves honestly. **Port-43 WHOIS, registrar website scraping and paid APIs are not used.**

### Input

Only `domains` is required.

```json
{
    "domains": ["apify.com", "https://www.github.com/apify", "billing@example.co.uk", "münchen.de"],
    "checks": ["dns", "emailAuth", "rdap", "availability"],
    "dkimSelectors": ["google", "selector1"]
}
```

| Field | Default | Notes |
|---|---|---|
| `domains` | required | Bare domains, URLs, email addresses. De-duplicated by registrable domain; the first spelling is kept in `input`. Email inputs are reduced to their domain; the part before @ is never stored (`input` shows `***@example.org`). |
| `checks` | all four | `dns`, `emailAuth`, `rdap`, `availability`. A check you do not pick is absent from the row. The price per row does not change. |
| `dkimSelectors` | none | DKIM selectors cannot be discovered, so you supply them. Each `<selector>._domainkey.<domain>` TXT record is looked up. |
| `onlyChanges` | `false` | Monitor mode, see below. |
| `monitorName` | `default` | One baseline per name. Use a different name per list. |
| `monitorIpChanges` | `false` | Count A/AAAA changes as changes (CDN addresses rotate, so they are ignored by default). |
| `maxDomains` | `0` (no limit) | Stop after this many rows are output. |
| `concurrency` | `10` (max 50) | Domains checked in parallel. |

Invalid entries (empty, IP addresses, bare public suffixes such as `co.uk`, unknown TLDs, malformed names) are **not written to the dataset and not charged**. They are listed with the reason in the `INPUT_REPORT` key-value record, which also shows how every accepted input was normalised. Email inputs are reduced to their domain; the part before @ is never stored, not even in `INPUT_REPORT`.

### Output

One dataset item per domain. Example (shortened):

```json
{
    "domain": "github.com",
    "input": "https://www.github.com/apify",
    "tld": "com",
    "publicSuffix": "com",
    "status": "ok",
    "dns": {
        "a": ["20.207.73.82"],
        "aaaa": [],
        "mx": [{ "priority": 0, "host": "github-com.mail.protection.outlook.com" }],
        "ns": ["dns1.p08.nsone.net", "ns-1283.awsdns-32.org"],
        "txt": ["v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com ... ~all"],
        "caa": [{ "flags": 0, "tag": "issue", "value": "digicert.com" }],
        "soa": { "primaryNs": "dns1.p08.nsone.net", "serial": 1656468023 },
        "cname": null
    },
    "emailAuth": {
        "spf": { "record": "v=spf1 ... ~all", "valid": true, "allQualifier": "~all", "dnsLookupCount": 10, "issues": [] },
        "dmarc": { "record": "v=DMARC1; p=quarantine; sp=reject; pct=100; ...", "policy": "quarantine", "subdomainPolicy": "reject", "pct": 100, "rua": ["mailto:dmarc@github.com"], "ruf": ["mailto:dmarc@github.com"], "issues": [] },
        "mtaSts": false,
        "tlsRpt": false,
        "bimi": false,
        "dkim": [],
        "grade": "A"
    },
    "rdap": {
        "registrar": "MarkMonitor Inc.",
        "registrarIanaId": "292",
        "createdAt": "2007-10-09T18:20:50.000Z",
        "updatedAt": "2026-09-07T09:22:52.000Z",
        "expiresAt": "2028-10-09T18:20:50.000Z",
        "daysToExpiry": 694,
        "statuses": ["client delete prohibited", "client transfer prohibited", "client update prohibited"],
        "nameservers": ["dns1.p08.nsone.net", "ns-1283.awsdns-32.org"],
        "dnssecSigned": false,
        "rdapServer": "rdap.verisign.com"
    },
    "availability": "registered",
    "checkedAt": "2026-11-15T12:00:00.000Z",
    "errors": []
}
```

Field notes:

- `status`: `ok`; `nxdomain` (the name does not exist in DNS: the row is still output and charged, because the answer is the result; `emailAuth` is `null` then); `error` is used internally when no requested check returned usable data (resolvers unreachable, or for example only `rdap` requested for a TLD without an RDAP server), and such a domain is **not output and not charged** (it is listed under `failedDomainList` in the `OUTPUT` record and in `INPUT_REPORT`).
- `availability`: `registered`, `likely-available`, or `unknown` (see Limits).
- `tld` is the last label (`uk`); `publicSuffix` is the full suffix (`co.uk`).
- `errors`: real failures only, such as `rdap: HTTP 429 ...` or `dns:CAA: SERVFAIL ...`. Other checks in the row are unaffected.
- `notes`: informational messages that are not failures, for example `rdap: no RDAP server for .io in the IANA bootstrap`.
- `OUTPUT` (key-value store): run statistics. `INPUT_REPORT`: every input and what happened to it.

### Email-authentication grade

The grade uses only the published DNS records. It is deliberately simple and fully documented here, so you can reproduce it.

| SPF points | Rule |
|---|---|
| 2 | Exactly one SPF record, valid, ending in `-all` or `~all`, with 10 or fewer DNS lookups |
| 1 | A record exists but is flawed: `?all`, no `all`, more than 10 lookups, multiple SPF records, or an include target that has no SPF record |
| 0 | No SPF record, or `+all` |

| DMARC points | Rule |
|---|---|
| 3 | `p=reject` or `p=quarantine` at `pct=100` (the default when `pct` is absent) |
| 2 | `p=reject` or `p=quarantine` with `pct` below 100 |
| 1 | `p=none` (monitoring only) |
| 0 | No valid DMARC record |

Grade by total: **5 = A, 4 = B, 3 = C, 1 or 2 = D, 0 = F.** So A needs a valid SPF and an enforcing DMARC policy at 100%; F means no SPF (or `+all`) and no DMARC; SPF alone with no DMARC is a D. The grade is `null` when SPF or DMARC could not be looked up (the reason is in `errors`); this includes a lookup failure (for example SERVFAIL) on an `include:` or `redirect=` target, while an include that exists in DNS but has no SPF record is a policy flaw that still scores 1. **Macro-based includes** such as `include:%{ir}.%{v}.%{d}.spf.example.com` (used by some enterprise mail gateways) cannot be expanded without a sending IP: they are counted as one lookup but not followed, are listed in `emailAuth.spf.macroTargets`, and do not make the record invalid. The grade is computed from the rest of the record. DNS-lookup counting follows RFC 7208: `include`, `a`, `mx`, `ptr`, `exists` and `redirect` each count one, recursively, with a loop and depth guard. MTA-STS, TLS-RPT, BIMI and DKIM are reported but do not change the grade. The grade says nothing about whether a domain actually sends mail.

### Monitor mode: only domains that changed

Turn on **Monitor mode** (`onlyChanges`) and give the list a **Monitor name**. The first run outputs every domain and stores a baseline in a named key-value store. Later runs output only the domains whose meaningful fields changed, each with a `changes` list:

```json
{ "domain": "example.com", "isNew": false, "changes": [
    { "field": "emailAuth.dmarc.policy", "from": "none", "to": "reject" },
    { "field": "rdap.expiresAt", "from": "2027-08-13T04:00:00.000Z", "to": "2028-08-13T04:00:00.000Z" } ] }
```

- Not compared: `checkedAt`, `input`, `errors`, `notes`, `rdap.daysToExpiry` (it changes daily), `rdap.rdapServer`, `dns.soa` (all fields: the serial changes with every zone edit and the primary nameserver flips between providers on multi-provider domains; SOA stays in the output), and A/AAAA addresses unless `monitorIpChanges` is on.
- If a check failed in the new run (for example a transient RDAP 429), only the field that failed keeps its previous value, so an error is never reported as a change, and a real change that happens while a check is failing is still reported once the check works again.
- The baseline of a changed domain is updated only after its row was delivered. A baseline that is merely out of date (nothing to report) is saved without delivering a row. Fields that failed earlier, sections you newly request, a different DKIM selector list and switching `monitorIpChanges` on or off are all filled in silently: they are never reported as a change and never charged.
- A run with no changes writes nothing and charges nothing; the status message says so.

### Limits

- **Availability is a signal, not a registry guarantee.** `likely-available` means the registry's RDAP server answered "not found" and the name has no DNS delegation. Reserved names, premium names, names in redemption or pending-delete, and registry-specific rules are not modelled. Always confirm with a registrar before relying on it.
- **TLDs without an RDAP server in the IANA bootstrap get no registration or expiry data** (`rdap` is `null`), and their availability comes from DNS only: `registered` if DNS shows nameservers or an SOA record, otherwise `unknown`. These TLDs give `unknown` availability (on 2026-11-15 this included `.de` and `.io`, among others; the list changes as registries add RDAP). If DNS shows nameservers or an SOA record, the domain is reported as `registered` instead. `rdap` is `null` and `errors` says there is no RDAP server.
- Registries differ in which RDAP fields they publish: `createdAt`, `updatedAt`, `registrar` or `dnssecSigned` can be `null`. Some registries rate-limit RDAP; the Actor retries politely, so very large `.com` lists take longer.
- Only the registrable domain is checked. `mail.example.com` is reduced to `example.com`; subdomain-level records are not inspected. DKIM needs selectors you supply.
- DNS answers come from public resolvers and reflect their caches (typically up to a few minutes behind the authoritative servers).
- The prefill runs in seconds, so the Apify daily auto-test passes comfortably.

### Privacy

RDAP responses can contain registrant, administrative and technical contact details. **This Actor never outputs them**: names, emails, phone numbers and addresses of registrants and contacts are not read, stored or returned (a unit test with a full registrant vCard checks this). From the registry data it takes only the registrar's organisation name and IANA ID, dates, status flags, nameservers and the DNSSEC flag. Email inputs are reduced to their domain; the part before @ is never stored. No personal data is collected.

### Pricing

**$2.00 per 1,000 domains** ($0.002 per domain row) on Apify's Free and Bronze discount tiers; Silver $1.60 / 1,000; Gold $1.20 / 1,000.

You pay per domain row written to the dataset (pay-per-event). Every domain row written is charged, including nxdomain and partially failed rows. Invalid inputs and domains for which no check returned usable data are never written and never charged. In monitor mode, only changed domains are written and charged. Apify's standard start charge also applies: $0.00005 per run for up to 1 GB of memory (the default 512 MB run costs $0.00005). There is no other start fee. Apify Store discount tiers are based on your total monthly spend on Apify.

Examples: a 500-domain list costs about $1.00 (about $0.80 on Silver); 10,000 domains cost about $20.00. A daily monitor over 500 domains where 5 domains change costs about $0.01 per day.

### Integrations

Use the Apify API, schedules, webhooks, Make, Zapier, n8n or Google Sheets to feed domain lists in and send results or monitor alerts out. A typical set-up: a daily schedule with `onlyChanges` on and a webhook that posts non-empty datasets to Slack.

### Use it from AI agents (MCP)

AI agents and assistants can find and run this Actor through the [Apify MCP server](https://mcp.apify.com) (`search-actors`, then `call-actor`), for example to answer "which of these 200 prospect domains have no DMARC policy?" or "which of our domains expire in the next 60 days?". The input is a plain list of domains, and the output is one record per domain with a letter grade and dates, so it is easy for an agent to use. The Actor runs with Apify's limited permissions.

### Other Actors from Tessellware

- [ATS Jobs Scraper](https://apify.com/tessellware/ats-jobs-scraper): job postings from Greenhouse, Lever, Ashby, Workday, Workable, SmartRecruiters and Recruitee in one schema, with an only-new-jobs monitor mode. Use it to find companies that are hiring, then check their domains here.

### FAQ

**Is it legal to check domains in bulk?** It uses only public internet infrastructure designed for these lookups: DNS (through public DNS-over-HTTPS resolvers) and RDAP, the registries' official registration-data service. It does not scrape websites, log in or collect personal data (see Privacy). You are responsible for using the results lawfully, for example when you use them for outreach.

**Is this WHOIS?** It uses RDAP, the IETF-standard successor to WHOIS, served by the registries themselves. It does not query port 43 and does not scrape websites.

**Why is a registrar or date missing?** The registry did not publish it in RDAP. The field is `null`.

**Does it output the domain owner?** No, never. See Privacy.

**Can I run it for thousands of domains?** Yes. DNS is fast; the limiting factor is registry RDAP rate limits (3 parallel requests per RDAP host, backing off on 429). Use `maxDomains` and the run cost limit to cap spend.

**What if the run is migrated or restarted?** The Actor remembers which domains it already delivered and never writes the same domain twice, except in rare crash cases where the dataset cannot be read back: at most one batch may be written again (up to 20 rows).

# Actor input Schema

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

One entry per domain. Accepts bare domains (`example.com`), URLs (`https://www.example.co.uk/page`) and email addresses (`jane@example.org`). Each is reduced to its registrable domain with the Public Suffix List (`www.bbc.co.uk` becomes `bbc.co.uk`), lowercased, converted to punycode if internationalised, and de-duplicated. Entries that are not valid domains are listed in the INPUT_REPORT record with the reason and are never charged.

## `checks` (type: `array`):

Which checks to include in each row. Leave empty for all four. `dns`: A, AAAA, MX, NS, TXT, CAA, SOA, CNAME. `emailAuth`: SPF (with DNS-lookup count), DMARC, MTA-STS, TLS-RPT, BIMI, DKIM and an A-F grade. `rdap`: registrar, created/updated/expiry dates, status, nameservers, DNSSEC. `availability`: registered / likely-available / unknown. You pay per domain row, whatever checks you pick.

## `dkimSelectors` (type: `array`):

DKIM keys cannot be discovered without knowing the selector. If you give selectors (for example `google`, `selector1`, `s1`), the `<selector>._domainkey.<domain>` TXT record of every domain is looked up and reported under `emailAuth.dkim`. At most 10 selectors are used (extra and invalid entries are ignored, with a warning in the log). Used only when the Email authentication check is on.

## `onlyChanges` (type: `boolean`):

Output only domains whose DNS, email-auth, registration or availability fields changed since the last run with the same Monitor name, plus a `changes` list of field-level differences. The first run outputs every domain and stores the baseline. Unchanged domains are not output and not charged. Use with a schedule.

## `monitorName` (type: `string`):

Name of the baseline used by monitor mode. Runs with the same name share one baseline; use a different name per list of domains.

## `monitorIpChanges` (type: `boolean`):

By default A and AAAA addresses are ignored when detecting changes, because CDNs and load balancers rotate them constantly. Turn this on to report them.

## `maxDomains` (type: `integer`):

Stop after this many domain rows have been output. Together with the maximum cost per run in the Apify Console this caps what a run can charge.

## `concurrency` (type: `integer`):

Domains checked in parallel. RDAP servers are always limited to 3 requests at a time each, so raising this mainly speeds up the DNS part.

## Actor input object example

```json
{
  "domains": [
    "apify.com",
    "github.com",
    "example.com"
  ],
  "checks": [
    "dns",
    "emailAuth",
    "rdap",
    "availability"
  ],
  "onlyChanges": false,
  "monitorName": "default",
  "monitorIpChanges": false,
  "maxDomains": 0,
  "concurrency": 10
}
```

# Actor output Schema

## `domains` (type: `string`):

One item per domain: status, availability, DNS records, email-authentication results and grade, RDAP registration and expiry data. In monitor mode also `changes`.

## `inputReport` (type: `string`):

Every input with how it was normalised (bare domain, URL, email, subdomain), or why it was rejected, ignored as a duplicate, skipped or failed.

## `runStats` (type: `string`):

Totals for the run: domains in, rows output, errors, duration, whether the run stopped early and why.

# 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": [
        "apify.com",
        "github.com",
        "example.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("tessellware/bulk-domain-checker").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": [
        "apify.com",
        "github.com",
        "example.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("tessellware/bulk-domain-checker").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": [
    "apify.com",
    "github.com",
    "example.com"
  ]
}' |
apify call tessellware/bulk-domain-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,tessellware/bulk-domain-checker"
        }
    }
}
```

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/JGOo4XpX6THad9ifd/builds/hhb4bYxzyQFRhAGo7/openapi.json
