Getting started
Every request needs the X-API-Key header. Adding a site takes one call — the only required fields are slug, name and url.
curl -X POST https://monitor.strategyk.com.au/api/v1/sites \
-H "X-API-Key: $MONITOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "acme-corp",
"name": "Acme Corp",
"url": "https://acmecorp.com.au",
"group": "clients",
"expect": {
"body_contains": ["Welcome to Acme"]
}
}'
Changes take about 10 seconds to take effect. The API writes the check definition to disk; a separate watcher then reloads the checking engine. A 201 means the site was accepted and stored — the first check result follows shortly after.
Always send body_contains
A status code alone is a weak signal. A hacked site, an expired CMS licence, a blank page after a bad deploy and a "coming soon" parking page all return 200 OK. Pass a short, stable phrase that only appears when the page has genuinely rendered — a headline, a product name, a phone number.
Avoid anything that changes: dates, prices, cart counts, CSRF tokens, or a year in a copyright line.
Add or update a site
Creates the site, or replaces it entirely if the slug already exists. This is an upsert — there is no separate update call, and a repeat POST is safe.
Returns 201 with:
{"status": "written", "slug": "acme-corp", "note": "gatus reloads within ~10s"}
Replaces, not merges. Fields you leave out revert to their defaults. To change one setting, GET the site first, modify the object, and POST the whole thing back.
List all sites
Returns every registered site as stored, including defaults that were filled in for you. Use this to confirm what the monitor actually holds.
{"sites": [{"slug": "acme-corp", "name": "Acme Corp", "url": "https://acmecorp.com.au/", ...}]}
Fetch one site
Returns the full stored object, or 404 if the slug is unknown. This is the object to modify and POST back when editing.
Remove a site
Stops checking immediately and deletes the definition. Returns 200, or 404 if the slug was not registered.
{"status": "deleted", "slug": "acme-corp"}
Replace the whole list
Declares the complete set of sites. Anything present in the monitor but absent from the payload is removed. Returns the slugs written and removed.
{"status": "reconciled",
"written": ["acme-corp", "beta-ltd"],
"removed": ["old-client"]}
This is the drift-correction call. Because the monitor keeps its own copy of the list, it stays up even when FullStack+ is down — but the two can diverge. Sending the authoritative list here on a schedule (nightly is plenty) re-synchronises them.
This deletes. A partial list silently stops monitoring every site you left out. Only call it with the complete set — for single changes use POST or DELETE.
Current health
Live results for every check. Use this to surface site health inside FullStack+ without duplicating any checking logic.
{
"total": 28,
"unhealthy": 1,
"endpoints": [
{
"name": "acme-corp",
"group": "clients",
"healthy": false,
"last_checked": "2026-08-30T11:52:04Z",
"response_time_ms": 4820,
"errors": ["[BODY] == pat(*Welcome to Acme*)"]
}
]
}
Each site produces two entries: the HTTP check under its slug, and the DNS check as {slug}-dns. The errors array names the specific assertions that failed, so you can show why a site is down, not just that it is.
Service health
The only unauthenticated endpoint. Returns {"ok": true, "sites": 13}. Use it for your own uptime checks on the monitor itself.
The site object
Only slug, name and url are required. Everything else has a working default.
| Field | Type | Default | Notes |
|---|---|---|---|
| slug | string | — | Your stable identifier. Lowercase letters, digits and hyphens, 1–62 characters. Also the key for updates and deletes. |
| name | string | — | Human-readable label. Appears in alert emails and push notifications. |
| url | string | — | Full URL including scheme. Redirects are followed. |
| group | string | null | Free-text grouping, e.g. clients. |
| interval_seconds | integer | 60 | Between 20 and 86400. |
| enabled | boolean | true | false keeps the record but stops checking — use for a site that is intentionally offline. |
| check_dns | boolean | true | Adds a separate DNS resolution check for the hostname. |
| expect | object | see below | What a healthy response looks like. |
| alerts | object | see below | Which channels to notify. |
expect
| Field | Type | Default | Notes |
|---|---|---|---|
| status | integer | 200 | Expected HTTP status after redirects. |
| body_contains | string[] | [] | All must be present. This is the check that catches a broken site returning 200. |
| body_not_contains | string[] | 8 error strings | None may be present. Defaults cover database errors, PHP fatals, WordPress critical errors and common gateway errors. |
| max_response_time_ms | integer | 5000 | Slower than this counts as a failure. |
| min_cert_days | integer | 14 | Alerts while there is still time to renew. HTTPS only. |
| min_body_bytes | integer | 512 | Catches blank pages and parking pages that return 200. |
No asterisks in match strings. * is the wildcard character in the underlying matcher, so a literal one would silently widen your assertion. Sending one returns 422 rather than accepting a check that does not mean what you wrote.
alerts
| Field | Default | Notes |
|---|---|---|
| true | Sent via Postmark to gavin@strategyk.com.au. | |
| push | true | SimplePush notification to Gavin's phone. |
| sms | false | Reserved for Ozetel SMS. Accepted now, inert until credentials are configured. |
What gets checked
Every site gets the full battery on each run. A single failed assertion fails the check.
| Check | Catches |
|---|---|
| DNS resolution | Domain stopped resolving, nameserver change gone wrong, expired domain |
| TLS handshake | Certificate invalid, misconfigured, or wrong hostname |
| Certificate expiry | Renewal failed — warns 14 days ahead by default |
| HTTP status | 500s, unexpected redirects, origin down |
| Response time | Degradation before it becomes an outage |
| Body size | Blank page, parking page, failed build |
| Content present | Page rendered but the real content is missing |
| Content absent | Database errors and PHP fatals served with a 200 |
Alerting
Alerts fire after two consecutive failures, not one. At a 60-second interval that means roughly two minutes of genuine trouble — enough to ignore a single dropped request without meaningfully delaying a real alert.
- Recovery notices are sent too. Two consecutive successes clear the alert, so you always learn when something came back.
- Email goes direct from the checking engine, independently of this API — so alerts still arrive if the API container is down.
- Push and SMS fan out through the API, which is where new channels get added.
Two permanent canaries run alongside your sites. One watches this API; the other checks the monitor's own internet access. If the egress canary fails at the same time as a wave of client sites, the fault is at the monitor's end, not at the sites — check that first before treating it as a mass outage.
Errors
| Code | Meaning |
|---|---|
| 200 | Success — list, fetch, delete, reconcile |
| 201 | Site created or replaced |
| 401 | Missing or wrong X-API-Key |
| 404 | Unknown slug |
| 422 | Validation failed — the response body names the offending field |
| 502 | Checking engine unreachable (/status only) |
Common 422 causes: a slug with uppercase letters or underscores, an interval_seconds outside 20–86400, a URL without a scheme, or an asterisk in a body match string.
Operations
For the StrategyK side, not for FullStack+.
- Lives at
/srv/containers/site-monitoron the EC2 container server. Three containers:api,gatus,reloader. - The internet-facing API has no Docker socket. The reloader holds it and has no network at all.
- A malformed check definition would stop all monitoring, so the reloader verifies the engine restarted and rolls the change back if it did not — recovery measured at 21 seconds.
- Rotate the key by editing
MONITOR_API_KEYin.envand runningdocker compose up -d. - Check history is kept in SQLite at
./data/gatus.db.