# SSL & Domain Expiry Monitor - Certificate + RDAP Checks (`lowlanddata/ssl-domain-monitor`) Actor

Certificate expiry and domain-registration expiry for a list of hosts, one row each, with a plain list of what is wrong. A real TLS handshake and RDAP - no third-party checking API, no rate limit.

- **URL**: https://apify.com/lowlanddata/ssl-domain-monitor.md
- **Developed by:** [Lowland Data](https://apify.com/lowlanddata) (community)
- **Categories:** Automation, Agents
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $18.50 / 1,000 per alert delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## SSL & Domain Expiry Monitor - Certificate + RDAP Checks

Checks a list of hosts and answers the two questions that cause the same outage: **when does the TLS certificate expire, and when does the domain registration lapse?** One row per host, with the countdown in days, the issuer, the registrar, and a plain list of what is wrong. Point it at 5 domains or 500.

**Live health record:** [lowlanddata.com/status/ssl-domain-monitor](https://lowlanddata.com/status/ssl-domain-monitor/) - 30-day success rate and daily canary results, failures included. Full input/output reference: [tool page](https://lowlanddata.com/tools/ssl-domain-monitor/).

The certificate comes from a real TLS handshake with the host - not a third-party checking API with a rate limit, so what you read is what the server actually serves. The registration data comes from **RDAP**, the structured protocol that replaced WHOIS text. No API key, no login, no scraping.

### Quick start (30 seconds, no keys needed)

1. Hit **Start** with the prefilled domains. One of them, `expired.badssl.com`, is expired on purpose - so your first run shows you both a healthy row and a failing one.
2. Replace them with your own. A hostname, a full URL, or `host:8443` all work; paste them one per line.
3. Add a **daily schedule** (Actor page -> Schedule) and set `alertsOnly: true`. From then on the runs are silent until something needs you.

### Who uses this

- **Agencies and freelancers** holding 40 client domains run one daily audit instead of 40 browser padlock checks.
- **Platform teams** watch internal endpoints on non-standard ports (`api.internal.example.com:8443`) that no public uptime service will look at.
- **Ops on call** wire `alertsOnly: true` to Slack, so the only message that ever arrives is one that needs an action.
- **Domain portfolio owners** care less about the certificate than about `daysToDomainExpiry` - a lapsed registration is unrecoverable in a way an expired certificate is not.
- **Anyone who has had a Sunday ruined by a certificate** that quietly ran out.

### What you get

One JSON item per host. Three real shapes, because a monitor is judged on the rows that are not healthy.

A healthy host:

```json
{
  "host": "lowlanddata.com",
  "port": 443,
  "domain": "lowlanddata.com",
  "reachable": true,
  "certificateIssuer": "Let's Encrypt",
  "certificateValidTo": "2026-12-04T09:41:00.000Z",
  "daysToCertificateExpiry": 87,
  "tlsVersion": "TLSv1.3",
  "hostnameMatches": true,
  "domainExpiresAt": "2027-09-05T12:00:00.000Z",
  "daysToDomainExpiry": 362,
  "registrar": "OVH sas",
  "nameservers": ["dns14.ovh.net", "ns14.ovh.net"],
  "problems": [],
  "isAlert": false,
  "checkedAt": "2026-09-08T20:41:12.000Z"
}
```

One that has already failed - the certificate expired, and the row says so in a field you can filter on:

```json
{
  "host": "expired.badssl.com",
  "reachable": true,
  "certificateIssuer": "COMODO CA Limited",
  "certificateValidTo": "2015-04-12T23:59:59.000Z",
  "daysToCertificateExpiry": -4167,
  "hostnameMatches": true,
  "problems": ["certificate-expired"],
  "isAlert": true,
  "isNewAlert": true,
  "checkedAt": "2026-09-08T20:41:12.000Z"
}
```

And one that could not be reached at all, which is a finding rather than an error:

```json
{
  "host": "does-not-exist-xyz123.com",
  "reachable": false,
  "error": "getaddrinfo ENOTFOUND does-not-exist-xyz123.com",
  "problems": ["tls-handshake-failed"],
  "isAlert": true,
  "isNewAlert": true
}
```

#### What the problems mean

| Value                  | What happened                                                                                                              |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `certificate-expired`  | `validTo` is in the past. Browsers are already refusing the site.                                                          |
| `hostname-mismatch`    | The certificate does not cover the hostname you asked for - a common cause of a scary browser warning on a working server. |
| `self-signed`          | Issuer and subject are the same. Fine inside a lab, not in public.                                                         |
| `not-yet-valid`        | `validFrom` is in the future, usually a clock or an early deploy.                                                          |
| `obsolete-tls-version` | The handshake settled on TLS 1.0/1.1, which modern clients are dropping.                                                   |
| `tls-handshake-failed` | DNS did not resolve, the port refused, or the handshake timed out. See `error`.                                            |

An empty `problems` array with `isAlert: true` means nothing is broken *yet* - a countdown simply crossed one of your thresholds. `certificateThresholdDays` or `domainThresholdDays` tells you which one.

### How change detection works

Every monitor on this store is vague about this, so here it is in full:

- **Where the memory lives.** A named key-value store on *your* account (`stateStoreName`). It holds one fingerprint per host: the problems found, plus which expiry threshold has been crossed. There is no server on my side holding your domain list.
- **What an alert is.** A host whose fingerprint *changed* since the last run: a problem appeared, a problem cleared, or a countdown fell past the next threshold (30 -> 14 -> 7 days by default). A certificate that has been expiring for a week does not re-alert every day.
- **`alertsOnly: false`** (the default) returns the full table for every host - what you want for an audit or a dashboard refresh.
- **`alertsOnly: true`** returns only hosts with a *new* alert - what you want on a schedule. When nothing changed the run finishes empty, and the status line still tells you the truth: `40 hosts checked, 2 needing attention (0 new since the last run)`.
- **A run cannot cry wolf.** If every host in a list of three or more fails its handshake at once, the run fails loudly instead of reporting your whole estate as down - that pattern means the run's own network broke, not your domains.
- **Starting over.** `resetState: true` forgets the fingerprints so everything alerts again.

### What monitoring 40 domains costs

Pay per outcome, so a quiet day is nearly free.

| Event              | Price                                | When it is charged                                  |
| ------------------ | ------------------------------------ | --------------------------------------------------- |
| **Expiry alert**   | $0.0199 (from $0.0185 on paid plans) | A host with a problem or a crossed threshold        |
| **Domain checked** | $0.00499                             | A healthy row in a full audit (`alertsOnly: false`) |
| **Actor start**    | $0.005                               | Per run, whatever it finds                          |

Live prices are always the ones on this page's Pricing tab. A worked example: 40 domains, checked daily with `alertsOnly: true`. A quiet day costs the actor start alone - about 15 cents a month - and you pay about 2 cents only when a domain genuinely needs attention. A full monthly audit of all 40 costs about 21 cents. New Apify accounts come with free monthly platform credits.

### Input reference

| Field                | Type     | Default             | What it does                                                               |
| -------------------- | -------- | ------------------- | -------------------------------------------------------------------------- |
| `domains`            | string\[] | three samples       | Hostnames, URLs or `host:port`, one per line                               |
| `alertThresholdDays` | number\[] | `[30, 14, 7]`       | Countdown points that raise an alert                                       |
| `alertsOnly`         | boolean  | `false`             | Report only hosts with a new alert - the setting for a schedule            |
| `checkDomainExpiry`  | boolean  | `true`              | Add the RDAP lookup: registration expiry, registrar, statuses, nameservers |
| `timeoutSeconds`     | integer  | `15`                | How long to wait for a handshake before recording the host as unreachable  |
| `concurrency`        | integer  | `8`                 | Hosts checked in parallel                                                  |
| `resetState`         | boolean  | `false`             | Forget the fingerprints and alert on everything again                      |
| `stateStoreName`     | string   | `ssl-monitor-state` | Which named store holds this monitor's memory                              |
| `maxItems`           | integer  | `1000`              | Stop after this many rows                                                  |

The list is also accepted under `urls`, `hosts`, `websites` or `startUrls`, and a single comma-separated string works as well as a list - so an AI agent's first guess at the input tends to run.

### Scheduling and API use

```bash
curl -X POST "https://api.apify.com/v2/acts/lowlanddata~ssl-domain-monitor/runs?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"domains":["example.com","shop.example.com","api.example.com:8443"],"alertsOnly":true}'
```

Pull just the hosts that need action:

```bash
curl "https://api.apify.com/v2/datasets/DATASET_ID/items?format=json&filter=isAlert"
```

In n8n or Make: Schedule -> this actor with `alertsOnly: true` -> Slack. Because a quiet run returns zero items, the message only fires when there is something to say.

### Use it with AI agents (MCP)

Every tool here doubles as an MCP endpoint: [ssl-domain-monitor/api/mcp](https://apify.com/lowlanddata/ssl-domain-monitor/api/mcp). A prompt that works as-is:

```text
Check the certificates and domain registrations for example.com, example.org and
shop.example.com, and list anything expiring within 45 days.
```

### Is this legal, and what about personal data

Yes. A TLS handshake is what every browser does before rendering a page, and RDAP is a public protocol run by the registries themselves for exactly this purpose. Nothing is authenticated to, no page is fetched, and one host means one handshake plus one lookup.

RDAP records can contain registrant contact blocks - names, emails, phone numbers - and in the EU the registries already redact most of them. **They are not read here.** The parser takes registration dates, statuses, nameservers and the registrar's company name; a test in the build feeds it a record stuffed with fake registrant contacts and fails if any of it reaches the output. Certificate fields are the issuing CA's organisation and the hostnames the certificate covers - company and machine facts, not people. So the rows are safe to store, forward and archive.

### Honest coverage notes

- **Some registries publish no expiry date over RDAP.** Several ccTLDs (`.be` and `.nl` among them) omit it by policy. The certificate half is unaffected; `domainExpiresAt` is simply absent rather than guessed.
- **The registrable domain is derived from a built-in suffix list** covering the common multi-label endings (`.co.uk`, `.com.au` and friends). An unusual public suffix may resolve to the wrong registry answer - tell me and it gets added.
- **Only the leaf certificate is read**, not the full chain. An incomplete-chain misconfiguration that browsers repair silently will not be flagged.
- **The handshake is direct, never proxied**, so the certificate you see is the one the host serves you. Only the RDAP lookups go through the proxy.

### FAQ

**Is this an uptime monitor?** No. It answers "will this break on a known date", not "is it up right now". The two belong on different schedules: uptime by the minute, expiry once a day.

**What about a certificate that auto-renews?** Let's Encrypt renewal at 30 days is normal and quiet. The value is catching the renewal that *did not* happen - the row keeps counting down and crosses 14, then 7.

**Can I check internal hosts?** Any host this actor can reach on the public internet, on any port. Hosts behind a VPN are not reachable.

**Why did a domain report no expiry date?** Its registry does not publish one over RDAP. See the coverage notes.

**Does an unchanged problem keep charging me?** In `alertsOnly: true`, no - it is not pushed again until it changes or clears.

**How many domains can one run handle?** 500 per run, 8 at a time by default; raise `concurrency` for a long list.

### More tools from Lowland Data

Same house rules everywhere: clean schemas, public sources, no personal data in the output.

| Tool                                                                                   | What it does                                                  |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| [Dependency Release Monitor](https://apify.com/lowlanddata/dependency-release-monitor) | New npm, PyPI and GitHub releases, with breaking-change flags |
| [US Federal Grants Monitor](https://apify.com/lowlanddata/grants-gov-monitor)          | New federal funding opportunities matching your profile       |
| [Company Jobs Scraper](https://apify.com/lowlanddata/company-jobs-scraper)             | Every open role from any Greenhouse or Lever board            |

Full catalogue: [lowlanddata.com/catalogue](https://lowlanddata.com/catalogue/).

### Support

A domain reported wrong, a registry that needs handling, a port that misbehaves? Open an issue on the **Issues** tab with the run ID, or write to <contact@lowlanddata.com>. Reports get read the same day.

# Actor input Schema

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

One per line. A hostname, a full URL, or host:port. Also accepted under urls, hosts or websites. Leave empty to use the sample list.

## `alertThresholdDays` (type: `array`):

A row is flagged once its countdown falls to one of these. The default ladder warns a month out, then escalates.

## `alertsOnly` (type: `boolean`):

Scheduled monitors want silence when nothing is wrong. Off gives the full audit table for every host.

## `checkDomainExpiry` (type: `boolean`):

Adds one RDAP lookup per domain: expiry, registrar, statuses and nameservers.

## `timeoutSeconds` (type: `integer`):

How long to wait for a TLS handshake before recording the host as unreachable.

## `concurrency` (type: `integer`):

Higher finishes a long list sooner.

## `resetState` (type: `boolean`):

Forget what was already reported, so the next run alerts on everything again.

## `stateStoreName` (type: `string`):

Named key-value store holding the last reported state per host.

## `maxItems` (type: `integer`):

Stop after this many rows.

## `proxyConfiguration` (type: `object`):

Used for the RDAP lookups. The TLS handshake always goes direct, so the certificate read is the real one.

## Actor input object example

```json
{
  "domains": [
    "lowlanddata.com",
    "apify.com",
    "expired.badssl.com"
  ],
  "alertThresholdDays": [
    30,
    14,
    7
  ],
  "alertsOnly": false,
  "checkDomainExpiry": true,
  "timeoutSeconds": 15,
  "concurrency": 8,
  "resetState": false,
  "stateStoreName": "ssl-monitor-state",
  "maxItems": 1000,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `checks` (type: `string`):

One row per host: certificate dates and issuer, days to expiry, TLS version, domain registration dates, registrar and nameservers, plus a problems list.

# 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": [
        "lowlanddata.com",
        "apify.com",
        "expired.badssl.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("lowlanddata/ssl-domain-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": [
        "lowlanddata.com",
        "apify.com",
        "expired.badssl.com",
    ] }

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

```

## MCP server setup

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