Certi is a Python-based Certificate Transparency (CT) log monitoring tool that helps you keep track of the SSL/TLS certificates issued for your domains. It periodically queries the SSLMate Cert Spotter API for every domain you monitor, stores the certificates it finds in a local SQLite database, and sends a notification through Apprise whenever a new certificate appears. Domains are managed through a small REST API with Swagger documentation.
It's aimed at self-hosters and domain owners who want to know when a certificate is issued for their domains, whether it was expected (a renewal) or not (a mis-issued or rogue certificate).
- What are Certificate Transparency logs
- Features
- Screenshots
- How it works
- Requirements
- Components and Frameworks used in Certi
- Limitations
- Installation
- Configuration
- Managing the application (REST API)
- Supported Notifications
- Security notes
- Troubleshooting
- Development
- Contributing
- License
Certificate logs are append-only ledgers of certificates. Because they're distributed and independent, anyone can query them to see what certificates have been included and when. Because they're append-only, they are verifiable by monitors. Organizations and individuals with the technical skills and capacity can run a log.
Thanks to Certificate Transparency, domain owners, browsers, academics, and other interested people can analyze and monitor logs. They're able to see which CAs have issued which certificates, when, and for which domains.
- Monitor all your domains (including their subdomains) for newly issued certificates.
- Get alerts through many communication channels thanks to Apprise.
- No alert flood on first run: certificates found during a domain's first scan are recorded as a baseline, and only certificates discovered afterwards trigger notifications (see the caveat in Troubleshooting if the first scan fails).
- Store all discovered certificates (issuer, validity dates, DNS names, SHA-256 hashes) in a local SQLite database.
- Manage your domains using a REST API (Swagger documentation included).
- Enable or disable monitoring per domain without deleting it.
- Multi-architecture Docker image (
linux/amd64,linux/arm64,linux/arm/v7).
flowchart LR
API[REST API :8081] -->|add / remove / toggle domains| DB[(SQLite<br/>db/certi.db)]
W[Scan worker] -->|read active domains| DB
W -->|GET /v1/issuances| CS[Cert Spotter API]
W -->|store new certificates| DB
W -->|new certificate| AP[Apprise]
AP --> N[Telegram, Slack, Discord, email, ...]
Certi runs two threads side by side (via multiprocessing.dummy):
- Scan worker — every
SLEEP_TIMEseconds it loads all active monitored domains and, for each one, querieshttps://api.certspotter.com/v1/issuanceswithinclude_subdomains=true(so each lookup is a full-domain query, see Limitations). It waits one second between domains. Certificates whose Cert Spotteridor public key SHA-256 are not yet in the database are stored, and a notification is sent for each one, except during a domain's first scan. - REST API — a FastAPI server (served by Uvicorn on port
8081) used to manage monitored domains and list discovered certificates.
The notification contains the monitored domain, the issuer, the start and end dates, and the list of DNS names on the certificate.
- Docker (or Docker Compose).
- An SSLMate Cert Spotter API key (a free account is enough, subject to the limits below).
- At least one Apprise notification URL if you want to receive alerts.
- Loguru for logging.
- FastAPI for the REST API.
- Apprise for notifications.
- Requests for querying the Cert Spotter API.
- SQLite for storage.
Certi uses the SSLMate Cert Spotter search API. The free API account has the following limitations:
- 100 single-hostname queries / hour.
- 10 full-domain queries / hour.
- 75 queries / minute.
- 5 queries / second.
A single-hostname query is a query which returns certificates for a single specific hostname (the include_subdomains parameter is false).
A full-domain query is a query which returns certificates for all descendant subdomains of the queried domain (the include_subdomains parameter is true).
Certi always queries with include_subdomains=true, so every monitored domain costs one full-domain query per scan. With the free plan, keep (number of active domains) × (scans per hour) at or below 10. The default SLEEP_TIME of 7200 seconds (one scan every 2 hours) allows up to 20 active domains.
Certi is a Docker-based application. The image is published on Docker Hub as techblog/certi.
services:
certi:
image: techblog/certi
container_name: certi
restart: always
ports:
- "8081:8081"
environment:
- API_KEY=<your Cert Spotter API key>
- SLEEP_TIME=7200
- NOTIFIERS=tgram://bottoken/ChatID
- LOG_LEVEL=INFO
volumes:
- ./data:/opt/certi/dbdocker compose up -dNote: don't leave
SLEEP_TIMEorLOG_LEVELas an empty value (SLEEP_TIME=). An empty variable overrides the image default and Certi will fail to start. Either set a value or remove the line.
docker run -d --name certi --restart always \
-p 8081:8081 \
-e API_KEY=<your Cert Spotter API key> \
-e NOTIFIERS="tgram://bottoken/ChatID" \
-v "$(pwd)/data:/opt/certi/db" \
techblog/certigit clone https://github.com/t0mer/certi.git
cd certi
docker build -t certi .The image is built on top of techblog/fastapi, which provides Python, FastAPI and Uvicorn.
| Variable | Default (in image) | Required | Description |
|---|---|---|---|
API_KEY |
(empty) | Yes | API key for the SSLMate Cert Spotter search API. Sent as a Bearer token. |
SLEEP_TIME |
7200 |
No | Time between scans, in seconds (default is 2 hours). Keep it high enough to stay within the search API limits. Must be an integer. |
NOTIFIERS |
(empty) | No | One or more Apprise URLs, separated by spaces. Leave empty to disable notifications. |
LOG_LEVEL |
DEBUG |
No | Log level. Possible values: DEBUG, INFO, WARNING, ERROR. |
Example with two notification channels:
NOTIFIERS=tgram://bottoken/ChatID discord://webhook_id/webhook_token
To prevent data loss, mount a volume for the application database.
/opt/certi/db is the path inside the container where the SQLite database (certi.db) is located. The tables are created automatically on startup, but the directory itself must exist, so always mount this volume (see Troubleshooting).
| Port | Description |
|---|---|
8081 |
REST API and Swagger documentation. The port is fixed in the code; map it to a different host port if needed (for example "9000:8081"). |
Certi has a small REST API for easy management. By default, the port is set to 8081. Swagger documentation is available by adding /docs to the end of the URL, for example http://<docker-host>:8081/docs. The OpenAPI schema is served at /openapi.json.
There is no web UI besides Swagger: add the domains you want to monitor through the API. The first scan runs right after startup, so domains added later are picked up on the next scan.
The API has the following endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/domains/get |
Get the list of all monitored domains. |
PUT |
/domains/add/{DomainName} |
Add a domain to the monitored domains list. |
DELETE |
/domains/delete/{DomainId} |
Delete a domain from the monitored domains list. |
POST |
/domains/active/{DomainId}/{state}?Active=true|false |
Set the domain status to active/inactive. The state is read from the Active query parameter. |
GET |
/certificates/get |
Get the list of all certificates that Certi found. |
Add a domain:
curl -X PUT http://localhost:8081/domains/add/example.com"{\"message\":\"Domain added successfully\",\"success\":\"true\"}"List monitored domains:
curl http://localhost:8081/domains/get[{"DomainId": 1, "DomainName": "example.com", "Active": 1, "FirstRun": 1}]Disable monitoring for domain 1:
curl -X POST "http://localhost:8081/domains/active/1/false?Active=false"Delete domain 1:
curl -X DELETE http://localhost:8081/domains/delete/1List discovered certificates:
curl http://localhost:8081/certificates/getEach certificate record contains CertificateId, Id (the Cert Spotter issuance ID), tbs_sha256, pubkey_sha256, issuer, not_before, not_after, dns_names and monitored_domain.
The add, delete and state endpoints return their result as a JSON-encoded string (
{"message": "...", "success": "true|false"}), not as a JSON object.
Certi sends notifications through Apprise, so every service supported by Apprise can be used. Check out the Apprise wiki for more information on the supported services.
Note: service availability depends on the Apprise version installed in your image. The Docker image and local installs pull the latest Apprise (unpinned), so fresh builds get Apprise 2.x, which removed Boxcar, Faast, Gitter, Microsoft Teams (
msteams://), PopcornNotify and Spontit. Those rows are marked (removed in Apprise 2.x) below. ServerChan (serverchan://) is also not recognized by Apprise 2.x and is marked (not recognized by Apprise 2.x). The Apprise wiki is the source of truth for the services and URL formats your version supports.
The table below lists some of the supported services, with example service URLs to put in NOTIFIERS. Click on any of the services listed below to get more details on how to configure Apprise for it.
| Notification Service | Service ID | Default Port | Example Syntax |
|---|---|---|---|
| Apprise API | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token |
| AWS SES | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN |
| Boxcar (removed in Apprise 2.x) | boxcar:// | (TCP) 443 | boxcar://hostname boxcar://hostname/@tag boxcar://hostname/device_token boxcar://hostname/device_token1/device_token2/device_tokenN boxcar://hostname/@tag/@tag2/device_token |
| Discord | discord:// | (TCP) 443 | discord://webhook_id/webhook_token discord://avatar@webhook_id/webhook_token |
| Emby | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/ emby://user:password@hostname |
| Enigma2 | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname |
| Faast (removed in Apprise 2.x) | faast:// | (TCP) 443 | faast://authorizationtoken |
| FCM | fcm:// | (TCP) 443 | fcm://project@apikey/DEVICE_ID fcm://project@apikey/#TOPIC fcm://project@apikey/DEVICE_ID1/#topic1/#topic2/DEVICE_ID2/ |
| Flock | flock:// | (TCP) 443 | flock://token flock://botname@token flock://app_token/u:userid flock://app_token/g:channel_id flock://app_token/u:userid/g:channel_id |
| Gitter (removed in Apprise 2.x) | gitter:// | (TCP) 443 | gitter://token/room gitter://token/room1/room2/roomN |
| Google Chat | gchat:// | (TCP) 443 | gchat://workspace/key/token |
| Gotify | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token gotifys://hostname/token?priority=high |
| Growl | growl:// | (UDP) 23053 | growl://hostname growl://hostname:portno growl://password@hostname growl://password@hostname:port Note: you can also use the get parameter version which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 |
| Home Assistant | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken hassio://user@hostname/accesstoken hassio://user:password@hostname:port/accesstoken hassio://hostname/optional/path/accesstoken |
| IFTTT | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event ifttt://webhooksID/Event1/Event2/EventN ifttt://webhooksID/Event1/?+Key=Value ifttt://webhooksID/Event1/?-Key=value1 |
| Join | join:// | (TCP) 443 | join://apikey/device join://apikey/device1/device2/deviceN/ join://apikey/group join://apikey/groupA/groupB/groupN join://apikey/DeviceA/groupA/groupN/DeviceN/ |
| KODI | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname kodi://user@hostname kodi://user:password@hostname:port |
| Kumulos | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey |
| LaMetric Time | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr lametric://apikey@hostname:port lametric://client_id@client_secret |
| Mailgun | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey mailgun://user@hostname/apikey/email mailgun://user@hostname/apikey/email1/email2/emailN mailgun://user@hostname/apikey/?name="From%20User" |
| Matrix | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname matrix://user@hostname matrixs://user:pass@hostname:port/#room_alias matrixs://user:pass@hostname:port/!room_id matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2 matrixs://token@hostname:port/?webhook=matrix matrix://user:token@hostname/?webhook=slack&format=markdown |
| Mattermost | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey mmost://hostname:80/authkey mmost://user@hostname:80/authkey mmost://hostname/authkey?channel=channel mmosts://hostname/authkey mmosts://user@hostname/authkey |
| Microsoft Teams (removed in Apprise 2.x) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/ |
| MQTT | mqtt:// or mqtts:// | (TCP) 1883 or 8883 | mqtt://hostname/topic mqtt://user@hostname/topic mqtts://user:pass@hostname:9883/topic |
| Nextcloud | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User nclouds://adminuser:pass@host/User1/User2/UserN |
| NextcloudTalk | nctalk:// or nctalks:// | (TCP) 80 or 443 | nctalk://user:pass@host/RoomId nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN |
| Notica | notica:// | (TCP) 443 | notica://Token/ |
| Notifico | notifico:// | (TCP) 443 | notifico://ProjectID/MessageHook/ |
| Office 365 | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN |
| OneSignal | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID onesignal://TemplateID:AppID@APIKey/UserID onesignal://AppID@APIKey/#IncludeSegment onesignal://AppID@APIKey/Email |
| Opsgenie | opsgenie:// | (TCP) 443 | opsgenie://APIKey opsgenie://APIKey/UserID opsgenie://APIKey/#Team opsgenie://APIKey/*Schedule opsgenie://APIKey/^Escalation |
| ParsePlatform | parsep:// or parseps:// | (TCP) 80 or 443 | parsep://AppID:MasterKey@Hostname parseps://AppID:MasterKey@Hostname |
| PopcornNotify (removed in Apprise 2.x) | popcorn:// | (TCP) 443 | popcorn://ApiKey/ToPhoneNo popcorn://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ popcorn://ApiKey/ToEmail popcorn://ApiKey/ToEmail1/ToEmail2/ToEmailN/ popcorn://ApiKey/ToPhoneNo1/ToEmail1/ToPhoneNoN/ToEmailN |
| Prowl | prowl:// | (TCP) 443 | prowl://apikey prowl://apikey/providerkey |
| PushBullet | pbul:// | (TCP) 443 | pbul://accesstoken pbul://accesstoken/#channel pbul://accesstoken/A_DEVICE_ID pbul://accesstoken/email@address.com pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE |
| Pushjet | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret pjet://hostname:port/secret pjets://secret@hostname/secret pjets://hostname:port/secret |
| Push (Techulus) | push:// | (TCP) 443 | push://apikey/ |
| Pushed | pushed:// | (TCP) 443 | pushed://appkey/appsecret/ pushed://appkey/appsecret/#ChannelAlias pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN pushed://appkey/appsecret/@UserPushedID pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN |
| Pushover | pover:// | (TCP) 443 | pover://user@token pover://user@token/DEVICE pover://user@token/DEVICE1/DEVICE2/DEVICEN Note: you must specify both your user_id and token |
| PushSafer | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey psafers://privatekey/DEVICE psafer://privatekey/DEVICE1/DEVICE2/DEVICEN |
| reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit reddit://user:password@app_id/app_secret/sub1/sub2/subN |
|
| Rocket.Chat | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID rocket://user:password@hostname/#Channel rocket://webhook@hostname rockets://webhook@hostname/@User/#Channel |
| Ryver | ryver:// | (TCP) 443 | ryver://Organization/Token ryver://botname@Organization/Token |
| SendGrid | sendgrid:// | (TCP) 443 | sendgrid://APIToken:FromEmail/ sendgrid://APIToken:FromEmail/ToEmail sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ |
| ServerChan (not recognized by Apprise 2.x) | serverchan:// | (TCP) 443 | serverchan://token/ |
| SimplePush | spush:// | (TCP) 443 | spush://apikey spush://salt:password@apikey spush://apikey?event=Apprise |
| Slack | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/ slack://TokenA/TokenB/TokenC/Channel slack://botname@TokenA/TokenB/TokenC/Channel slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN |
| SMTP2Go | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey smtp2go://user@hostname/apikey/email smtp2go://user@hostname/apikey/email1/email2/emailN smtp2go://user@hostname/apikey/?name="From%20User" |
| Streamlabs | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/ strmlabs://AccessToken/?name=name&identifier=identifier&amount=0¤cy=USD |
| SparkPost | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey sparkpost://user@hostname/apikey/email sparkpost://user@hostname/apikey/email1/email2/emailN sparkpost://user@hostname/apikey/?name="From%20User" |
| Spontit (removed in Apprise 2.x) | spontit:// | (TCP) 443 | spontit://UserID@APIKey/ spontit://UserID@APIKey/Channel spontit://UserID@APIKey/Channel1/Channel2/ChannelN |
| Syslog | syslog:// | (UDP) 514 (if hostname specified) | syslog:// syslog://Facility syslog://hostname syslog://hostname/Facility |
| Telegram | tgram:// | (TCP) 443 | tgram://bottoken/ChatID tgram://bottoken/ChatID1/ChatID2/ChatIDN |
| twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret twitter://user@CKey/CSecret/AKey/ASecret twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2 twitter://CKey/CSecret/AKey/ASecret?mode=tweet |
|
| Twist | twist:// | (TCP) 443 | twist://password:login twist://password:login/#channel twist://password:login/#team:channel twist://password:login/#team:channel1/channel2/#team3:channel |
| XBMC | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname xbmc://user@hostname xbmc://user:password@hostname:port |
| XMPP | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://user:password@hostname xmpps://user:password@hostname:port?jid=user@hostname/resource xmpps://user:password@hostname/target@myhost, target2@myhost/resource |
| Webex Teams (Cisco) | wxteams:// | (TCP) 443 | wxteams://Token |
| Zulip Chat | zulip:// | (TCP) 443 | zulip://botname@Organization/Token zulip://botname@Organization/Token/Stream zulip://botname@Organization/Token/Email |
- The REST API has no authentication. Anyone who can reach port 8081 can list, add and delete monitored domains and read the stored certificates. Don't expose it to the internet; keep it on a trusted network or put it behind a reverse proxy with authentication.
API_KEYand the Apprise URLs inNOTIFIERSoften contain secrets (bot tokens, webhook keys, passwords). Keep them out of version control, for example by using an.envfile with Docker Compose.- The data volume contains only certificate metadata and the list of monitored domains; no credentials are stored in the database.
- The container exits on startup with
ValueError: invalid literal for int()—SLEEP_TIMEis set to an empty value. Set it to a number of seconds or remove the variable to use the default (7200). - The container exits on startup with a log level error —
LOG_LEVELis set to an empty value. Set it toDEBUG,INFO,WARNINGorERROR, or remove the variable. unable to open database filein the logs — the database directory/opt/certi/dbdoes not exist. Mount a volume on/opt/certi/db.Error getting certificates for domain {...}: 401/429— the API key is missing or invalid (401), or you hit the Cert Spotter rate limits (429). CheckAPI_KEY, increaseSLEEP_TIMEor reduce the number of active domains.- Scanning stops after an error — when an unexpected error occurs in the scan loop, Certi logs
Please fix the errors and restart the applicationand stops scanning (the API keeps running). Fix the cause and restart the container. - No notification for existing certificates — this is expected: certificates found during a domain's first scan are stored without sending alerts. Use
GET /certificates/getto see them. - A burst of alerts for old certificates — if a domain's first scan fails (for example an invalid API key or a rate-limit error), the domain is still marked as scanned, so the next successful scan sends a notification for every existing certificate. Make sure
API_KEYis valid before adding domains.
Project layout:
certi/
├── certi.py # entry point: scan worker, Cert Spotter queries, Apprise notifications
├── server.py # FastAPI REST API (port 8081)
├── sqliteconnector.py # SQLite access (db/certi.db)
├── certificate.py # certificate model
└── monitored_domain.py # monitored domain model
Run locally without Docker (Python 3):
pip install -r requirements.txt fastapi uvicorn jinja2
cd certi
mkdir -p db
export API_KEY=<your Cert Spotter API key> SLEEP_TIME=7200 LOG_LEVEL=DEBUG NOTIFIERS=""
python3 certi.pyrequirements.txt is used for local runs only: the Dockerfile does not install it, it installs apprise on top of the techblog/fastapi base image, which provides FastAPI, Uvicorn and the other dependencies. FastAPI, Uvicorn and Jinja2 (imported by server.py) are not listed in requirements.txt, so install them explicitly as shown above.
Docker images are published by GitHub Actions: docker-publish.yml builds techblog/certi:latest and techblog/certi:<VERSION> for linux/amd64, linux/arm64 and linux/arm/v7 when a GitHub release is published (or on manual dispatch). The version comes from the VERSION file. publish-ghcr.yml is a manual workflow that can publish ghcr.io/t0mer/certi, but no public GHCR image is available yet. jcr-image.yml pushes to a private JFrog registry.
Issues and pull requests are welcome. Please open an issue first to discuss larger changes.
Certi is licensed under the Apache License 2.0.

