Skip to main content
Silent Outage

Start free

The REST API

Everything the dashboard does, over HTTP, with a key you scope yourself — projects, checks, incidents and where alerts go.

Full CRUD over projects, checks, incidents and alert destinations, plus the ping endpoint your jobs report to — so that everything the dashboard does can be automated.

The machine-readable description is the source of truth and it is generated from the routing table, not written alongside it:

GET https://<your-app-host>/api/v1/openapi.json

That endpoint needs no key. A specification is not customer data, and a client that cannot read the spec without a key cannot generate the code that uses one. It is generated from the same routing table the server enforces, so the document and the routes cannot drift apart.


Authenticating

Authorization: Bearer sok_…

One header, one scheme. There is deliberately no ?api_key= form: query strings end up in access logs, Referer headers and browser history, which is three new places for a bearer credential to sit.

Getting a key

Keys are created from the dashboard session, not from the API. Sign in and open /account/api-keys: name the key, tick the scopes it needs, and it is shown to you once. The same screen lists what the account holds and revokes one.

The routes that screen posts to are session-authenticated and are not part of the /api/v1 surface:

POST   /api/account/api-keys      { "name": "ci", "scopes": ["checks:read", "checks:write"] }
GET    /api/account/api-keys
DELETE /api/account/api-keys/<id>

There is no apikeys:* scope and no key-management route on the /api/v1 surface. A key cannot mint or revoke another key, so a leaked key is bounded by its own scopes and its own revocation instead of being able to issue itself a fresh, wider one.

The key is shown once

The POST response is the only moment the key exists outside your process. Store it then. There is nowhere to read it back from afterwards: what is stored is a SHA-256 of it, in a column the schema pins to exactly 64 hexadecimal characters so that the key itself does not fit, and no read anywhere in the product selects that digest. What the list shows is a display prefix — sok_ plus eight characters — which identifies a key and authenticates nothing.

Lose it and you issue a new one from /account/api-keys. That is the intended path.

Revoking

DELETE /api/account/api-keys/<id> marks the key revoked; the row stays. A revoked key answers 401 with `"reason": "revoked"`, so the operator whose script just stopped working is told why rather than left wondering whether it was mistyped. Revoking twice reports the first timestamp.


Scopes

A key carries an explicit list. Nothing is implied and there is no wildcard.

ScopeOpens
projects:readlist and read projects
projects:writecreate, rename, retimezone, delete a project
checks:readlist and read checks, and their ping history
checks:writecreate, edit, pause, resume, delete a check
incidents:readlist and read incidents
incidents:writeacknowledge and resolve an incident
channels:readlist and read alert destinations (metadata only)
channels:writeadd and remove an alert destination
status:readthe current state of a project's checks and its open incidents

A request without the scope its route declares is 403, and the response names the scope it wanted. status:read is separate from checks:read on purpose: it is the read you would wire into a badge or a widget, and it must be handable out without also handing out every check's ping URL.

Every read is confined to the account the key belongs to. Another account's project, check, incident or channel is 404 — not 403, which would confirm it exists.


Rate limiting

120 requests per key per rolling 60-second window. Per key, not per account, so a noisy CI key cannot starve the key a status widget reads through, and rotating one key is a remedy.

Every authenticated response carries:

RateLimit-Limit: 120
RateLimit-Remaining: 118
RateLimit-Reset: 47

Over the limit is 429 with Retry-After in seconds (never 0). A 403 costs a request too — otherwise enumerating what a stolen key can reach would be free.


What the API deliberately cannot do

  • Create or delete an incident. The evaluator owns the incident record: an incident is a conclusion drawn from observations, and an endpoint that could open one would be a monitoring product reporting an outage nobody observed. "CRUD for incidents" is read plus the two transitions a human actually makes.
  • Resolve a check. POST /incidents/<id>/resolve closes the record. It does not touch the check's state, which is the evaluator's alone — so resolving an incident on a check that is still failing closes this one and the next Down opens a new one.
  • Read a notification destination. The address is sealed at rest with a KMS and exists in one place. The API returns kind, verification state and dates, and never the address.
  • Edit a destination. Editing an address in place would move where alerts go without re-confirming it. Delete and re-add. For the same reason there is no "resend the confirmation" route: re-sending needs the sealed address unsealed, and that is not something a key-authenticated surface should be able to trigger.
  • Delete a non-empty project. A project cascades to its checks, their ping history and their incidents. Deleting one is 409 while it still holds checks — delete those first, so the destruction stays proportional to the intent.

The ping endpoint

POST /ping/<uuid> is part of the public surface and is documented in the same OpenAPI file, but it is served by the part of the system that receives reports rather than by the app — the spec gives that path its own servers entry, so the origin it names is whatever the deployment answers pings on, and the app has no handler for it at all.

Which host answers it. Today every part of Silent Outage runs on one machine, so the edge that receives your reports, the process that sends your alerts and the dashboard answer on one hostname and share one failure domain: if that machine is unreachable, so are all three. Separating them is the intended arrangement and it is suspended, deliberately and in writing, until there is a second machine to separate them onto. The alert queue is a table, so a page computed while the sender is down survives it; the notification dispatcher is the only thing that sends, with its own build and its own service; and it shares no code, no dependency and no write authority with ingestion.

It takes no API key: the token in the path is the whole credential. It accepts GET, HEAD and POST, because a cron wrapper reaches it with whichever its author had to hand, and it answers 200 as soon as the ping is durably written and after nothing else.

curl https://<ping-host>/ping/<uuid>            # success
curl https://<ping-host>/ping/<uuid>/start      # a run began
curl https://<ping-host>/ping/<uuid>/fail       # a run failed
curl "https://<ping-host>/ping/<uuid>/$?"       # the exit-status form

A request body is captured as the run's output and truncated at 10 KiB — never rejected, because a 413 would lose the run report itself.


Errors

StatusMeans
400the request is malformed, or a config the write path refuses (a bad cron line, a grace that outlives its period). The message says which.
401no key, an unknown key, or a revoked one (reason)
402your plan does not include this. upgrade carries the plan that lifts it.
403the key does not hold requiredScope
404no such thing in your account
405wrong method; allow lists the right ones
409the state conflicts — a non-empty project, an already-resolved incident
429rate limited; see Retry-After
502the write succeeded but a side effect (the confirmation email) did not
The REST API · Silent Outage