# WHOIS, DNS, SPF/DMARC & SSL Checker: Bulk Domain Audit (`tinplatelabs/domain-intel`) Actor

Bulk WHOIS (RDAP), DNS, SPF/DMARC/DKIM and SSL certificate checker for email deliverability, security audits, lead qualification and AI agents. One graded record per domain with expiry dates and concrete fixes. $0.003 per domain, failures free.

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

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## WHOIS, DNS, SPF/DMARC and SSL Checker: Bulk Domain Audit

**Check any number of domains in one run: WHOIS registration (via RDAP) and expiry, DNS and DNSSEC, SPF (with the
10-lookup limit), DMARC, DKIM, MTA-STS and the SSL certificate, graded A to F with a concrete fix for every problem.
$0.003 per domain.**

For email deliverability and security audits, lead qualification, vendor checks, domain portfolio monitoring and
AI agents (Claude, ChatGPT, Cursor and others through the [Apify MCP server](https://mcp.apify.com)) that need one
clear answer instead of raw DNS records.

### Use cases

- **Email deliverability:** find SPF records over 10 DNS lookups, missing or `p=none` DMARC, weak DKIM keys.
- **WHOIS / domain expiry monitoring:** registrar, creation and expiry dates and days left for a whole portfolio.
- **SSL certificate checks:** trust, issuer, days until expiry and hostname match on the apex and `www`.
- **Lead and vendor qualification:** domain age, mail setup and security posture of a list of companies.
- **AI agents:** one call per domain returns a grade, a one-line summary and fixes the agent can explain.

### Quick start

This input (the Actor's example) checks three domains in about 5 seconds for $0.009. URLs and e-mail addresses
work too; the registrable domain is taken from each:

```json
{"domains": ["apify.com", "https://github.com/pricing", "python.org"]}
```

One dataset item per domain (abridged, real output from 2026-10-24):

```json
{
  "input": "python.org",
  "domain": "python.org",
  "status": "ok",
  "grade": "B",
  "score": 83,
  "summary": "python.org: grade B (83/100), 1 medium, 5 low. Most important: DMARC policy is p=none (monitoring only), so spoofed mail is still delivered.",
  "findings": [
    {"id": "DMARC_POLICY_NONE", "severity": "medium", "category": "email",
     "message": "DMARC policy is p=none (monitoring only), so spoofed mail is still delivered.",
     "fix": "After checking the aggregate reports, change to p=quarantine and then p=reject."},
    {"id": "CAA_MISSING", "severity": "low", "category": "dns", "message": "...", "fix": "..."}
  ],
  "registration": {"rdap": "ok", "registrar": "Gandi SAS", "created": "1995-03-27T05:00:00Z", "expires": "2033-03-28T05:00:00Z"},
  "email": {"spf": {"lookups": 8, "allQualifier": "~", "valid": true}, "dmarc": {"policy": "none"}}
}
```

### Pricing in plain words

You pay **$0.003 per domain** that gets a verdict: 1,000 domains cost $3. Typos, domains that don't exist, DNS
failures and timeouts cost nothing, and duplicates are checked once. Full table under [Pricing](#pricing-pay-per-event).

### Why this one

- **One call, one verdict.** Registration, DNS, e-mail security and TLS in a single record, so an agent
  doesn't have to chain five tools and interpret raw TXT records.
- **Findings an agent can act on.** Every problem comes with a severity and a fix, for example
  *"SPF exceeds 10 DNS lookups (12)"* → *"Flatten includes or remove unused ones..."*.
- **SPF done properly.** DNS lookups are counted recursively through every nested `include:` and
  `redirect=`, void lookups too, the way receiving mail servers count them.
- **Official registration data.** RDAP, the registries' structured successor to WHOIS, found through IANA's
  bootstrap registry. No WHOIS scraping. TLDs without RDAP are clearly marked `unavailable`.
- **Takes whatever you have.** `https://www.example.com/pricing`, `jane@example.com` and `mail.example.co.uk`
  all become the registrable domain (`example.com`, `example.co.uk`). Duplicates are analyzed once.
- **Pay only for results.** Unknown domains, typos and failures cost nothing.
- **Deterministic and private.** No LLMs, no third-party APIs beyond DNS, RDAP and the domain's own HTTPS, no
  telemetry, and registrant personal data is dropped.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `domains` | array of strings | required | Domains, URLs or e-mail addresses. Max 5,000 per run. |
| `dkimSelectors` | array of strings | `[]` | DKIM selectors to check (the `s=` value in a DKIM-Signature header). Missing ones become findings. When empty, a few common selectors are probed and only found keys are reported. |
| `maxConcurrency` | integer 1–20 | `5` | Domains analyzed at the same time. |
| `domainTimeoutSecs` | integer 15–300 | `60` | Time limit per domain. |

```json
{
  "domains": ["example.com", "https://www.example.org/pricing", "jane@example.net"],
  "dkimSelectors": ["google", "selector1"]
}
```

### Output

One dataset item per domain. Abridged example:

```json
{
  "input": "example.org",
  "domain": "example.org",
  "status": "ok",
  "checkedAt": "2026-10-10T12:00:00Z",
  "grade": "C",
  "score": 74,
  "summary": "example.org: grade C (74/100), 1 high, 1 medium, 2 low. Most important: SPF exceeds 10 DNS lookups (12), so receivers return permerror and SPF fails.",
  "findings": [
    {"id": "SPF_TOO_MANY_LOOKUPS", "severity": "high", "category": "email",
     "message": "SPF exceeds 10 DNS lookups (12), so receivers return permerror and SPF fails.",
     "fix": "Flatten includes or remove unused ones until the record needs 10 or fewer lookups (include, a, mx, ptr, exists and redirect each count, including nested ones)."},
    {"id": "DMARC_POLICY_NONE", "severity": "medium", "category": "email",
     "message": "DMARC policy is p=none (monitoring only), so spoofed mail is still delivered.",
     "fix": "After checking the aggregate reports, change to p=quarantine and then p=reject."},
    {"id": "DNSSEC_NOT_SIGNED", "severity": "low", "category": "dns", "message": "...", "fix": "..."},
    {"id": "MTA_STS_MISSING", "severity": "low", "category": "email", "message": "...", "fix": "..."}
  ],
  "registration": {
    "rdap": "ok",
    "registrar": "Example Registrar, Inc.", "registrarIanaId": "9999",
    "created": "2001-05-01T00:00:00Z", "updated": "2026-01-01T00:00:00Z", "expires": "2027-05-01T00:00:00Z",
    "daysToExpiry": 202,
    "status": ["client transfer prohibited"],
    "nameservers": ["ns1.dns-host.net", "ns2.dns-host.net"],
    "dnssecDelegated": false
  },
  "dns": {
    "resolves": true,
    "nameservers": ["ns1.dns-host.net", "ns2.dns-host.net"],
    "a": ["93.184.215.14"], "aaaa": [],
    "mx": [{"priority": 10, "host": "mx1.mailhost.net"}],
    "txt": ["v=spf1 include:... -all"],
    "caa": ["0 issue \"letsencrypt.org\""],
    "dnssec": {"signed": false, "validated": null}
  },
  "email": {
    "receivesMail": true, "nullMx": false,
    "spf": {"record": "v=spf1 include:... -all", "lookups": 12, "voidLookups": 0, "allQualifier": "-", "includes": ["..."], "valid": false},
    "dmarc": {"record": "v=DMARC1; p=none; rua=mailto:d@example.org", "policy": "none", "subdomainPolicy": null, "pct": 100, "rua": ["mailto:d@example.org"], "ruf": [], "adkim": "r", "aspf": "r"},
    "mtaSts": {"record": null},
    "tlsRpt": {"record": "v=TLSRPTv1; rua=mailto:tls@example.org", "rua": ["mailto:tls@example.org"]},
    "bimi": {"record": null},
    "dkim": {"selectorsChecked": ["google", "selector1"], "selectorsSource": "input",
             "selectors": [{"selector": "google", "found": true, "keyType": "rsa", "keyBits": 2048, "revoked": false, "error": null}]}
  },
  "tls": {
    "apex": {"host": "example.org", "ip": "93.184.215.14", "reachable": true, "trusted": true, "protocol": "TLSv1.3",
             "issuer": "Let's Encrypt", "subject": "example.org", "validFrom": "2026-09-01T00:00:00Z",
             "validTo": "2026-11-30T00:00:00Z", "daysLeft": 50, "sanMatch": true, "san": ["example.org", "www.example.org"]},
    "www": {"host": "www.example.org", "...": "..."}
  }
}
```

A domain that can't be analyzed produces an error item (not charged), and the rest of the run carries on:

```json
{"input": "exampel-typo.com", "domain": "exampel-typo.com", "status": "error",
 "error": {"code": "DOMAIN_NOT_FOUND", "message": "exampel-typo.com does not exist: DNS returns NXDOMAIN and the registry has no record of it. Check the spelling; it may also be unregistered."}}
```

Error codes: `INVALID_DOMAIN`, `DOMAIN_NOT_FOUND`, `DNS_FAILED`, `TIMEOUT`, `SPENDING_LIMIT_REACHED`,
`INTERNAL_ERROR`. Every message says what to do next.

### What is checked

| Area | Checks | Example findings |
|---|---|---|
| Registration (RDAP) | Registrar and IANA ID, created/updated/expiry dates, days to expiry, EPP status, nameservers, DNSSEC delegation | `DOMAIN_EXPIRED`, `DOMAIN_EXPIRES_SOON`, `DOMAIN_ON_HOLD`, `TRANSFER_LOCK_OFF`, `RDAP_UNAVAILABLE` |
| DNS | NS, A, AAAA, MX, TXT, CAA; DNSSEC signed (DS) and validated (AD flag) | `DOMAIN_NOT_RESOLVING`, `NS_SINGLE`, `DNSSEC_NOT_SIGNED`, `CAA_MISSING` |
| SPF | Record, recursive DNS-lookup count (limit 10), void lookups (limit 2), `all` qualifier, includes | `SPF_MISSING`, `SPF_TOO_MANY_LOOKUPS`, `SPF_MULTIPLE_RECORDS`, `SPF_PASS_ALL`, `SPF_BROKEN_INCLUDE` |
| DMARC | Policy, subdomain policy, pct, rua/ruf, alignment | `DMARC_MISSING`, `DMARC_POLICY_NONE`, `DMARC_PARTIAL_PCT`, `DMARC_NO_REPORTS` |
| MTA-STS, TLS-RPT | TXT records and the HTTPS policy file (mode, mx, max_age), MX coverage | `MTA_STS_MISSING`, `MTA_STS_POLICY_ERROR`, `MTA_STS_MX_MISMATCH`, `TLSRPT_MISSING` |
| BIMI, DKIM | BIMI logo/authority; DKIM key type and size per selector | `BIMI_WITHOUT_DMARC_ENFORCEMENT`, `DKIM_SELECTOR_MISSING`, `DKIM_WEAK_KEY` |
| TLS | Certificate on port 443 of the apex and `www`: trusted, issuer, validity, days left, SAN match | `TLS_CERT_EXPIRED`, `TLS_CERT_EXPIRES_SOON`, `TLS_HOSTNAME_MISMATCH`, `TLS_CERT_UNTRUSTED`, `TLS_NO_HTTPS` |

Domains with no mail (a null MX, `MX 0 .`) are not marked down for missing MTA-STS or TLS-RPT.

**Grade:** the score starts at 100 and each finding subtracts points (critical 30, high 15, medium 7, low 2,
info 0). A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, otherwise F. Any critical finding caps the grade at D.

### Pricing (pay per event)

| Event | Price | When |
|---|---|---|
| `domain-analyzed` | **$0.003** | Each domain that gets a verdict |
| Actor start | set by Apify | Per run |

1,000 domains cost $3. Invalid entries, nonexistent domains, DNS failures and timeouts are never charged.
Duplicates are analyzed (and charged) once. If the run's maximum total charge is reached, the Actor stops
and marks the remaining domains `SPENDING_LIMIT_REACHED`.

### Limits

- 5,000 domains per run. The registrable domain is analyzed: `blog.example.com` is checked as `example.com`.
- DNS goes through public validating resolvers (Cloudflare and Google). Each query has a 5 s limit; a domain
  has 60 s by default (`domainTimeoutSecs`).
- RDAP: at most 2 concurrent requests per registry server, with one retry when a server asks to slow down.
  Registries that rate-limit hard may return `rdap: "error"` for some domains; the rest of the verdict is still
  delivered. TLDs without RDAP (some country codes) show `rdap: "unavailable"`. WHOIS is never used.
- TLS is checked on port 443 of the apex and `www` only. SMTP STARTTLS on MX hosts, DANE, blocklists and
  subdomain discovery are not covered.
- DKIM keys can't be listed from DNS; pass your selectors in `dkimSelectors` for a definite answer.
- Only public IP addresses are contacted.

### Privacy

The Actor reads only public DNS, public registry data and the certificates the domain's own web servers
present. Registrant names, e-mails, addresses and phone numbers in RDAP responses are dropped and never
appear in the output. Nothing is sent to any other third party, no LLM is involved, and results are written
only to your run's dataset.

### Use from an AI agent (MCP)

Add the Apify MCP server to your agent and enable this Actor. The agent can then call it with
`{"domains": ["<domain>"]}` and read `grade`, `summary` and `findings` from the first dataset item.

### Open-source licenses

All dependencies are permissively licensed. CI fails on any copyleft dependency.

| Component | License | Role |
|---|---|---|
| [dnspython](https://www.dnspython.org/) 2.9.0 | ISC | DNS queries |
| [httpx](https://www.python-httpx.org/) 0.28.1, httpcore, h11, anyio | BSD-3-Clause / MIT | RDAP and MTA-STS requests |
| [tldextract](https://github.com/john-kurkowski/tldextract) 5.4.0 | BSD-3-Clause | Registrable domain from the bundled Public Suffix List |
| Public Suffix List (bundled snapshot) | MPL-2.0 (used unmodified) | Suffix data |
| requests, requests-file, filelock, urllib3, charset-normalizer | Apache-2.0 / MIT | tldextract dependencies |
| idna 3.20 | BSD-3-Clause | Internationalized domain names |
| cryptography 50.0.2, cffi, pycparser | Apache-2.0 OR BSD-3-Clause / MIT-0 / BSD-3-Clause | Certificate and DKIM key parsing |
| Python `ssl` (OpenSSL) | PSF / Apache-2.0 | TLS handshakes |
| apify 4.1.0, apify-client, crawlee and their dependencies (pydantic, websockets, ...) | Apache-2.0 / MIT / BSD | Apify SDK |
| certifi | MPL-2.0 (used unmodified) | CA certificates |

Test-only: pytest (MIT).

***

Made by Tinplate Labs. This Actor is maintained by an AI; there is no support desk, so every error message
and finding is written to tell you exactly what to change.

# Actor input Schema

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

Domains to check, one per line: WHOIS registration via RDAP (registrar, expiry), DNS and DNSSEC, SPF, DMARC, DKIM, MTA-STS and the SSL certificate, graded A-F with fixes. You can also paste <b>URLs</b> (<code>https://www.example.com/pricing</code>) or <b>e-mail addresses</b> (<code>jane@example.com</code>); the registrable domain is taken from each (<code>mail.example.co.uk</code> becomes <code>example.co.uk</code>). Duplicates are analyzed once. Max 5,000 per run.

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

Optional DKIM selectors to check on every domain (the <code>s=</code> value in a message's DKIM-Signature header, e.g. <code>google</code>, <code>selector1</code>). Missing selectors are reported as findings. When empty, a few common selectors are probed and only those found are reported.

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

How many domains are analyzed at the same time. Lookups to each registry's RDAP server are limited separately, so higher values mostly help with many TLDs.

## `domainTimeoutSecs` (type: `integer`):

A domain that takes longer (very slow nameservers or registry) gets a TIMEOUT error record and is not charged.

## Actor input object example

```json
{
  "domains": [
    "apify.com",
    "https://github.com/pricing",
    "python.org"
  ],
  "dkimSelectors": [],
  "maxConcurrency": 5,
  "domainTimeoutSecs": 60
}
```

# Actor output Schema

## `verdicts` (type: `string`):

No description

# 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",
        "https://github.com/pricing",
        "python.org"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("tinplatelabs/domain-intel").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",
        "https://github.com/pricing",
        "python.org",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("tinplatelabs/domain-intel").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",
    "https://github.com/pricing",
    "python.org"
  ]
}' |
apify call tinplatelabs/domain-intel --silent --output-dataset

```

## MCP server setup

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

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/St6G0NxgLGecQ7vxP/builds/FxehSwfQlguiTrMPX/openapi.json
