API and webhooks
Add domains from your deploy pipeline, read certificate results from your own tools, and get each alert as a signed request to your own server. Available on the Pro and Business plans.
API keys
The workspace's Owner and Admins create keys on the Developers page of the dashboard. A key is read-only or read and write, works for one workspace, and is shown once: store it as a secret in your CI. Revoking it stops it at once.
Send it with every request:
curl -H "Authorization: Bearer $CERTHALO_API_KEY" https://certhalo.com/api/v1/domainsEndpoints
Base URL https://certhalo.com/api/v1. A domain is written as on the dashboard, example.com or example.com:8443, URL-encoded in paths. Responses are JSON. The full description is in OpenAPI format for Postman, Insomnia or code generators.
| Request | Key | What it does |
|---|---|---|
| GET /domains | Any | Every domain in the workspace: status, grade, expiry date, days left, issuer, last and next check. |
| POST /domains | Read and write | Adds a domain and runs its first check. Body: {"domain": "example.com"}. Your plan's limit applies. |
| GET /domains/{domain} | Any | One domain with its latest full result: certificate, chain, findings, revocation and TLS versions. |
| DELETE /domains/{domain} | Read and write | Stops monitoring it and deletes its check history. |
| GET /domains/{domain}/history | Any | Its checks, newest first, for the last 90 days. ?limit=1…500 (default 100). |
| POST /domains/{domain}/check | Read and write | Checks it now and returns the new result. Once every 5 minutes per domain. |
A domain, as listed
{
"domain": "shop.example.com",
"host": "shop.example.com",
"port": 443,
"status": "ok", // pending, ok, warning, critical or error
"grade": "A",
"validTo": "2027-01-14T23:59:59.000Z",
"daysRemaining": 77,
"issuer": "R11",
"lastCheckedAt": "2026-10-29T08:59:48.624Z",
"nextCheckAt": "2026-10-29T14:59:48.624Z",
"error": null // why the last check failed, if it did
}A single domain adds result: the full result of its latest check.
Errors and limits
Errors look like {"error": {"code": "not_found", "message": "…"}}:
401 unauthorized: no key, or it was revoked.402 plan_required: the workspace's plan doesn't include the API.403 forbidden: a read-only key tried to change something.404 not_found: the workspace doesn't monitor that domain.422 invalid: not a domain, already monitored, or the plan's limit is reached.429 rate_limited: more than 60 requests a minute per key, or a domain checked on demand within 5 minutes. Wait for the number of seconds inRetry-After.
Example: GitHub Actions
Add a new site to monitoring when it's deployed, then check it straight away (a read and write key, stored as the secret CERTHALO_API_KEY):
- name: Monitor the certificate with CertHalo
env:
CERTHALO_API_KEY: ${{ secrets.CERTHALO_API_KEY }}
run: |
# 201 when added, 422 when it's already monitored: both are fine here.
curl -sS -X POST https://certhalo.com/api/v1/domains \
-H "Authorization: Bearer $CERTHALO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "app.example.com"}'
curl -sS -X POST https://certhalo.com/api/v1/domains/app.example.com/check \
-H "Authorization: Bearer $CERTHALO_API_KEY"Webhooks
Connect a webhook under Connect a channel on the Alerts page, next to Slack and Microsoft Teams. CertHalo then sends each alert as a POST with a JSON body. Like a chat channel, it gets Everything or Urgent only. The URL must use HTTPS and answer with a 2xx status within 5 seconds; a timeout or 5xx is retried once, and after 5 failed deliveries in a row (or a 404 or 410) the webhook is turned off and the Owner and Admins are emailed.
POST /certhalo HTTP/1.1
Content-Type: application/json
CertHalo-Event: certificate.expiring
CertHalo-Delivery: evt_3f6c0f1c9a2b4d5e8f7a6b5c4d3e2f1a
CertHalo-Signature: t=1793289599,v1=5d1c…
{
"id": "evt_3f6c0f1c9a2b4d5e8f7a6b5c4d3e2f1a",
"type": "certificate.expiring",
"createdAt": "2026-10-29T15:29:59.000Z",
"workspace": { "id": "…", "name": "Acme IT Ops" },
"domain": "shop.example.com",
"data": { "daysRemaining": 7, "validTo": "2026-11-05T23:59:59.000Z" },
"summary": { "title": "shop.example.com: SSL certificate expires in 7 days", "detail": "…" },
"reportUrl": "https://certhalo.com/check/shop.example.com"
}Events
| certificate.expiring | A reminder before expiry (on your reminder days) |
| certificate.expired | The certificate has expired |
| certificate.renewed | A new certificate with a later expiry date |
| certificate.issuer_changed | A certificate from a different issuer |
| certificate.revoked | The issuer revoked it |
| certificate.untrusted | The chain no longer leads to a trusted root |
| certificate.chain_needs_fix | Browsers repair the chain, but apps and APIs can't |
| certificate.hostname_mismatch | The certificate doesn't cover the domain |
| domain.unreachable | Two checks in a row couldn't connect |
| domain.recovered | Reachable again |
| test | Sent when you connect a webhook or press “Send a test” |
Use CertHalo-Delivery to ignore an event you've already handled. data holds what the event is about: daysRemaining, validTo, previousValidTo, issuer, previousIssuer, revokedAt or reason, when they apply.
Checking the signature
Each webhook has its own secret (whsec_…), shown once when you connect it; New secret on the Alerts page replaces it. The signature is an HMAC-SHA256 of the timestamp, a full stop and the raw request body. Check it against the raw body, before parsing the JSON, and reject timestamps more than 5 minutes old.
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyCertHalo(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}# Python
import hashlib, hmac, time
def verify_certhalo(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Questions or something missing? Email support@certhalo.com. Not on Pro or Business yet? See the plans.