# Domain Expiry & WHOIS/RDAP Change Monitor (`changefeeds/domain-expiry-registration-monitor`) Actor

Tracks domain expiry, registrar, nameserver, WHOIS/RDAP status and DNS changes for up to 5000 domains per run. Get a changefeed of only what changed since the last check -- expiring soon, registrar moved, went available -- via dataset, webhook, or Apify integrations.

- **URL**: https://apify.com/changefeeds/domain-expiry-registration-monitor.md
- **Developed by:** [Changefeeds Tools](https://apify.com/changefeeds) (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 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

## Domain Watch — domain expiry, WHOIS/RDAP & DNS change monitor

For domain portfolio owners, registrars, brand-protection teams, and anyone
who needs to know the moment a watched domain's expiry, registrar,
nameservers, WHOIS/RDAP status, or DNS records move. Feed it up to 5000
domains and a schedule; it returns a **changefeed** — only what's different
since the last run — instead of a full re-scrape you have to diff yourself.

Apify actor that checks a list of domains against **RDAP** — the official,
public, JSON successor to WHOIS — and flags what has **changed since the last
run**: expiries coming up, registrar moves, nameserver changes, DNS changes,
and registration-status changes.

### What it does

For each domain it:

1. Resolves the official RDAP server from the IANA bootstrap file
   (`https://data.iana.org/rdap/dns.json`), falling back to `rdap.org`.
2. Fetches registration data: registrar, IANA registrar ID, registration /
   expiry / last-changed dates, EPP status flags, nameservers, DNSSEC.
3. Optionally resolves A, AAAA, MX, NS and TXT records.
4. Diffs against the previous run (stored in a key-value store) and emits
   change flags (see below).
5. Writes one dataset row per domain, a summary object to the default
   key-value store (key `OUTPUT`), and optionally POSTs changed/expiring rows
   to a webhook.
6. Requests are polite via one shared per-host limiter: max 1 in-flight
   request per host, at least 500 ms between requests per host (at least
   1100 ms for `rdap.org`, which allows only 10 requests per 10 seconds),
   `429` responses honored with `Retry-After` as a host-wide cooldown,
   manually-followed redirects (max 3 hops), 15 s timeout.

### What is compared (and what is not)

The diff compares, per domain: registration **status**, **registrar**,
**expiry date**, **EPP status flags**, **nameservers**, and each DNS record
type (A, AAAA, MX, NS, TXT). Fields that essentially never change — creation
date, last-changed date, registrar IANA ID, DNSSEC — are reported in the row
but **not** diffed.

Change flags: `baseline` (first run), `none`, `became_registered`,
`became_available` (no registration found at the registry — not a guarantee
it can be bought),
`registrar_changed`, `expiry_changed`, `status_changed`,
`nameservers_changed`, `dns_changed:<type>`, `lookup_failed` (RDAP lookup
failure — no field comparison is made), `snapshot_read_failed`, and
`snapshot_write_failed` (the result was delivered and charged, but its
history snapshot could not be stored after retries).

A transiently failed **DNS** record type (e.g. a timeout) does **not**
produce `lookup_failed`: the last observed value for that record type is
kept, the comparison is skipped for it, and the other record types are
still compared. A DNS record type that has never been observed before is
baselined silently — its first successful observation produces no
`dns_changed:<type>` flag (the snapshot distinguishes "never observed"
from "observed empty").

### Input

```json
{
  "domains": ["example.com", "https://www.example.io/about"],
  "expiringWithinDays": 30,
  "includeDns": true,
  "snapshotKey": "my-monitor-set",
  "webhookUrl": "https://example.com/hook"
}
```

- `domains` (required, max 5000): schemes, paths and `www.` are stripped
  automatically; internationalized names are punycoded.
- `expiringWithinDays` (default 30): flag domains expiring within this window.
- `includeDns` (default true): resolve and diff DNS records. When `false`,
  the previous run's DNS records are kept in the snapshot unchanged (no
  `dns_changed` flags are produced and no DNS history is lost). Only
  observed record types are stored: a type never seen before is treated as
  unknown and baselined silently on its first observation.
- `snapshotKey` (optional): key for the previous-run snapshot. Default is a
  hash of the sorted domain list, so identical domain sets share a snapshot.
- `webhookUrl` (optional): receives `{ summary, rows }` with changed and
  expiring rows (max 500) after the run.

### Output

One dataset row per domain:

```json
{
  "domain": "example.com",
  "status": "registered",
  "registrar": "RESERVED-INTERNET ASSIGNED NUMBERS AUTHORITY",
  "registrar_iana_id": "376",
  "created": "1995-08-14T04:00:00Z",
  "expires": "2027-08-13T04:00:00Z",
  "updated": "2026-08-01T18:32:11Z",
  "days_to_expiry": 319,
  "expiring_soon": false,
  "rdap_status": "200",
  "status_flags": ["server delete prohibited", "server transfer prohibited", "server update prohibited"],
  "nameservers": ["a.iana-servers.com", "b.iana-servers.com"],
  "dnssec": true,
  "dns": { "a": ["93.184.216.34"], "aaaa": [], "mx": [], "ns": [], "txt": [], "dns_error": [] },
  "changes": ["baseline"],
  "checked_at": "2026-09-28T12:00:00.000Z",
  "error": null
}
```

`status` is one of:

- `registered` — the registry's RDAP server returned the domain object.
- `not_registered` — the **registry's own RDAP server** (found via the IANA
  bootstrap) answered 404: no registration found at the registry (not a
  guarantee it can be bought).
- `unsupported_tld` — no authoritative RDAP server is known for the TLD
  (the `rdap.org` fallback returned 404 or the TLD is missing from the
  bootstrap). Not charged; the snapshot is not overwritten.
- `error` — lookup or infrastructure failure. Not charged; the snapshot is
  not overwritten.

The summary written to the key-value store under key `OUTPUT` looks like:

```json
{
  "domains": 2, "registered": 2, "not_registered": 0, "unsupported_tld": 0,
  "errors": 0, "expiring_soon": 0, "changed": 2, "first_run": true,
  "charged": 2, "charge_limit_reached": false,
  "skipped_after_charge_limit": 0, "snapshot_write_failed": 0,
  "snapshot_key": "a1b2c3d4e5f60718"
}
```

On a first run every successfully looked-up row gets `changes: ["baseline"]`,
and those rows count toward `changed` in the summary. Failure rows — status
`error`, or changes that are only `lookup_failed`, `snapshot_read_failed`,
`invalid_response` or `snapshot_write_failed` — are excluded from `changed`
and from the webhook.

### Pricing (pay per event)

You pay **$0.002 per successfully looked-up domain** — that is **$2 per 1,000
domains checked** (DNS resolution and change detection are included at no
extra charge). Domains whose lookup fails (`error`, `unsupported_tld`,
snapshot-read failure) are **not charged**. Each charged domain is billed
individually; the actor reserves the remaining budget before starting each
domain and stops dispatching new work when your run's max total charge is
reached (recorded in the summary as `charge_limit_reached`, with
already-charged domains still counted in `charged`).

### What it costs

Pay-per-event, **$0.002 per successfully checked domain** — nothing else is
billed (no per-run platform fee is configured, DNS resolution and diffing are
included). Domains that fail to look up (`error`, `unsupported_tld`, snapshot
read failure) are free.

- **First run, 500-domain portfolio:** every domain is a fresh baseline, so
  all 500 are charged: `500 × $0.002 = $1.00` for that run.
- **Steady state, same 500 domains, daily schedule:** each day only the same
  500 domains are re-checked (the changefeed just tells you which of them
  moved): `500 × $0.002 = $1.00`/day → `$1.00 × 30 ≈ $30.00`/month.
- **Larger portfolio, 5,000 domains, weekly schedule:** `5,000 × $0.002 =
  $10.00` per run, once a week → `$10.00 × 4.33 ≈ $43.30`/month.

### Use cases

**Daily expiry and takeover watch for a domain portfolio.** Set `domains` to
your full list (owned domains, competitor names you're tracking, or expired
brand names you want back), add an Apify **Schedule** (e.g. daily at
06:00 UTC) that reuses the same saved input so every run reads the same
snapshot, and set `webhookUrl` to a Slack incoming-webhook URL (or a
Zapier/Make "Catch Hook" step) — either directly, or through Apify's
platform-level **Integrations** tab on the schedule/run, which can also push
rows straight into a Google Sheet. Each morning you get a message with only
the domains that changed: which ones enter the `expiringWithinDays` window,
which registrar or nameservers moved, and which became available.

### Honest limits

- **RDAP coverage varies by TLD.** All gTLDs (.com, .net, .io, ...) are
  covered; **some ccTLDs have no RDAP service at all** — those fall back to
  `rdap.org`, and when it knows no authoritative server the result is
  `unsupported_tld` (not "available").
- **"Not registered" is not a purchase guarantee.** A 404 from the registry
  means no registration was found at that registry; premium/reserved names,
  registry locks, or landrush states can still prevent registration.
- **Registrant contact data is usually redacted** under GDPR/registry policy,
  and this actor does not collect it. You get registrar, dates, status,
  nameservers and DNSSEC only.
- Some registries return sparse data (no expiry date, no registrar ID); those
  fields are `null`.
- Failed RDAP lookups do not overwrite the previous snapshot and are reported
  as `lookup_failed` instead of fabricated field changes. A transient DNS
  failure keeps the last observed value for the affected record type, skips
  the comparison for it, and still compares every other record type — an
  outage never erases or falsifies your change history.

### Scheduling

In Apify, open the actor and add a **schedule** (e.g. daily at 06:00 UTC)
with your saved input. Each scheduled run reads the previous run's snapshot,
so you automatically get change flags like `expiry_changed` and
`became_registered` every day.

# Actor input Schema

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

Domains to check, e.g. example.com. Schemes and www. are stripped automatically; IDN names are punycoded. Max 5000.

## `expiringWithinDays` (type: `integer`):

Flag domains whose expiry falls within this many days of the run.

## `includeDns` (type: `boolean`):

Also resolve A, AAAA, MX, NS and TXT records and diff them between runs.

## `snapshotKey` (type: `string`):

Key under which the previous-run snapshot is stored. Leave empty to use the default (a hash of the sorted domain list), or set it to share one snapshot across related input sets.

## `webhookUrl` (type: `string`):

Optional HTTPS URL. On completion, a JSON payload with the summary and changed/expiring rows (max 500) is POSTed here.

## Actor input object example

```json
{
  "domains": [
    "example.com",
    "apify.com"
  ],
  "expiringWithinDays": 30,
  "includeDns": true
}
```

# Actor output Schema

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

No description

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

// Run the Actor and wait for it to finish
const run = await client.actor("changefeeds/domain-expiry-registration-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",
        "apify.com",
    ] }

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,changefeeds/domain-expiry-registration-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/wuE7LZbA1JhU4LEJ5/builds/B4Xu0e1MamPTPgt5o/openapi.json
