CI & automation
Keep the domains you monitor next to the code that serves them, and keep the two in step automatically.
Why not just POST a monitor?
Because a workflow runs more than once. A pipeline that called POST /monitors on every push would create a duplicate on every re-run, retry and re-merge — and a fortnight later the same domain is being probed forty times, and billed forty times.
POST /api/v1/syncasks a different question. Not “add this”, but “here is the set I want — work out the difference.” Running it a hundred times with an unchanged file makes zero writes and reports everything unchanged.
1. Declare your monitors
{
"project": "production",
"monitors": [
{ "name": "www", "type": "https", "target": "https://example.com", "public": true },
{ "name": "api", "type": "https", "target": "https://api.example.com",
"interval_seconds": 30,
"settings": {
"valid_status_codes": [200],
"body_keyword": "ok",
"ssl_expiry_warning_days": 14
} },
{ "name": "db", "type": "tcp", "target": "db.example.com:5432" },
{ "name": "nightly-backup", "type": "heartbeat", "grace_seconds": 3600 }
]
}name is the identity — it is how a monitor is matched from one run to the next. Renaming one creates a new monitor and reports the old one as removable.
2. Apply it on push
name: Sync monitors
on:
push:
branches: [main]
paths: ['monitors.json']
pull_request:
paths: ['monitors.json']
env:
BEACON_URL: https://sysops247.io
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# On a pull request, show what WOULD change and apply none of it, so a
# reviewer sees the plan before it is merged.
- name: Plan
if: github.event_name == 'pull_request'
run: |
jq '. + {dry_run: true}' monitors.json > payload.json
curl -sS --fail-with-body -X POST "$BEACON_URL/api/v1/sync" \
-H "Authorization: Bearer $BEACON_API_KEY" \
-H "Content-Type: application/json" \
-d @payload.json > result.json
jq -r '.items[] | " \(.action)\t\(.name)"' result.json
env:
BEACON_API_KEY: ${{ secrets.BEACON_API_KEY }}
- name: Apply
if: github.event_name == 'push'
run: |
curl -sS --fail-with-body -X POST "$BEACON_URL/api/v1/sync" \
-H "Authorization: Bearer $BEACON_API_KEY" \
-H "Content-Type: application/json" \
-d @monitors.json > result.json
jq -r '.items[] | " \(.action)\t\(.name)"' result.json
# A monitor rejected on its own merits still returns 200, because the
# rest of the file WAS applied. Check the count, not just the status.
failed=$(jq -r '.failed' result.json)
if [ "$failed" != "0" ]; then
jq -r '.items[] | select(.action=="error") | "::error::\(.name): \(.error)"' result.json
exit 1
fi
env:
BEACON_API_KEY: ${{ secrets.BEACON_API_KEY }}That is the whole integration. Push a change to monitors.json and your monitoring follows it.
3. Read the response
{
"project": "production",
"dry_run": false,
"created": 1, "updated": 1, "unchanged": 2,
"removed": 0, "would_remove": 1, "failed": 0,
"items": [
{ "name": "api", "action": "updated", "id": "..." },
{ "name": "db", "action": "unchanged", "id": "..." },
{ "name": "old-api", "action": "would_remove", "id": "..." },
{ "name": "www", "action": "created", "id": "..." }
]
}| Field | Type | Notes |
|---|---|---|
| created | action | Did not exist; now does. |
| updated | action | Existed and differed; now matches the file. |
| unchanged | action | Already matched — no write was performed. |
| would_remove | action | No longer declared. Pass prune to remove it. |
| removed | action | Deleted, because prune was set. |
| error | action | This one failed; the others still applied. |
Items are sorted by name, so consecutive runs diff cleanly.
Deleting is opt-in
Remove a monitor from the file and it is reported, not deleted. To actually remove it, add "prune": true.
Understand this default before you turn it off. A workflow with a bad path filter, an empty matrix, or a failed template step declares zero monitors. With pruning on, that one mistake deletes your production monitoring — at the exact moment nobody notices, because the monitoring is what just went. Off, the same mistake changes nothing and tells you what it would have done.
Turn it on once you trust the pipeline, and the file becomes the single source of truth: delete a line, and the monitor goes with it.
Preview before merging
"dry_run": truecomputes the plan and applies none of it — which is what the pull-request step above does, so a reviewer sees “this adds 2 and removes 1” before anyone merges.
Partial failures
One rejected monitor does not discard the rest. If entry three hits your plan limit, entries one, two and four still apply, and three comes back as:
{ "name": "extra", "action": "error", "error": "monitor limit reached for the free plan" }The response is 200 in that case — some of your declaration was applied, and a 4xx would tell your workflow nothing happened, prompting exactly the retry that creates duplicates. Check failed in the body, as the example workflow does.
Other things worth automating
# Fail a deploy if anything is already down
down=$(curl -s "$BEACON_URL/api/v1/monitors?status=down" \
-H "Authorization: Bearer $BEACON_API_KEY" | jq '.pagination.total')
[ "$down" = "0" ] || { echo "::error::$down monitor(s) already down"; exit 1; }
# Suppress alerts for a planned deploy
curl -X POST "$BEACON_URL/api/v1/maintenance-windows" \
-H "Authorization: Bearer $BEACON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Deploy","starts_at":"2026-01-01T22:00:00Z","ends_at":"2026-01-01T23:00:00Z"}'See the API reference for everything else.
Troubleshooting
| Field | Type | Notes |
|---|---|---|
| 401 | error | Revoked, expired, or mistyped key. Every failure returns the same message on purpose — check its status in the dashboard. |
| 403 | error | The key's role is too low. A viewer key cannot write. |
| 422 | error | A plan limit. The other monitors in the file still applied; this one is in items with action "error". |
| Everything says created | symptom | The name changed between runs. Name is the identity; a renamed monitor is a new one. |