Integrations - ThinkHumanDotDev/marmot GitHub Wiki
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 |
POST /api/mcp, GET /.well-known/mcp.json
|
API key of the org (scoped) / public | MCP server for AI agents |
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/orgs/:orgId/otel-collectors[/:id/…] |
otel-collector:read (member) / :manage (admin) |
OpenTelemetry export |
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); a bulk request (POST …/monitors/bulk) spends one write per monitor it names and may name at mostAPI_KEY_WRITE_RATE_LIMITmonitors; - changes made with a key appear in the audit log like any other
(
monitor.created,incident.updated, …) with actor typeapiKeyand the key's name (actor typemcpand the key's name plus the tool when the change came through the MCP server), 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 -H "Authorization: Bearer $MARMOT_KEY" 'https://marmot.example.com/api/orgs/1/monitors/7/stats?range=30d'
curl -H "Authorization: Bearer $MARMOT_KEY" 'https://marmot.example.com/api/orgs/1/monitors/7/heartbeats?limit=20'
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.
Push monitors can also be pinged by email, through the worker's SMTP listener or the inbound-parse webhook
POST /api/inbound-email: see Ping-by-email.
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) |
monitor_response_time_quantile |
common + window + quantile
|
response time (ms) at quantile 0.5, 0.75, 0.9, 0.95, 0.99 over 24h and 30d; only with ?quantiles=true
|
monitor_response_time_quantile is opt-in because it reads every monitor's latency histograms on each
scrape: add params: { quantiles: ['true'] } to the scrape config (or call /api/metrics?quantiles=true).
The values are estimated from the stat rollups' histograms and stay within one histogram bucket (about
±19 %) of the exact percentile.
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.
(landing in the current release, #99)
Prometheus scraping gives the current state when the scraper asks. The OpenTelemetry export works the other way round: after every check the worker pushes the result as OTLP metrics to a collector, so Grafana, Datadog, Honeycomb, New Relic or your own OpenTelemetry Collector see one data point per check next to the rest of your telemetry, and Marmot need not be reachable from the backend.
Admins add collectors under Settings → OpenTelemetry (/{orgSlug}/settings/opentelemetry;
permissions otel-collector:read, members by default, and otel-collector:manage, admins by default).
A collector has:
- an endpoint: an OTLP/HTTP metrics URL. Like
OTEL_EXPORTER_OTLP_ENDPOINT, a URL without a path (http://otel-collector:4318) gets/v1/metricsappended; any other path is used as-is (OTEL_EXPORTER_OTLP_METRICS_ENDPOINTsemantics). Credentials in the URL are refused: put them in a header. -
headers sent with every export (
Authorization,X-Scope-OrgID,DD-API-KEY, …), at most 20. Values are encrypted at rest (AES-256-GCM with a key derived fromPAYLOAD_SECRET), never returned by the API or shown again in the UI, and redacted in the audit log; only their names are visible. Leaving a value empty when editing keeps the stored one. RotatingPAYLOAD_SECRETmakes stored values unreadable: enter them again. -
default: the organization's default collector (one per organization). Every monitor exports to it
unless the monitor picks a collector of its own or turns Export metrics off (monitor form →
OpenTelemetry; fields
otlpExportandotlpCollectorin the API). A monitor whose own collector is inactive exports nothing; it does not fall back to the default. - active: inactive collectors receive nothing.
The list shows when each collector last accepted an export and the last error; Send test (the paper
plane) pushes one marmot.collector.test data point right away.
Every check produces these data points, stamped with the check's time:
| Metric | Type | Unit | Value |
|---|---|---|---|
marmot.check.duration |
gauge | ms |
response time (when the check measured one) |
marmot.check.phase.duration |
gauge | ms |
one timing phase per point, attribute marmot.check.phase = dns, connect, tls, ttfb, transfer (HTTP types and TCP port) |
marmot.check.status |
gauge | 1 |
1 the check succeeded (UP, DEGRADED, a "Recovering n/N" success), 0 it failed |
marmot.check.errors |
sum, monotonic, cumulative | {error} |
failed checks counted by the exporting process since it started |
marmot.check.packet_loss |
gauge | 1 |
ping monitors: share of echo requests without a reply (0 or 1: one request per check) |
Data point attributes: marmot.monitor.id, marmot.monitor.name, marmot.monitor.type,
marmot.monitor.target (URL without credentials or query string, or host[:port]), marmot.location
(local for the server's workers, else the probe location's slug) and
http.response.status_code when there was a response (not on marmot.check.errors and
marmot.check.packet_loss). Resource attributes: service.name = marmot-synthetic-check,
service.version, service.instance.id (host and process of the worker, so the cumulative counters of
several workers stay apart) and marmot.organization.id.
Beats that ran no check are not exported: maintenance, checks held while the worker was offline, deferred checks and quorum repairs. Push monitors and probe results are exported by the web process, which records them.
Each process keeps one exporter per collector and reuses it for every check: data points are batched for
OTLP_EXPORT_INTERVAL_MS (5 s) or until OTLP_EXPORT_MAX_BATCH (1,000) are waiting, then sent in one
POST with the OTLP/HTTP JSON encoding (Content-Type: application/json), which every OTLP/HTTP
receiver accepts. Following the OTLP specification, 408, 429, 502, 503, 504 and network errors
are retried twice with backoff (honouring Retry-After); other answers drop the batch. A collector that
stays down never grows the worker's memory beyond OTLP_EXPORT_MAX_QUEUE points (oldest dropped first).
Failures are logged and shown on the collector; they never change a check's result. Requests go through
the outbound address guard, so with MONITOR_DENY_PRIVATE_ADDRESSES on, collectors on
private addresses are refused. OTLP_EXPORT_ENABLED=false turns the export off for the whole instance
(see Configuration).
# otel-collector.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
prometheusremotewrite:
endpoint: http://mimir:9009/api/v1/push
debug: {}
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheusremotewrite, debug]Collector endpoint in Marmot: http://otel-collector:4318 (Marmot appends /v1/metrics). Add an
Authorization header if the receiver sits behind an authenticating proxy.
In Grafana Cloud open Connections → OpenTelemetry (OTLP) and create a token. Then add a collector with
- endpoint
https://otlp-gateway-<zone>.grafana.net/otlp/v1/metrics - header
Authorization=Basic <base64 of "<instance id>:<token>">
The metrics arrive in Mimir with Prometheus names, for example marmot_check_duration_milliseconds,
marmot_check_status_ratio and marmot_check_errors_total, and the attributes as labels
(marmot_monitor_name, marmot_location, …):
# p95 response time per monitor over 5 minutes
quantile_over_time(0.95, marmot_check_duration_milliseconds[5m])
# failed checks per minute and location
sum by (marmot_monitor_name, marmot_location) (rate(marmot_check_errors_total[5m])) * 60
Other backends work the same way: Honeycomb (https://api.honeycomb.io/v1/metrics, header
x-honeycomb-team, plus x-honeycomb-dataset), Datadog through the Datadog Agent's or the Collector's
OTLP receiver, New Relic (https://otlp.nr-data.net/v1/metrics, header api-key).
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.
Teams that already alert from Prometheus Alertmanager, Grafana or another tool can let those alerts
update the status page and open incidents, without re-creating every check as a Marmot monitor. Admins
manage integrations under Settings → Integrations (integration:manage); members see them and
their event log (integration:read). The management API is /api/orgs/:orgId/integrations[/:id[/events | /rotate-token]] (see the management API reference).
Each integration has a secret token (mi + 40 hex characters, shown once on creation or rotation,
only its SHA-256 is stored) and receives alerts at:
-
POST /api/inbound/<token>: the JSON body in the integration's format; -
<token>@INBOUND_EMAIL_DOMAIN: when ping-by-email is on, mail reaches the integration through the same SMTP listener and inbound-parse webhook as push monitors.
| Format | Parsed as |
|---|---|
alertmanager |
Alertmanager webhook (version: 4): one alert per alerts[] entry, keyed by fingerprint; labels and annotations are matchable fields, annotations.summary (else alertname) is the title. |
grafana |
Grafana unified alerting (same shape as Alertmanager) and legacy dashboard alerts (ruleId, ruleName, state, tags; ok and paused mean resolved). |
generic |
Any JSON object or array. Dot paths ($.event.title, items[0].id) name the title, status, severity, dedup key and description; status values listed under "resolved" resolve the alert. |
The subject is the title; the integration's resolved keyword (default resolved) in the subject resolves the alert with the same subject. Rules match subject, from and body. |
Rules are checked in order; the first enabled rule that matches decides what a firing alert does.
A rule matches a field (a label, annotation or JSON field; empty means the title and description)
that equals, contains or matches a regex (case-insensitive) its value; an empty value matches
everything. Alerts no rule matches are logged and ignored. A rule can:
- update a public component (opt-in, off by default): post a status-page incident named after the alert that sets the chosen component to degraded performance, partial outage or major outage;
-
open an incident:
draftdeclares an internal declared incident with the rule's severity (linked to the public incident when there is one),publishposts a status-page incident; - drive a push monitor: firing records a failure, resolved a success.
When the alert resolves, everything its firing opened is resolved: the public incident gets a
resolved update (the component is operational again), the declared incident moves to resolved, the
push monitor records a success.
- The token is compared in constant time; unknown, malformed and paused tokens get
404. -
INBOUND_ALERT_RATE_LIMITrequests per minute per token (and per client address behind a trusted proxy),429withRetry-Afterbeyond; bodies overINBOUND_ALERT_MAX_BYTESget413; at most 100 alerts are read per payload. - Alerts are keyed by
(integration, dedup key): a repeated or retried firing is aduplicateand changes nothing, so Alertmanager'srepeat_interval, Grafana re-notifications and sender retries never open a second incident. A firing after a resolve opens new incidents. - The event log keeps the latest 100 payloads per integration with secret-looking keys (
token,api_key,password,authorization, …) redacted, and per alert the rule, the outcome (opened,resolved,duplicate,unmatched,unknown,error) and the actions taken.
The answer is 200 { ok, alerts, dropped, results: [{ dedupKey, status, outcome }] }.
# alertmanager.yml
receivers:
- name: marmot
webhook_configs:
- url: https://status.example.com/api/inbound/mi0123…
send_resolved: true
{ "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, }, }