# SSL Certificate Checker and DNS Lookup: SPF, DMARC, Expiry (`nightwave-owner/domain-inspector`) Actor

Returns DNS records (A, AAAA, MX, NS, TXT, CAA), parsed SPF and DMARC and the SSL/TLS certificate (issuer, expiry date, days left, SAN) for each domain, with a status and a list of issues. Bulk, no API key, onlyNew for change and expiry monitoring.

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

## Pricing

$2.00 / 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.

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

## SSL Certificate Checker and DNS Lookup: SPF, DMARC, Expiry

An SSL certificate checker, DNS lookup and SPF/DMARC check for a list of domains in one run. For each domain it returns the DNS records (A, AAAA, MX, NS, TXT and CAA), the SPF and DMARC records parsed into fields, and the SSL/TLS certificate served on port 443: issuer, valid from and to, days left, subject alternative names (SAN), protocol and whether it is trusted and matches the name. Each row ends in a status (`ok`, `warning` or `error`) and a list of issues in plain words, such as "SSL: the certificate expires in 12 days." or "DMARC: Policy p=none only monitors: spoofed mail is not stopped."

No API key, no third-party service. The actor asks DNS directly and opens a TLS handshake to the domain's own server, nothing else. With `onlyNew` it becomes a monitor: a daily run delivers only the domains where something changed, for example a certificate that has entered its warning window, a renewed certificate, a new MX host or a changed SPF record.

### Example from a real run

This is the input and one row of the output from a run on the Apify platform on 3 October 2026 (run `YZyLnshoRE6gZ61Lo`). The certificate lists 41 names; 37 of them are cut from the `san` list below to keep it short. Nothing else is edited.

Input (empty input checks these three domains):

```json
{
  "domains": ["nightwave.se", "example.com", "wikipedia.org"]
}
```

Output row for `wikipedia.org`:

````json
{
  "domain": "wikipedia.org",
  "status": "ok",
  "issues": [],
  "sslDaysLeft": 31,
  "sslValidTo": "2026-11-03T19:15:40.000Z",
  "sslIssuer": "YE2",
  "mxHosts": [
    "mx-in1001.wikimedia.org",
    "mx-in2001.wikimedia.org"
  ],
  "nameservers": [
    "ns0.wikimedia.org",
    "ns1.wikimedia.org",
    "ns2.wikimedia.org"
  ],
  "spfAll": "~",
  "dmarcPolicy": "reject",
  "dns": {
    "a": [
      "208.80.154.224"
    ],
    "aaaa": [
      "2620:0:861:ed1a::1"
    ],
    "mx": [
      {
        "priority": 10,
        "exchange": "mx-in1001.wikimedia.org"
      },
      {
        "priority": 10,
        "exchange": "mx-in2001.wikimedia.org"
      }
    ],
    "ns": [
      "ns0.wikimedia.org",
      "ns1.wikimedia.org",
      "ns2.wikimedia.org"
    ],
    "txt": [
      "google-site-verification=AMHkgs-4ViEvIJf5znZle-BSE2EPNFqM1nDJGRyn2qk",
      "v=spf1 include:_cidrs.wikimedia.org ~all",
      "yandex-verification: 35c08d23099dc863"
    ],
    "caa": [
      "0 iodef \"mailto:dns-admin@wikimedia.org\"",
      "0 issue \"letsencrypt.org\"",
      "0 issue \"pki.goog\""
    ],
    "spf": {
      "record": "v=spf1 include:_cidrs.wikimedia.org ~all",
      "all": "~",
      "includes": [
        "_cidrs.wikimedia.org"
      ],
      "lookupCount": 1,
      "warnings": []
    },
    "dmarc": {
      "record": "v=DMARC1; p=reject; rua=mailto:dmarc-rua@wikimedia.org;",
      "policy": "reject",
      "subdomainPolicy": null,
      "pct": 100,
      "reportDomains": [
        "wikimedia.org"
      ],
      "alignmentDkim": "relaxed",
      "alignmentSpf": "relaxed",
      "warnings": []
    },
    "exists": true,
    "errors": []
  },
  "ssl": {
    "issuer": "YE2",
    "issuerOrganization": "Let's Encrypt",
    "subject": "*.wikipedia.org",
    "validFrom": "2026-08-05T19:15:41.000Z",
    "validTo": "2026-11-03T19:15:40.000Z",
    "daysLeft": 31,
    "san": [
      "*.m.mediawiki.org",
      "*.m.wikibooks.org",
      "*.m.wikidata.org",
      "*.m.wikimedia.org",
      "... 37 more names"
    ],
    "serialNumber": "062387C4D2B6E686767EFF54CE0B7E26BDDF",
    "fingerprintSha256": "08:E0:B6:5D:F4:1F:B0:75:EC:91:C1:DC:D2:CA:CC:4E:8C:7D:DA:53:B7:54:80:65:25:11:7B:32:F7:B7:32:53",
    "protocol": "TLSv1.3",
    "isTrusted": true,
    "trustError": null,
    "hostnameMatches": true
  },
  "sslError": null,
  "checkedAt": "2026-10-03T13:33:37.133Z",
  "changeFingerprint": "aed2d80383644afb"
}```

The other two rows had status `warning`: `nightwave.se` because its DMARC policy is `p=none` and it has no CAA record, and `example.com` because its DMARC record has no `rua` address and it has no CAA record. The run checked three domains in about two seconds and used 0.0002 USD of platform usage.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `domains` | array | three examples | Domains or URLs. `https://www.site.se/path` is reduced to `www.site.se`, international names are converted to punycode (`räksmörgås.se` to `xn--rksmrgs-5wao1o.se`), and duplicates are checked once. Invalid entries are skipped with a warning in the log. |
| `checks` | array | `["dns", "ssl"]` | `dns` for the DNS records, SPF and DMARC, `ssl` for the certificate. Use `["ssl"]` for certificate expiry only. |
| `expiryWarningDays` | integer | `30` | A certificate that expires within this many days gives status `warning`. An expired one gives `error`. |
| `maxResults` | integer | `50` | Highest number of domains per run. Raise it for big lists (up to 10 000). |
| `onlyNew` | boolean | `false` | Deliver and charge only domains that changed since the last run with the same input. See "Monitoring and scheduling". |

### Output

One row per domain, in the order given. The table view in Apify Console shows the summary fields; the full row has the details.

| Field | Description |
|---|---|
| `domain` | The host name that was checked, lower case ASCII |
| `status` | `error` when something is broken (the domain does not exist, the certificate has expired, is not valid for the name or is not trusted, no TLS answer), `warning` when something should be fixed (certificate expires soon, missing MX, SPF, DMARC or CAA, weak SPF or DMARC), else `ok` |
| `issues` | Every finding behind the status, errors first |
| `sslDaysLeft` | Whole days until the certificate expires, negative after expiry |
| `sslValidTo` | Certificate expiry time, ISO 8601 in UTC |
| `sslIssuer` | Name of the issuing CA certificate, for example `R11` or `WE1` |
| `mxHosts` | Mail servers, lowest priority number first |
| `nameservers` | NS records |
| `spfAll` | Qualifier of the SPF `all` mechanism: `-` (fail), `~` (softfail), `?` (neutral) or `+` (pass) |
| `dmarcPolicy` | DMARC `p` value: `none`, `quarantine` or `reject` |
| `dns` | All records: `a`, `aaaa`, `mx`, `ns`, `txt`, `caa`, and `spf` (record, all, includes, lookupCount, warnings) and `dmarc` (record, policy, subdomainPolicy, pct, reportDomains, alignmentDkim, alignmentSpf, warnings) |
| `ssl` | `issuer`, `issuerOrganization`, `subject`, `validFrom`, `validTo`, `daysLeft`, `san`, `serialNumber`, `fingerprintSha256`, `protocol`, `isTrusted`, `trustError`, `hostnameMatches` |
| `sslError` | Why no certificate could be read, for example a timeout or a refused connection |
| `changeFingerprint` | Short hash of the domain's state, used by `onlyNew`. It changes when a record, the certificate or the status changes |
| `checkedAt` | Time of the check, ISO 8601 in UTC |

### Monitoring and scheduling

Set `onlyNew` to `true` and run the actor every day on your list of domains. The first run delivers every domain. After that a domain is delivered (and charged) only when its state has changed since the previous run with the same input: a new or renewed certificate, a changed MX, NS, TXT or CAA record, a domain that stops resolving, or a status change, such as a certificate that crosses `expiryWarningDays`. A day passing on its own is not a change, so a quiet day gives an empty dataset.

The state is a list of fingerprints in a named key-value store in your Apify account (`nightwave-state-domain-inspector`), one record per input. `onlyNew` and `maxResults` are not part of the remembered input. The IP addresses of A and AAAA records are left out of the fingerprint, because CDNs and load balancers answer with a different address on each lookup; a domain that loses all its addresses still counts as a change. To start over, delete the record in the key-value store.

A second run straight after the first, with `onlyNew` and the same input, returns 0 rows and is not charged, because nothing has changed in between.

Example: every morning at 07:00, report the domains whose certificate expires within 21 days or whose records changed. In Apify Console, open **Schedules**, create a schedule with the cron expression `0 7 * * *` and add this actor with the input below. Connect a webhook or an integration (Slack, e-mail, Make, Zapier) to the run to get the rows where you work.

```json
{
  "domains": ["example.com", "shop.example.com", "example.se"],
  "expiryWarningDays": 21,
  "maxResults": 500,
  "onlyNew": true
}
````

The same schedule through the Apify API:

```sh
curl -X POST "https://api.apify.com/v2/schedules?token=<YOUR_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"name": "daily-domain-check", "cronExpression": "0 7 * * *", "timezone": "Europe/Stockholm", "isEnabled": true, "isExclusive": true,
       "actions": [{"type": "RUN_ACTOR", "actorId": "nightwave-owner~domain-inspector",
                    "runInput": {"contentType": "application/json; charset=utf-8", "body": "<the input above as a JSON string>"}}]}'
```

### Use cases

- Never miss a certificate renewal: a daily list of every domain whose certificate expires within your warning window, across all your sites, subdomains and customers.
- Audit e-mail authentication: which of your domains lack SPF or DMARC, use `+all`, exceed the 10 lookup limit or still sit at `p=none`.
- Watch for unexpected DNS changes on important domains (new MX or NS hosts, changed TXT records).
- Enrich a list of company websites with mail provider (from MX), DNS host (from NS) and certificate authority.
- Feed an AI agent through the Apify MCP server: a run with one domain answers in a few seconds.

### Limitations

- **No WHOIS or RDAP data yet.** Registrar, registration and expiry dates of the domain itself are not included in this version. See "Data source and license".
- **One certificate per name.** The certificate is read from port 443 of the exact name you give, so `example.com` and `www.example.com` are separate checks. Servers behind a load balancer may serve different certificates; you get the one that answered.
- **Trust** is checked against Node.js's built-in list of root certificates (Mozilla's CA store). `isTrusted: false` with `trustError` explains why, for example `DEPTH_ZERO_SELF_SIGNED_CERT` or `UNABLE_TO_VERIFY_LEAF_SIGNATURE` (a missing intermediate certificate).
- **SPF lookups** are counted in the record itself (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`). Includes inside includes are not followed, so a record under 10 can still exceed the limit in total.
- **DMARC report addresses** are reduced to their domain (`reportDomains`) so no mailbox names end up in the output.
- **DNS** answers come from the resolver of the Apify platform, with a 4 second timeout and one retry. A lookup that times out or fails is asked again at public resolvers (1.1.1.1, 8.8.8.8, 9.9.9.9). A lookup that still fails is listed in `issues` and gives status `error`, so check again before acting on it.
- **Polite by design.** One TLS handshake and a handful of DNS queries per domain, ten domains at a time. No HTTP request is sent and no page is downloaded.

### FAQ

**What does 1 000 domains cost?**
2 USD (0.002 USD per domain, event `domain`), plus Apify platform usage, which is small: a test run with 60 well known domains took 5 seconds and used 0.0004 USD.

**Why did a domain show up with `onlyNew` when nothing seemed to change?**
Something in its state did: compare `changeFingerprint`, `issues` and the records with the previous row. A common cause is a certificate that was renewed, or one that crossed the warning window.

**Can I check only certificates?**
Yes, set `checks` to `["ssl"]`.

### Data source and license

The actor uses only open Internet protocols and asks each domain's own servers, so there is no third-party data source and no terms of use to accept:

- DNS (RFC 1035) for A, AAAA, MX, NS, TXT and CAA (RFC 8659) records, SPF (RFC 7208) and DMARC (RFC 7489).
- The TLS handshake (RFC 8446) on port 443, reading the certificate the server presents to every visitor.

The output contains only what the domain owner publishes in DNS and in the certificate. No personal data is looked up. TXT and CAA records are returned as published; DMARC report addresses are reduced to their domain.

RDAP (registration data) is not part of this version. The terms of use of Verisign's RDAP service for .com and .net, read on 3 October 2026 at verisign.com, say that the data may not be used to "enable high volume, automated, electronic processes that send queries or data to the systems of Verisign or an ICANN-accredited registrar, except as reasonably necessary to register domain names or modify existing registrations". Until it is settled how a bulk tool can respect that, the actor does not query RDAP.

### Pricing

Pay per result: 0.002 USD per delivered domain (event `domain`), which is 2 USD per 1 000. Duplicates and invalid entries are not charged, and with `onlyNew` unchanged domains are not charged. Apify bills platform usage on top as usual.

### Contact

Built and maintained by Nightwave AB. Questions, bugs and feature requests: kontakt@nightwave.se

### På svenska

Actorn kontrollerar en lista med domäner och ger för varje domän DNS-posterna (A, AAAA, MX, NS, TXT och CAA), SPF- och DMARC-posterna uppdelade i fält, och SSL/TLS-certifikatet på port 443: utfärdare, giltighetstid, dagar kvar, alternativa namn (SAN) och om det är betrott och gäller namnet. Varje rad får status `ok`, `warning` eller `error` och en lista med anmärkningar i klartext.

- Ingen API-nyckel och ingen tredjepartstjänst: actorn frågar DNS och gör en TLS-handskakning mot domänens egen server. Ingen webbsida hämtas.
- Med `onlyNew: true` och en daglig körning levereras och debiteras bara domäner där något har ändrats, till exempel ett certifikat som närmar sig utgångsdatum (`expiryWarningDays`, standard 30 dagar), ett förnyat certifikat eller en ny MX-post.
- Inga personuppgifter: bara det domänägaren själv publicerar i DNS och i certifikatet. Adresser för DMARC-rapporter kortas till domänen.
- RDAP (registrar och registreringsdatum) ingår inte i den här versionen, eftersom Verisigns villkor för .com och .net inte tillåter automatiserade frågor i stor volym.
- Pris: 0,002 USD per domän (2 USD per 1 000) plus Apifys plattformsanvändning.
- Kontakt: kontakt@nightwave.se

# Actor input Schema

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

Domains or URLs to inspect, for example \["example.com", "https://www.company.se/"]. URLs are reduced to the host name, international names are converted to punycode and duplicates are checked once. Empty input checks three example domains.

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

Which checks to run: "dns" (A, AAAA, MX, NS, TXT, CAA, SPF and DMARC) and "ssl" (the certificate on port 443). Default is both, for example \["ssl"] for certificate expiry only.

## `expiryWarningDays` (type: `integer`):

A certificate that expires within this many days gives status "warning", for example 30.

## `maxResults` (type: `integer`):

The highest number of domains checked in one run, for example 50. Domains beyond it are skipped with a warning in the log. Raise it for large lists (up to 10 000).

## `onlyNew` (type: `boolean`):

Deliver and charge only domains whose records, certificate or status changed since the previous run with the same input, for example true for a daily schedule. See "Monitoring and scheduling" in the README. Defaults to false.

## Actor input object example

```json
{
  "domains": [
    "example.com",
    "company.se"
  ],
  "checks": [
    "dns",
    "ssl"
  ],
  "expiryWarningDays": 30,
  "maxResults": 50,
  "onlyNew": true
}
```

# Actor output Schema

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

One row per checked domain, as JSON. Open in Apify Console or download via the dataset API.

# 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": [
        "nightwave.se",
        "example.com",
        "wikipedia.org"
    ],
    "checks": [
        "dns",
        "ssl"
    ],
    "expiryWarningDays": 30,
    "maxResults": 50,
    "onlyNew": false
};

// Run the Actor and wait for it to finish
const run = await client.actor("nightwave-owner/domain-inspector").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": [
        "nightwave.se",
        "example.com",
        "wikipedia.org",
    ],
    "checks": [
        "dns",
        "ssl",
    ],
    "expiryWarningDays": 30,
    "maxResults": 50,
    "onlyNew": False,
}

# Run the Actor and wait for it to finish
run = client.actor("nightwave-owner/domain-inspector").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": [
    "nightwave.se",
    "example.com",
    "wikipedia.org"
  ],
  "checks": [
    "dns",
    "ssl"
  ],
  "expiryWarningDays": 30,
  "maxResults": 50,
  "onlyNew": false
}' |
apify call nightwave-owner/domain-inspector --silent --output-dataset

```

## MCP server setup

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

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/Dd5a6FCRgJ3x5N3rg/builds/7FC9mirZWkZbIgpMh/openapi.json
