Ops API
Admin APIs of the Full preset (internal/modules/ops), implemented in examples/full-single. The full schema is in the app's api/openapi.json and at /docs. Decisions: ADR-0026, ADR-0031, ADR-0033, ADR-0036, ADR-0037, ADR-0038, ADR-0040.
Authentication#
/ops/* requests use a signed-in session: a browser's session cookie, or a bearer token from POST /v1/auth/login with "transport": "bearer". The account needs a platform role (authentication guide):
go run ./cmd/api grant-role you@example.com platform_admin
TOKEN=$(curl -s -X POST http://127.0.0.1:8080/v1/auth/login -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your password","transport":"bearer"}' | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/ops/settings
| Situation | Response |
|---|---|
| No or invalid session | 401 unauthenticated |
| Signed in without the operation's permission | 403 forbidden |
Role platform_admin |
Every ops permission |
Role ops_viewer |
ops.settings.read, ops.jobs.read, ops.audit.read, ops.releases.read, ops.mail.read |
Changes are attributed to the signed-in user in history, job metadata and audit events.
Permissions#
| Permission | Allows |
|---|---|
ops.settings.read |
List and read settings and their history |
ops.settings.write |
Change and reset settings |
ops.jobs.read |
Read job definitions, scheduled jobs, runs and queues |
ops.jobs.write |
Change and reset job configuration; pause and resume queues |
ops.jobs.run |
Run a job now, retry or cancel a run |
ops.audit.read |
List and read audit events |
ops.releases.read |
List releases and the instances running them |
ops.mail.read |
See how the app sends email |
ops.mail.test |
Send a test email |
ops.auth.read |
See which sign-in methods are configured |
ops.system.read |
See an instance's health checks, database pool, migrations and runtime |
Missing permission: 403 forbidden.
Conventions#
- Errors are
application/problem+jsonwithcode,detailandrequest_id. - Changes send the
versionthey last read; a newer version returns a*_version_conflicterror. Read again and retry. - Unknown request fields are ignored.
Runtime settings#
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/settings?group= |
List settings | 200 {settings: [...]} |
GET /ops/settings/{key} |
One setting | 200 |
PUT /ops/settings/{key} |
Change: {value, version, reason?} |
200 |
DELETE /ops/settings/{key} |
Reset to default: {version, reason?} |
200 |
GET /ops/settings/{key}/history?before=&limit= |
Changes, newest first | 200 {changes: [...]} |
curl -X PUT http://127.0.0.1:8080/ops/settings/example.ping_message \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value":"hello","version":0,"reason":"demo"}'
{
"key": "example.ping_message",
"kind": "string",
"group": "example",
"description": "Reply of GET /v1/ping. …",
"value": "hello",
"default": "pong",
"modified": true,
"invalid_stored_value": false,
"version": 1,
"updated_at": "2026-09-14T12:00:00Z",
"updated_by": "usr_mfrggzdfmztwq2lk",
"reason_required": false,
"restart_required": false,
"restart_pending": false,
"constraints": {"max_len": 100}
}
Retention#
How long data is kept is a runtime setting per kind of data (ADR-0051), so changes need the setting's reason, appear in its history and audit log, and apply on every instance without a restart. GET /ops/retention (ops.settings.read) lists them:
| Data | Setting | Default | Deleted by |
|---|---|---|---|
audit_events |
audit.retention |
365 days (30 days to 10 years) | retention job, daily at 04:15 |
settings_history, job_definition_history |
ops.history_retention |
365 days (30 days to 10 years) | retention job |
release_instances |
releases.instance_retention |
90 days (1 day to 3 years) | each instance, when it starts |
deleted_accounts |
auth.deleted_account_retention |
30 days | auth_cleanup job |
deleted_organisations (multi-tenant apps) |
orgs.deleted_org_retention |
30 days | orgs_purge job |
Each policy shows retention (a Go duration) and retention_seconds, the enforcing job with its last_run and next_run_at, and oldest_at for the data the retention job deletes. The job deletes 5,000 rows per statement until nothing is older, and records a retention.purged audit event with the row count and cutoff for each kind of data, so a shortened retention stays visible after the rows are gone.
curl -X PUT http://127.0.0.1:8080/ops/settings/audit.retention \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value":"17520h","version":0,"reason":"two-year compliance requirement"}'
Maintenance mode#
Turn it on with the maintenance.enabled setting (a reason is required); every instance applies it within a second (ADR-0051). While it's on, requests answer 503 with problem code maintenance, detail set to maintenance.message (a generic message when empty) and a Retry-After header from maintenance.retry_after (5 minutes by default).
Still served, so load balancers keep instances in rotation and staff can turn it off: /livez, /readyz, /version, /openapi.json, /docs, /.well-known/*, /ops/* and /v1/auth/*. Sign-in works for everyone, but every product route after it answers 503.
curl -X PUT http://127.0.0.1:8080/ops/settings/maintenance.message \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value":"Upgrading the database until 10:00 UTC","version":0}'
curl -X PUT http://127.0.0.1:8080/ops/settings/maintenance.enabled \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value":true,"version":0,"reason":"database upgrade"}'
When nobody can reach /ops, run it where the app's environment is set:
go run ./cmd/api maintenance on --message "Back at 10:00 UTC"
go run ./cmd/api maintenance off
The command writes the same settings as system:cli, recorded in their history and the audit log.
Job definitions#
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/jobs/definitions |
All definitions with effective and default config, next and last run | 200 {definitions: [...]} |
GET /ops/jobs/scheduled |
Enabled scheduled jobs, soonest first | 200 {definitions: [...]} |
GET /ops/jobs/definitions/{name} |
One definition | 200 |
PUT /ops/jobs/definitions/{name} |
Change only the sent fields: {enabled?, schedule?, timeout?, max_attempts?, queue?, priority?, version, reason?} |
200 |
DELETE /ops/jobs/definitions/{name} |
Reset to code defaults: {version, reason?} |
200 |
GET /ops/jobs/definitions/{name}/history?before=&limit= |
Changes: action, old_config, new_config, reason, actor |
200 {changes: [...]} |
POST /ops/jobs/definitions/{name}/run |
Run now | 202 with the run |
curl -X PUT http://127.0.0.1:8080/ops/jobs/definitions/heartbeat \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"schedule":"@every 2h","timeout":"30s","version":0,"reason":"less noise"}'
{
"name": "heartbeat",
"description": "Logs a heartbeat. …",
"config": {"enabled": true, "schedule": "@every 2h", "timeout": "30s", "max_attempts": 3, "queue": "default", "priority": 1},
"defaults": {"enabled": true, "schedule": "@every 1h", "timeout": "1m0s", "max_attempts": 3, "queue": "default", "priority": 1},
"modified": true,
"invalid_override": false,
"version": 1,
"updated_by": "usr_mfrggzdfmztwq2lk",
"next_run_at": "2026-09-14T14:00:00Z",
"last_run": {"id": 42, "kind": "heartbeat", "state": "completed", "attempt": 1, "…": "…"}
}
A reason is required to disable a job or change an enabled job's schedule.
Job runs#
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/jobs/runs?kind=&queue=&state=&limit=&cursor= |
Runs, newest first; state is comma-separated: available, cancelled, completed, discarded, pending, retryable, running, scheduled |
200 {jobs: [...], next_cursor?} |
GET /ops/jobs/runs/{id} |
One run with attempt errors | 200 |
POST /ops/jobs/runs/{id}/retry |
Make it run again now | 200 |
POST /ops/jobs/runs/{id}/cancel |
Cancel; a running job's context is cancelled | 200 |
Run fields: id, kind, queue, state, attempt, max_attempts, priority, created_at, scheduled_at, attempted_at, finalized_at, errors[{at, attempt, message}], request_id, actor_kind, actor_id. Arguments are never returned.
GET /ops/jobs/overview (ops.jobs.read, ADR-0051) summarises job work across every instance:
{
"queues": [
{"name": "default", "active": true, "paused": false,
"available": 0, "scheduled": 3, "running": 1, "retryable": 2, "discarded_last_day": 1}
],
"failing": [{"name": "send_digest", "last_run": {"state": "retryable", "errors": [...]}, ...}]
}
queues lists queues with active workers or unfinished jobs; failing lists job definitions, in the same shape as GET /ops/jobs/definitions, whose most recent run is retrying or was discarded.
Queues#
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/queues |
Active queues: name, paused, paused_at, created_at, updated_at |
200 {queues: [...]} |
POST /ops/queues/{name}/pause |
Stop every instance fetching from the queue | 204 |
POST /ops/queues/{name}/resume |
Resume it | 204 |
Audit log#
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/audit?actor_kind=&actor_id=&action=&action_prefix=&resource_type=&resource_id=&org_id=&outcome=&request_id=&from=&to=&limit=&cursor= |
Events, newest first; filters combine; from (inclusive) and to (exclusive) are RFC 3339 times compared with occurred_at |
200 {events: [...], next_cursor?} |
GET /ops/audit/{id} |
One event | 200 |
GET /ops/audit/stats?group_by=&from=&to=&actor_kind=&actor_id=&action=&action_prefix=&resource_type=&org_id=&outcome= |
Counts in a window of at most 90 days (default the last 7), grouped by action, outcome, actor_kind, resource_type or day (UTC) |
200 {from, to, group_by, total, groups: [{key, count}], other} |
Stats return the 50 largest groups, with the rest counted in other; group_by=day returns every day with events, in order. A wider window or an unknown grouping returns 422 invalid_audit_filter.
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8080/ops/audit/stats?group_by=action&action_prefix=auth.&outcome=failure'
curl -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8080/ops/audit?action_prefix=settings.&limit=20'
{
"events": [
{
"id": 7,
"occurred_at": "2026-09-14T12:00:00.123Z",
"recorded_at": "2026-09-14T12:00:00.125Z",
"actor_kind": "user",
"actor_id": "usr_mfrggzdfmztwq2lk",
"action": "settings.value.changed",
"resource_type": "setting",
"resource_id": "example.ping_message",
"outcome": "success",
"request_id": "req_99c4a38756b2eb8f",
"trace_id": "951ff1fe97c8f8616496d020314fea38",
"metadata": {"reason": "demo", "reset": false, "version": 1}
}
],
"next_cursor": "7"
}
request_idlinks an event to its access log line, trace and any jobs the request enqueued.ipanduser_agentappear on events recorded during a request, such as sign-ins.- Metadata values under sensitive keys such as
passwordortokenare stored as"[REDACTED]"; oversized metadata is replaced with{"metadata_dropped": "too_large"}. - Events can't be changed. The
retentionjob deletes events older thanaudit.retention(365 days by default) and records aretention.purgedevent for each deletion: see Retention.
Releases#
Every instance records its build when it starts (version, commit, build time, whether the tree had uncommitted changes, Go version, host), sends a heartbeat every 30 seconds and marks itself stopped when it shuts down cleanly. An instance is running until it stops or misses three heartbeats, so crashed instances drop out after about 90 seconds. Instances last seen more than 90 days ago are deleted.
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/releases?limit=&cursor= |
Releases (one per version and commit), newest first: first_started_at, last_seen_at, running, starts, modified |
200 {releases: [...], next_cursor?} |
GET /ops/releases/current |
Releases running now, each with its running instances; more than one during a rolling deploy | 200 {releases: [...]} |
GET /ops/releases/instances?version=&commit=&running=&limit=&cursor= |
Instance starts, newest first | 200 {instances: [...], next_cursor?} |
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/ops/releases/current
{
"releases": [
{
"version": "v1.4.0",
"commit": "3f9a1c2b7d4e8a90",
"instances": [
{"id": 12, "instance_id": "9b1c…", "version": "v1.4.0", "commit": "3f9a1c2b7d4e8a90",
"build_time": "2026-09-15T09:58:00Z", "modified": false, "go_version": "go1.26.1", "host": "acme-api-7d9f8-x2kq",
"started_at": "2026-09-15T10:02:11Z", "last_seen_at": "2026-09-15T10:31:41Z", "running": true}
]
}
]
}
Builds without version control information or a link-time version show "version": "dev" and no commit.
System#
GET /ops/system describes the instance that answers (ADR-0051); behind a load balancer, repeat it to reach others, and use GET /ops/releases/instances for the whole fleet. It never includes the database URL, dependency host names, environment variables or settings values.
| Section | Fields |
|---|---|
instance |
id (matches instance_id in /ops/releases/instances), version, commit, build_time, modified, started_at, uptime_seconds |
checks |
Each readiness check (as /readyz runs it): name, status (ok or error), duration_ms |
database |
status, error (a fixed description, never the driver's message), ping_ms; pool: total, idle, in_use, max, acquires, average_acquire_ms, empty_acquires (waited for a connection), canceled_acquires; migrations: current, latest, pending |
runtime |
go_version, gomaxprocs, goroutines, heap_in_use_bytes, last_gc_pause_ms, gcs |
jobs |
workers this instance runs and its queues |
A failing database still returns 200, with database.status and the postgres check set to error. migrations.pending above 0 means this build's migrations haven't been applied: run go run ./cmd/migrate.
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/ops/system
Email#
| Method and path | Purpose | Success |
|---|---|---|
GET /ops/mail |
Provider (resend or smtp), delivery (mailpit or provider), non-secret details from the environment, and the current from_name, from_email, reply_to |
200 |
POST /ops/mail/test |
Queue a test email: {to} |
202 {status: "queued", to, delivery} |
{"provider": "smtp", "delivery": "provider", "details": {"host": "smtp.postmarkapp.com", "port": "587", "tls": "starttls", "auth": "username and password"},
"from_name": "Acme", "from_email": "hello@acme.com", "reply_to": "support@acme.com"}
Resend details are {"api_key": "configured"} or "missing". Change the sender with PUT /ops/settings/mail.from_email (and mail.from_name, mail.reply_to). The test email's delivery appears in GET /ops/jobs/runs?kind=apistock.mail.send. Setup: email guide.
Sign-in methods#
Which sign-in methods this deployment has configured, and what turns the others on (ADR-0045, sign-in provider setup). Permission ops.auth.read (ops_viewer, platform_admin).
| Endpoint | Purpose | Success |
|---|---|---|
GET /ops/auth/providers |
Each method: key (email_password, authenticator_app, passkeys, passkeys_ios, passkeys_android, google, google_ios, google_android, apple, apple_ios), name, enabled, detail for enabled methods (relying party ID and origins, app IDs, Android packages), missing environment variables and the guide section for the others |
200 {methods: [...]} |
Values of secrets are never returned; the same report is printed at start in development and by go run ./cmd/api auth-providers.
Error codes#
| Code | Status | When |
|---|---|---|
unauthenticated |
401 | No valid session |
forbidden |
403 | Missing permission |
not_found |
404 | No such route |
validation_failed |
422 | Request doesn't match the schema |
setting_not_found |
404 | Unknown setting key |
setting_version_conflict |
409 | Setting changed since it was read |
setting_reason_required |
422 | Reason missing for a setting that requires one |
invalid_setting_value |
422 | Value fails the setting's type or validation; detail says why |
job_definition_not_found |
404 | Unknown job name |
job_definition_version_conflict |
409 | Definition changed since it was read |
job_reason_required |
422 | Reason missing to disable or reschedule |
invalid_job_config |
422 | Schedule, timeout, attempts or priority out of bounds; detail says why |
job_definition_disabled |
409 | Run now on a disabled job |
job_not_found |
404 | Unknown run ID (or removed by retention) |
queue_not_active |
422 | No worker runs that queue |
invalid_cursor |
400 | Malformed cursor |
invalid_job_state |
422 | Unknown state filter |
audit_event_not_found |
404 | Unknown audit event ID (or removed by retention) |
invalid_audit_filter |
422 | Unknown outcome, malformed action_prefix, or from not before to |
invalid_recipient |
422 | The test email recipient isn't an email address |
maintenance |
503 | Maintenance mode is on; detail is maintenance.message and Retry-After is set. Any route but health checks, docs, sign-in and /ops |
Error codes are public API: new ones are added, existing ones never change.
Audit actions#
| Action | Resource |
|---|---|
settings.value.changed |
setting |
jobs.definition.changed |
job_definition |
jobs.definition.run_requested |
job_definition |
jobs.run.retried |
job |
jobs.run.cancelled |
job |
jobs.queue.paused |
job_queue |
jobs.queue.resumed |
job_queue |
mail.test.requested |
mail (ID: the provider; the recipient is not recorded) |
examples/full-single stores these events in the audit_events table (modules/auditpg) and lists them with GET /ops/audit. Reading the audit log is not itself audited.