Skip to content

REST API

{/* Generated from docs/api.md by scripts/prepare.mjs. Edit that file. */}

Base URL: https://<your-instance>/api. Everything below is versioned under /api/v1, except authentication (/api/auth) and /health.

Two credentials, one header.

API keys (sw_…) are scoped to a single project and are what machines use:

Terminal window
curl -H "Authorization: Bearer sw_3f9a2b1c8d7e6f50_XXXXXXXX…" \
https://watch.example.com/api/v1/checks

A key can manage the checks of its own project and nothing else. It can never read another project, and it can never create or list API keys — a leaked CI key cannot mint itself more access.

User sessions are what the web UI uses: POST /api/auth/login returns a short-lived access token (JWT) and a refresh token. Refresh tokens are single-use and rotated; replaying one is treated as theft and revokes every session of that user.

Endpoint Purpose
POST /api/auth/register Create an account (plus a first project)
POST /api/auth/login Exchange credentials for tokens
POST /api/auth/refresh Rotate the refresh token
POST /api/auth/logout Revoke the presented refresh token
GET /api/auth/me The current user
POST /api/auth/password Change the password (revokes all sessions)
POST /api/auth/delete-account Delete the account and its data, at once and for good

Deleting an account. POST /api/auth/delete-account with {"password": "…"} requires a user session (an API key is refused) and the account’s password again, and answers 204. It removes the user, its sessions, and every project it is the only member of, with those projects’ checks, ping and incident history, API keys and channels. A project shared with other members only loses this member; if the account is the project’s only owner while other members remain, the request is refused with 409 and nothing is deleted. A wrong password, or an account locked by too many wrong ones, answers 403 rather than 401, because the caller is signed in and a 401 means “your session expired”. Wrong passwords count towards the same lockout as login. The security log keeps a record, with the account’s address, for its retention period.

Heartbeats are not part of /api. They live at /p/:pingKey, are unauthenticated (the key is the credential), and accept GET and POST because curl in a crontab is the most common client.

Route Meaning
GET|POST /p/<key> The run succeeded
GET|POST /p/<key>/start The run started — enables duration measurement
GET|POST /p/<key>/fail The run failed; the check goes down immediately
GET|POST /p/<key>/<exitCode> 0 succeeds, anything else fails

Optional ?duration_ms=1234 reports the execution time when the client measures it itself. A request body (up to PING_BODY_MAX_BYTES, truncated, never rejected) is stored with the ping — handy for the last lines of a job’s output.

Responses are plain text and minimal: OK, PAUSED, NOT FOUND (404), RATE LIMITED (429), BAD REQUEST (400), UNAVAILABLE (503). A mistyped key stays a 404 on purpose: it must never look like success to the script calling it.

Two rate limits apply, both answering 429 with Retry-After. One is per key (PING_RATE_LIMIT_PER_MINUTE), which bounds a runaway job. The other is per source address, and is charged only by keys that turn out not to exist — so it is spent by something walking the URL space and never by a working client. Once it is spent, that address is refused until the window turns over, whatever key it presents. Note the asymmetry: a refusal is a 429 and never a 404, because telling a job with a valid URL that its check does not exist would send it looking for a problem that is not there.

Endpoint Notes
GET /api/v1/checks Across every project you can see. Filters: state, environment, tag, search, orphaned, limit, cursor
GET /api/v1/projects/:projectId/checks One project
POST /api/v1/projects/:projectId/checks Create
GET /api/v1/checks/:checkId Read
PATCH /api/v1/checks/:checkId Update, pause ({"paused": true}) or resume
DELETE /api/v1/checks/:checkId Delete, with its history. Requires the admin role
GET /api/v1/checks/:checkId/pings Ping history, newest first
GET /api/v1/checks/:checkId/incidents Incident history

Creating an interval check:

Terminal window
curl -X POST https://watch.example.com/api/v1/projects/$PROJECT/checks \
-H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
-d '{
"name": "Nightly backup",
"scheduleType": "interval",
"periodSeconds": 86400,
"graceSeconds": 3600,
"environment": "production"
}'

Or a cron check:

{
"name": "Nightly backup",
"scheduleType": "cron",
"cronExpression": "0 2 * * *",
"timezone": "Europe/Paris",
"graceSeconds": 3600
}

A schedule is one or the other, never both. Cron expressions may have 5 fields (Unix) or 6 (leading seconds, as Spring and Quartz write them), and support ?, L, 5L and MON#2. Quartz’s W (nearest weekday) is rejected: the server cannot compute its occurrences, and a check whose deadline cannot be computed is worse than no check.

The response includes pingUrl — the only thing your job needs.

The endpoint the client starters call at startup, and the contract any new client library should implement.

POST /api/v1/checks/sync
Authorization: Bearer <api-key>
Content-Type: application/json
{
"environment": "production",
"source": "spring-boot-starter",
"prune": true,
"checks": [
{
"key": "com.acme.jobs.BackupJob#run",
"name": "BackupJob.run",
"cron": "0 0 2 * * *",
"timezone": "Europe/Paris",
"grace_seconds": 300
},
{
"key": "com.acme.jobs.PollJob#poll",
"name": "PollJob.poll",
"interval_seconds": 900
}
]
}
{
"checks": [
{
"key": "com.acme.jobs.BackupJob#run",
"id": "5a1c…",
"pingKey": "8f2e…",
"pingUrl": "https://watch.example.com/p/8f2e…",
"created": true
}
],
"orphaned": ["com.acme.jobs.OldJob#run"]
}

Rules that matter:

  • Upsert by (project, key, environment). The key is the client’s stable identity, so restarts and redeployments land on the same check and keep their history. Environment is part of the identity so a staging deployment declaring the same jobs does not overwrite production’s.
  • Checks missing from the payload are flagged orphaned, never deleted. An automatic delete would destroy a check’s history at the first refactoring. They are shown as orphaned in the UI, and deleting them is your decision.
  • An unchanged schedule does not move the deadline. A restarting application must not grant all of its jobs a fresh grace period.
  • The payload is atomic. One invalid schedule fails the request rather than applying half of it.

With a user session instead of an API key, pass ?projectId=….

Endpoint Notes
GET /api/v1/projects/:projectId/channels List. Configuration is never returned — only a masked target
POST /api/v1/projects/:projectId/channels Create (email, webhook, slack, teams, discord)
PATCH /api/v1/projects/:projectId/channels/:id Rename or enable/disable
DELETE /api/v1/projects/:projectId/channels/:id Delete
POST /api/v1/projects/:projectId/channels/:id/test Send a sample alert now, and report the failure verbatim

Webhook payloads carry a signature when the channel has a secret:

X-SilenceWatch-Event: check.down
X-SilenceWatch-Timestamp: 1769812345
X-SilenceWatch-Signature: sha256=<hex>

with signature = HMAC_SHA256(secret, "<timestamp>.<raw body>"). Verify it, and reject timestamps that are too old to be honest.

Every error has the same shape:

{
"statusCode": 400,
"error": "BAD_REQUEST",
"message": "Validation failed",
"details": [{ "path": "periodSeconds", "message": "Number must be greater than or equal to 30" }]
}

404 is used where 403 would confirm that something exists: asking for another tenant’s project returns “not found”, because “forbidden” would be an answer.

  • Heartbeats: PING_RATE_LIMIT_PER_MINUTE per ping key (120 by default)
  • Authentication: AUTH_RATE_LIMIT_PER_MINUTE per IP and route (10)
  • Everything else: API_RATE_LIMIT_PER_MINUTE per IP (600)

Rejections carry Retry-After.