# Bulk Domain Intel: WHOIS/RDAP, DNS, Email Security & SSL (`bigdavidson/domain-intel-bulk`) Actor

Bulk domain lookup: registrar and expiry (RDAP), A/AAAA/MX/NS/TXT/CAA records, SPF/DMARC/MTA-STS/BIMI email-security grade, mail provider, SSL certificate expiry, optional tech-stack detection.

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

## Pricing

from $3.00 / 1,000 domain reports

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

## Bulk Domain Intel: WHOIS/RDAP, DNS, Email Security & SSL

Paste a list of domains, URLs or e-mail addresses. For each one you get **one flat JSON row** with:

- **Registration (RDAP, the official successor to WHOIS):** registrar and IANA ID, creation, update and expiry dates, domain age, days to expiry, registry status codes and DNSSEC. Available for every generic TLD (`.com`, `.net`, `.org`, `.io`, `.ai`, `.app`, new gTLDs) and about 80 country-code TLDs. See [which TLDs have registration data](#which-tlds-have-registration-data).
- **DNS:** A, AAAA, MX, NS, TXT and CAA records, plus the **mail provider** (Google Workspace, Microsoft 365, Proofpoint, Mimecast and others) and the **DNS provider** (Cloudflare, Route 53, Akamai and others).
- **Email security grade (A–F, 0–100):** SPF (DNS lookups counted recursively against the limit of 10), DMARC policy, pct and reporting domains, MTA-STS mode, TLS-RPT, BIMI, and common DKIM selectors. Each problem is listed in plain English.
- **SSL/TLS certificate:** whether it is valid, the issuer, days to expiry, SAN count, key type and TLS version. Expired or untrusted certificates are still read and reported.
- **Tech stack (optional):** hosting and CDN, CMS, JavaScript frameworks, analytics, ads, marketing, chat, consent and payment tools. Detection uses one homepage GET (headers and static HTML) plus SaaS clues in DNS: every TXT verification token, SPF senders and the www CNAME. Every detection comes with its evidence.

It works with no API key or login. Data comes only from official and public sources: the IANA RDAP bootstrap, the registries' own RDAP servers, public DNS, a TLS handshake and, in tech mode, one homepage request per domain.

**Who it's for:** sales ops and RevOps teams enriching account lists (mail provider, tech stack), security and IT teams auditing email spoofing protection and certificate expiry across a portfolio, domain investors and brand-protection teams watching expiry dates, SEO agencies, and AI agents that need a quick "what is this domain?" answer.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `domains` | array of strings | — (empty = `apify.com` as an example) | Domains, URLs or e-mails, one per item. `https://www.Example.com/page` → `example.com`; `jane@acme.io` → `acme.io`. A leading `www.` is removed; other subdomains are kept. Duplicates are removed. Up to **10,000** per run. |
| `techStack` | boolean | `false` | Adds tech-stack detection (one homepage GET per domain). |
| `includeRdap` | boolean | `true` | Registration data from RDAP. Turning it off does not change the price. |
| `includeTls` | boolean | `true` | TLS certificate check on port 443 (the apex first, then `www.`). Turning it off does not change the price. |
| `maxRecords` | integer | none | Optional cap on the number of domains checked (and charged). |

```json
{
  "domains": ["stripe.com", "https://www.shopify.com/pricing", "jane@notion.so"],
  "techStack": true
}
```

The fields above are checked against the input schema, so `domains` must be an array and `maxRecords` an integer. Callers that have something else can use the aliases, which are read leniently:

- `domain`, `urls`, `websites` or `emails` instead of `domains`. These take an array or a single string such as `"a.com, b.com"` (commas, semicolons, spaces and new lines all separate). Any other key containing "domain", "url", "website" or "hostname" (`domainNames`, `domain_list`, `urlList`) is read as the domain list too, with a warning in the log.
- Objects such as `{"url": "https://x.com"}`, `{"website": ...}` or `{"domain": ...}` work as items.
- `tech`, or `"mode": "tech"`, instead of `techStack`; `whois` or `rdap` instead of `includeRdap`; `ssl` or `tls` instead of `includeTls`; `limit` instead of `maxRecords`. Other spellings of the field names also work (`tech_stack`, `include_tls`, `TechStack`) and take priority over the defaults the platform fills in. These accept `"yes"`, `"no"`, `"on"`, `"off"` and numbers given as strings.

Inside an array, each item is one value: `"exa mple.com"` is rejected as a typo, never read as `mple.com`. Values that can't be a domain get a **free** row with `status: "invalid_input"` that says why. That covers IP addresses, `localhost`, spaces, an object with no URL field, and a top-level domain that does not exist such as `shop.cmo` (checked against the DNS root). If the input holds data only under field names the actor doesn't recognise (e.g. `{"names": [...]}`), you get one free row that names those fields, and nothing is checked or charged. The run never fails because of bad input, including text with broken Unicode characters.

### Output

Every row has the **same keys**, whatever happens. Fields that don't apply are `null` or `[]`. Here is a real row from a local run with `{"domains": ["apify.com"]}` (core mode):

```json
{
  "position": 1,
  "input": "apify.com",
  "domain": "apify.com",
  "registrable_domain": "apify.com",
  "status": "ok",
  "checked_at": "2026-09-25T00:58:12Z",
  "registered": true,
  "registrar": "Amazon Registrar, Inc.",
  "registrar_iana_id": "468",
  "created_at": "2009-06-02T17:14:10Z",
  "updated_at": "2026-05-16T16:53:04Z",
  "expires_at": "2035-06-02T17:14:10Z",
  "domain_age_days": 6323,
  "days_to_expiry": 3172,
  "registry_status": ["client transfer prohibited"],
  "dnssec": true,
  "rdap_server": "rdap.verisign.com",
  "rdap_error": null,
  "resolves": true,
  "a": ["143.204.204.42", "143.204.204.5", "143.204.204.87", "143.204.204.97"],
  "aaaa": ["2600:9000:2058:2000:9:a03e:6540:93a1", "2600:9000:2058:2c00:9:a03e:6540:93a1", "2600:9000:2058:6200:9:a03e:6540:93a1", "2600:9000:2058:7200:9:a03e:6540:93a1", "2600:9000:2058:8400:9:a03e:6540:93a1", "2600:9000:2058:9200:9:a03e:6540:93a1", "2600:9000:2058:cc00:9:a03e:6540:93a1", "2600:9000:2058:f800:9:a03e:6540:93a1"],
  "mx": ["aspmx.l.google.com", "alt1.aspmx.l.google.com", "alt2.aspmx.l.google.com", "aspmx2.googlemail.com", "aspmx3.googlemail.com"],
  "ns": ["ns-1225.awsdns-25.org", "ns-1928.awsdns-49.co.uk", "ns-449.awsdns-56.com", "ns-839.awsdns-40.net"],
  "txt": ["v=MCPv1; k=ed25519; p=1rDnoEigwTr+4JjG89+sLbdNnswCnLjmaeT34srW53Y=", "v=spf1 a mx include:_spf.google.com include:mailgun.org include:amazonses.com include:19497222.spf05.hubspotemail.net -all", "aisle-domain-verification-ymx7gd=j3J4hCmXHIc9R0RNQCfa8xdSf", "google-site-verification=R_4agUZyHorQrO5QGFa6ZB_o-uDxq82NY-EteDUpPvo", "google-site-verification=XU737OZxx6NvGd3NQK9MRt98qxRMjx3RByq-O0cy16I", "google-site-verification=ZqRh2_YSBHj2bo_KsEdu6MfmTMKJYLpDDL3oPtOrN_A", "google-site-verification=jrECJwBG9eeO_FUrOMhp7JY1oF8JSBxhzmLsLGvrt9s", "mixpanel-domain-verify=44615d9c-27f2-4587-a781-8a0a1c734e9a", "openai-domain-verification=dv-WTM7Nna92Owwh4sgmDOVUyyt", "tito-domain-verification=pbg2696rvzbax2t4dl7h5ynqm", "zapier-domain-verification-challenge=2c358aa9-fa36-419f-b21a-ca9eb6f2791d"],
  "txt_count": 11,
  "caa": ["0 issue \"amazonaws.com\"", "0 issue \"digicert.com\"", "0 issue \"letsencrypt.org\"", "0 issuewild \"amazonaws.com\"", "0 issuewild \"letsencrypt.org\""],
  "mx_provider": "Google Workspace",
  "dns_provider": "Amazon Route 53",
  "dns_error": null,
  "email_security_grade": "A",
  "email_security_score": 100,
  "email_security_issues": [],
  "spf_record": "v=spf1 a mx include:_spf.google.com include:mailgun.org include:amazonses.com include:19497222.spf05.hubspotemail.net -all",
  "spf_all": "-all",
  "spf_includes": ["_spf.google.com", "mailgun.org", "amazonses.com", "19497222.spf05.hubspotemail.net"],
  "spf_dns_lookups": 10,
  "dmarc_record": "v=DMARC1; p=reject; sp=reject; pct=100; rua=mailto:dmarc-reports@apify.com; ri=604800",
  "dmarc_policy": "reject",
  "dmarc_subdomain_policy": "reject",
  "dmarc_pct": 100,
  "dmarc_rua_domains": ["apify.com"],
  "dmarc_source": "domain",
  "mta_sts_mode": "enforce",
  "tls_rpt": true,
  "bimi_logo": "https://apify.com/ext/apify-bimi-logo.svg",
  "bimi_certificate_url": "https://apify.com/ext/apify-bimi-certificate.pem",
  "dkim_selectors_found": ["google"],
  "tls_valid": true,
  "tls_host": "apify.com",
  "tls_version": "TLSv1.3",
  "tls_issuer": "Amazon (Amazon RSA 2048 M04)",
  "tls_subject": "*.apify.com",
  "tls_san_count": 4,
  "tls_key": "RSA 2048",
  "tls_not_before": "2026-07-03T00:00:00Z",
  "tls_not_after": "2027-01-16T23:59:59Z",
  "tls_days_to_expiry": 113,
  "tls_error": null,
  "hosting": null,
  "www_cname": null,
  "http_status": null,
  "final_url": null,
  "http_server": null,
  "html_generator": null,
  "tech_names": [],
  "tech": [],
  "http_error": null,
  "note": null
}
```

With `techStack: true`, the tech fields are filled in. This excerpt is from a real run on `allbirds.com`:

```json
{
  "domain": "allbirds.com",
  "hosting": "Shopify",
  "www_cname": "shops.myshopify.com",
  "http_status": 200,
  "final_url": "https://www.allbirds.com/",
  "http_server": "cloudflare",
  "tech_names": ["Apple (domain verification)", "Attentive", "Cloudflare", "DocuSign", "Google Search Console",
                 "Google Tag Manager", "Microsoft 365", "Miro", "OneTrust", "Shopify"],
  "tech": [
    {"name": "Shopify", "category": "Ecommerce", "evidence": "dns:www CNAME shops.myshopify.com"},
    {"name": "Cloudflare", "category": "CDN", "evidence": "header:server cloudflare"},
    {"name": "Google Tag Manager", "category": "Tag manager", "evidence": "html:googletagmanager.com/gtm.js"},
    {"name": "Attentive", "category": "SMS marketing", "evidence": "html:cdn.attn.tv"},
    {"name": "OneTrust", "category": "Consent management", "evidence": "html:optanon"},
    {"name": "Microsoft 365", "category": "Productivity", "evidence": "dns:TXT ms=ms"},
    {"name": "Apple (domain verification)", "category": "Business services", "evidence": "dns:TXT apple-domain-verification"},
    {"name": "DocuSign", "category": "E-signature", "evidence": "dns:TXT docusign"},
    {"name": "Google Search Console", "category": "SEO", "evidence": "dns:TXT google-site-verification"},
    {"name": "Miro", "category": "Productivity", "evidence": "dns:TXT miro-verification"}
  ],
  "http_error": null
}
```

#### Field notes

- **`txt` / `txt_count`:** `txt` holds at most 25 records, in a fixed order: policy records such as `v=spf1` first, then alphabetical. `txt_count` is the total number published. Large companies often publish 50–110 TXT records. SPF parsing and tech detection always read all of them, not only the 25 shown.
- **`spf_dns_lookups`:** the RFC 7208 count, with every `include:`/`redirect=` followed to the end (up to 40 queries and 10 levels deep). If a target can't be resolved or those limits are hit, the number is a lower bound and the issue says "at least N".
- **`dmarc_policy`:** the policy that applies to the domain you asked about. For a subdomain with no DMARC record of its own, the organizational domain's record is used (`dmarc_source: "organizational_domain"`), and the policy that applies is that record's `sp=`, or its `p=` when there is no `sp=` (RFC 7489). The grade uses the same policy. Example from a real run: `newsroom.ibm.com` has no record, and `ibm.com` publishes `p=reject; sp=none`. The row therefore shows `dmarc_policy: "none"` and grade D (30), and the issue list explains why. `dmarc_record` always shows the record as published.
- **`dmarc_subdomain_policy`:** the policy that actually applies to subdomains. That is `sp=` when it is published; otherwise subdomains inherit `p=`.
- **Duplicate records:** if a domain publishes two SPF or two DMARC records (a misconfiguration that receivers treat as broken), the one shown is picked in a fixed order, so it doesn't change between runs. The issue list flags the duplicate.
- **`bimi_certificate_url`:** the `a=` URL of the BIMI certificate (VMC/CMC), exactly as published. The certificate itself is not downloaded or validated.
- **`registrar_iana_id`:** only real IANA registrar IDs are kept. Placeholders such as `0000` (used by some ccTLD registries) become `null`.
- **`email_security_grade` is `null`** when a lookup the grade depends on (`_dmarc`, `_mta-sts`, `_smtp._tls` or `default._bimi`) timed out. A timeout is never read as "no record". The issue list says which lookup failed, `dns_error` names it, and the row is `partial`.
- **`position`:** the domain's place among the valid values in your input. Rows are pushed in small batches as domains finish, so sort by `position` if order matters.

#### `status` values

| status | meaning | charged? |
|---|---|---|
| `ok` | every enabled lookup answered | yes |
| `partial` | the domain exists and the row has data, but one section is missing. Causes: the TLD has no usable public RDAP (e.g. `.de`, `.co`, `.eu`, `.jp`, `.edu`, `.tw`); a registry rate limit or timeout; or a DNS lookup that kept failing. See `rdap_error` / `dns_error`. | yes |
| `unregistered` | the registry says "not found" and the name doesn't exist in DNS, so it is probably available | yes |
| `not_in_dns` | the name doesn't exist in DNS (NXDOMAIN), but the registry didn't confirm it is unregistered. Either the TLD has no public RDAP (a `.de` name that is probably available), or RDAP was off or failed, or the name is registered but not in DNS (on hold, or a subdomain that doesn't exist). `note` says which. | yes |
| `error` | every lookup failed (a network or upstream outage) | **no** (free row) |
| `invalid_input` | the value couldn't be read as a domain, or its top-level domain doesn't exist | **no** (free row) |
| `not_checked` | one summary row, added only when the run stopped before checking every domain because of your maximum charge or the run timeout. `note` says how many domains were left, lists the first few, and gives the reason. | **no** (free row) |

The dataset has three views: **Overview**, **Email security** and **Tech stack**. You can export them as JSON, CSV or Excel.

#### Email security score

| Part | Points |
|---|---|
| SPF | 20 for one valid record, plus 15 for `-all` or 10 for `~all`/`redirect=` (`?all` and `+all` score nothing). −10 if it needs more than 10 DNS lookups. |
| DMARC | 10 for a valid record, plus 35 for `p=reject` or 25 for `p=quarantine`, scaled by `pct` (`p=none` scores 0 and is flagged). A subdomain that inherits its parent's record is scored on the parent's `sp=` policy. |
| MTA-STS | 10 for `enforce`, 5 for `testing` |
| TLS-RPT | 5 |
| BIMI | 5 |

Grades: A ≥ 85, B ≥ 70, C ≥ 50, D ≥ 30, F below 30. Domains that receive no mail (no MX, or a null MX) get the MTA-STS, TLS-RPT and BIMI points automatically. For a parked domain, `-all` plus `p=reject` is the whole job. DKIM results are reported but not scored, because selectors can't be listed from DNS.

### Pricing

Pay per event: you pay only for rows you receive.

| Event | Price | When |
|---|---|---|
| `actor-start` | $0.01 | once per run, when the first paid row is delivered |
| `domain-report` | $0.003 | per domain row (core mode, or tech mode when the homepage couldn't be fetched, e.g. a bot-protection 403) |
| `domain-report-tech` | $0.004 | per domain row in tech mode where the homepage was fetched and fingerprinted |

Invalid inputs, including typos with a non-existent TLD, are **free**, and so are rows where every lookup failed and the `not_checked` summary row. Rows on TLDs without usable public RDAP are charged at the normal price: they still carry full DNS, email and TLS data. The run's status message lists those TLDs. `not_in_dns` rows are charged too, because the DNS and registry lookups were made and answered.

**Worked examples:**

- One domain from an AI agent: $0.01 + $0.003 = **$0.013**
- 100 domains, core mode: $0.01 + 100 × $0.003 = **$0.31**
- 1,000 domains, core mode: $0.01 + 1,000 × $0.003 = **$3.01**
- 1,000 domains, tech mode: $0.01 + 1,000 × $0.004 = **$4.01** at most. Homepages that block the request are billed at $0.003.
- 10,000 domains, core mode: **$30.01**

Set a maximum charge per run and the actor stops cleanly at that limit. It never pushes a row it can't bill, free rows don't count against the limit, and the run still ends as SUCCEEDED. A free `not_checked` row then says how many domains were left and why. If the limit doesn't even cover `actor-start` plus one report ($0.013), nothing is checked or charged and that row says so. In tech mode, rows in flight are budgeted at the tech price until they are charged. A row that falls back to the core price frees its difference for later domains. If Apify migrates or restarts the run, it continues where it stopped and does not charge a delivered domain or `actor-start` a second time. Apify's platform usage (compute) is billed separately and is small.

### Speed and limits

Local measurements on 2026-09-24, 131 well-known domains across 16 TLDs (including `.de`, `.jp`, `.edu` and `.tw`):

- **Core mode:** 37 s, peak memory 113 MB. When most domains share one TLD (e.g. `.com`), expect about **3–4 domains per second**, because each registry's RDAP server is limited to about 3 requests per second with at most 2 in flight. That is on purpose: RDAP is a shared public service. Mixed TLDs run faster.
- **Tech mode:** 57 s (about 2.3 domains per second), peak memory 124 MB. It makes one homepage GET per domain, with a 1.5 MB cap on the decompressed body and 35 s per homepage.
- **10,000 domains in tech mode** (the largest allowed input) against a simulated instant network with a 350 KB homepage for every domain: 265 s at 77 MB peak. Nearly all of that time is HTML fingerprinting, about 9 ms per 350 KB page on a desktop CPU. Memory stays flat because rows are pushed in small batches. A real run is limited by the upstream servers, not by this.

Each part of a domain's check has its own time limit: DNS and email 60 s, RDAP 40 s, TLS 30 s, homepage 35 s. A registry that keeps rate-limiting (HTTP 429) is retried, honouring `Retry-After`, until its 40 s are used up. A slow homepage is cut off at its limit. Either way the row is still delivered, as `partial` with `rdap_error` or with `http_error`, and keeps the DNS, email and TLS data. Compressed homepages and MTA-STS files are decompressed with a size cap, so a hostile "zip bomb" server can't exhaust memory. The run stops scheduling new domains before the run timeout and cuts off anything still in flight in time to save it. Short timeouts still produce rows: a 30 s run timeout, as an agent might set, checks a few domains. For very large lists, raise the timeout or split the list.

### FAQ

**Is this WHOIS?**
It uses RDAP, the structured JSON protocol that replaced WHOIS for generic TLDs. Registry servers are found through the official IANA bootstrap file.

<a id="which-tlds-have-registration-data"></a>
**Which TLDs have registration data?**
All generic TLDs listed in the IANA bootstrap: `.com`, `.net`, `.org`, `.info`, `.biz`, `.gov`, `.app`, `.dev`, `.xyz` and the other new gTLDs. On 2026-09-24 these country-code TLDs also had it: `.ac` `.ad` `.ai` `.ar` `.as` `.au` `.bm` `.br` `.ca` `.cc` `.cm` `.cr` `.cv` `.cx` `.cz` `.ec` `.fi` `.fj` `.fm` `.fo` `.fr` `.gd` `.gs` `.gy` `.hn` `.ht` `.id` `.in` `.io` `.is` `.ke` `.kg` `.kr` `.ky` `.lb` `.ly` `.me` `.mg` `.ml` `.ms` `.mu` `.na` `.nf` `.ng` `.nl` `.no` `.pg` `.pl` `.pm` `.pn` `.pw` `.re` `.rw` `.sd` `.sg` `.sh` `.si` `.sn` `.sr` `.ss` `.tf` `.th` `.to` `.tv` `.tz` `.ua` `.uk` `.us` `.uz` `.vg` `.vi` `.wf` `.ye` `.yt` `.zm`. `.io`, `.me`, `.ac`, `.sh` and `.us` use the registries' own public RDAP servers, which aren't yet listed in the bootstrap.
**Every other ccTLD has no usable public RDAP.** That includes `.de`, `.eu`, `.co` (Colombia), `.es`, `.it`, `.ch`, `.at`, `.be`, `.dk`, `.se`, `.pt`, `.ie`, `.nz`, `.jp`, `.cn`, `.ru`, `.mx`, `.so`, `.tr` and `.za`, plus the sponsored `.edu` and `.mil`. `.tw` is listed in the IANA bootstrap, but on 2026-09-24 TWNIC's server answered every query with HTTP 426 and an empty body. The actor asks it once per run and then reports the refusal on every `.tw` row. Those rows still carry full DNS, email and TLS data and are marked `partial`, with the reason in `rdap_error`. A name on one of these TLDs that doesn't exist in DNS is marked `not_in_dns` ("probably unregistered"), because the registry can't confirm it. If your list is mostly EU or `.co` domains, run a few first and check whether the DNS/email/TLS data is what you need.

**Does it return registrant names, e-mails or phone numbers?**
No. Only the registrar, dates, status codes and DNSSEC are extracted. Registrant contact data is mostly redacted anyway, and this actor never collects personal data.

**How are subdomains handled?**
`blog.example.co.uk` is checked as given for DNS, email and TLS. Registration data is looked up for the registered domain `example.co.uk`, using the Public Suffix List. If a subdomain has no DMARC record, the organizational domain's record is used, as the DMARC standard (RFC 7489) specifies. The policy that applies is then that record's subdomain policy `sp=` (or `p=` when there is no `sp=`), and the grade uses it. `dmarc_source` says which record applied. A subdomain that doesn't exist in DNS is marked `not_in_dns`.

**Why is `mx_provider` "Other" or "Self-hosted"?**
The MX hosts didn't match a known provider. "Self-hosted" means they sit under the domain itself. The raw `mx` hosts are always included.

**Why wasn't some tool detected?**
Tech detection reads the homepage HTML and response headers without running JavaScript. Tags loaded later through a tag manager can be missed. Sites behind bot protection may answer 403; you then get header and DNS clues only, billed at the core price. The fingerprints are a hand-maintained set of about 320 technologies, plus 57 mail providers and 47 DNS providers. No GPL-licensed fingerprint database is used.

**Can a detection be wrong?**
An HTML detection means the vendor's marker appears somewhere in the page. A page that embeds another vendor's snippet, such as a widget or a script loaded only under some condition, can show that vendor even though the site doesn't run on it. Markers are chosen to limit this. For example, Shopify is detected from theme and store-asset paths, not from any mention of `cdn.shopify.com`. The `evidence` field shows exactly what matched, so every detection can be checked.

**Why does `txt` show "verification" entries as technologies?**
`google-site-verification=`, `MS=ms…`, `stripe-verification=` and similar tokens show that the domain owner verified the domain with that service. That is a strong signal the service is in use. The `evidence` field always shows where each detection came from.

**Can I use the registration data for cold outreach?**
Registries publish RDAP under terms that forbid using it for unsolicited commercial messages and for high-volume abuse of their systems. This actor rate-limits itself per registry and returns no contact data. You are responsible for how you use the results.

### Use with AI agents / MCP

This actor is built to be called as a tool by AI agents through the [Apify MCP server](https://mcp.apify.com):

- **Lenient input:** agents can send URLs, e-mail addresses, mixed-case domains or `{"url": ...}` objects, and use aliases (`domain`, `urls`, `tech`, `limit`) that also accept plain strings. Bad values get a free explanatory row instead of a failed run.
- **Small, flat, stable output:** one row per domain, with the same keys every time and self-describing field names. Nothing is nested except the `tech` list of `{name, category, evidence}`.
- **Clear cost:** $0.013 for a single-domain check, $0.003 for each additional domain ($0.004 in tech mode).
- **Called with no input at all** (`{}` or an empty `domains` list), the actor checks `apify.com` as an example for $0.013 and the row's `note` says so. If the input has data only under field names the actor doesn't recognise, nothing is charged: a free row names those fields instead.
- **Short timeouts and budgets are safe:** a 30 s run timeout still checks a few domains. When the maximum charge or the timeout cuts a run short, a free `not_checked` row explains it, so an agent that reads only the dataset still learns why.

Example agent call:

```json
{ "domains": ["acme.com"], "techStack": true }
```

Typical prompts include "Is acme.com's email protected against spoofing?", "When does this domain expire and who is the registrar?", "Which of these 200 domains use Microsoft 365?" and "What CMS and analytics does this site run?"

### Data sources

| Data | Source |
|---|---|
| RDAP server lookup | [IANA RDAP bootstrap](https://data.iana.org/rdap/dns.json), loaded once per run |
| Registration data | each registry's own RDAP server (e.g. `rdap.verisign.com`, `rdap.publicinterestregistry.org`, `rdap.nominet.uk`) |
| Registered-domain split and TLD list | [Public Suffix List](https://publicsuffix.org/), ICANN section, loaded once per run (with a built-in fallback); unknown TLDs are confirmed against the DNS root |
| DNS | the system resolver, with 1.1.1.1 / 8.8.8.8 as fallback |
| MTA-STS policy | `https://mta-sts.<domain>/.well-known/mta-sts.txt` (RFC 8461), fetched only when the `_mta-sts` TXT record exists |
| TLS certificate | a direct TLS handshake to port 443 |
| Tech stack | one GET of the domain's homepage (no cookies kept) |

All requests identify themselves with the User-Agent `bigdavidson-apify-actor/domain-intel-bulk (+https://apify.com/bigdavidson)`.

*Unofficial: not affiliated with IANA, ICANN, any domain registry or registrar, or any vendor whose technology is detected. All names are trademarks of their owners.*

### Changelog

- **0.1 (2026-09-24):** first release. RDAP (IANA bootstrap plus the .io/.me/.ac/.sh/.us registry servers), DNS (A/AAAA/MX/NS/TXT/CAA), mail and DNS provider inference, email security grade (SPF with a recursive lookup count, DMARC with organizational fallback scored on the parent's `sp=` policy, MTA-STS policy, TLS-RPT, BIMI, common DKIM selectors), TLS certificate check, and an optional tech-stack mode. Each section has its own time limit, so slow registries or homepages produce partial rows instead of lost ones. `not_in_dns` status for names that don't exist in DNS when the registry can't confirm them as unregistered. A free `not_checked` row appears when the maximum charge or the timeout cuts a run short. Decompression is size-capped. Runs resume without double charges after a platform migration.

# Actor input Schema

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

Array of domains to check, one per item (an item may also hold a comma-separated list). Full URLs and e-mail addresses are accepted: scheme, path, port, a leading www. and the e-mail local part are stripped; other subdomains are kept (registration data is looked up for the registered domain). Duplicates are removed. Up to 10,000 per run. Cost: $0.003 per domain report ($0.004 with Tech stack mode) + $0.01 per run. Invalid values (IPs, spaces, non-existent TLDs like .cmo) get a free explanatory row. Registration data exists for all generic TLDs and ~80 ccTLDs (not .de, .eu, .co, .es, .it, .jp, .edu ...): other rows are 'partial' with DNS/email/TLS data. To send a single string instead of an array, use the key 'urls' or 'domain'. If left empty, apify.com is checked as an example.

## `techStack` (type: `boolean`):

Also fetch each homepage once and detect hosting/CDN, CMS, frameworks, analytics, marketing and chat tools (static HTML + headers), plus SaaS inferred from DNS (TXT verification tokens, SPF senders, www CNAME). Rows where the homepage was fetched cost $0.004 instead of $0.003.

## `includeRdap` (type: `boolean`):

Look up registrar, creation/expiry dates, registry status and DNSSEC from the official registry RDAP service (IANA bootstrap). Same price either way; turn off for DNS/email/SSL only.

## `includeTls` (type: `boolean`):

Open one TLS connection to port 443 (apex, then www) and report issuer, validity, days to expiry, SAN count and key type. Same price either way.

## `maxRecords` (type: `integer`):

Optional cap on how many domains are checked (and charged) in this run. Leave empty for no cap beyond the 10,000 per-run limit.

## Actor input object example

```json
{
  "domains": [
    "apify.com",
    "example.com"
  ],
  "techStack": false,
  "includeRdap": true,
  "includeTls": true
}
```

# Actor output Schema

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

No description

## `overview` (type: `string`):

No description

## `email` (type: `string`):

No description

## `tech` (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",
        "example.com"
    ]
};

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

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

```

## MCP server setup

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

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/DDqvaq0D0eNECjsIb/builds/2tf2HgprphP499uXi/openapi.json
