Repository navigation
Integrations
Marmot's machine-facing endpoints live under src/app/api/ and mirror Uptime Kuma's, so existing
README badges, cron jobs and Grafana dashboards keep working after a switch.
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/badge/:monitorId/<type>[/<range>] |
public status page or API key of the org | shields.io-style SVG badges |
ALL /api/push/:token[/<signal>] |
the monitor's push token | heartbeat for push monitors |
GET /api/metrics |
API key | Prometheus exposition |
/api/orgs/:orgId/** |
session or API key of the org (scoped) | management API |
GET /api/openapi.json, GET /api/docs
|
public | management API reference |
GET/POST /api/orgs/:orgId/api-keys |
session, api-key:read / api-key:create (admin) |
manage keys |
PATCH/DELETE /api/orgs/:orgId/api-keys/:id |
session, api-key:delete (admin) |
disable / re-enable / revoke |
GET /api/orgs/:orgId/audit-logs[/export] |
session, audit-log:read (admin) |
audit log as JSON or CSV |
GET/POST /api/orgs/:orgId/webhooks[/:id/…] |
session, webhook:read / webhook:manage (admin) |
outbound webhooks |
API keys belong to an organization, not a user, and are managed by admins and owners under
Settings → API keys (/{orgSlug}/settings/api-keys). A key looks like
mk_<prefix>_<secret>
mk_ marks a Marmot key, the 8-character prefix is the public identifier shown in the list, and the
secret is 32 random bytes. Only the SHA-256 of the full key is stored (api-keys.keyHash); the plaintext is
returned exactly once by POST /api/orgs/:orgId/api-keys ({ doc, key }) and shown once in the UI with a
copy button. Keys can carry an expiry (expiresAt, or expiresInDays on create), be disabled
(PATCH { active: false }) and revoked (DELETE). lastUsedAt is stamped on use, at most once a minute.
Send the key in any of these ways:
Authorization: Bearer mk_…
X-API-Key: mk_…
Authorization: Basic base64(<anything>:mk_…) # Prometheus basic_auth
Server side, authenticateApiKey(payload, request) (src/server/api-keys) resolves a request to
{ organizationId, apiKey, scope } or null (missing, unknown, disabled, expired). Endpoints then scope
their queries to that organization. Keys are deliberately not a Payload auth strategy: they never grant
access to Payload's REST/GraphQL API (/api/<collection>) or the UI.
Every key has a scope, chosen on creation and fixed afterwards (keys created before scopes existed are
read):
| Scope | Methods | Acts as | Typical use |
|---|---|---|---|
read |
GET, HEAD
|
viewer |
Prometheus, badges, dashboards, reporting |
write |
all (POST, PATCH, …) |
member |
CI/CD, Terraform, the CLI, MCP: monitors, status pages, incidents, maintenance |
The route handlers under /api/orgs/:orgId/** that the UI uses are the management API. They accept a
signed-in session or an API key of the organization in the URL. A key is turned into a synthetic principal
with a single membership (viewer for read, member for write), so the same permission checks,
per-organization overrides and collection access apply as for a person with that role
(src/server/auth/request-auth.ts). On top of that:
- a key of another organization gets
403; an unknown, disabled or expired key401; -
readkeys get403on anything butGET/HEAD/OPTIONS; - keys never reach member, invitation, invite-link, API key, audit log, SSO, billing, permission or ownership routes
(
403), whatever the organization's permission overrides say; - each key may send
API_KEY_RATE_LIMITrequests per minute (default 600), of whichAPI_KEY_WRITE_RATE_LIMIT(default 60) may be writes; above that the API answers429withRetry-After(Configuration); - changes made with a key appear in the audit log like any other
(
monitor.created,incident.updated, …) with actor typeapiKeyand the key's name, so automation is told apart from people; the key's own life cycle isapi_key.created,api_key.enabled,api_key.disabledandapi_key.revoked; - keys are not password logins: SSO-only mode (
OIDC_DISABLE_LOCAL_LOGIN) and organizations'enforceSsodo not affect them (revoke or disable keys to cut automation off); - outbound webhooks (
/api/orgs/:orgId/webhooks/**) follow the permissions like any other resource:webhook:readandwebhook:manageare admin-only by default, so keys reach them only where the organization lowered them toviewer(read keys) ormember(write keys); signing secrets are only ever returned by create and rotate. Changes made with a key reach webhooks withactor.type: apiKey; - on-demand checks (
POST …/monitors/:id/check,POST …/checks) need awritekey and share the organization'sON_DEMAND_CHECKS_PER_MINUTEbudget; monitor-incident acknowledgements and resolutions made with a key are recorded with sourceapi.
The API is described by an OpenAPI 3.1 document at /api/openapi.json (request bodies are generated from
the same zod schemas the handlers validate with) and browsable at /api/docs. Each operation carries
x-marmot-api-key-scope (read, write, or null for session-only routes) and
x-marmot-permission.
curl -H "Authorization: Bearer $MARMOT_KEY" https://marmot.example.com/api/orgs/1/monitors
curl -X POST -H "Authorization: Bearer $MARMOT_KEY" -H 'content-type: application/json' \
-d '{"name":"API","type":"http","url":"https://api.example.com/health","interval":60}' \
https://marmot.example.com/api/orgs/1/monitorsThe marmot CLI is a client of this API: monitors as code (plan/apply of a YAML file),
status, heartbeats, incidents and maintenance from the terminal or CI.
/api/badge/:monitorId/status
/api/badge/:monitorId/uptime[/24h|30d|1y] (also 24, 720, 8760 hours)
/api/badge/:monitorId/ping[/24h|30d|1y]
/api/badge/:monitorId/avg-response[/24h|30d|1y]
/api/badge/:monitorId/cert-exp
/api/badge/:monitorId/response
For one badge with the overall state of a whole status page, see Status pages → Status badge.
Badges are rendered with badge-maker (the shields.io
renderer) and answered with Content-Type: image/svg+xml, Cache-Control: public, max-age=300 and
Access-Control-Allow-Origin: *.
Who can see a badge. A badge is served when the monitor appears in a group of a published status
page, or when the request carries an API key of the monitor's organization (for private dashboards:
<img src="https://marmot.example.com/api/badge/42/uptime/30d"> cannot send headers, so use a status page
for README badges). Everything else — unknown ids, monitors of other organizations, unpublished pages — is a
404, so badge URLs do not reveal which ids exist.
Query parameters (all optional, same names as Uptime Kuma):
| Parameter | Badges | Default |
|---|---|---|
style |
all |
flat (plastic, flat-square, for-the-badge, social) |
label |
all |
Status, Uptime (24h), Avg. Ping (24h), Avg. Response (24h), Cert Exp., Response
|
labelPrefix, labelSuffix
|
uptime, ping, avg-response, cert-exp, response | — |
prefix, suffix
|
uptime, ping, avg-response, cert-exp, response | suffix %, ms, days
|
color, labelColor
|
uptime, ping, avg-response, response | uptime: red→green by ratio; others blue
|
upLabel, downLabel, pendingLabel, maintenanceLabel, degradedLabel
|
status |
Up, Down, Pending, Maintenance, Degraded
|
upColor, downColor, pendingColor, maintenanceColor, degradedColor
|
status (upColor/downColor also cert-exp) |
#66c20a, #c2290a, #f8a306, #1747f5, #eed202
|
warnColor, warnDays, downDays
|
cert-exp |
#eed202, 14, 7
|
date |
cert-exp | show the expiry date instead of remaining days |
Uptime and ping figures come from the stats rollups (getUptime / getAvgPing, see
Architecture), status and response from the monitor's cached last heartbeat, cert-exp
from monitors.certInfo once the certificate-expiry job (#24) stores it ("No/Bad Cert" until then).
The pure builder is buildBadge() in src/server/badges/badge.ts.
Create a monitor of type Push; Marmot mints a pushToken. Call the URL from the system you monitor, by
any HTTP method:
curl "https://marmot.example.com/api/push/<token>?status=up&msg=OK&ping=12"
| Path | Signal |
|---|---|
/api/push/:token |
success (status=down reports a failure, as in Uptime Kuma) |
/api/push/:token/start |
a run started: it must finish within the grace period |
/api/push/:token/fail |
explicit failure |
/api/push/:token/log |
record an event (message and body) without changing the status |
/api/push/:token/<0-255> |
exit code: 0 is a success, anything else a failure (message Exit code <n>) |
| Query | Values | Default |
|---|---|---|
status |
up, down (bare token URL only) |
up |
msg |
free text (250 chars) |
OK (Failure reported for /fail) |
ping |
milliseconds, 0…10^11 | the duration of the paired run, if any |
rid |
run id (UUID or up to 64 letters, digits, - or _) |
none: pairs with the latest anonymous run |
The response is { "ok": true } with a Ping-Body-Limit: 10000 header; unknown or paused tokens and
unknown signals return 404 { ok: false, msg }, an invalid ping or rid 400. The first 10 000 bytes of a
request body (POST, PUT, …) are stored with the signal, so a job can send its output:
# report start, exit code, duration and the last 10 000 bytes of output
url="https://marmot.example.com/api/push/<token>"
curl -fsS -m 10 --retry 5 -o /dev/null "$url/start"
output="$(/usr/local/bin/backup.sh 2>&1)"; code=$?
printf '%s' "$output" | tail -c 10000 | curl -fsS -m 10 --retry 5 -o /dev/null --data-binary @- "$url/$code"The monitor page shows the same as copy-ready curl, bash-wrapper and crontab snippets, the schedule in plain language ("Every day at 02:00 · Europe/Berlin"), the next expected ping and the Ping log (the newest 100 signals with their output).
What a signal does. Successes and failures record a heartbeat through the engine (recordExternalBeat
in src/server/engine/worker.ts): maintenance windows, retries (maxRetries → PENDING) and upsideDown
apply exactly as for polled checks, the monitor's status.lastPushAt is stamped, and the stats, realtime
and notification listeners fire, so dashboards update live and channels are notified on transitions. A
start opens a run (status.pushRuns); the success or failure that closes it (same rid, otherwise the
latest run) stores the run's duration as the heartbeat's ping, so the response-time chart shows job
durations. A run longer than pushMaxDuration is reported DOWN. log only adds to the ping log.
When a ping is late. The periodic push check in the worker turns the monitor DOWN when the next ping is
overdue: interval + grace after the last success or failure, or, for cron schedules, the next occurrence
of the cron expression (in the schedule's time zone, DST-aware) plus grace. A started run that does not
finish within the grace period, or a reported failure, keeps it DOWN until the next success. Without a
configured grace the window is interval + 10 %, as before. See
Monitor types for the fields and the exact rules.
GET /api/metrics with an API key returns the organization's monitors in Prometheus text format. The
registry is built per scrape from the database (src/server/metrics/prometheus.ts,
prom-client); nothing is kept in the web process.
scrape_configs:
- job_name: marmot
scrape_interval: 60s
metrics_path: /api/metrics
scheme: https
static_configs:
- targets: ['marmot.example.com']
authorization:
type: Bearer
credentials: mk_… # or basic_auth: { username: marmot, password: mk_… }| Metric | Labels | Value |
|---|---|---|
monitor_status |
common |
1 up, 0 down, 2 pending, 3 maintenance, 4 degraded |
monitor_response_time |
common | last ping in ms (-1 when the beat had no ping) |
monitor_uptime_ratio |
common + window
|
0.0…1.0 over 24h and 30d
|
monitor_cert_days_remaining |
common | from monitors.certInfo (only when present) |
monitor_cert_is_valid |
common |
1 / 0, from monitors.certInfo
|
marmot_checker_online |
location |
1 online, 0 offline (self connectivity check, only when enabled) |
Common labels: monitor_id, monitor_name, monitor_type, monitor_url, monitor_hostname,
monitor_port (empty string when a monitor has no such field). Names and labels match Uptime Kuma's
server/prometheus.js; 4 (degraded: a successful check slower than the monitor's degradedAfter) is a
Marmot addition, so alert rules written for Kuma that test monitor_status == 1 should use
monitor_status == 1 or monitor_status == 4 to keep treating slow checks as up. Monitors without a heartbeat
yet have no monitor_status sample. Without a valid
key the endpoint answers 401 with a WWW-Authenticate header.
Organizations can send every event that happens in them to their own HTTP endpoints: monitor state
changes, incidents, maintenance windows and every change the audit log records
(monitors, status pages, subscribers, members, …). Admins and owners manage endpoints under
Settings → Webhooks (/{orgSlug}/settings/webhooks; permissions webhook:read and webhook:manage,
both admin by default and adjustable under Settings → Permissions). Viewers and members see neither the
endpoints nor their delivery log, and nobody can read a secret back after it was shown.
An endpoint has a URL (http:// or https://), an optional description, a list of events and a signing
secret (whsec_…), generated by Marmot and shown once, when the endpoint is created or its secret
rotated. Rotating keeps the previous secret signing next to the new one for 24 hours, so receivers can switch
without dropping deliveries.
Subscribe to exact types, to a whole group with a wildcard (incident.*, monitor.*) or to everything
(*, which also covers event types added later).
| Group | Types | Source |
|---|---|---|
monitor |
monitor.down, monitor.up, monitor.degraded
|
the engine's state machine (notifying beats) |
monitor |
monitor.created, updated, deleted, paused, resumed, cloned
|
audit log |
incident |
incident.opened, incident.update_posted, incident.resolved, incident.reopened
|
incident timeline |
incident |
incident.created, updated, deleted
|
audit log |
maintenance |
maintenance.scheduled, reminder, started, update_posted, completed, cancelled
|
maintenance windows |
maintenance |
maintenance.created, updated, deleted, paused, resumed
|
audit log |
member |
member.role_changed, member.removed, member.ownership_transferred; invitation.created, accepted, deleted
|
audit log |
| everything else |
<entity>.created/updated/deleted (and enabled/disabled/revoked) of status pages, subscribers, channels, tags, proxies, Docker hosts, templates, API keys, SSO, the organization, webhook endpoints; import.completed
|
audit log |
The settings dialog lists every type; WEBHOOK_EVENT_TYPES in src/lib/webhook-events.ts is the
catalogue. monitor.up is sent when a monitor recovers from DOWN (to UP or DEGRADED) and when it goes back
from DEGRADED to UP; monitor.degraded when a successful check is slower than degradedAfter. Reminders
(resendInterval) are not webhook events. Opening an incident sends incident.opened and
incident.update_posted (its first timeline entry). Sign-ins and instance settings are never sent.
Each delivery is a POST with a JSON body:
-
monitor state (
monitor.down|up|degraded):data.monitor { id, name, type, url, hostname }(URL without credentials or query),status,previousStatus,heartbeat { id, time, msg, ping }. -
maintenance windows:
data.maintenance { id, title, description, strategy, statusPages, monitors },data.occurrence { id, publicId, state, start, end, startedAt, completedAt, cancelledAt, … },data.update(the posted entry ornull) anddata.reminderMinutes. -
audit events:
data.object { type, id, label },data.actor { type, id, label }(user, API key, MCP or system),data.changedFields,data.previous/data.current(the audit log's redacted values: passwords, tokens and secrets never leave Marmot) anddata.metadata,data.auditLogId. -
webhook.test(Send test event):data { endpoint { id, url }, test: true }; sent whatever the endpoint subscribes to.
Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Marmot-Webhooks/1 |
X-Marmot-Webhook-Version |
1; bumped only for incompatible envelope changes |
X-Marmot-Event |
the event type |
X-Marmot-Delivery |
delivery id: stable across the retries of one delivery, new for redeliveries |
X-Marmot-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")> |
Use the event id to ignore duplicates (a retry after a timeout, a manual redelivery).
Compute HMAC-SHA256 with the endpoint's secret over <t>.<raw request body> (the exact bytes received,
before any JSON parsing), compare it in constant time with each v1= value of the header (there are two
during a secret rotation; any match is valid), and reject timestamps older than five minutes to stop
replays. The same scheme signs status page subscriber webhooks;
verifyWebhookSignature in src/server/webhooks/signature.ts is the reference implementation.
Node.js (Express):
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.MARMOT_WEBHOOK_SECRET // whsec_…
const TOLERANCE_SECONDS = 5 * 60
function verify(rawBody, header) {
if (!header) return false
const parts = header.split(',').map((part) => part.trim().split('='))
const t = Number(parts.find(([key]) => key === 't')?.[1])
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value)
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest()
return signatures.some((value) => {
const given = Buffer.from(value, 'hex')
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
})
}
const app = express()
app.post('/hooks/marmot', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body.toString('utf8'), req.get('X-Marmot-Signature'))) return res.sendStatus(401)
const event = JSON.parse(req.body)
console.log(event.type, event.data)
res.sendStatus(204)
})
app.listen(8080)Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, abort, request
SECRET = os.environ["MARMOT_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE_SECONDS = 5 * 60
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
pairs = [part.strip().split("=", 1) for part in header.split(",")]
timestamps = [value for key, value in pairs if key == "t"]
signatures = [value for key, value in pairs if key == "v1"]
if not timestamps or not timestamps[0].isdigit():
return False
t = int(timestamps[0])
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, value) for value in signatures)
app = Flask(__name__)
@app.post("/hooks/marmot")
def marmot_webhook():
if not verify(request.get_data(), request.headers.get("X-Marmot-Signature")):
abort(401)
event = request.get_json()
print(event["type"], event["data"])
return "", 204Events are queued after the change is committed (src/server/webhooks/events.ts); the worker sends them
from the notifications queue (webhook-delivery jobs, src/server/webhooks/deliver.ts):
-
Timeout 10 seconds; redirects are not followed (a
3xxcounts as a failure). -
Success is any
2xxanswer. -
Retries: network errors, timeouts,
5xx,408and429are retried with exponential backoff (40 s, 80 s, 160 s, … twelve attempts over about 23 hours, ±20 % jitter). Other answers fail at once. -
Outbound guard: requests go through the same address guard as monitors and notification channels.
With
MONITOR_DENY_PRIVATE_ADDRESSES=true(orMONITOR_DENY_CIDRS), URLs that resolve to a private address are refused when saved (literal addresses) and at delivery (every resolved address); such deliveries are not retried. -
Automatic disable: after
WEBHOOK_DISABLE_AFTER_FAILURES(default 5) deliveries in a row failed for good, the endpoint is disabled (webhook_endpoint.disabledin the audit log, "Disabled after failures" in the list) and the organization's owners and admins get an email. Pending retries of a disabled endpoint are cancelled. Any successful delivery resets the streak; re-enabling the endpoint does too.
Delivery log. Every delivery is logged per endpoint (Delivery log in the endpoint's menu,
/{orgSlug}/settings/webhooks/:id): event type, result (pending, retrying, succeeded, failed,
cancelled), attempts, response status, duration, and on expanding a row the request headers (signature
values redacted) and body and the response headers (cookies and tokens redacted) and the first 2 KB of the
response body. Admins can redeliver any logged event and send a test event; both are sent at once,
a single attempt, also to a disabled endpoint (to check a fix before re-enabling it), and do not count
towards the automatic disable. The log is pruned after WEBHOOK_DELIVERY_RETENTION_DAYS (default 14 days).
API (session, webhook:read to read, webhook:manage for everything else):
| Endpoint | Purpose |
|---|---|
GET /api/orgs/:orgId/webhooks |
endpoints (never the secret) and the event catalogue (eventGroups) |
POST /api/orgs/:orgId/webhooks { url, events, description? }
|
create; the response carries secret once |
GET/PATCH/DELETE /api/orgs/:orgId/webhooks/:id |
read, change (url, events, description, active), delete with its log |
POST /api/orgs/:orgId/webhooks/:id/rotate-secret |
new secret, returned once |
POST /api/orgs/:orgId/webhooks/:id/test |
send webhook.test now; returns the logged delivery |
GET /api/orgs/:orgId/webhooks/:id/deliveries?page=&state= |
delivery log, newest first, 25 per page |
POST /api/orgs/:orgId/webhooks/:id/deliveries/:deliveryId/redeliver |
send the logged event again now; returns the new delivery |
The webhook notification channel type stays what it was: a per-monitor alert channel with Uptime Kuma's
payload. Use outbound webhooks for integrations that need every event, signatures and a delivery history.
This wiki is published from docs/ on every push to main; edits made in the wiki are overwritten.
Found a mistake? Open a pull request with the docs(docs): … prefix.
Run it
- Getting started
- Configuration
- Deployment
- Kubernetes
- Single sign-on
- Security
- Telemetry
- Billing
- Demo mode
Use it
- Monitors
- Monitor types
- Probe locations
- Notifications
- Status pages
- Maintenance
- Organizations and members
- Integrations
- MCP server for AI agents
- Import and export
- CLI and monitors as code
- GitHub Action
Change it
{ "id": "evt_5c0e…", // event id: the same in every delivery and redelivery of this event "type": "incident.opened", "createdAt": "2026-10-07T12:00:00.000Z", "orgId": "1", "data": { // depends on the type, e.g. for incidents: "incident": { "id": "7", "publicId": "k3x9q2ab", "title": "API errors", "status": "investigating", "impact": "major_outage", "statusPage": "3", "resolvedAt": null, }, "update": { "id": "66f1…", "status": "investigating", "message": "Markdown", "postedAt": "…", "components": [], }, "previousStatus": null, }, }