Both the REST API and the MCP server expose the same nine operations against the same data, but they're no longer gated identically (roadmap D-3). The REST API stays paid plans only (Growth or Scale: the free tier has no programmatic access to it at all). The MCP server is split by operation: its four read tools (list_checks, get_check_status, list_status_pages, list_incidents) work on every plan, free included; its five write tools require Growth or Scale, the same rule as every REST route. Full write access on either surface, and REST access of any kind, remain the actual monetization hook, 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's API & MCP access section on any plan, free included. 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 | Surface |
|---|---|---|
Missing Authorization header | 401 | Both |
| Key doesn't exist / was revoked | 401 | Both |
| Key belongs to a free-tier account | 403 | REST only. MCP instead allows the four read tools and refuses only the five write tools, see "Free-tier access" under MCP server below. |
| Key's permission scope doesn't allow the action (see Permission scopes below) | 403 / isError: true | Both |
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, POST /incidents, POST /incidents/:id/updates, 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, and if you're on Growth or Scale: a free-tier account can only create or hold read keys, since neither surface's write path works for a free account regardless of the key's own scope (apps/web/app/account/api-keys-actions.ts). 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.
Who a key belongs to
A key belongs to the ACCOUNT, not to the person who generated it. It grants the same company-wide scope whoever holds it, every colleague with API-key permission can see and revoke it, and it keeps working if its creator's role changes.
The key list names whoever created each key, so you can tell which is which when several people share an account.
Removing someone from the team revokes the keys they created. This is immediate and cannot be undone: the next request on such a key gets a 401, and the fix is to generate a replacement and update whatever was using the old one. Offboarding a colleague is exactly when a credential they created should stop working, so if a shared integration was built on one person's key, move it to a key someone still on the team generated before removing them. Changing their role does not revoke anything.
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 (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, MCP list_checks, MCP get_check_status, MCP list_status_pages, MCP list_incidents |
| Write | 30/min | POST /checks, PATCH /checks/:id, DELETE /checks/:id, POST /incidents, POST /incidents/:id/updates, MCP create_check, MCP update_check_regions, MCP delete_check, MCP create_incident, MCP add_incident_update |
A REST 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). An MCP call over budget gets the same error message and a retryAfterSeconds field, but delivered as a tool result with isError: true. MCP tool errors aren't surfaced as HTTP status codes, so there's no 429 or Retry-After header on that surface (see MCP server below).
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 /api/v1
Unauthenticated endpoint index: no Authorization header required, since this returns a static list of routes, never account data. Safe to hit for discovery (including automated/LLM tool discovery) before you have a key.
curl https://realuptime.io/api/v1{
"name": "RealUptime API v1",
"documentation": "https://realuptime.io/docs/api",
"authentication": "Authorization: Bearer <api key>. Growth/Scale plans only: generate a key from the dashboard's API & MCP access section.",
"endpoints": [
{ "method": "GET", "path": "/api/v1/checks" },
{ "method": "POST", "path": "/api/v1/checks" },
{ "method": "GET", "path": "/api/v1/checks/:id" },
{ "method": "PATCH", "path": "/api/v1/checks/:id" },
{ "method": "DELETE", "path": "/api/v1/checks/:id" },
{ "method": "GET", "path": "/api/v1/status-pages" },
{ "method": "GET", "path": "/api/v1/incidents" },
{ "method": "POST", "path": "/api/v1/incidents" },
{ "method": "POST", "path": "/api/v1/incidents/:id/updates" }
],
"mcp": "https://mcp.realuptime.io/mcp"
}GET /checks
List every monitor on the account: every type, not only the ones REST/MCP can create (see "Monitor types beyond http" below).
curl https://realuptime.io/api/v1/checks \
-H "Authorization: Bearer ru_live_..."{
"checks": [
{ "id": "...", "account_id": "...", "name": "API", "url": "https://api.example.com/health", "type": "http", "tcp_host": null, "tcp_port": null, "tcp_tls": false, "interval_seconds": 60, "selected_regions": ["iad", "sjc", "fra", "nrt"] }
]
}Monitor types beyond http
The dashboard can also create heartbeat, tcp, and dns monitors, and this endpoint (and MCP's list_checks/get_check_status) returns ALL of an account's checks regardless of type: a customer with a TCP or heartbeat monitor sees it here too, not just their http ones. type is one of "http" | "heartbeat" | "tcp" | "dns"; tcp_host/tcp_port/tcp_tls are non-null only on a tcp row (null/null/false otherwise), matching the Check interface in packages/db/index.ts. url is null for heartbeat and tcp checks. Neither surface here exposes dns_hostname / dns_record_type / dns_expected_value for a dns check, or the heartbeat-specific grace period/ping-token fields: listChecksForAccount and getCheckForAccount (packages/db/checks.ts) don't select those columns, so a dns check's type field reads "dns" here with no way to see what it resolves. REST and MCP create_check can still only create http checks (see POST /checks below): the other three types are dashboard-only in v1.
POST /checks
Create an http monitor: the only type this endpoint (and MCP's create_check) can create; heartbeat, tcp, and dns monitors are dashboard-only. Enforces the account's tier allowance (TIER_LIMITS in packages/db/checks.ts, an alias for INCLUDED_MONITORS in packages/db/allowances.ts: 5 on free, 25 on Growth, 150 on Scale as of the 2026-08-15 pricing pass).
What happens past that allowance depends on overage pricing (packages/db/allowances.ts, 2026-08-09 ruling), which is environment-gated and off by default:
- Free is always a hard cap: overage requires a payment method, which a
free account has none of. Free always gets 403 once it hits its allowance.
- Growth/Scale, with no overage rate configured
(MONITOR_OVERAGE_CENTS_PER_UNIT unset), behave exactly like free: 403 once the allowance is hit. This is the safe default and, unless the owner has priced overage, the only behavior in production today.
- Growth/Scale, with an overage rate configured, can keep creating
monitors past the allowance and get billed per extra unit per month ($MONITOR_OVERAGE_CENTS_PER_UNIT/100 each), up to an optional MONITOR_OVERAGE_MAX_UNITS ceiling: past that ceiling it's 403 again.
Either way the error body is the same: { "error": "Your <tier> plan allows up to <limit> monitors. Upgrade to add more." }: on the overage-ceiling path "upgrade" is not literally true (the ceiling is a flat environment setting, not tier-scoped), but no caller has ever hit it in production.
The limit check and the insert run inside one transaction guarded by a per-account Postgres advisory lock (pg_advisory_xact_lock, createCheck in packages/db/checks.ts), so concurrent create calls for the same account queue up one at a time instead of racing past the cap. A single-statement insert ... where (select count(*) ...) < limit was tried first but isn't safe under Postgres's default READ COMMITTED isolation, so it was replaced with the lock-then-count-then-insert sequence.
Billing sync gap (REST `DELETE /checks/:id`, MCP `create_check`, MCP `delete_check`): after a successful create, this REST route calls syncMonitorOverage (apps/web/lib/stripe-overage.ts) to keep the account's Stripe "additional monitor" line item in step with its real monitor count: same as the dashboard's create AND delete actions. REST's DELETE /checks/:id and MCP's create_check/delete_check do not call it, so a monitor created or deleted only through those three paths leaves Stripe's overage line item stale until the hourly reconciler (apps/web/lib/overage-drift-worker.ts) corrects it. That reconciler exists specifically to catch drift like this (it reports every correction as an operational error first), so this is a bounded, monitored gap rather than a silent one, but it means an MCP-only customer's bill can lag their real usage by up to an hour rather than updating inline like the dashboard's does.
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": { ... } } (the returned object also carries an internal isFirstCheck boolean, true only when this was the account's very first monitor, safe to ignore), or 400 if name/url are missing or the wrong type, name exceeds 200 characters, name/url contain a control character, url doesn't parse, regions is present but empty or contains a duplicate or unknown region, intervalSeconds is present but not a positive finite number, the request body isn't valid JSON, 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. Each region may only appear once: a duplicate (e.g. ["iad","iad"]) is also rejected with 400. Both rules come from the same shared schema (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). This is a summary of 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 dashboard returns a fixed generic message ("That URL can't be monitored. Use a public http:// or https:// address."); the REST API returns the validator's specific reason string verbatim (e.g. "That target resolves to a private or reserved address and can't be used."); the MCP tool call returns the same specific reason string and 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 partially validated, then clamped. The shared zod schema (checkCreateShape in packages/db/api-schemas.ts) rejects with 400 any value that isn't a finite, positive number: wrong type, NaN/Infinity, zero, or negative (e.g. intervalSeconds: -100 or "fast" both return 400 and never reach createCheck). Anything that clears that bar (a finite number ≥ 1) is then 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 from 1–59 becomes 60; above 86400 becomes 86400; omitted becomes the 60s default. The REST API never returns a 400 solely because a positive, schema-valid interval falls outside [60, 86400]; the saved check simply reflects the clamped value, which may differ from what you sent, but it does return 400 for a zero, negative, non-finite, or non-numeric intervalSeconds. The MCP create_check tool uses the same shared input schema (packages/db/api-schemas.ts) and therefore the same validate-then-clamp semantics, identically on every surface.
GET /checks/:id
One monitor's current status, aggregated across the check's selected regions (up to 4) 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": "...", "type": "http", "tcp_host": null, "tcp_port": null, "tcp_tls": false, "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 (every one of the check's selected regions is fresh and down), stale (at least one region has ever reported, but every report that currently exists is 3 minutes old or more. A selected region that's never been probed at all doesn't block this; it's simply absent from consideration, same as it is from the regions array), or unknown (no region has ever reported for this check). This is 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 every one of the check's selected regions is confirmed fresh and down. For example, a check with all 4 live regions selected, 3-of-4 fresh-down with the 4th unprobed reports degraded, not down; a check configured via PATCH /checks/:id with fewer than 4 selected regions can reach down with correspondingly fewer fresh-down regions. 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 the request body isn't valid JSON, or if regions is missing/empty/contains an unknown region/contains a duplicate 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), and reach every visitor within 60 seconds: expireTime in apps/web/next.config.mjs caps how long a shared cache may go on serving the render made before the change.
DELETE /checks/:id
Removes the monitor and its history. Returns 204 on success, 404 if not found/not owned by this account. Does not sync Stripe overage billing inline: see the "Billing sync gap" note under POST /checks above.
GET /status-pages
{
"statusPages": [
{
"id": "...", "account_id": "...", "slug": "acme", "name": "Status",
"custom_domain": null, "page_title": null, "page_description": null,
"logo_url": null, "domain_status": "none", "domain_error": null
}
]
}Returns every status page the account holds, oldest first. Free and Growth accounts include one page, so their response is the same 1-item array it has always been; Scale includes up to 10 (lead decision 2026-08-17), so a Scale account that added pages in the dashboard sees them all here. The array shape predates multiple pages and did not change, so this is not a breaking change for any existing client. The first element is the account's default page: the one signup auto-creates on the first monitor, and the one every surface that does not name a page explicitly still means. page_title/page_description/logo_url are null until a customer sets branding. custom_domain reflects the raw hostname column regardless of verification state; whether it's actually live is domain_status (one of "none"/"pending_dns"/"issuing"/"issued"/"error", see "Custom domains" below), not the mere presence of a value. domain_error is populated only when domain_status is "error".
The object deliberately says nothing about page password protection (see "Password-protected pages" below). The password hash is never selectable through this API, and the protection flag is not exposed either: this endpoint is authenticated as the account that owns the page, so it could safely report the flag, but adding it would create a second place for "is this page private" to be answered from, and the value of having exactly one is worth more than the field. Read protection state in the dashboard.
Where a status page lives
A status page is served at its own subdomain, at the root of that host:
| Surface | Address |
|---|---|
| The page | https://<slug>.realuptime.io/ |
| Uptime badge (SVG) | https://<slug>.realuptime.io/badge.svg (?days=30 for a 30-day window) |
| Embeddable widget | https://<slug>.realuptime.io/embed (?days=30) |
| Logo proxy | https://<slug>.realuptime.io/logo |
The older realuptime.io/status/<slug> form (and its /badge.svg, /embed and /logo sub-paths) permanently redirects to the address above, so links, README badges, and embedded iframes pasted before this change keep working without an edit. The redirect is a 308, which preserves the request method, so a form POST to an old address completes rather than being downgraded to a GET.
Renaming a page's slug moves its address to the new subdomain, and the old subdomain redirects to the new one. That works across a rename chain the same way the old path form did.
Nothing else in RealUptime is reachable on a status subdomain: the dashboard, /login, the REST API and the marketing site all return 404 there. This is the same guarantee custom domains have, for the same reason: a hostname carrying a customer's brand should not also expose ours.
Password-protected pages
Growth and Scale accounts can put a shared password in front of their public status page. A visitor without it gets a password form instead of any status, and the page's uptime badge (https://<slug>.realuptime.io/badge.svg) and embeddable widget (https://<slug>.realuptime.io/embed) both return 404 for as long as the page is protected, so a private page never reports its status from a third-party site.
Like custom domains and webhook notifications, this is dashboard-only: there is no REST or MCP endpoint to set, change, or clear a page password, 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).
One asymmetry worth knowing, because it differs from every other paid status-page benefit: the tier gates SETTING a password and never gates honouring one. A page that is already protected stays protected after a downgrade to Free, and its owner can still turn protection off on any tier. A billing event must never publish a page a customer made private, and must never trap them on one either.
Custom domains
Growth and Scale accounts can serve their status page at their own hostname (e.g. status.yourcompany.com) instead of <slug>.realuptime.io. 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, .localhost, .arpa, plus the RFC 2606 reserved TLDs .test/.example/.invalid, and known cloud metadata hostnames). Any hostname containing a non-ASCII character is rejected outright. There is no IDN/punycode-acceptance path in v1, so a visually-similar homograph domain simply never validates. 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 five honest states: None (none, no domain attached, the default), 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 <slug>.realuptime.io.
- 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. This is shared with the MCP list_incidents tool via the incidentsListShape/incidentsListSchema definitions in packages/db/api-schemas.ts. Returns every incident across every monitor on the account, newest first. "Every monitor" is literal: since migration 065 this includes page-less incidents, opened for a monitor that is on no status page at all (the RealUptime Monitor shape, docs/monitor-plan.md). Those carry "status_page_id": null; everything else about them is identical, including acknowledgement and escalation. Before 065 a private monitor could go down without producing an incident at all, so this list simply had nothing to say about it. A client that assumed status_page_id was always a string should treat it as nullable.
{
"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,
"acknowledged_at": null, "acknowledged_by": null
},
{
"id": "...", "status_page_id": null, "check_id": "...", "region": "iad",
"title": "US-East is down",
"body": "Our US-East probe is reporting Internal API as down. Other regions are unaffected.",
"status": "investigating", "opened_at": "...", "resolved_at": null,
"acknowledged_at": null, "acknowledged_by": null
}
]
}acknowledged_at/acknowledged_by (on-call, migration 054) record which PERSON on the account's team took responsibility for the incident and when; acknowledged_by is a users.id, or null if nobody has yet, or if the person who did has since left the team. Acknowledging and releasing an incident are dashboard-only: like page passwords, custom domains, and webhook endpoints, there is no REST or MCP way to set or clear them, so every incident returned here and by MCP's list_incidents / create_incident / add_incident_update carries the field but neither surface can change it.
POST /incidents
Opens a new incident against one of the account's own status pages and monitors (components), and writes the opening timeline update in the same transaction.
statusPageId is required and stays required, deliberately, even though migration 065 made incidents.status_page_id nullable in the database. An operator-authored incident is an announcement, and an announcement with no page has no audience; page-less incidents exist only so a private monitor's automatic outage can be acknowledged and escalated, and the prober is their only writer. The same rule applies to MCP's create_incident. If you want to open one against a private monitor, put the monitor on a status page first.
Requires the read_write scope; subject to the 30/min write rate limit.
curl -X POST https://realuptime.io/api/v1/incidents \
-H "Authorization: Bearer ru_live_..." \
-H "Content-Type: application/json" \
-d '{"statusPageId":"...","checkId":"...","region":"iad","title":"API is down","body":"Investigating elevated error rates."}'| Field | Type | Required |
|---|---|---|
statusPageId | UUID | yes |
checkId | UUID | yes |
region | "iad" | "sjc" | "fra" | "nrt" | yes |
title | string, 1-200 characters | yes |
body | string, 1-5000 characters | yes |
title and body reject control characters (the same screen every other free-text field in this API applies). Validated through the shared incidentCreateShape/incidentCreateSchema definitions in packages/db/api-schemas.ts, so REST and the MCP create_incident tool below agree on every bound.
checkId must be a component of `statusPageId`, and region must be one of the regions that check is actually probed from (regions on the monitor, see PATCH /checks/:id). Both are enforced, and both refuse with 404:
- A check the account owns but which is not on that page would publish an
incident on a public page for a component that page does not list, while notifying nobody (the subscriber fan-out keys off the page's component list). Private monitor-product checks with no public page are exactly this case.
- A region the check is not probed from is a claim with no observation
behind it, and the automatic recovery path can never clear it, because a region we do not probe never transitions.
Returns 201 with { "incident": { ... } } (same shape as the entries in GET /incidents, acknowledgement fields included) on success. Returns 400 if the request body isn't valid JSON or fails validation, or 404 if statusPageId/checkId don't both belong to the authenticated account, the check isn't a component of the page, or the region isn't probed: all four are enforced by the same insert that creates the row, not a separate lookup, so a foreign, mismatched or unpublished id is indistinguishable from "not found" and never reaches a 500.
Sends email. On success, every confirmed subscriber of that status page gets an incident notification email. This is the first REST write with that side effect: every other write route only touches monitor rows. There is no way to suppress it per-request; if you don't want subscribers notified for a given incident, don't call this endpoint until you're ready for that notification to go out.
POST /incidents/:id/updates
Posts a staged update against an existing incident, advancing its lifecycle status (investigating -> identified -> monitoring -> resolved) in the same call. Requires the read_write scope; subject to the 30/min write rate limit.
curl -X POST https://realuptime.io/api/v1/incidents/<id>/updates \
-H "Authorization: Bearer ru_live_..." \
-H "Content-Type: application/json" \
-d '{"status":"identified","body":"Root cause found, deploying a fix."}'| Field | Type | Required |
|---|---|---|
status | "investigating" | "identified" | "monitoring" | "resolved" | yes |
body | string, 1-5000 characters | yes |
force | boolean | no, see below |
Validated through the shared incidentIdShape/incidentUpdateCreateShape definitions in packages/db/api-schemas.ts, shared with the MCP add_incident_update tool below. Posting resolved stamps the incident's resolved_at; posting any other status after a resolved incident clears it (re-opening the incident).
Resolving while the monitor is still failing
Behavior change, 2026-08-17. No API version bump. Resolving over a live outage used to succeed silently here; it now needs force.
status: "resolved" returns 409 when our own probes currently read the incident's component down or degraded, unless the request body carries force: true. With force: true the call proceeds exactly as it did before. Every other status ignores force entirely, and no other endpoint is affected, so a caller that was not resolving over a live outage needs no change.
The 409 body names what we see, in the same words a person would read:
{
"error": "Our last probe still reports Checkout API from iad down. Marking this incident resolved tells every subscriber and every status page visitor that the outage is over. Send force: true to resolve anyway.",
"check": "Checkout API",
"region": "iad",
"state": "down"
}state uses the same staleness-aware vocabulary as GET /checks/:id, and the same aggregation produces it. Only down and degraded trigger the conflict: operational, stale (the region stopped reporting) and unknown (it never reported) are not observations we can hold against the caller, so they resolve without force.
This exists because the dashboard has always asked the same question before publishing Resolved, and a status page saying Resolved over a live outage is the one claim this product cannot afford. It is a prompt, not a policy: an operator can legitimately know the fix landed before the next probe round confirms it, which is what force is for. A degraded component counts as live for exactly the same reason a down one does.
The automatic path cannot clean this up afterwards, which is why the check happens before the write: the prober only opens an incident on a transition INTO down, and a check that is already down never transitions.
Returns 201 with { "update": { ... } } on success. Returns 400 for a malformed body, 404 if the incident id isn't a valid UUID or doesn't belong to the authenticated account, 409 for the resolve conflict above. Concurrent updates to the same incident are serialized server-side (a transaction-scoped advisory lock, see addIncidentUpdate in packages/db/incidents.ts); an identical update (same status, same body) submitted twice within 10 seconds is treated as a double-submit and returns the first update's row again rather than creating a duplicate timeline entry.
Sends email. On success (and not deduped as a double-submit), every confirmed subscriber of the incident's status page gets a notification email with the new status and update text. Same no-suppression caveat as POST /incidents above.
Slack notifications
The first alert channel: paste an incoming webhook URL from the dashboard's "Slack" section under Notifications, and RealUptime posts a message on the same down/recovery transitions that drive every other channel below. Like webhook notifications and PagerDuty, this feature has no tier gate and is managed from the dashboard, not via the REST API: there is no endpoint to set, read, or clear the webhook URL programmatically.
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. That suppression is per component since migration 060: a maintenance window silences only the components it covers, and only a window that names no components covers the whole status page.
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. - There is a ceiling on how many enabled endpoints one account may hold at
once, matching that plan's included monitor count (see MAX_NOTIFICATION_ENDPOINTS in packages/db/allowances.ts for the current numbers). This is a safety bound on outbound fan-out, not a plan feature: every transition sends one POST per endpoint, so the endpoint count multiplies outbound traffic and something has to bound it. It sits far above normal use. Removing an endpoint frees its slot immediately.
- 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's stored server-side in plaintext (required so RealUptime can re-sign every future delivery) but is never re-exposed through the dashboard or any API response after that first display; 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 (60s, doubling: 60s, 120s, 240s, 480s) 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. The pasted key must be 8-256 characters after trimming. PagerDuty doesn't publish a strict format for this key, so this is a loose sanity check, not real key validation.
Delivery guarantees
Same outbox pattern, retry schedule (60s, doubling: 60s/120s/240s/480s, 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. This is 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 nine 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? (number ≥ 1; not integer- or range-checked at the schema layer, rounded and clamped to 60–86400 downstream, see below), 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) |
create_incident | POST /incidents | statusPageId (uuid), checkId (uuid), region ("iad" | "sjc" | "fra" | "nrt"), title (1-200 characters), body (1-5000 characters) |
add_incident_update | POST /incidents/:id/updates | incidentId (uuid), status ("investigating" | "identified" | "monitoring" | "resolved"), body (1-5000 characters), force? (boolean, see below) |
Free-tier access
Unlike the REST API (paid plans only, see Authentication above), MCP read access is free: list_checks, get_check_status, list_status_pages, and list_incidents all work with a free-tier key. create_check, update_check_regions, delete_check, create_incident, and add_incident_update all require a Growth or Scale plan; a free-tier key calling one of them gets isError: true with an upgrade message instead of running (checkFreeTierWrite in apps/mcp/index.ts), the same shape as a read-scope key hitting a write tool. The two checks are independent: a paid account's read-scope key is still refused by the permission-scope check, and a free account's key is refused by the tier check regardless of its own stored permission scope (a grandfathered read_write key from before an account downgraded to free, for example).
create_incident and add_incident_update require a read_write key (a read key gets isError: true with the same "read-only" message the REST routes return) and share the write rate limit. Both send email to the status page's confirmed subscribers on success, same as their REST equivalents above: see POST /incidents and POST /incidents/:id/updates for the exact conditions and caveats.
add_incident_update carries the same resolve guard as its REST twin (behavior change, 2026-08-17): status: "resolved" returns isError: true with a message telling you to pass force: true when our probes currently read the component down or degraded. Both surfaces read one shared predicate (isLiveOutageState, packages/db/incident-live-state.ts), so neither can quietly let something through the other would stop. Passing force: true proceeds exactly as before, and every other status ignores it. See POST /incidents/:id/updates above for which states count as live and why.
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, update_check_regions, and delete_check all apply the write rate limit; list_checks, get_check_status, list_status_pages, and list_incidents all apply the read rate limit: the same budgets as the REST routes (see Rate limits above), not a separate MCP allowance. 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.
create_check and delete_check do not sync Stripe overage billing inline the way the REST POST /checks route and the dashboard do: see the "Billing sync gap" note under POST /checks above.
Every tool returns the same JSON shape as its REST equivalent, serialized as a text content block. The exception is delete_check, whose REST equivalent (DELETE /checks/:id) returns an empty 204 No Content body; the MCP tool instead returns { "deleted": true }. 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 (a rate-limited call gets a retryAfterSeconds field inside the JSON text content, not an HTTP 429 or Retry-After header; those only exist on the REST surface).
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 |
405 | HTTP method not implemented on that route (e.g. DELETE /checks, PUT /incidents), returned automatically by the framework, not by application code |
409 | Resolving an incident whose monitor we still read down or degraded, without force: true (see POST /incidents/:id/updates) |
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; a handler exception, which the server catches and turns into a plain 500; and some argument-validation failures on a tool's input schema, which the underlying MCP SDK can reject as a JSON-RPC-level error before a normal tool result is ever produced, rather than as isError: true.
Agent protocol (internal wire contract, not the public API)
POST /api/agent/v1/poll, POST /api/agent/v1/results and POST /api/agent/v1/metrics are the wire contract between the RealUptime Monitor agent and this server (docs/monitor-plan.md phases 1 and 2). This is not part of the public REST API. It is spoken by one program we ship, versioned with that program, and it is documented here so the two halves cannot drift, not as a surface a customer integrates against. Nothing on it is covered by the REST API's tier gate, its key format, its rate limits, or its error vocabulary.
Credential
A per-agent bearer token, rua_ followed by 32 random bytes as hex, minted once when the agent is registered on /monitoring and shown exactly once. The server stores only its SHA-256 hash, so a lost token cannot be recovered and is replaced by registering a new agent.
Authorization: Bearer rua_<64 hex characters>Every failure on all three routes answers 404 with a tiny JSON body: a missing header, a malformed token, an unknown token and a revoked token are deliberately indistinguishable, so the endpoints cannot confirm whether a guessed token exists or whether a stolen one has been revoked yet. Every response is no-store.
Rate limit: 120 requests per minute per agent, counted on the token's hash and consumed before the lookup, so a flood of unknown tokens is throttled too. Over the limit answers 429 with Retry-After: 60. One budget covers all three routes: the token is the unit being protected, and a separate bucket per endpoint would let a compromised token spend three times as much.
POST /api/agent/v1/poll
No body. Returns the checks bound to this agent, and stamps the agent's last-seen time as a side effect of the same call.
{
"checks": [
{
"id": "b2c3d4e5-...",
"type": "http",
"url": "https://10.0.0.5:8080/health",
"tcpHost": null,
"tcpPort": null,
"tcpTls": false,
"dnsHostname": null,
"dnsRecordType": null,
"dnsExpectedValue": null,
"intervalSeconds": 60
}
]
}type is http, tcp, or dns. Exactly the fields that type needs are non-null. A revoked agent is served nothing, because its token no longer resolves at all.
POST /api/agent/v1/results
{
"results": [
{
"checkId": "b2c3d4e5-...",
"ok": false,
"statusCode": 503,
"latencyMs": 122,
"error": "connection refused",
"checkedAt": "2026-08-17T14:04:05.000Z"
}
]
}checkId, ok and checkedAt are required; statusCode, latencyMs and error are optional and may be null. At most 100 results per call: a larger batch is refused with 400 so a buffering agent slices its backlog rather than sending one request that times out and is retried forever.
{ "accepted": 1, "rejected": 0, "clamped": 0 }Three rules govern what happens to each result:
- Accepted results enter the same pipeline a regional probe's result does:
the raw row, the hysteresis state machine (two consecutive failures to go down, one success to recover), incidents, escalation, and the full alert fan-out. The locus recorded is agent: followed by the first eight characters of the agent id, in the same region column the four fleet regions use.
- **A result for a check this agent is not bound to is dropped and counted in
rejected.** It never fails the batch: erroring would confirm which ids exist, and one stale id would block every genuine result behind it.
- `checkedAt` is clamped and counted in `clamped` when it is in the future
or more than ten minutes old. Buffering through a connectivity loss is expected, so timestamps inside that window are honoured exactly; outside it, server time is used instead, so a wrong clock cannot backfill history that has already been rolled up, or make a check look freshly checked when nothing checked it.
POST /api/agent/v1/metrics
Server health from the machine the agent runs on (docs/monitor-plan.md phase 2). Separate from /results because it answers a different question: not whether a target is up, but what this machine is doing.
{
"vantage": "host",
"vantageDetail": null,
"collectorVersion": "0.2.0",
"samples": [
{
"sampledAt": "2026-08-18T14:04:05.000Z",
"cpuUsedRatio": 0.42,
"cpuCores": 8,
"memoryTotalBytes": 17179869184,
"memoryUsedBytes": 9663676416,
"load1": 1.2,
"load5": 0.9,
"load15": 0.7,
"filesystems": [
{ "mountPoint": "/", "totalBytes": 500107862016, "usedBytes": 462742192128 }
]
}
]
}At most 100 samples per call, each with at most 32 filesystems. A larger batch is refused with 400, so a buffering agent slices its backlog rather than sending one request that times out and is retried forever.
{ "accepted": 1, "rejectedStale": 0, "rejectedFuture": 0, "rejectedDuplicate": 0 }Units
Every number has exactly one legal form. These are enforced by CHECK constraints in the database as well as by the route, so a collector that gets one wrong fails on its first batch instead of drawing a plausible chart that is wrong by a factor of a hundred.
| Field | Unit |
|---|---|
cpuUsedRatio | fraction of TOTAL capacity across all cores, 0 to 1 |
cpuCores | whole number of cores that total is across |
memoryTotalBytes, memoryUsedBytes | bytes; used is total minus AVAILABLE |
totalBytes, usedBytes (filesystems) | bytes |
load1, load5, load15 | raw kernel load averages, unnormalised |
- CPU is a ratio, not a percentage, and not per-core.
0.87, never87.
A percentage invites the 0-100 versus 0-1 ambiguity at every boundary the number crosses, and a per-core figure cannot be compared between two machines without also knowing the core count. "How full is this machine" is the question both a chart and an alert threshold ask, and it is capacity relative. cpuCores travels alongside so a per-core view stays derivable.
- Memory `used` is total minus available, not total minus free. On Linux,
cache and buffers are reclaimable, so total-minus-free reports a healthy machine as permanently full.
- Percentages are never reported. The server derives them from the totals,
so a chart and an alert cannot disagree about what "90% full" meant.
- Load is all three or none. A platform without load averages omits all
three and must never send zeroes, which read as an idle machine.
Vantage
vantage is required and is either host or container. vantageDetail is optional free text for humans ("docker", "lxc", "kubernetes").
A collector running inside a container reads the CONTAINER's cgroup limits for CPU and memory and its overlay filesystem for disk. Those are real numbers, and reported as host metrics they are a lie nothing downstream can detect: the chart looks plausible and the thresholds are wrong by an unknowable factor.
So the server pins whichever vantage an agent first reports and refuses any later batch that disagrees with 409, naming both vantages. An operator genuinely moving a collector from the host into a container registers a new agent, which is already the answer for rotating a token. The vantage is also stored on every sample, so history read a month later still says what the number was a measurement of.
What happens to each sample
- `sampledAt` is dropped, not clamped. Accepted between 24 hours old and
one minute into the future; anything outside that is counted in rejectedStale or rejectedFuture and never written. This is deliberately unlike /results, which rewrites an out-of-window checkedAt to server time: clamping a time series would invent a data point at an instant nothing was measured, and would collapse a whole buffered batch onto one row.
- **A repeated instant is counted in
rejectedDuplicateand the first writer
keeps it.** A redelivered batch is therefore idempotent. A count that stays non-zero across FRESH batches means two collectors are running on one token; neither can overwrite the other's history, and the number is how that shows up.
- A malformed batch is refused whole, with `400` naming the field. Unlike a
result naming a stale check id, there is no benign reason for a metric sample to be malformed, and half a batch on a chart is worse than none: nobody would think to distrust it.
Why agent-bound checks are never probed by us
An agent exists to watch things only the customer's own network can reach, so its targets are private by design and skip the target safety check every fleet monitor goes through. The other half of that trade is absolute: a check bound to an agent is never returned to the regional fleet's scheduler, so no machine of ours ever dials a private address. Neither rule is safe without the other.
Why two auth implementations
apps/web/lib/api-auth.ts and the authenticate() function in apps/mcp/index.ts both verify the key and look up the account, but no longer apply an identical tier rule: the REST implementation still rejects a free-tier account outright, while the MCP implementation lets a free-tier account through and leaves tier enforcement to each write tool (checkFreeTierWrite in apps/mcp/index.ts), so its four read tools stay free. apps/mcp deliberately has no dependency on the Next.js app: it's a standalone service that only depends on packages/db, so this logic is duplicated, and now diverges on purpose, rather than shared across that boundary.