# Domain Health Check – SSL, Expiry & Email Auth (`pontio/domain-health`) Actor

Check a list of domains for expiring or invalid SSL certificates, domain registrations about to lapse, and missing or weak SPF, DMARC and MX, with every problem spelled out as an issue code.

- **URL**: https://apify.com/pontio/domain-health.md
- **Developed by:** [Gabor Molnar](https://apify.com/pontio) (community)
- **Categories:** Developer tools, SEO tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 domain health checkeds

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

## Domain Health Check – SSL, Expiry & Email Auth

Send a list of domains and get one row per domain that says what is wrong with it: an SSL certificate that has expired, is about to, or is not trusted; a domain registration about to lapse; missing or weak SPF, DMARC and MX. Every problem comes back as an issue code. A `checked` row with an empty `issues` list passed every check it ran. Registration expiry is checked only when `domainExpiresAt` is filled in, and it is empty for a subdomain or a registry without RDAP. On any other `reasonCode` the checks did not finish, and an empty list tells you nothing. Up to 1,000 domains per run. Put it on an Apify schedule to turn it into a monitor.

### What you get

One dataset item per domain. Here is a real one for `wrong.host.badssl.com`, a test host serving a certificate issued for another name:

```json
{
  "domain": "wrong.host.badssl.com",
  "reasonCode": "checked",
  "issues": [
    "ssl_expiring",
    "ssl_hostname_mismatch",
    "no_mx",
    "spf_missing",
    "dmarc_missing"
  ],
  "tls": {
    "host": "wrong.host.badssl.com",
    "outcome": "certificate",
    "trusted": false,
    "error": "ERR_TLS_CERT_ALTNAME_INVALID",
    "issuer": "Let's Encrypt",
    "validFrom": "2026-07-28T20:03:02.000Z",
    "validTo": "2026-10-26T20:03:01.000Z",
    "daysLeft": 29
  },
  "registrar": null,
  "domainExpiresAt": null,
  "domainDaysLeft": null,
  "mxHosts": [],
  "spfRecord": null,
  "spfGrade": "none",
  "dmarcPolicy": null,
  "dmarcGrade": "none",
  "checkedAt": "2026-09-27T18:16:08.385Z"
}
```

And `github.com` on the same day: `"issues": []`, a trusted Sectigo certificate with 63 days left, registration with MarkMonitor until 2028.

### Use it when

- You look after many domains (your own, or clients') and want one list of what needs attention this week.
- You want a warning before a certificate or a registration lapses, not after.
- You are auditing email authentication across a portfolio and need SPF and DMARC graded, not dumped.
- An agent needs a yes/no health verdict with reasons instead of reading raw DNS.

### Input

| Field | Type | Required | What it does |
| --- | --- | --- | --- |
| `domains` | array of strings | yes | Up to 1,000 per run. A URL or an email address is reduced to its host name (a leading `www.` is dropped, other subdomains are kept). Each distinct name gets one row and at most one charge: inputs that normalize to the same name count once. |
| `warnDays` | integer | no | Flag a certificate or registration that expires within this many days. Default 30, range 1 to 365. Applies to every domain in the run. |

### Issue codes

Listed most serious first, in the order they appear in `issues`.

| Issue | Meaning |
| --- | --- |
| `domain_not_found` | The name does not resolve in DNS (NXDOMAIN). Nothing else is checked. When the registry still holds a record, `registrar` and `domainExpiresAt` are filled in: a registered domain that stopped resolving has lapsed or is on hold, and the expiry date tells you which. |
| `domain_expired` | The registry's expiry date has passed. Registries usually hold a lapsed domain for a while before releasing it, so it can often still be renewed. |
| `domain_expiring` | The registration expires within `warnDays`. |
| `no_https` | Nothing accepts connections on port 443, or the name has no address. When the domain itself has no address, `www.` is checked instead. |
| `ssl_expired` | The certificate's end date has passed. |
| `ssl_expiring` | The certificate expires within `warnDays`. |
| `ssl_hostname_mismatch` | The certificate was issued for a different name. |
| `ssl_untrusted` | The certificate failed verification for a reason other than its dates or its name: self-signed, an unknown root, a server that omits an intermediate certificate, or not valid yet. `tls.error` holds the exact reason. |
| `no_mx` | No MX record. A null MX (`0 .`, "this domain takes no mail") is a deliberate setting and is not flagged. |
| `spf_missing`, `spf_multiple_records`, `spf_weak` | No SPF record; more than one, which receivers treat as none; or `?all`/`+all`, which tells receivers not to reject mail from anyone else. |
| `dmarc_missing`, `dmarc_multiple_records`, `dmarc_not_enforced` | No DMARC policy (a subdomain covered by its parent's policy counts as having one); more than one record; or `p=none`. |

DKIM is not checked. It can only be probed by guessing selector names, so "none found" would not mean "none configured".

### Output

| Field | What it holds |
| --- | --- |
| `domain` | The normalized domain. |
| `reasonCode` | What happened. See the pricing table. |
| `issues` | The problems found, as codes from the table above. |
| `tls` | The HTTPS probe: `host` checked, `outcome` (`certificate` or `no_https`), `trusted`, `error`, `issuer`, `validFrom`, `validTo`, `daysLeft`. `null` when the probe could not finish or the domain does not exist. |
| `registrar`, `domainExpiresAt`, `domainDaysLeft` | From RDAP. `null` for a subdomain, and for a registry that publishes no RDAP (`.de` among many country domains). |
| `mxHosts` | MX hosts by priority. |
| `spfRecord`, `spfGrade` | `strict` (`-all`), `moderate` (`~all`), `weak` (`?all`/`+all`) or `none`. |
| `dmarcPolicy`, `dmarcGrade` | `enforced` (`reject`), `partial` (`quarantine`), `unprotected` (`none`) or `none`. |
| `checkedAt` | When the row was produced, so rows from scheduled runs line up. |

### Pricing

Pay per event, $2.00 per 1,000 domains checked ($0.002 each), charged on the `domain-health-checked` event.

A row is charged when every check answered. Problems are what you are paying to find, so a domain with an expired certificate or no DMARC is charged like a healthy one. A domain that no longer exists is charged too: for a domain you monitor, that is the most important finding there is. A registry that publishes no RDAP leaves the registration fields empty and the row is still charged, because that is a permanent fact about the registry, not an outage. The same goes for a subdomain, which has no registration of its own. Neither row can report `domain_expired` or `domain_expiring`, so its empty `issues` covers the certificate and email checks only. `domainExpiresAt: null` tells you which rows these are.

| `reasonCode` | Charged |
| --- | --- |
| `checked` (every check answered) | yes |
| `domain_not_found` (the name does not resolve in DNS) | yes |
| `https_unreachable` (the HTTPS probe could not finish: a timeout, a reset or a failed handshake, so we could not find out) | no |
| `upstream_error` (DNS or RDAP failed or throttled us) | no |
| `invalid_domain` (not a registrable domain: an IP, `localhost`, a bare TLD) | no |

Free rows still carry what was found, but their `issues` list only covers the checks that answered.

If you set a maximum total charge for a run, the Actor stops as soon as that limit is reached instead of working for free. Items after that point are left out of the dataset and not charged, and the run log says how many; submit them in a new run.

### Limits

- One certificate per domain: the one served on port 443 of the domain, or of `www.` when the domain has no address of its own. Other subdomains are not discovered; list them as their own entries.
- Trust is checked against Node.js's bundled root store. A server that omits an intermediate certificate is reported as `ssl_untrusted`, even though some browsers repair that by fetching the missing certificate.
- Each run is a snapshot. The Actor keeps nothing between runs, so it reports what is wrong now, not what changed since last time. Schedule it and read `issues` on the `checked` and `domain_not_found` rows; any other row did not finish its checks.
- Domains are checked one after another. If the run reaches its timeout it stops there; the rows already written stay in the dataset.

### Data sources

A TLS handshake with the domain's own web server; DNS over HTTPS (Cloudflare `1.1.1.1`); RDAP, the registries' official successor to WHOIS. No paid data source.

# Changelog

This Actor's version history is a separate document: https://apify.com/pontio/domain-health/changelog.md

# Actor input Schema

## `warnDays` (type: `integer`):

Flag a certificate or domain registration expiring within this many days

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

Domains to check, e.g. example.com (max 1000 per run; a URL or email is reduced to its host name). One row per distinct name; inputs that normalize to the same name count once.

## Actor input object example

```json
{
  "warnDays": 30,
  "domains": [
    "example.com"
  ]
}
```

# Actor output Schema

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

Results produced by this run, in its default dataset.

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

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

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

```

## MCP server setup

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

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/Utaezxu2SWCmyKhgS/builds/yG4xYXqBz3Yfw4x8i/openapi.json
