# Bulk SSL Certificate Expiry Monitor: Any Port & STARTTLS (`sysadmin-toolbox/bulk-ssl-expiry-monitor`) Actor

Check SSL/TLS certificate expiry for thousands of hosts on any port: HTTPS, LDAPS, IMAPS, RDP, plus STARTTLS for IMAP, POP3, SMTP and FTP. Days left, issuer, SANs, chain trust, hostname match, key, TLS version. Check every IP behind a name and flag changed certs.

- **URL**: https://apify.com/sysadmin-toolbox/bulk-ssl-expiry-monitor.md
- **Developed by:** [SysAdmin Toolbox](https://apify.com/sysadmin-toolbox) (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

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

## Bulk SSL Certificate Expiry Monitor: Any Port & STARTTLS

Check **SSL/TLS certificate expiry for hundreds or thousands of hosts** in one run, and get a clear **OK / WARN / CRITICAL / EXPIRED / ERROR** status for each. It works on **any port**, not just 443: HTTPS on custom ports, LDAPS (636), IMAPS (993), POP3S (995), RDP (3389), Kubernetes/API endpoints, plus **STARTTLS** for IMAP (143), POP3 (110), SMTP and FTP.

Every row tells you **days left**, expiry date, subject and SANs, issuer, **chain trust**, **hostname match**, key type and size, signature algorithm, TLS version and cipher, serial number and SHA-256 fingerprint. A `SUMMARY` record gives counts per status and the soonest expiry.

### Why this one

- **Any port + STARTTLS.** Mail servers, directory servers and admin consoles expire too. Point it at `imap://mail.example.com`, `ldaps://dc1.example.com` or `203.0.113.10:8443`.
- **Check every IP behind a hostname.** With `checkAllIps`, it handshakes with each A/AAAA address, using the same SNI. That finds the single load-balancer node or server still serving last year's certificate.
- **Change detection for schedules.** Set `monitorKey` and schedule the actor (daily or weekly). Each row then shows `changedSinceLastRun`, so you can confirm renewals actually went live.
- **Real verdicts, not just dates.** It catches expired or missing intermediates, self-signed and unknown-root chains, hostname mismatches, RSA keys under 2048 bits, SHA-1 signatures and TLS 1.0/1.1.
- **Private CA support.** Paste your internal CA in `extraCaPem`, or set `trustIssues` to WARN/Ignore to rate expiry only.
- **Alert-friendly.** `onlyProblems` writes only the rows that need attention, sorted worst first. The run's status message reads like "1 expired, 2 critical, 3 warn".

### Input

```json
{
  "hosts": [
    "example.com",
    "https://www.example.org/login",
    "api.example.net:8443",
    "imaps://mail.example.com",
    "imap://mail.example.com",
    "ldaps://dc1.example.com",
    "203.0.113.10:636",
    "[2001:db8::10]:443"
  ],
  "warnDays": 30,
  "criticalDays": 7,
  "onlyProblems": false
}
```

| Field | Default | Notes |
| --- | --- | --- |
| `hosts` | required | Domains, `host:port`, IPv4/IPv6, or URLs (`https`, `ldaps`, `imaps`, `pop3s`, `smtps`, `ftps`, `rdp`, `imap`, `pop3`, `smtp`, `ftp`). Paths are ignored, `*.` wildcards are stripped, IDNs are converted, duplicates are removed. Paste lists separated by lines, commas or spaces; `#` starts a comment. Up to 10,000 targets. |
| `port` | 443 | Default port for entries without one |
| `warnDays` / `criticalDays` | 30 / 7 | Expiry thresholds |
| `onlyProblems` | false | Output only non-OK or changed rows |
| `trustIssues` | CRITICAL | Severity for an untrusted chain or hostname mismatch: `CRITICAL`, `WARN` or `IGNORE` |
| `checkAllIps` | false | Check each resolved IP separately |
| `includeIpv6` | false | With `checkAllIps`, also test AAAA addresses |
| `starttls` | auto | `auto` (25/587 SMTP, 143 IMAP, 110 POP3, 21 FTP), `none`, or force `smtp`/`imap`/`pop3`/`ftp` |
| `servername` | – | SNI override for all targets (for example, to check an origin IP behind a CDN) |
| `timeoutSecs` | 10 | Per host: connect + STARTTLS + handshake |
| `concurrency` | 10 | Parallel handshakes (max 50) |
| `monitorKey` | – | Enables change detection; state is kept in your named key-value store `ssl-expiry-monitor-state` |
| `extraCaPem` | – | Extra trusted CA certificates (PEM) |

### Output

One row per host:port (or per IP with `checkAllIps`), worst first:

```json
{
  "host": "example.com",
  "port": 443,
  "ip": "203.0.113.10",
  "status": "WARN",
  "daysLeft": 21,
  "validTo": "2026-11-06T23:59:59.000Z",
  "validFrom": "2026-08-08T00:00:00.000Z",
  "subjectCN": "example.com",
  "issuerO": "Let's Encrypt",
  "issuerCN": "R12",
  "sans": ["example.com", "www.example.com"],
  "sanCount": 2,
  "chainTrusted": true,
  "authorizationError": null,
  "hostnameMatch": true,
  "selfSigned": false,
  "protocol": "TLSv1.3",
  "cipher": "TLS_AES_256_GCM_SHA384",
  "keyType": "EC",
  "keyBits": 256,
  "signatureAlgorithm": "sha256WithRSAEncryption",
  "serialNumber": "04A1...",
  "fingerprintSha256": "3F:...:9C",
  "chainLength": 3,
  "chain": [{ "subjectCN": "example.com", "issuerCN": "R12", "validTo": "2026-11-06T23:59:59.000Z", "daysLeft": 21 }],
  "issues": ["expires in 21 day(s) (warn <= 30)"],
  "error": null,
  "changedSinceLastRun": null,
  "checkedAt": "2026-10-16T19:00:00.000Z"
}
```

Status rules:

- **EXPIRED**: the end date has passed.
- **CRITICAL**: expires within `criticalDays`, isn't valid yet, or (by default) has an untrusted chain or a hostname mismatch.
- **WARN**: expires within `warnDays`, uses a weak key or signature, negotiated legacy TLS, or has an intermediate expiring before the leaf.
- **ERROR**: DNS failure, refused connection, timeout, a port that doesn't speak TLS, or STARTTLS refused. The reason is in `error`.

The key-value store record **`SUMMARY`** holds counts per status, the soonest upcoming expiry, a list of problems, invalid inputs and the number of duplicates removed. Download the dataset as CSV, Excel or JSON, or connect it to email, Slack or webhooks with Apify integrations.

### Scheduling it as a monitor

1. Save your host list as a task and set `monitorKey` (for example, `prod-certs`) and `onlyProblems: true`.
2. Add a schedule (weekly works for most teams; daily if you have short-lived certificates).
3. Add a notification or webhook on the run so problems reach your inbox or chat.

### Notes and limits

- Outbound **port 25** is blocked on Apify's cloud, so MX-on-25 checks time out there. SMTP submission on **587** (STARTTLS) and **465** works, as do IMAP/POP3 (STARTTLS or implicit), LDAPS, HTTPS and custom ports.
- Apify's cloud has no IPv6 egress, so `[v6]:port` targets fail with "network unreachable". `checkAllIps` tests IPv4 addresses only unless you turn on `includeIpv6`.
- Revocation (OCSP/CRL) isn't checked. Trust is evaluated against Node.js's bundled Mozilla root store, plus any CA you add.
- Hosts must be reachable from the public internet. Private/RFC 1918 addresses won't connect from Apify's cloud.
- Only a TLS handshake is made: no HTTP request, no login, no mail sent.

### Pricing

Pay per event: **$1 per 1,000 endpoints checked** ($0.001 per host:port, or per IP with `checkAllIps`). Invalid or duplicate inputs aren't charged.

### Related

- [Bulk SPF / DMARC / MX Auditor](https://apify.com/sysadmin-toolbox/email-security-audit): email authentication checks for many domains.
- [Microsoft 365 Tenant Finder](https://apify.com/sysadmin-toolbox/m365-tenant-finder): tenant ID and IdP for company domains.
- [Bulk ASN IP Lookup](https://apify.com/sysadmin-toolbox/asn-ip-lookup): IP to ASN, org, country and BGP prefix.

# Actor input Schema

## `hosts` (type: `array`):

Domains, host:port, IPs or URLs. One per line (you can also paste comma- or space-separated lists). Examples: example.com, example.com:8443, https://example.com/login, imaps://mail.example.com, 203.0.113.10:636, \[2001:db8::1]:443. Duplicates are removed. Max 10,000 targets per run.

## `port` (type: `integer`):

Used for entries without an explicit port or URL scheme.

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

Certificates expiring within this many days get status WARN.

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

Certificates expiring within this many days get status CRITICAL. Already-expired certificates are EXPIRED.

## `onlyProblems` (type: `boolean`):

Write only non-OK rows (WARN, CRITICAL, EXPIRED, ERROR, or changed certificates) to the dataset. The SUMMARY record still counts every host.

## `trustIssues` (type: `string`):

How to rate a certificate that is in date but not trusted (self-signed, unknown CA, missing intermediate) or does not match the hostname. Use WARN or Ignore for internal hosts with a private CA, or add your CA below.

## `checkAllIps` (type: `boolean`):

Resolve all A/AAAA records and handshake with each address (same SNI). Finds the one load-balancer node or server still serving an old certificate. Each IP counts as one check.

## `includeIpv6` (type: `boolean`):

By default 'check every IP' tests IPv4 addresses only (Apify's cloud has no IPv6 egress, so IPv6 checks there fail with 'network unreachable'). Names with only AAAA records are still tried over IPv6. Turn on when running somewhere with IPv6.

## `starttls` (type: `string`):

Upgrade plaintext protocols before the TLS handshake. Implicit-TLS ports such as 443, 465, 636, 993, 995, 8443 and 3389 need no STARTTLS. Note: outbound port 25 is blocked on Apify's cloud; 587 (STARTTLS) and 465 work.

## `servername` (type: `string`):

Optional. Send this name as SNI (and check the certificate against it) for every target. Useful when checking IP addresses or origin servers behind a CDN. By default each hostname is its own SNI and IP targets send none.

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

Covers connect, STARTTLS and the TLS handshake.

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

Parallel handshakes.

## `monitorKey` (type: `string`):

Optional. Give a name such as 'prod-certs' and schedule the actor: each run stores certificate fingerprints in your named key-value store 'ssl-expiry-monitor-state' under this key and flags certificates that changed since the previous run (changedSinceLastRun).

## `extraCaPem` (type: `string`):

Optional. Paste one or more PEM CA certificates (private or corporate CA) to trust in addition to the standard Mozilla root store.

## Actor input object example

```json
{
  "hosts": [
    "github.com",
    "google.com",
    "expired.badssl.com",
    "imap.gmail.com:993"
  ],
  "port": 443,
  "warnDays": 30,
  "criticalDays": 7,
  "onlyProblems": false,
  "trustIssues": "CRITICAL",
  "checkAllIps": false,
  "includeIpv6": false,
  "starttls": "auto",
  "timeoutSecs": 10,
  "concurrency": 10
}
```

# 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 = {
    "hosts": [
        "github.com",
        "google.com",
        "expired.badssl.com",
        "imap.gmail.com:993"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("sysadmin-toolbox/bulk-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 = { "hosts": [
        "github.com",
        "google.com",
        "expired.badssl.com",
        "imap.gmail.com:993",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("sysadmin-toolbox/bulk-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 '{
  "hosts": [
    "github.com",
    "google.com",
    "expired.badssl.com",
    "imap.gmail.com:993"
  ]
}' |
apify call sysadmin-toolbox/bulk-ssl-expiry-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,sysadmin-toolbox/bulk-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/EUjHyRxg4gePVBy2v/builds/bLUYdL3heMxbdSwh0/openapi.json
