# Domain & SSL Certificate Expiry Monitor (`isnard/domain-ssl-expiry-monitor`) Actor

Check domain registration expiry (RDAP) and TLS certificate expiry for a list of domains. Status ok, warning, critical or expired, plus what changed since the last run. Built for daily schedules.

- **URL**: https://apify.com/isnard/domain-ssl-expiry-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 $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 & SSL Certificate Expiry Monitor

Give it a list of domains. For each one you get the domain registration expiry date (from RDAP, the official successor of WHOIS), the registrar, the registry status codes and nameservers, and the TLS certificate served on port 443: issuer, subject, SANs, expiry date, days left and whether the chain is valid.

Each result gets a status (`ok`, `warning`, `critical`, `expired` or `error`) and a list of what changed since the previous run: certificate renewed, registrar changed, expiry date moved, nameservers changed. Run it on a daily schedule and send yourself an email or a Slack message.

### Who it is for

- Agencies, MSPs and freelancers who manage domains and websites for clients.
- IT and ops teams who do not want a forgotten domain renewal or an expired certificate to take a site down.
- Anyone with domains spread over several registrars.

### What it checks

| Part | What | Notes |
| --- | --- | --- |
| Domain | Registration and expiry dates | From the RDAP server of the TLD, found through the IANA bootstrap file (`https://data.iana.org/rdap/dns.json`). |
| Domain | Registrar | Name from the registrar vCard, or the registrar handle when the registry gives no name. |
| Domain | Status codes | For example `client transfer prohibited`, `redemption period`, `pending delete`. |
| Domain | Nameservers | As listed in the registry. |
| Certificate | Issuer, subject, SANs, serial, valid from, valid until, days left | Leaf certificate served on port 443 with SNI. |
| Certificate | Chain valid | True when the chain is trusted by the standard CA bundle and the certificate matches the hostname. When it is not valid, the certificate is still read so you see its dates, and `chain_error` says why. |

Some TLDs have no RDAP server listed with IANA (for example `.io` and `.de` at the time of writing). For those the result has `rdap_status: "rdap_unavailable"` and only the certificate is checked, so the status is based on the certificate alone.

### Status

The status is the worst of the domain expiry, the certificate expiry and the chain check:

| Status | When |
| --- | --- |
| `ok` | Nothing expires within `warnDays` and the chain is valid. |
| `warning` | The domain or the certificate expires within `warnDays` days (default 30). |
| `critical` | The domain or the certificate expires within `criticalDays` days (default 7), or the certificate chain is not valid. |
| `expired` | The domain or the certificate is past its expiry date. |
| `error` | Neither the RDAP lookup nor the TLS connection returned data. |

`status_reasons` explains the status in plain words, for example `Certificate expires in 5 days.`

When one part fails and the other works (for example the domain has no website on port 443), the status is based on the part that worked and the problem is listed in `errors`.

### Change detection

After each check the main values are saved in a named key-value store called `domain-ssl-expiry-monitor-state` in your account. On the next run they are compared:

| Change code | Meaning |
| --- | --- |
| `cert_renewed` | New certificate with a later expiry date. |
| `cert_issuer_changed` | Different certificate authority. |
| `domain_renewed` | Registration expiry moved later. |
| `domain_expiry_moved` | Registration expiry moved earlier. |
| `registrar_changed` | The domain moved to another registrar. |
| `nameservers_changed` | Different nameservers. |
| `domain_status_codes_changed` | Registry status codes changed (for example a transfer lock was removed). |
| `status_changed` | The status itself changed, for example from `ok` to `warning`. |

`changed` is true when there is at least one change. `change_details` has the previous and current value of each one. `first_check` is true when there was no stored result yet. If a lookup fails in one run, its values are not reported as changed and the old values are kept for the next comparison.

The store is shared by all runs and tasks of this Actor in your account. To start over, delete it in Storage > Key-value stores.

### Input

```json
{
    "domains": ["google.com", "github.com", "mercadolibre.com.ar"],
    "hostnames": ["www.github.com", "api.github.com"],
    "warnDays": 30,
    "criticalDays": 7,
    "timeoutSecs": 20
}
```

- `domains`: registered domains (the name you buy from a registrar). URLs are reduced to the hostname and a leading `www.` is removed. Each domain gets an RDAP lookup and a certificate check on the domain itself.
- `hostnames` (optional): extra hostnames for a certificate check only, such as `www.` or `api.` subdomains. Each one is a separate result.
- `warnDays` (default 30) and `criticalDays` (default 7): thresholds for the status. `criticalDays` must not be larger than `warnDays`.
- `timeoutSecs` (default 20): time limit for each RDAP request and each TLS connection. RDAP requests are retried once after a timeout or a server error.

Invalid lines are skipped with a warning in the log and are not charged. Duplicates are checked once.

### Output

One dataset item per domain or hostname. Example (nameservers shortened):

```json
{
    "input": "github.com",
    "host": "github.com",
    "type": "domain",
    "checked_at": "2026-10-29T09:26:07Z",
    "status": "ok",
    "status_reasons": [],
    "rdap_status": "ok",
    "rdap_server": "https://rdap.verisign.com/com/v1/",
    "registrar": "MarkMonitor Inc.",
    "domain_created_at": "2007-10-09T18:20:50Z",
    "domain_expires_at": "2028-10-09T18:20:50Z",
    "domain_days_left": 711,
    "domain_status_codes": ["client delete prohibited", "client transfer prohibited", "client update prohibited"],
    "nameservers": ["dns1.p08.nsone.net", "ns-1283.awsdns-32.org"],
    "tls_status": "ok",
    "tls_version": "TLSv1.3",
    "chain_valid": true,
    "chain_error": null,
    "cert_issuer": "Sectigo Limited",
    "cert_issuer_dn": "CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB",
    "cert_subject": "github.com",
    "cert_sans": ["github.com", "www.github.com"],
    "cert_serial": "a59ebdb596751db7f5c095079613953c",
    "cert_not_before": "2026-09-01T00:00:00Z",
    "cert_not_after": "2026-11-29T23:59:59Z",
    "cert_days_left": 31,
    "first_check": false,
    "previous_checked_at": "2026-10-28T09:25:47Z",
    "changed": false,
    "changes": [],
    "change_details": [],
    "errors": []
}
```

All dates are UTC in ISO 8601. Days left are whole days rounded down, so a certificate that expired an hour ago shows `-1`.

### Scheduling and alerts

The Actor is meant to run on a schedule. A daily run with a notification:

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. In the task, open the **Integrations** tab:
   - Email: add **Send email** on the event **Run succeeded** (and one on **Run failed**). The email links to the run and its dataset.
   - Slack: add the **Slack** integration, connect your workspace and pick a channel.

If you only want a message when something needs attention, add a **webhook** integration (or connect Make, Zapier or n8n) on **Run succeeded**. In that flow, read the run's dataset and send a message only for items where `status` is not `ok` or `changed` is true.

Starting a run from your own scheduler through the API:

```bash
curl -X POST "https://api.apify.com/v2/acts/<username>~domain-ssl-expiry-monitor/runs?token=<API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"domains": ["example.com", "example.org"], "warnDays": 30, "criticalDays": 7}'
```

### Pricing

Pay per event: you pay once per domain or hostname checked (`domain-checked` event). You are only charged when at least one of the two lookups (RDAP or TLS) returned data. Names that do not exist, and names where both lookups failed, are not charged. The price is 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

- Only registered domains get an RDAP lookup. A subdomain in `domains` (for example `shop.example.com`) usually returns `not_found`; put subdomains in `hostnames` instead.
- Some registries do not publish an expiry date in RDAP. Then `domain_expires_at` is null and the status is based on the certificate.
- Some RDAP servers are slow or rate-limit requests. A failed RDAP lookup is reported in `errors` and the certificate is still checked.
- Only port 443 is checked, and only the dates of the leaf certificate are reported. An expired intermediate certificate shows up as `chain_valid: false`.
- `chain_valid` uses the standard CA bundle. Certificates from private CAs show as not valid.
- Results are a snapshot at the time of the run. A renewal at the registrar can take some time to appear in the registry's RDAP data.

### 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`):

Registered domains, one per line (example.com, mercadolibre.com.ar). URLs are reduced to their hostname and a leading www. is removed. Each domain gets an RDAP lookup (registration expiry, registrar, status codes, nameservers) and a TLS certificate check on port 443.

## `hostnames` (type: `array`):

Optional. Hostnames whose TLS certificate should also be checked, for example www.example.com or api.example.com. No RDAP lookup is done for these. Each one is a separate result.

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

Status becomes warning when the domain or the certificate expires within this many days.

## `criticalDays` (type: `integer`):

Status becomes critical when the domain or the certificate expires within this many days. Must be less than or equal to the warning threshold.

## `timeoutSecs` (type: `integer`):

Maximum time for each RDAP request and each TLS connection.

## Actor input object example

```json
{
  "domains": [
    "example.com",
    "github.com"
  ],
  "warnDays": 30,
  "criticalDays": 7,
  "timeoutSecs": 20
}
```

# 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/domain-ssl-expiry-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/domain-ssl-expiry-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/domain-ssl-expiry-monitor --silent --output-dataset

```

## MCP server setup

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