# DNS Record Change Monitor (`isnard/dns-change-monitor`) Actor

Resolve A, AAAA, MX, NS, TXT, CNAME, CAA, SPF and DMARC records for a list of domains and report what changed since the last run, with a severity for NS, MX, SPF and DMARC changes. Built for daily schedules.

- **URL**: https://apify.com/isnard/dns-change-monitor.md
- **Developed by:** [Matias Isnard](https://apify.com/isnard) (community)
- **Categories:** Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 domain 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

## DNS Record Change Monitor

Give it a list of domains. On each run it resolves A, AAAA, MX, NS, TXT, CNAME and CAA records, plus the SPF record and the DMARC record, and compares them with the records it saw on the previous run. You get the current records, whether anything changed, and for each changed record type which values were added and which were removed.

Changes that matter for security get a higher severity: a changed nameserver or mail server, or an SPF or DMARC record that changed or disappeared. Run it on a daily schedule and get an email or a Slack message when `changed` is true.

### Who it is for

- Agencies, MSPs and IT teams who manage domains for clients and want to know when someone touches the DNS.
- Security teams watching for nameserver hijacks, mail rerouting or a removed DMARC policy.
- Anyone who wants a record of when DNS changed, without running their own cron job.

### What it checks

| Type | Queried name | Notes |
| --- | --- | --- |
| A, AAAA | the domain | IPv4 and IPv6 addresses. See rotation below. |
| MX | the domain | Priority and mail server, for example `10 aspmx.l.google.com`. |
| NS | the domain | Nameservers. |
| TXT | the domain | All TXT records, including verification tokens. |
| CNAME | the domain | Only a CNAME on that exact name. A domain apex normally has none. |
| CAA | the domain | Which certificate authorities may issue certificates. |
| SPF | the domain | The TXT records that start with `v=spf1`. |
| DMARC | `_dmarc.<domain>` | The TXT records that start with `v=DMARC1`. |

Queries go to the public resolvers 1.1.1.1 and 8.8.8.8, so the result is what the rest of the internet sees.

Records are normalized before they are compared: sorted, duplicates removed, trailing dots removed, and TTLs ignored. Hostnames are lowercased. TXT, SPF and DMARC content keeps its case, because TXT data is case-sensitive (a verification token with a changed letter is a different token).

### Change detection

After each check the records are saved in a named key-value store called `dns-change-monitor-state` in your account, one entry per domain. On the next run they are compared and each changed type is listed:

```json
{ "type": "NS", "added": ["ns1.other-dns.example"], "removed": ["ns-421.awsdns-52.com"], "severity": "high" }
```

| Severity | When |
| --- | --- |
| `critical` | SPF or DMARC was removed (records only removed, none added). |
| `high` | NS, MX, SPF or DMARC changed. |
| `medium` | CAA changed. |
| `low` | A, AAAA, TXT or CNAME changed. |
| `none` | Nothing changed. |

The item's `severity` is the highest one among its changes.

- `firstRun` is true when there was no snapshot for the domain yet. Nothing is reported as changed on the first run.
- A record type you add to the input later has no snapshot yet, so it is not reported as changed on that run either.
- If a query fails (timeout or server failure), that type is not compared, the error is listed in `errors`, and the old values are kept for the next run. A failed query is never reported as removed records.
- A domain that does not exist (NXDOMAIN) is reported with `status: "nxdomain"`. Its last snapshot is kept.

#### Round-robin and geo DNS

Many large sites return a different subset of IP addresses on each query. Without care, that shows up as a change every day. With `ignoreARecordRotation` on (the default), when the A or AAAA set differs from the snapshot the Actor queries it a second time. If the second answer matches the snapshot, the difference is treated as rotation and not reported. If it still differs, the union of both answers is compared with the snapshot and stored. Turn it off if you want every difference reported.

#### Separate tasks

The store is shared by all runs of this Actor in your account. If two tasks watch the same domain (for example with different record types), give each one its own `stateKey` so they keep separate snapshots. To start over, delete the store in Storage > Key-value stores, or use a new `stateKey`.

### Input

```json
{
    "domains": ["github.com", "google.com", "mercadolibre.com.ar"],
    "recordTypes": ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "CAA", "SPF", "DMARC"],
    "ignoreARecordRotation": true,
    "stateKey": "client-a"
}
```

- `domains`: domains or hostnames. URLs are reduced to the hostname. Subdomains such as `www.example.com` are checked as given. Invalid lines are skipped with a warning and not charged. Duplicates are checked once.
- `recordTypes` (default: all nine).
- `ignoreARecordRotation` (default true).
- `stateKey` (optional).

### Output

One dataset item per domain. Example (TXT records shortened):

```json
{
    "input": "github.com",
    "domain": "github.com",
    "checkedAt": "2026-10-30T03:59:50Z",
    "previousCheckedAt": "2026-10-29T03:59:49Z",
    "firstRun": false,
    "status": "ok",
    "records": {
        "A": ["4.228.31.150"],
        "AAAA": [],
        "MX": ["0 github-com.mail.protection.outlook.com"],
        "NS": ["dns1.p08.nsone.net", "dns2.p08.nsone.net", "ns-1283.awsdns-32.org", "ns-421.awsdns-52.com"],
        "TXT": ["MS=ms44452932", "v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com ~all"],
        "CNAME": [],
        "CAA": ["0 issue \"digicert.com\"", "0 issue \"letsencrypt.org\""],
        "SPF": ["v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com ~all"],
        "DMARC": ["v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:dmarc@github.com"]
    },
    "changed": true,
    "changes": [
        { "type": "NS", "added": ["ns-520.awsdns-01.net"], "removed": [], "severity": "high" }
    ],
    "severity": "high",
    "errors": []
}
```

`status` is `ok` when at least one record type was resolved, `nxdomain` when the domain does not exist, and `error` when every query failed.

### Daily schedule and alerts

1. Open the Actor, fill in your domains and click **Save as a new task**.
2. Go to **Schedules** > **Create new**, select the task and set a cron expression, for example `0 7 * * *` (every day at 07:00). Choose your time zone.
3. Alerts only when something changed. In the task, open **Integrations** and add a webhook (or Make, Zapier or n8n) on the event **Run succeeded**. In that flow, read the run's dataset and send a message only for items where `changed` is true.

A small script that does the same with an email or Slack message, run after the schedule:

```bash
## Items of the last run of the task where changed is true
curl -s "https://api.apify.com/v2/actor-tasks/<username>~<task-name>/runs/last/dataset/items?token=<API_TOKEN>" \
  | jq '[.[] | select(.changed == true) | {domain, severity, changes}]' > changes.json

## Slack: post to an incoming webhook if there is anything
if [ "$(jq length changes.json)" -gt 0 ]; then
  jq -n --rawfile t changes.json '{text: ("DNS changes:\n" + $t)}' \
    | curl -s -X POST -H "Content-Type: application/json" -d @- "https://hooks.slack.com/services/XXX/YYY/ZZZ"
fi

## Email: same check, then for example
## mail -s "DNS changes" you@example.com < changes.json
```

To alert only on security-relevant changes, use `select(.severity == "high" or .severity == "critical")`.

### Pricing

Pay per event:

- `domain-checked`: once per domain that was resolved. Domains that do not exist (NXDOMAIN), invalid input and domains where every query failed are not charged.
- `change-detected`: once per domain with at least one change, no matter how many records changed. Never charged on the first run of a domain.

The prices are shown on the Actor page. You can cap the cost of a run with the "Maximum cost per run" setting; the Actor stops cleanly when the cap is reached.

### Limitations

- Results are what the public resolvers return at the time of the run. A change made at your DNS provider can take up to the record's TTL to show up.
- Sites with large geo-distributed pools can still produce A/AAAA changes when the pool really moves. Rotation filtering reduces noise, it does not remove it fully.
- Only the apex TXT and `_dmarc` TXT are read. DKIM selectors and other subdomains are not checked unless you add them as their own domains (TXT is then compared for that name).
- DNSSEC is not validated.

### Run locally

```bash
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt   # on Linux/macOS: .venv/bin/pip
## put input in storage/key_value_stores/default/INPUT.json
.venv/Scripts/python -m my_actor
python -m unittest discover -s tests -t .
```

Author: Matias Isnard.

# Actor input Schema

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

Domains or hostnames, one per line (example.com, www.example.com). URLs are reduced to their hostname. Each one is resolved and compared with its snapshot from the previous run.

## `recordTypes` (type: `array`):

Record types to resolve and compare. SPF is the TXT record that starts with v=spf1 on the domain. DMARC is the TXT record on \_dmarc.<domain>.

## `ignoreARecordRotation` (type: `boolean`):

When an A or AAAA set differs from the snapshot, query it again. If the second answer matches the snapshot, the difference is treated as round-robin or geo DNS rotation and not reported. Otherwise the union of both answers is compared.

## `stateKey` (type: `string`):

Optional. Snapshots are stored per domain under this key, so two tasks that watch the same domain with different settings do not overwrite each other. Leave empty to share snapshots across all runs.

## Actor input object example

```json
{
  "domains": [
    "example.com",
    "github.com"
  ],
  "recordTypes": [
    "A",
    "AAAA",
    "MX",
    "NS",
    "TXT",
    "CNAME",
    "CAA",
    "SPF",
    "DMARC"
  ],
  "ignoreARecordRotation": true
}
```

# Actor output Schema

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

// Run the Actor and wait for it to finish
const run = await client.actor("isnard/dns-change-monitor").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",
        "github.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("isnard/dns-change-monitor").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",
    "github.com"
  ]
}' |
apify call isnard/dns-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,isnard/dns-change-monitor"
        }
    }
}
```

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/igOZkiZPw94c3xHiP/builds/zBzAWSQlbCmfbmSPE/openapi.json
