Report API and CLI
Jobs in Kubernetes are found by the agent. Everything else (a Windows scheduled task, a macOS launchd job, a GitHub Actions schedule, a Lambda, a probe on a NAS) pushes its result to jobwatch. The first report for a name creates the check, so there is no setup step per job, and the job owns its own schedule.
Contract: proto/phase5.md. Server and web details: docs/architecture.md section 11.
1. Create a key
Settings → API keys → New key (Admins and Owners; a cluster-scoped Admin can only create keys inside their own clusters).
| Field | Notes |
|---|---|
| Role | Reporter can only send reports (POST /v1/report, GET /v1/report/config) and gets 403 everywhere else: use it for scripts and CI. Viewer, Operator and Admin act like a member with that role, plus report. A key never has a higher role than its creator, and never Owner. |
| Clusters | For jobs outside Kubernetes a “cluster” is an environment label (ci, billing-prod, casino-ops). The key may only report to, and see, these. Roles, scoping and routing work like for real clusters. |
| Expires | Optional. Past it the key gets 401. |
The secret (jwk_…) is shown once; jobwatch stores only a hash. Rotate a key to get a new secret: the old one keeps
working for the overlap you choose (default 24 h) so you can switch jobs without downtime. Revoking is final and takes
effect within 10 seconds. Keys cannot create other keys.
Put the key in the environment of the job, never in the command line:
export JOBWATCH_URL=https://jobwatch.example.com
export JOBWATCH_API_KEY=jwk_...
2. The CLI
Download jobwatch-report-<os>-<arch>[.exe] from the agent release (linux, darwin, windows × amd64, arm64;
verify it as in verify.md). One static binary, no runtime.
jobwatch-report run --check NAME [--cluster C] [--schedule CRON --tz TZ | --every DUR] [--grace DUR]
[--max-duration DUR] [--label k=v]... [--no-logs] [--spool DIR] -- command [args...]
jobwatch-report send --check NAME --status start|success|fail [--summary S] [--exit-code N] [--run-id ID]
[--duration 2m3s] [--reason MSG] [--log-file PATH|-] ...
jobwatch-report config # log mode, org key fingerprint, allowed clusters
run sends start, runs the command with stdin/stdout/stderr passed through, keeps the last 20 lines, masks secrets
(proto/fixtures/masking.json, including jwk_/jwa_ strings), seals them in a sealed org, sends success or fail
with the exit code and duration, and exits with the command’s exit code. Reporting never fails the job: 10 s timeout,
3 attempts with back-off, then a warning on stderr (a missing key only costs the reports). With --spool DIR, events that
could not be delivered are kept (mode 0600) and sent before the next run.
Declarative monitoring (--schedule/--every, --grace, --max-duration, --label) goes with the reports: what you
pass updates the check, what you leave out is kept. Without a schedule the check is unscheduled: failures alert, but a
missing run cannot. A run longer than --max-duration opens a ping_slow incident.
Logs follow the org’s mode: plaintext orgs get the masked tail; sealed orgs get it end-to-end encrypted with the org
key (the server stores ciphertext and never decrypts); metadata orgs get none. Set JOBWATCH_ORG_KEY_FINGERPRINT (the
fingerprint from Settings → Encryption) to pin the key: on a mismatch, an unknown mode or any error, no log leaves the
machine. --no-logs always wins.
3. Examples
Linux cron, systemd, any shell
0 3 * * * JOBWATCH_URL=... JOBWATCH_API_KEY=... jobwatch-report run --check billing/export --cluster billing-prod --schedule "0 3 * * *" --tz Europe/Bucharest --grace 30m -- /opt/billing/export.sh
Plain curl
curl -sS -X POST "$JOBWATCH_URL/v1/report" \
-H "Authorization: Bearer $JOBWATCH_API_KEY" -H 'Content-Type: application/json' \
-d '{"check":"billing/export","cluster":"billing-prod","status":"fail","exitCode":3,
"reason":{"code":"exit_code","message":"export.sh exited 3"},"summary":"upload failed",
"logTail":["upload failed: 403 Forbidden"],"periodSeconds":86400,"graceSeconds":1800}'
# → 202 {"checkId":"…","jobKey":"billing-prod/checks/Check/…","created":true}
Plaintext orgs only: mask secrets in logTail yourself. Sealed orgs: use the CLI (or implement jwseal1).
PowerShell (no binary)
$body = @{ check = 'billing/export'; cluster = 'billing-prod'; status = 'success'; durationMs = 41000;
periodSeconds = 86400; graceSeconds = 1800 } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$env:JOBWATCH_URL/v1/report" -Body $body -ContentType 'application/json' `
-Headers @{ Authorization = "Bearer $env:JOBWATCH_API_KEY" } -TimeoutSec 10
Windows Task Scheduler
Action: C:\tools\jobwatch-report.exe, arguments
run --check billing/export --cluster billing-prod --every 1d --grace 30m -- C:\jobs\export.cmd. Put
JOBWATCH_URL and JOBWATCH_API_KEY in the task’s account environment (System Properties → Environment Variables, or
setx for that user), not in the arguments.
macOS launchd
<key>ProgramArguments</key><array>
<string>/usr/local/bin/jobwatch-report</string><string>run</string><string>--check</string><string>mac/backup</string>
<string>--cluster</string><string>laptops</string><string>--every</string><string>1d</string><string>--</string>
<string>/Users/me/bin/backup.sh</string></array>
<key>EnvironmentVariables</key><dict>
<key>JOBWATCH_URL</key><string>https://jobwatch.example.com</string>
<key>JOBWATCH_API_KEY</key><string>jwk_...</string></dict>
(Keep the plist readable by you only.)
GitHub Actions
on:
schedule: [{ cron: "0 3 * * *" }]
jobs:
nightly:
runs-on: ubuntu-latest
env:
JOBWATCH_URL: ${{ vars.JOBWATCH_URL }}
JOBWATCH_API_KEY: ${{ secrets.JOBWATCH_API_KEY }} # a Reporter key limited to cluster "ci"
steps:
- uses: actions/checkout@v4
- run: |
curl -fsSLO https://github.com/<owner>/jobwatch/releases/download/agent-v<version>/jobwatch-report-linux-amd64
chmod +x jobwatch-report-linux-amd64
./jobwatch-report-linux-amd64 run --check github/nightly --cluster ci --schedule "0 3 * * *" --grace 30m -- ./scripts/nightly.sh
AWS Lambda (any language, plain HTTPS)
import json, os, urllib.request
def report(status, **extra):
body = json.dumps({"check": "lambda/nightly", "cluster": "aws", "status": status, **extra}).encode()
req = urllib.request.Request(os.environ["JOBWATCH_URL"] + "/v1/report", body, {
"Authorization": "Bearer " + os.environ["JOBWATCH_API_KEY"], "Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5)
except Exception as e: # never fail the function because of reporting
print("jobwatch report failed:", type(e).__name__)
def handler(event, ctx):
report("start", runId=ctx.aws_request_id)
try:
...
report("success", runId=ctx.aws_request_id, periodSeconds=86400)
except Exception as e:
report("fail", runId=ctx.aws_request_id, reason={"code": type(e).__name__, "message": str(e)[:200]})
raise
4. The API in short
POST /v1/report (one event, 202 {checkId, jobKey, created}), POST /v1/report/batch ({events:[…≤100]} → 207, one
result per event), GET /v1/report/config. Bodies are JSON: ≤ 64 KiB (batch ≤ 1 MiB). Auth: Authorization: Bearer jwk_…
over TLS. Identity of a check is (org, cluster, check).
| Event field | Notes |
|---|---|
check (required) | [A-Za-z0-9._/-]{1,200} |
status (required) | start, success or fail. start opens a run, success/fail finish it (paired by runId, else the latest open start), or record a one-shot run (a probe). |
cluster | default: the key’s only cluster, else custom |
eventId | ≤ 64 chars; the same id within 24 h is accepted again without side effects (safe retries) |
runId, at, durationMs, exitCode, summary (≤ 200), reason {code, message} (fail only) | at defaults to receive time (≤ 300 s ahead, ≤ 24 h back) |
logTail (≤ 20 × 1000) / logEnvelope | plaintext orgs / sealed orgs (needs runId of [A-Za-z0-9._-], bound to {cluster}/report/{check}); the other form is dropped and counted |
schedule + timeZone or periodSeconds, graceSeconds, maxDurationSeconds | declarative; present = update, absent = keep |
labels | ≤ 16, keys [a-z0-9_.-]{1,63}, values ≤ 200 |
Errors are application/problem+json: 400 with a field path, 401 unknown/revoked/expired key, 403 role or cluster
outside the key, 409 check-limit (default 1000 report checks per org), 413, 429 with Retry-After (default 20
requests/s, burst 200, per key). Incidents: ping_failed (a fail), ping_missed (nothing at due time + grace),
ping_slow (longer than maxDurationSeconds); the next good run resolves them, and a failure takes precedence over a
miss over slow.
Reports are authenticated but not signed (unlike agent batches): see SECURITY.md.