REST API and MCP server
Both the REST API and the MCP server expose the same six operations against the same data, gated by the same rule: paid plans only (Growth or Scale, the free tier has no programmatic access). This is the actual monetization hook for API/MCP access, enforced in code (apps/web/lib/api-auth.ts, apps/mcp/index.ts), not just on the pricing page.
Authentication
Both surfaces use the same key, generated from the dashboard (API & MCP access section, Growth/Scale accounts only). The key is shown once, at creation, it's stored hashed (SHA-256) server-side, so if you lose it, generate a new one and revoke the old.
Authorization: Bearer ru_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| Failure | Status |
|---|---|
Missing Authorization header | 401 |
| Key doesn't exist / was revoked | 401 |
| Key belongs to a free-tier account | 403 |
| Key's permission scope doesn't allow the action (see Permission scopes below) | 403 |
Permission scopes
Every key has a scope, chosen at creation from the dashboard and shown next to the key's name:
| Scope | Can do |
|---|---|
read | List and get: GET /checks, GET /checks/:id, GET /status-pages, GET /incidents, and the equivalent MCP read tools |
read_write | Everything read can, plus create/update/delete: POST /checks, PATCH /checks/:id, DELETE /checks/:id, and the equivalent MCP write tools |
The dashboard defaults new keys to read (the safer default), pick read_write explicitly if the key needs to create or modify monitors. Enforcement is server-side on every mutating REST route (requireWritePermission in apps/web/lib/api-auth.ts) and every mutating MCP tool (checkWritePermission in apps/mcp/index.ts), not just hidden in the dashboard form, a read key hitting a write route gets a clean 403 ({ "error": "This API key is read-only. Generate a read-write key to perform this action." }); the MCP equivalent sets isError: true on the tool result with the same message.
Keys created before scopes existed keep full read_write access (grandfathered, migration 018_api_key_permissions.sql), they were minted under an all-powers regime with no scope concept at all, so narrowing them retroactively would silently break whatever they're already wired into. To get the read-only guarantee on an existing integration, generate a new read key and revoke the old one.
Renaming a key (also from the API & MCP access section) never changes its scope or its plaintext value, only its display name.
Rate limits
Per-API-key token-bucket budgets (packages/db/rate-limit.ts), shared by the REST API, the MCP server, and the dashboard's own check-creation form, they're the same limiter instances/keying scheme, not separate per-surface quotas:
| Budget | Limit | Applies to |
|---|---|---|
| Read | 120/min | GET /checks, GET /checks/:id, GET /status-pages, GET /incidents |
| Write | 30/min | POST /checks, PATCH /checks/:id, DELETE /checks/:id, MCP create_check, MCP update_check_regions |
A request over budget gets:
{ "error": "Rate limit exceeded. Try again shortly." }with status 429 and a Retry-After header (whole seconds until the bucket has a token again) on every rate-limited route.
This is a single-process, in-memory limiter, budgets are per web/MCP instance, not cluster-wide.
REST API
Base URL: https://realuptime.io/api/v1 (or http://localhost:3000/api/v1 locally).
GET /checks
List every monitor on the account.
curl https://realuptime.io/api/v1/checks \
-H "Authorization: Bearer ru_live_..."{
"checks": [
{ "id": "...", "account_id": "...", "name": "API", "url": "https://api.example.com/health", "interval_seconds": 60 }
]
}POST /checks
Create a monitor. Enforces the account's tier limit (TIER_LIMITS in packages/db/checks.ts, 3 on free, 10 on Growth, 50 on Scale) and returns 403 once the limit is hit. The limit check and the insert run as one atomic SQL statement (createCheck in packages/db/checks.ts), so concurrent create calls from a burst of requests can't race past the cap.
curl -X POST https://realuptime.io/api/v1/checks \
-H "Authorization: Bearer ru_live_..." \
-H "Content-Type: application/json" \
-d '{"name":"API","url":"https://api.example.com/health"}'| Field | Type | Required |
|---|---|---|
name | string | yes |
url | string (must include scheme, e.g. https://) | yes |
intervalSeconds | number | no, defaults to 60 |
regions | array of "iad" | "sjc" | "fra" | "nrt" | no, defaults to all four |
Returns 201 with { "check": { ... } }, or 400 if name/url are missing, url doesn't parse, regions is present but empty, or the target fails the safety check below.
Regions
Every check probes from all four live regions (iad/sjc/fra/nrt) by default, matching the product's original all-region promise. Pass regions at creation to restrict which of the four probe this check; the field is optional and omitting it (or every check created before this field existed) keeps the all-four behavior exactly. At least one region is required, an empty array is rejected with 400, the same shared-schema rule (regionsShape in packages/db/api-schemas.ts) enforced identically by the dashboard form and the MCP create_check/update_check_regions tools.
curl -X POST https://realuptime.io/api/v1/checks \
-H "Authorization: Bearer ru_live_..." \
-H "Content-Type: application/json" \
-d '{"name":"API","url":"https://api.example.com/health","regions":["iad","fra"]}'To change an existing check's regions later, use PATCH /checks/:id (below). The scheduler (getDueChecks in packages/db/index.ts) only ever considers a check due in a region it selected; the public status page and its history bars render only a check's selected regions, never a phantom "no data" row for a region it was never checked from.
Monitor target validation
Every check-creation path, this endpoint, the dashboard "add monitor" form, and the MCP create_check tool, runs the target URL through the same guard (validateMonitorTarget in packages/db/target-guard.ts) before it's ever saved:
- Scheme must be
http://orhttps://. - Port must be the scheme default or one of
80,443,8080,8443. - The hostname (or every IP it resolves to, if it's not a literal IP) is
rejected if it's loopback, private, link-local, carrier-grade NAT, multicast, reserved/documentation, or the link-local metadata range (which covers 169.254.169.254), summarized checks against the standard blocked IPv4/IPv6 ranges, not an exhaustive list here.
localhost, bare/no-dot hostnames, and hostnames ending in.internal,
.flycast, or .local are rejected outright, without a DNS lookup.
On a validation failure the REST API and dashboard return a generic "can't be monitored" message (the exact wording differs slightly by surface); the MCP tool call fails with isError: true.
This same check runs again at probe time, on every redirect hop (packages/checker/index.ts): a target that passed validation at creation can still redirect to a private address later (DNS rebinding, or an operator changing what the target redirects to), so each hop through up to 3 redirects (4 requests total) is independently re-validated. A redirect to an unsafe target, or exceeding the hop limit, fails the probe rather than following it.
intervalSeconds clamping
intervalSeconds is not validated and rejected, it's clamped server-side (clampIntervalSeconds in packages/db/checks.ts) on every creation path (dashboard, REST API, MCP): rounded to the nearest integer, then bounded to [60, 86400] (1 minute to 24 hours). A value below 60 becomes 60; above 86400 becomes 86400; omitted or non-finite becomes the 60s default. The REST API never returns a 400 for an out-of-range interval, the saved check simply reflects the clamped value, which may differ from what you sent. The MCP create_check tool uses the same shared input schema (packages/db/api-schemas.ts) and therefore the same clamp semantics: any finite value ≥ 1 is accepted at the schema layer and clamped downstream, identically on every surface.
GET /checks/:id
One monitor's current status, aggregated across all 4 regions plus the raw per-region breakdown.
curl https://realuptime.io/api/v1/checks/<id> \
-H "Authorization: Bearer ru_live_..."{
"check": { "id": "...", "account_id": "...", "name": "API", "url": "...", "interval_seconds": 60, "selected_regions": ["iad", "sjc", "fra", "nrt"] },
"status": "operational",
"regions": [
{
"check_id": "...",
"region": "iad",
"current_state": "operational",
"consecutive_fail_count": 0,
"consecutive_ok_count": 1,
"last_changed_at": "2026-08-04T01:29:02.228Z",
"last_checked_at": "2026-08-04T01:29:02.228Z"
}
]
}Note the two different "regions" concepts in this response: check.selected_regions is which regions this check is configured to probe from (see Regions above); the top-level regions array is per-region live status, and only ever contains entries for regions that have reported at least once, it can never contain an entry for a region outside selected_regions.
status is operational (no fresh region down), degraded (some fresh regions down, or full down coverage isn't confirmed yet), down (all 4 regions fresh and down), stale (every region has been probed at least once but none of those reports are less than 3 minutes old), or unknown (no region has ever reported for this check), the same staleness-aware aggregation the public status page uses (aggregateStatusForDisplay in packages/db/index.ts). A region whose last report is more than 3 minutes old is excluded from the aggregation entirely, so a check that's actually down but hasn't been probed recently reports stale, not a stale operational; likewise down only fires when all 4 regions are confirmed fresh and down, 3-of-4 fresh-down with the 4th unprobed reports degraded, not down. regions may have fewer than 4 entries for a monitor that hasn't been probed from every region yet.
Returns 404 if the check doesn't exist or belongs to a different account, ownership is always scoped to the authenticated key's account, there's no way to fetch another account's check by guessing its id.
PATCH /checks/:id
Updates which regions probe this check. Currently the only editable field.
curl -X PATCH https://realuptime.io/api/v1/checks/<id> \
-H "Authorization: Bearer ru_live_..." \
-H "Content-Type: application/json" \
-d '{"regions":["iad","fra"]}'| Field | Type | Required |
|---|---|---|
regions | array of "iad" | "sjc" | "fra" | "nrt" | yes, at least one |
Returns 200 with { "check": { ... } } on success, 400 if regions is missing/empty/contains an unknown region, or 404 if the check doesn't exist or isn't owned by this account. The scheduler picks up a regions change on its very next tick per region (no restart or propagation delay); the public status page and history bars stop rendering a dropped region on their next render (revalidate = 30, matching the page's existing ISR window).
DELETE /checks/:id
Removes the monitor and its history. Returns 204 on success, 404 if not found/not owned by this account.
GET /status-pages
{ "statusPages": [{ "id": "...", "account_id": "...", "slug": "acme", "name": "Status", "custom_domain": null }] }v1 scope is one status page per account (matching the dashboard's auto-create-on-first-monitor flow), always returns a 0- or 1-item array. custom_domain reflects the raw hostname column regardless of verification state; whether it's actually live is domain_status (not included in this response, see "Custom domains" below), not the mere presence of a value.
Custom domains
Growth and Scale accounts can serve their status page at their own hostname (e.g. status.yourcompany.com) instead of realuptime.io/status/<slug>. Like webhook notifications, this is dashboard-only: there is no REST endpoint to attach or detach a domain, and free-tier accounts see an upgrade prompt instead of the form (enforced server-side in the dashboard action, not just hidden in the UI).
Setup flow
- From the dashboard's "Custom domain" section, enter a hostname. It's
validated as a plain public DNS name: no scheme or path, not an IP address, not realuptime.io or any subdomain of it, not an internal or reserved name (.internal, .flycast, .local, RFC 2606 reserved TLDs). Unicode domains are normalized to punycode before comparison, so a visually-similar homograph can't be typo-squatted onto an existing customer's domain. Uniqueness is enforced across all accounts by a database constraint, not just an application-level check, so two accounts racing to attach the exact same hostname can't both win.
- Create a CNAME record for your hostname pointing to
realuptime-web.fly.dev.
- The dashboard polls Fly's certificate API on every page load and shows
one of four honest states: Waiting for DNS (pending_dns, the CNAME hasn't been observed yet), Issuing certificate (issuing, DNS looks correct and Let's Encrypt issuance is in progress), Live (issued, serving your status page over HTTPS), or Error (error, something went wrong registering the domain, remove it and try again).
- Once
issued, requests to your hostname serve exactly your status page
(/) and nothing else, the rest of realuptime (dashboard, login, REST API, marketing pages) is not reachable through a customer's own domain. Canonical URLs and Open Graph metadata on that render use your domain, not realuptime.io/status/<slug>.
- Detaching a domain from the dashboard removes the Fly certificate
registration and clears the hostname, freeing it up to be claimed by any account (including a different one).
Why TLS issuance is the ownership proof
There is no separate domain-ownership challenge (a TXT record, an email link). Let's Encrypt only issues a certificate after confirming, via the CNAME, that the requester controls the hostname's DNS, reaching issued already proves that control. This is the same trust model most "bring your own domain" SaaS products use. A hostname that never gets pointed at realuptime-web.fly.dev simply stays at pending_dns forever: it never routes traffic and never gets a certificate.
GET /incidents
curl "https://realuptime.io/api/v1/incidents?limit=50" \
-H "Authorization: Bearer ru_live_..."Optional ?limit= query param, 1–500, defaults to 100. An out-of-range or non-numeric limit returns 400 rather than being silently clamped or falling back to the default, shared with the MCP list_incidents tool via incidentsListSchema (packages/db/api-schemas.ts). Returns every incident across every monitor on the account, newest first:
{
"incidents": [
{
"id": "...", "status_page_id": "...", "check_id": "...", "region": "nrt",
"title": "Asia-Pacific is down",
"body": "Our Asia-Pacific probe is reporting API as down. Other regions are unaffected.",
"status": "investigating", "opened_at": "...", "resolved_at": null
}
]
}Webhook notifications
A third alert channel alongside Slack and operator email (roadmap F-3). Configure one or more HTTPS endpoint URLs from the dashboard's "Webhook notifications" section; realuptime POSTs a signed JSON payload to each one on the same down/recovery transitions that already drive Slack messages and operator email, respecting the same scheduled-maintenance suppression as those two channels.
This feature is separate from the REST API/MCP surface above: it has no tier gate (Slack alerts and operator email don't either), and endpoints are managed from the dashboard, not via the REST API.
Endpoint requirements
- URL must use
https://. Plainhttp://is rejected. - The target goes through the same SSRF safety check as a monitor URL
(see "Monitor target validation" above): no loopback, private, link-local, carrier-grade NAT, multicast, reserved, or metadata-range destinations, checked at the time you save the endpoint AND again immediately before every delivery attempt (a URL that resolves safely today can resolve somewhere unsafe later).
- A signing secret is generated when you add the endpoint and shown
once, copy it down immediately. It is not stored anywhere you can view it again; remove the endpoint and add a new one to rotate it.
Payload
{
"event": "down",
"check": { "id": "...", "name": "API", "url": "https://api.example.com/health" },
"region": "iad",
"state": "down",
"timestamp": "2026-08-06T01:29:02.228Z",
"incident": { "id": "...", "title": "US-East is down" }
}| Field | Type | Notes |
|---|---|---|
event | string | down or recovery |
check.id / check.name / check.url | string | The monitor that transitioned |
region | string | iad, sjc, fra, or nrt, the region that transitioned, not necessarily every region |
state | string | down or operational, the region's new state after this transition |
timestamp | string | ISO 8601, when the delivery was enqueued |
incident | object or null | The incident this transition opened or resolved, if the check belongs to a status page; null for a standalone monitor with no public page |
This shape is stable: existing fields will not be renamed or removed, but new fields may be added, so parse it tolerantly (ignore unknown keys).
Verifying the signature
Every request carries two headers:
X-Realuptime-Event: down
X-Realuptime-Signature: 5f4e5c1b9a3d...X-Realuptime-Signature is an HMAC-SHA256 of the exact request body bytes, hex-encoded, keyed with your endpoint's signing secret. Recompute it and compare with a constant-time comparison, never ===:
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidSignature(secret, rawBody, providedSignatureHex) {
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const providedBuf = Buffer.from(providedSignatureHex, "hex");
if (expectedBuf.length !== providedBuf.length) return false;
return timingSafeEqual(expectedBuf, providedBuf);
}Use the raw, unparsed request body for this, not a re-serialized copy of the parsed JSON: re-serializing can change key order or whitespace and would compute a different signature than the one that was sent.
Delivery guarantees
- Retried with exponential backoff (30s, doubling, capped at 30 minutes) up
to 5 attempts, then dead-lettered. Same outbox pattern as operator email (see "Notification delivery retries" in the roadmap), a claim-based PostgreSQL queue, not a separate queue service.
- A 5-second timeout per attempt. A slow or hanging endpoint counts as a
failed attempt and is retried, not held open.
- Any non-2xx response, a timeout, a connection error, or the endpoint
failing a re-check of the safety rules above all count as a failed attempt.
- Redirects are not followed. Respond
2xxdirectly at the URL you
registered.
- Response bodies are never read or trusted for anything beyond a short
snippet in delivery logs. Delivery success is judged purely by HTTP status code.
PagerDuty notifications
A fourth alert channel alongside Slack, operator email, and generic webhooks. Paste your PagerDuty Events API v2 integration (routing) key from the dashboard's "PagerDuty" section under Notifications; realuptime sends a trigger event when a region goes down and a matching resolve event with the same dedup_key on recovery, so the incident PagerDuty opened closes automatically on your side.
Like webhook notifications, this feature has no tier gate and is managed from the dashboard, not via the REST API. One integration per account; a new key you save replaces the previous one.
Delivery guarantees
Same outbox pattern, retry schedule (30s, doubling, capped at 30 minutes, 5 attempts then dead-lettered), and 5-second per-attempt timeout as webhook notifications above. Delivery success is judged purely by HTTP status code (PagerDuty's Events API v2 returns 202 on a successfully queued event).
MCP server
https://mcp.realuptime.io/mcp, a stateless `StreamableHTTPServerTransport` endpoint (POST, JSON-RPC 2.0, no session state, same Authorization: Bearer header as the REST API on every request). Health check at /health.
Request bodies over 100KB are rejected before parsing, checked first against the Content-Length header (fast-path, before auth even runs), then enforced again against the actual bytes read as a fallback. Every input this server takes is a handful of short strings, so 100KB has no legitimate use here; an oversized request gets a plain 413 with { "error": "Request body too large." }, not a JSON-RPC error envelope.
Point any MCP client at that URL with the bearer key set. The seven tools mirror the REST API exactly:
| Tool | Equivalent to | Arguments |
|---|---|---|
list_checks | GET /checks | none |
get_check_status | GET /checks/:id | checkId (uuid) |
create_check | POST /checks | name, url, intervalSeconds? (int, 60–86400), regions? (array, defaults to all four) |
update_check_regions | PATCH /checks/:id | checkId (uuid), regions (array, at least one required) |
delete_check | DELETE /checks/:id | checkId (uuid) |
list_status_pages | GET /status-pages | none |
list_incidents | GET /incidents | limit? (1–500) |
get_check_status's status field uses the exact same staleness-aware aggregateStatusForDisplay aggregation as GET /checks/:id (see above), operational, degraded, down, stale, or unknown. This was not always true: before this fix, get_check_status aggregated with the raw aggregateStatus, which has no staleness concept, so a check with stale per-region data could read operational via MCP while the public status page correctly read stale for the same check.
create_check's intervalSeconds argument uses the same shared schema as the REST endpoint (checkCreateShape in packages/db/api-schemas.ts, see intervalSeconds clamping above), any finite value ≥ 1 passes schema validation and is clamped to [60, 86400] downstream by clampIntervalSeconds, identically on both surfaces; it is not separately range-checked at the schema layer. create_check's regions argument and update_check_regions's regions argument both use the same regionsShape (see Regions above), at least one region required, unknown region values rejected. checkId (get_check_status, delete_check, update_check_regions) and limit (list_incidents) are likewise validated via shared schemas (checkIdShape, incidentsListShape) rather than each tool declaring its own inline shape. create_check and update_check_regions both apply the write rate limit; create_check also applies the same target-validation guard as the REST endpoint (see above). create_check, update_check_regions, and delete_check all also require a read_write key (see Permission scopes above), a read key gets isError: true with a "read-only" message instead of reaching the database, the same rule the REST API's POST/PATCH/DELETE routes enforce.
Every tool returns the same JSON shape as its REST equivalent, serialized as a text content block. A "not found", over-limit, unsafe-target, or rate-limited condition sets isError: true on the tool result rather than throwing or returning an HTTP error status, the JSON-RPC call itself still succeeds at the transport level.
Manual protocol check
curl -X POST https://mcp.realuptime.io/mcp \
-H "Authorization: Bearer ru_live_..." \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}'Each request is independent (stateless transport), a client library normally handles the initialize → tools/list → tools/call sequence for you; this is only useful for a manual sanity check.
Errors
Status codes in use across the REST API:
| Status | Meaning |
|---|---|
400 | Malformed request body, missing/invalid required field, or a monitor target that failed the safety check |
401 | Missing, invalid, or revoked API key |
403 | Key belongs to a free-tier account, the account is at its monitor limit for its tier, or the key's scope is read and the route requires read_write (see Permission scopes above) |
404 | Check doesn't exist, or exists but isn't owned by this account |
429 | Rate limit exceeded (see Rate limits above) |
500 | Unhandled server error |
The MCP server surfaces the equivalent failures as isError: true tool results instead of HTTP status codes (the transport call itself still returns 200/JSON-RPC success), except request-body-too-large (413) and malformed JSON (400), which are rejected before a tool ever runs, and a handler exception, which the server catches and turns into a plain 500.
Why two auth implementations
apps/web/lib/api-auth.ts and the authenticate() function in apps/mcp/index.ts implement the identical rule (verify key → look up account → reject free tier) but as separate code. apps/mcp deliberately has no dependency on the Next.js app, it's a standalone service that only depends on packages/db, so the ~15 lines are duplicated rather than shared across that boundary.