Status pages
Verified uptime
A status page is something a company says about itself. Verified uptime is what UpButler measured from the outside, signed so that anyone, a customer, a procurement team or an agent choosing a vendor, can check it without trusting you or us.
What you get
- A verify page at
upbutler.com/verify/<your page>: live uptime over 30 and 90 days, which components are verified and which are self-reported, how it is measured, and every attestation issued. - Badges for a README or website that show the verified number and link to that page.
- Signed attestations: one after every finished month, and on demand for the last 30 or 90 days. Each has a page at
upbutler.com/verify/att_…that checks the signature on the server and again in your browser, and prints as a one-page certificate ("Save as PDF").
Verified and self-reported
A component is verified when every monitor behind it is one UpButler runs itself, from its own regions:
| Component source | Counts as | Why |
|---|---|---|
Monitors of kind http, tcp, dns, script, browser, mcp, llm | Verified | UpButler's probes make the request and see the result. |
Monitors of kind heartbeat, agent, error_rate, spend, balance, manifest | Self-reported | Your systems report in. UpButler records it but did not observe it. |
| Push, manifest and manual components | Self-reported | The status is whatever was sent or set. |
Self-reported components are listed in every attestation with "verified": false, their source and the uptime their status history implies. They are never part of the summary. A component with mixed monitors (one verified kind, one not) is self-reported. Hidden components, and components whose uptime display is switched off, are left out completely.
How the numbers are computed
- One count per check. Every scheduled check is counted once on its UTC day. A multi-region monitor counts once per round, after the quorum: the round is down only when a strict majority of the regions that reported saw a failure. Manual "test now" checks are not counted.
- Uptime = (checks − down checks) ÷ checks × 100, rounded to three decimals. Degraded checks count as up and are reported separately.
- Several monitors behind one component are pooled: all their checks count. The reported interval is the slowest of them.
- Estimated downtime: per UTC day, the share of that day's checks that were down times the minutes of the day, summed and rounded to 0.1 minute.
- Summary uptime pools every check of every verified component, so you can recompute it from the
componentsarray. A verified component with no check in the period is listed withuptimePct: nulland is not counted. - Incidents are those UpButler's probes opened on verified components that started inside the period.
incidentMinutesis their total duration (an incident still open at the end counts up to the end of the period);medianRecoveryMinutesis the median duration of the resolved ones. Incidents created by hand and incident drills are not counted. - Periods are UTC. A month is
frominclusive totoexclusive.30dand90dattestations cover complete days and end at the last midnight; the live numbers on the verify page and on badges include today and are therefore not signed.
The document
An attestation is a JSON object with two members, served at https://upbutler.com/verify/<id>.json (CORS open, immutable):
{
"payload": {
"schema": "upbutler.attestation/v1",
"id": "att_1k4hkch1djhfd4v8hmr",
"issuer": "https://upbutler.com",
"subject": { "slug": "acme", "name": "Acme", "url": "https://status.acme.com" },
"period": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-10-01T00:00:00.000Z", "label": "2026-09" },
"issuedAt": "2026-10-01T00:00:41.512Z",
"method": {
"description": "UpButler's own probes check each verified component on a fixed interval …",
"regions": ["Helsinki", "Nuremberg"],
"quorum": "A check from one region counts as down when that check fails; a multi-region round counts as down only when a strict majority of the regions that reported saw a failure …",
"intervals": [{ "component": "api", "intervalSec": 60 }]
},
"components": [
{ "key": "api", "name": "API", "verified": true, "uptimePct": 99.991,
"checks": 43200, "downChecks": 4, "degradedChecks": 12, "downtimeMinutes": 4,
"intervalSec": 60, "regions": ["Helsinki", "Nuremberg"] }
],
"selfReported": [
{ "key": "billing", "name": "Billing", "verified": false, "source": "push", "uptimePct": 99.86 }
],
"summary": {
"uptimePct": 99.991, "checks": 43200, "downChecks": 4,
"incidentCount": 1, "incidentMinutes": 4.2, "medianRecoveryMinutes": 4.2,
"componentsVerified": 1, "componentsSelfReported": 1
}
},
"signature": { "alg": "Ed25519", "kid": "ubk_53f97ea4d5b69a24", "value": "niSRwTl2qUSJN63N…86 base64url characters" }
}The payload is public by design. It contains component names and keys and the names of probe regions. It never contains monitor ids, URLs, hostnames, error text or anything about your workspace.
Signature
- Take
payloadand serialize it as canonical JSON per RFC 8785 (JCS): object keys sorted by UTF-16 code unit, no whitespace, strings escaped asJSON.stringifydoes, numbers in ECMAScript form (100,99.5,99.991). Array order is kept. - Encode that string as UTF-8.
signature.valueis the Ed25519 signature (RFC 8032, pure Ed25519, 64 bytes) over those bytes, base64url without padding.signature.kidnames the key. Look it up in the published key list and verify.
All dates in the payload are ISO 8601 strings and all percentages have at most three decimals, so the canonical form is the same in every language.
Public keys
curl https://upbutler.com/.well-known/upbutler-attestation-keys.json
{
"issuer": "https://upbutler.com",
"keys": [
{ "kid": "ubk_53f97ea4d5b69a24", "alg": "Ed25519",
"publicKey": "0qL0…43 base64url characters (32 raw bytes)",
"jwk": { "kty": "OKP", "crv": "Ed25519", "x": "0qL0…", "kid": "ubk_53f97ea4d5b69a24", "use": "sig", "alg": "EdDSA" },
"createdAt": "2026-10-10T00:46:11.000Z", "retiredAt": null, "active": true }
]
}The list holds every key that ever signed. publicKey is the raw 32-byte Ed25519 key in base64url; jwk is the same key as a JWK. A key id is ubk_ plus the first 16 hex characters of the SHA-256 of the raw public key, so you can pin it. The same list is available as GET /api/v1/public/attestation-keys.
Verify it yourself
You need the document and the key list, nothing else. Save both and the check works offline.
// verify.mjs — node verify.mjs att_… (Node 18+ or Bun, no dependencies)
import { createPublicKey, verify } from 'node:crypto';
const canonicalize = (v) =>
v === null || typeof v !== 'object' ? JSON.stringify(v)
: Array.isArray(v) ? '[' + v.map(canonicalize).join(',') + ']'
: '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}';
const base = 'https://upbutler.com';
const doc = await (await fetch(base + '/verify/' + process.argv[2] + '.json')).json();
const { keys } = await (await fetch(base + '/.well-known/upbutler-attestation-keys.json')).json();
const key = keys.find((k) => k.kid === doc.signature.kid);
if (!key || doc.signature.alg !== 'Ed25519') throw new Error('unknown key');
const publicKey = createPublicKey({ key: key.jwk, format: 'jwk' });
const ok = verify(null, Buffer.from(canonicalize(doc.payload)), publicKey, Buffer.from(doc.signature.value, 'base64url'));
console.log(ok ? 'valid' : 'NOT VALID', doc.payload.subject.slug, doc.payload.period.label, doc.payload.summary.uptimePct + '%');
if (key.retiredAt) console.log('signed with a key retired on', key.retiredAt);# verify.py — python verify.py att_… (pip install cryptography)
import base64, json, sys, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def b64url(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def get(url): return json.load(urllib.request.urlopen(url))
def canonicalize(v):
if isinstance(v, dict):
# RFC 8785 sorts keys by UTF-16 code unit
keys = sorted(v, key=lambda k: k.encode("utf-16-be"))
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonicalize(v[k]) for k in keys) + "}"
if isinstance(v, list):
return "[" + ",".join(canonicalize(x) for x in v) + "]"
if isinstance(v, float):
# ECMAScript number form: 100.0 -> 100, 99.5 -> 99.5 (attestation numbers have at most 3 decimals)
return str(int(v)) if v == int(v) else repr(v)
return json.dumps(v, ensure_ascii=False)
doc = get(f"https://upbutler.com/verify/{sys.argv[1]}.json")
keys = get("https://upbutler.com/.well-known/upbutler-attestation-keys.json")["keys"]
key = next(k for k in keys if k["kid"] == doc["signature"]["kid"])
Ed25519PublicKey.from_public_bytes(b64url(key["publicKey"])).verify(
b64url(doc["signature"]["value"]), canonicalize(doc["payload"]).encode("utf-8")) # raises InvalidSignature
print("valid", doc["payload"]["subject"]["slug"], doc["payload"]["period"]["label"], doc["payload"]["summary"]["uptimePct"])Key rotation
UpButler signs with one active key at a time. When the key is rotated, the old one gets a retiredAt date and stays in the list: it no longer signs, and everything it signed before stays valid. The verify page says so when an attestation was signed with a retired key. Only UpButler can rotate its key; there is no API for it.
Badges
[](https://upbutler.com/verify/acme)| URL | Shows |
|---|---|
/badge/<slug>/uptime.svg | Verified uptime of the page, or of one component. |
/badge/<slug>/status.svg | Current status of the page, or of one component. |
/badge/<slug>.svg | The original status badge. Still works, unchanged. |
| Option | Values | Meaning |
|---|---|---|
days | 30 (default), 90 | Uptime window, including today. A plan that keeps less history measures what it keeps, and the badge names that window. |
component | a component key | One component instead of the whole page. |
style | flat (default), flat-square, plastic, upbutler | upbutler is the branded badge with the mark. |
theme | light (default), dark | For the background the badge sits on. |
label | text, up to 60 characters | Replaces the default label. |
The uptime badge shows the verified number. When a page or component has no verified data, it shows the self-reported number in neutral grey, labelled "self-reported", whatever label says; it never reads "verified". Badges are cached for five minutes and answer If-None-Match with 304. An unknown or private page gets a "not found" badge with status 404.
Light and dark:
<a href="https://upbutler.com/verify/acme">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://upbutler.com/badge/acme/uptime.svg?days=90&style=upbutler&theme=dark">
<img src="https://upbutler.com/badge/acme/uptime.svg?days=90&style=upbutler" alt="Verified uptime" height="28">
</picture>
</a>
<!-- GitHub README: one image per colour mode -->
[](https://upbutler.com/verify/acme)
[](https://upbutler.com/verify/acme)The status page editor has a Badges tab that builds these with a live preview.
API
# Issue one now: "30d", "90d" (the most recent complete UTC days) or a finished month
curl -X POST https://upbutler.com/api/v1/pages/acme/attestations \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"period": "2026-09"}'
# → 201 { "id": "att_…", "created": true, "url": "https://upbutler.com/verify/att_…", "document": { "payload": …, "signature": … } }
# List a page's attestations
curl https://upbutler.com/api/v1/pages/acme/attestations -H "Authorization: Bearer $UPBUTLER_API_KEY"
# No key needed: one attestation with the server's check, the public keys, and the live numbers
curl https://upbutler.com/api/v1/public/attestations/att_…
curl https://upbutler.com/api/v1/public/attestation-keys
curl "https://upbutler.com/api/v1/public/pages/acme/verified-uptime?days=90"One attestation exists per page and period. Asking for the same period again returns the same document with "created": false. A page with no verified component cannot be attested: the request fails with validation_failed, because there would be nothing UpButler measured to sign. Private pages are skipped by the monthly issue; their owner can still issue one on demand and share the link.
Plans
Badges and the verify page with live numbers are on every plan, including Free. Signed attestations, monthly and on demand, start on Starter; on Free the API answers plan_limit and names the plan. Periods cannot reach further back than the history your plan keeps.