Uptime monitoring · by UptimeRobot s.r.o.
Build your own UptimeRobot.
A scheduled job that fetches your URLs from two places and pings Slack when they fail. UptimeRobot sells that by the monitor, the second, and the seat.
What you're paying now
$663 per year
Team plan for 100 monitors billed annually ($39 × 12 = $468), one extra login seat ($15 × 12 = $180), and a 100-credit SMS pack ($15).
Build it in any tool
Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.
Paste anywhere
Build my own UptimeRobot. Fetch https://buildyourown.software/like/uptimerobot/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals.
Claude Code
claude "Build my own UptimeRobot. Fetch https://buildyourown.software/like/uptimerobot/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
Codex CLI
codex "Build my own UptimeRobot. Fetch https://buildyourown.software/like/uptimerobot/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it does
UptimeRobot checks a URL, port, host, or DNS record on an interval from several locations. When a check fails it emails you, texts you, or posts to Slack, Discord, or a webhook. It keeps response-time history, tracks each outage as an incident, and hosts a public status page you can point customers at.
The paid tiers are about speed and count: faster check intervals (60, 30, or 15 seconds), more monitors, more status pages, SMS credits, and login seats for teammates. The free plan checks every 5 minutes and covers most personal projects.
What you actually depend on is small. Fetch the URL, compare the status code, do it again from a second region before you page anyone, write down when it started and when it ended. That's a scheduler, a table, and a few outbound webhooks.
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Free
$0
50 monitors
- –5-minute check interval
- –HTTP, keyword, port, and ping monitors
- –1 basic status page
- –3 months of data retention
- –5 integrations
- –No login or notify seats
Solo
$12
per month billed annually for 10 monitors ($13 monthly); $24 for 50 monitors ($28 monthly)
- –60-second check interval
- –SSL, DNS, API, UDP, and heartbeat monitors
- –3 status pages on your own domain
- –12 months of data retention
- –10 to 20 SMS/voice credits, one time
Team
$39
per month billed annually for 100 monitors ($46 monthly)
- –30-second check interval
- –3 login seats and 3 notify-only seats
- –100 white-labeled status pages
- –24 months of data retention
- –Webhook, Zapier, and PagerDuty integrations
- –30 SMS/voice credits, one time
Extra login seats are $15 per month on annual billing or $19 monthly.
Scale
$79
per month billed annually for 200 monitors ($98 monthly); $192 for 500 monitors ($228 monthly)
- –15-second check interval
- –5 login seats and 5 notify-only seats
- –Unlimited status pages
- –24 months of data retention
- –50 to 200 SMS/voice credits, one time
Enterprise above this is quote-only with custom intervals and SOC 2 paperwork.
The fine print
- SMS and voice credits don't renew with your plan. The 30 credits on Team are a one-time grant. After that you buy packs: 10 for $3, 100 for $15, 1,000 for $100.
- Free and Solo have no login seats. The first time a teammate needs to see the dashboard you're on Team at $468 a year.
- Monitor count is a hard tier boundary. Monitor 51 on Solo means a new price, and monitor 101 on Team means Scale.
- Faster intervals are the main upsell. The free plan checks every 5 minutes, and a 60-second check is the first thing behind the paywall.
- Data retention is 3 months on Free. Your response-time history and old incidents disappear unless you pay.
- Monthly billing costs about 15 to 20 percent more than annual across every tier, and extra seats follow the same split ($15 versus $19).
Prices checked September 10, 2026 at uptimerobot.com. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
10 of 13 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
HTTP(S) monitors
Fetch a URL on a schedule and check status code, a keyword in the body, and response time against a threshold.
Why: This is the product. It's one fetch call with a timeout and three comparisons.
- Build it
Port, ping, and DNS monitors
TCP connect to a host and port, ICMP ping, and DNS lookups that compare the answer to what you expect.
Why: Each one is a few lines of Node. DNS with an expected value catches hijacks and botched migrations.
- Build it
SSL certificate expiry
Warn 30, 14, and 7 days before a certificate expires or when the chain is invalid.
Why: Every HTTP check already receives the certificate. Reading the expiry date is free.
- Build it
Heartbeat (cron) monitors
Your job pings a unique URL. If the ping stops arriving, you get alerted.
Why: The only way to know a nightly backup silently stopped running. It's one endpoint and one timestamp column.
- Build it
Multi-region checks with confirmation
Run each check from more than one location and only alert when both agree the target is down.
Why: Without this you'll get paged for a routing blip between one datacenter and your server. Two Fly machines or a GitHub Actions cron gets you there.
- Build it
Alerts to email, Slack, Discord, and webhooks
Send a down notice and a recovery notice with duration to every channel attached to a monitor.
Why: Slack and Discord are incoming webhook URLs. Email is Resend. The generic webhook is a POST with an HMAC header.
- Maybe
SMS alerts
Text a phone number when a monitor goes down.
Why: Twilio costs less than a cent per message and takes an hour to wire up. Skip it if Slack on your phone already wakes you.
- Build it
Incident records
Each outage gets a start, end, duration, cause, and a place for notes.
Why: This is what turns a stream of failed checks into something you can look at a month later.
- Build it
Response time charts and uptime percentages
Per-region response time over 24 hours, 7 days, and 30 days, plus 30- and 90-day uptime.
Why: The number your customers ask for. Uptime is incident seconds divided by window seconds. The chart is a bucketed query.
- Build it
Public status page with email subscribers
A page per group of monitors with 90-day bars, current status, incident history, and subscribe by email.
Why: This is the feature people pay for so they can stop answering 'is it down?' in support.
- Build it
Maintenance windows
A scheduled period where failures don't alert and don't count against uptime.
Why: Deploys and migrations happen. One table with a timezone column and a check in the evaluator.
- Skip
15 to 60 second check intervals
Check every few seconds instead of every few minutes.
Why: A 1-minute check catches an outage within 2 to 3 minutes after confirmation. Below that, the delay is in whoever reads the alert.
- Skip
Dependency monitoring, mobile app, and seats
Third-party status feeds, a native app, and login versus notify-only seat types.
Why: You own the app now. Everyone on your team can log in, and Slack is your mobile app.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
Data model
Monitor
id · workspace_id · group_id · name · type (http | tcp | ping | dns | ssl | heartbeat) · target · port · config (jsonb, per type) · interval_seconds · timeout_ms · regions[] · status (pending | up | down | paused) · heartbeat_token · last_heartbeat_at
config holds the type-specific bits: expected statuses, keyword, max_response_ms, DNS record type and expected values, SSL warn days, heartbeat grace.
Check result
id · monitor_id · region · checked_at · ok · status_code · response_ms · error · detail (jsonb) · in_maintenance
One row per check per region. Index on (monitor_id, checked_at desc). Delete after 90 days; daily rollups keep the history.
Region state
monitor_id · region · consecutive_failures · next_run_at · last_result_at · last_error
Primary key (monitor_id, region). The scheduler claims due rows with SKIP LOCKED so two probers never double-check.
Incident
id · monitor_id · started_at · resolved_at · duration_seconds · cause · first_failed_region · is_maintenance · acknowledged_by · notes
Opened by the evaluator when two regions confirm, closed when every healthy region reports ok. Maintenance incidents never alert and never count against uptime.
Alert channel
id · workspace_id · type (email | slack | discord | webhook | sms) · config (jsonb) · enabled
Joined to monitors through monitor_alert_channels. Every send is logged in alert_deliveries with attempts and errors.
Status page
id · workspace_id · slug · name · custom_domain · is_public · monitors (join with display_name and position) · subscribers (email, confirmed_at, unsubscribe_token)
Maintenance window
id · workspace_id · name · starts_at_local · ends_at_local · timezone (IANA) · recurrence (none | weekly) · weekdays[] · monitor_ids[]
Stored as local times plus an IANA zone so a 02:00 to 03:00 window survives DST changes.
Key flows
Run a check from a region
- 1.A prober process in each region polls GET /api/prober/due?region=us-east every 10 seconds.
- 2.The server claims rows where next_run_at <= now() for that region, bumps next_run_at by interval_seconds, and returns the monitors.
- 3.The prober runs the type-specific check with a timeout, measures milliseconds, and POSTs a batch of results.
- 4.The server stores each result, updates consecutive_failures for that region, and hands the new state to the evaluator.
Confirm an outage and alert
- 1.The evaluator marks a monitor down only when at least two regions each have two consecutive failures.
- 2.If a region hasn't reported in 5 minutes it's stale, and the remaining region needs three consecutive failures instead.
- 3.On the down transition, open an incident with the first error as the cause. If a maintenance window is active, flag it and skip alerts.
- 4.Queue one delivery per attached channel. Retry up to 3 times with backoff. Never send the same event to the same channel twice.
- 5.When every non-stale region's latest result is ok, resolve the incident, compute duration, and send the recovery message.
Heartbeat monitor
- 1.Create a heartbeat monitor and copy its URL: /api/hb/<token>.
- 2.Your cron job curls that URL at the end of each run.
- 3.A minute-by-minute evaluator opens an incident when last_heartbeat_at is older than interval_seconds plus grace_seconds.
- 4.The next ping resolves it. No regions involved.
Public status page
- 1.Visitor opens /s/<slug> or the custom domain.
- 2.The page shows an overall banner, one row per monitor with a 90-day bar built from daily rollups, and the last 30 days of incidents.
- 3.Times render in the visitor's browser timezone with the zone label shown.
- 4.Subscribe form sends a confirmation email. Confirmed subscribers get an email when an incident on a listed monitor opens and when it resolves.
Uptime and response time
- 1.A nightly job writes one daily_uptime row per monitor: checks, failures, down seconds, avg and p95 response.
- 2.Uptime for 30 or 90 days is (window seconds minus non-maintenance incident seconds inside the window) divided by window seconds, starting at the monitor's created_at if younger.
- 3.Response time charts bucket check_results into 5-minute, 1-hour, or 6-hour bins per region depending on range.
Integrations
- Postgres (Neon, Supabase, or Fly Postgres)requiredMonitors, results, incidents, rollups.
- Fly.io machines in two regionsrequiredRun the prober in us-east and eu-west. FLY_REGION tells each machine who it is.
- GitHub Actions scheduled workflowoptionalA free second (or third) region that runs the prober every 5 minutes. Delays of several minutes are normal.
- ResendrequiredAlert emails, subscriber confirmations, and incident notices to subscribers.
- Slack incoming webhooksoptionalDown and recovery messages as Block Kit.
- Discord webhooksoptionalSame messages as embeds.
- Twilio Programmable SMSoptionalText alerts. Off unless TWILIO_* env vars are set.
- Cloudflare DNS over HTTPS or node:dnsrequiredDNS monitors resolve against a fixed resolver so results are consistent across regions.
- Vercel Cron or a scheduler processrequiredHit the evaluate, rollup, and retention endpoints on a schedule.
05
The build prompt
The full spec the one-line command points at. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.
# Build an uptime monitor (replacing UptimeRobot)
You are building a self-hosted uptime monitor for a small team's own sites, APIs, and cron jobs. It replaces UptimeRobot for people who want HTTP, port, ping, DNS, SSL, and heartbeat checks, alerts that don't fire on a single blip, incident history, and a public status page. Build it end to end. Getting the "is it really down?" decision right matters more than adding monitor types.
## Stack
- Next.js (App Router) with TypeScript and Tailwind for the dashboard and status pages
- Postgres (Neon, Supabase, or Fly Postgres) with Drizzle. Use `timestamptz` everywhere.
- A separate Node 20 process called the **prober** in `apps/prober/`. It runs in each region and only talks to the app over HTTPS. It never touches the database.
- Deploy: the web app on Vercel or Fly.io; the prober on Fly.io machines in two regions (`iad` and `ams`). Also ship a GitHub Actions workflow that runs the prober once every 5 minutes as an optional third region.
- Auth: magic link via Supabase Auth, Clerk, or NextAuth, whichever the repo already has
- Resend for email. Twilio for SMS, optional. `date-fns` and `date-fns-tz` for every date calculation.
- Charts: `uplot` or Recharts. Keep the bundle small.
If the repo already has a different stack, use it and keep the behavior identical.
## Regions
A region is a string. Default regions are `us-east` and `eu-west`. The prober reads its region from `REGION`, falling back to a mapping from `FLY_REGION` (`iad` → `us-east`, `ams` → `eu-west`). The GitHub Actions run uses `REGION=gha`. Region names, display labels, and staleness thresholds live in `lib/regions.ts`:
```ts
export const REGIONS = {
"us-east": { label: "US East", staleAfterSeconds: 300 },
"eu-west": { label: "EU West", staleAfterSeconds: 300 },
gha: { label: "GitHub Actions", staleAfterSeconds: 900 },
};
```
## Data model
Every table has `id` (uuid unless noted), `created_at`, `updated_at`.
- `workspaces`: `name`, `slug`, `prober_secret` (random 32 bytes, base64url), `retention_days` (default 90).
- `users`: `email` (unique), `name`. `workspace_members`: `workspace_id`, `user_id`, `role` (`owner` | `member`).
- `monitor_groups`: `workspace_id`, `name`, `position`.
- `monitors`: `workspace_id`, `group_id` (nullable), `name`, `type` (`http` | `tcp` | `ping` | `dns` | `ssl` | `heartbeat`), `target` (URL for http, hostname or IP otherwise), `port` (nullable int), `config` (jsonb, see below), `interval_seconds` (60 to 3600, default 300), `timeout_ms` (default 10000), `regions` (text[], default `{us-east,eu-west}`), `status` (`pending` | `up` | `down` | `paused`), `paused_at` (nullable), `heartbeat_token` (unique, nullable, 24 random bytes base64url), `last_heartbeat_at` (nullable), `renotify_minutes` (nullable int).
- `monitor_region_state`: `monitor_id`, `region`, `consecutive_failures` (int, default 0), `next_run_at`, `last_result_at` (nullable), `last_ok` (bool, nullable), `last_error` (nullable). Primary key (`monitor_id`, `region`). One row per monitor per region in `monitors.regions`; heartbeat monitors have none.
- `check_results`: `id` bigserial, `monitor_id`, `region`, `checked_at`, `ok` (bool), `status_code` (nullable int), `response_ms` (nullable int), `error` (nullable text), `detail` (jsonb, nullable), `in_maintenance` (bool). Index on (`monitor_id`, `checked_at desc`).
- `incidents`: `monitor_id`, `started_at`, `resolved_at` (nullable), `duration_seconds` (generated: `extract(epoch from resolved_at - started_at)`), `cause` (text), `first_failed_region`, `is_maintenance` (bool), `acknowledged_by` (nullable user), `acknowledged_at`, `notes` (text). Partial unique index on `monitor_id` where `resolved_at is null` so a monitor has at most one open incident.
- `alert_channels`: `workspace_id`, `name`, `type` (`email` | `slack` | `discord` | `webhook` | `sms`), `config` (jsonb), `enabled` (bool). `monitor_alert_channels`: (`monitor_id`, `channel_id`) primary key.
- `alert_deliveries`: `incident_id`, `channel_id`, `event` (`down` | `up` | `reminder`), `attempts` (int), `sent_at` (nullable), `last_error` (nullable). Unique on (`incident_id`, `channel_id`, `event`) for `down` and `up` so the same message can't go twice. Reminders get a `sequence` int in the unique key.
- `status_pages`: `workspace_id`, `slug` (unique, url-safe), `name`, `description`, `custom_domain` (nullable, unique), `is_public` (bool), `show_response_times` (bool). `status_page_monitors`: `status_page_id`, `monitor_id`, `display_name`, `position`.
- `status_page_subscribers`: `status_page_id`, `email`, `confirm_token`, `confirmed_at` (nullable), `unsubscribe_token`. Unique on (`status_page_id`, `email`).
- `maintenance_windows`: `workspace_id`, `name`, `timezone` (IANA), `starts_at_local` (text `YYYY-MM-DDTHH:mm` for one-off, `HH:mm` for weekly), `ends_at_local`, `recurrence` (`none` | `weekly`), `weekdays` (int[] 0 to 6, Sunday is 0, used when weekly), `active` (bool). `maintenance_window_monitors`: (`window_id`, `monitor_id`).
- `daily_uptime`: `monitor_id`, `day` (date, UTC), `checks`, `failed_checks`, `down_seconds`, `maintenance_seconds`, `avg_response_ms`, `p95_response_ms`. Primary key (`monitor_id`, `day`).
### `monitors.config` by type
Validate with zod on create and update.
- `http`: `method` (`GET` | `HEAD` | `POST`, default GET), `expected_status` (string like `"200-299"` or `"200,301,302"`, default `"200-299"`), `keyword` (nullable), `keyword_mode` (`present` | `absent`), `max_response_ms` (nullable), `headers` (record), `body` (nullable), `follow_redirects` (bool, default true, max 5 hops), `verify_tls` (bool, default true).
- `tcp`: nothing beyond `port` (required).
- `ping`: `count` (default 3).
- `dns`: `record_type` (`A` | `AAAA` | `CNAME` | `MX` | `TXT` | `NS`), `resolver` (IP, default `1.1.1.1`), `expected_values` (string[], may be empty).
- `ssl`: `warn_days` (default 14). `port` defaults to 443.
- `heartbeat`: `grace_seconds` (default 60). `interval_seconds` is how often you expect the ping.
## Screens
1. **Sign in and workspace.** First user creates a workspace and becomes owner. Owners invite by email.
2. **Dashboard (`/`).** Monitors grouped by `monitor_groups`. Each row: status dot, name, type, target, 24-hour uptime, 30-day uptime, last response time per region, a 24-hour sparkline. Filter by status and type; search by name and target. Top banner shows open incidents and any stale region. "New monitor" button.
3. **New and edit monitor (`/monitors/new`, `/monitors/[id]/edit`).** The form changes with `type`. Heartbeat shows the ping URL with a copy button after save. Every type has a "Test now" button that runs the check once from the server and shows the result without storing it.
4. **Monitor detail (`/monitors/[id]`).** Current status and how long it's been that way, uptime for 24h, 7d, 30d, 90d, response time chart with a region toggle and range tabs (24h, 7d, 30d), recent checks table (region, time, ok, status, ms, error), incident list, pause and resume, delete.
5. **Incidents (`/incidents`, `/incidents/[id]`).** List with filters for open, resolved, monitor, and date. Detail shows a timeline: first failure per region with its error, the confirmation moment, acknowledgement, resolution, duration. Editable notes. Acknowledge button.
6. **Alert channels (`/settings/alerts`).** Add a channel per type. Email: address. Slack and Discord: webhook URL. Webhook: URL plus a generated secret. SMS: E.164 phone number (only shown when Twilio env vars exist). Each channel has "Send test". Attach channels to monitors from the monitor form. A workspace-level default set applies to new monitors.
7. **Status pages (`/status-pages`, `/status-pages/[id]`).** Create with name and slug, pick monitors, set display names and order, toggle public, set custom domain, see subscriber count with an export button.
8. **Public status page (`/s/[slug]`, also served for `custom_domain` via the Host header in middleware).** Banner: "All systems operational", "Partial outage", or "Major outage" (all listed monitors down). Per monitor: name, current status, 90-day bar with one segment per UTC day colored by that day's uptime (green ≥ 99.9, yellow ≥ 99, red below, gray no data), 90-day uptime percentage. Below: incidents from the last 30 days with start, end, duration, and monitor. Subscribe form. Times render in the visitor's browser timezone and show the zone label (e.g. "PDT"). Server-render everything; hydrate only the time formatting and subscribe form. `noindex` when `is_public` is false and require a signed-in workspace member.
9. **Maintenance (`/maintenance`).** List of windows with next occurrence, create and edit form with a timezone picker.
10. **Settings (`/settings`).** Members, prober secret with rotate button, region health (last result time per region), retention days.
## The prober (`apps/prober/`)
A single Node process. Loop forever:
1. `GET {APP_URL}/api/prober/due?region={REGION}` with header `Authorization: Bearer {PROBER_SECRET}`. Returns up to 50 monitors whose row in `monitor_region_state` for this region has `next_run_at <= now()`. The server claims them in one statement: `UPDATE monitor_region_state SET next_run_at = now() + interval_seconds WHERE ... FOR UPDATE SKIP LOCKED RETURNING ...`. Paused monitors and heartbeat monitors are never returned.
2. Run each check concurrently (limit 10 at a time) with `timeout_ms` as a hard cap. Measure with `performance.now()`.
3. `POST {APP_URL}/api/prober/results` with a JSON array of `{ monitor_id, region, checked_at, ok, status_code, response_ms, error, detail }`. Retry the POST 3 times on network failure, then drop the batch and log it.
4. Sleep 10 seconds. With `--once` (used by GitHub Actions) run steps 1 to 3 one time and exit.
Check implementations, each in its own file under `apps/prober/checks/` and exported as `run(monitor): Promise<CheckResult>`:
- **http**: `undici` fetch with an `AbortController`. Send `User-Agent: byo-uptime/1.0`. Follow up to 5 redirects if enabled. Parse `expected_status` into ranges and compare. If `keyword` is set, read at most 1 MB of the body and search case-insensitively; `present` fails when missing, `absent` fails when found. If `max_response_ms` is set and exceeded, fail with `"Slow response: 2140 ms > 1000 ms"`. Error strings: `"HTTP 503"`, `"Keyword 'Welcome' not found"`, `"Keyword 'Error' found"`, `"Timeout after 10000 ms"`, `"TLS error: certificate has expired"`, `"DNS lookup failed"`, `"Connection refused"`. Put `{ final_url, redirects, tls_expires_at }` in `detail` when available.
- **tcp**: `net.connect({ host, port })` with timeout. Ok on `connect`. Errors: `"Connection refused"`, `"Timeout after N ms"`, `"Host not found"`.
- **ping**: spawn `ping -c {count} -W 2 {host}` and parse packet loss and average RTT. Fail when loss is 100 percent with `"100% packet loss"`. If the `ping` binary is missing or ICMP is blocked (exit code with "Operation not permitted"), fall back to a TCP connect on port 443 then 80 and set `detail.fallback = "tcp"`. Install `iputils-ping` in the Dockerfile.
- **dns**: `new dns.promises.Resolver()` with `setServers([resolver])`. Resolve `record_type`. Fail on `ENOTFOUND`, `SERVFAIL`, or timeout. If `expected_values` is non-empty, every expected value must appear in the answers (compare case-insensitively, strip trailing dots). Error: `"Expected 203.0.113.5, got 198.51.100.7"`. Store answers in `detail.answers`.
- **ssl**: `tls.connect({ host, port, servername: host })`, read `getPeerCertificate(true)`. Fail if `authorized` is false with the `authorizationError`. Compute `days_left` from `valid_to`. Fail when `days_left < warn_days` with `"Certificate expires in 10 days"` or `"Certificate expired 3 days ago"`. `detail`: `{ valid_to, issuer, days_left }`.
## Evaluator (`lib/evaluate.ts`, pure, fully unit-tested)
Called by `POST /api/prober/results` for each result inside a transaction, and by the minute cron for heartbeats.
Input: the monitor, all its `monitor_region_state` rows after applying the new result, the region config, `now`, and whether a maintenance window is active for this monitor.
Rules:
1. Applying a result: `ok` sets that region's `consecutive_failures` to 0; a failure increments it. Set `last_result_at`, `last_ok`, `last_error`.
2. A region is **stale** when `now - last_result_at > staleAfterSeconds` for that region, or it has never reported. Stale regions are ignored in the rules below.
3. A monitor is **confirmed down** when at least two non-stale regions each have `consecutive_failures >= 2`. If exactly one region is non-stale, it's confirmed down when that region has `consecutive_failures >= 3`. If no region is non-stale, do nothing.
4. A monitor is **confirmed up** when every non-stale region has `last_ok = true`.
5. Transition `up`/`pending` → `down`: set `monitors.status = down` and open an incident with `cause` = the `last_error` of the region that failed first (earliest `last_result_at` among failing regions) and `first_failed_region` set. If maintenance is active, set `is_maintenance = true` and create no deliveries. Otherwise create one `alert_deliveries` row with `event = down` per enabled attached channel.
6. Transition `down` → `up`: set `resolved_at = now`, status `up`, and create `event = up` deliveries unless `is_maintenance`.
7. While `down`, if `renotify_minutes` is set and `now - started_at` crosses a multiple of it, create a `reminder` delivery with the next `sequence`.
8. A monitor that is `paused` ignores results entirely. Pausing while an incident is open resolves the incident with `notes` appended: "Resolved by pause".
9. Heartbeat monitors: down when `last_heartbeat_at` (or `created_at` if never pinged) is older than `interval_seconds + grace_seconds`. Up on the next ping. The cause is `"No heartbeat for 7m 30s"`. Regions don't apply.
Return a list of effects (`open_incident`, `resolve_incident`, `create_deliveries`, `set_status`) so the function stays pure and the route applies them.
## Alert delivery (`lib/deliver.ts`)
A worker triggered right after deliveries are created, and again by the minute cron for anything with `sent_at is null and attempts < 3`. Backoff 30s, 2m, 10m.
Every message includes: monitor name, target, event, cause, started time, and for `up` the duration formatted like `1h 12m`. Include a link to the incident.
- **email**: Resend. Subject `"[DOWN] API is down: HTTP 503"` or `"[UP] API recovered after 12m"`.
- **slack**: POST Block Kit with a header block, a section with fields, and a button to the incident.
- **discord**: POST an embed with red (`0xE5484D`) for down and green (`0x30A46C`) for up.
- **webhook**: POST JSON `{ event, monitor: { id, name, type, target }, incident: { id, started_at, resolved_at, duration_seconds, cause }, sent_at }` with headers `X-Uptime-Event`, `X-Uptime-Timestamp`, and `X-Uptime-Signature: sha256=<hmac of timestamp + "." + body using the channel secret>`. Document verification in the README. Treat any non-2xx as a failure.
- **sms**: Twilio REST API, `"DOWN: API (HTTP 503) since 14:02 UTC"`, max 160 characters. Skip and mark `last_error = "Twilio not configured"` when env vars are missing.
## Heartbeat endpoint
`GET` or `POST /api/hb/[token]`. Look up the monitor by `heartbeat_token`, set `last_heartbeat_at = now()`, insert a `check_results` row with `region = "heartbeat"` and `ok = true`, run the evaluator, return `200 OK` with a plain-text body. Unknown token returns 404. Accept `?status=fail` to record a failed run: insert a failed result and open an incident immediately with cause `"Job reported failure"`. Rate limit to 10 requests per minute per token.
## Maintenance windows
`isInMaintenance(monitorId, now)` in `lib/maintenance.ts`: for each active window attached to the monitor, compute the current occurrence in the window's timezone with `date-fns-tz`. One-off: `[starts_at_local, ends_at_local)` converted from the zone. Weekly: for today and yesterday in that zone, if the weekday is in `weekdays`, build the interval from `starts_at_local` and `ends_at_local` on that date (if end is before start it crosses midnight). Return true if `now` falls inside any interval. Write a test for a 02:00 to 03:00 window on a DST change day in `America/New_York` and one that crosses midnight in `Asia/Kolkata`.
During maintenance, `check_results.in_maintenance = true`. Incidents opened during maintenance are flagged and never alert. An incident that is already open when a window starts keeps alerting for recovery. Maintenance seconds are excluded from the uptime denominator.
## Uptime, rollups, retention
- `uptimePercent(monitorId, windowDays, now)`: window starts at `max(now - windowDays, monitor.created_at)`. Down seconds = sum of non-maintenance incident intervals clipped to the window (open incidents end at `now`). Maintenance seconds = sum of maintenance intervals clipped to the window. Result = `(window - maintenance - down) / (window - maintenance) * 100`, rounded to 3 decimals. A monitor with no checks yet returns `null` and renders as "no data".
- `GET /api/cron/rollup` (daily at 00:10 UTC) writes `daily_uptime` for yesterday for every monitor: check counts, `avg_response_ms` and `p95_response_ms` across regions, down and maintenance seconds from incidents clipped to that UTC day. Idempotent: upsert.
- `GET /api/cron/evaluate` (every minute): heartbeat timeouts, reminders, and retry pending deliveries. Also recompute region staleness for the settings screen.
- `GET /api/cron/retention` (daily): delete `check_results` older than `retention_days`. Never delete incidents or `daily_uptime`.
- All cron routes require `Authorization: Bearer {CRON_SECRET}`. Ship a `vercel.json` with the schedules and a `ROLE=scheduler` mode for the prober image that hits them on the same schedule, so the whole thing can run on Fly alone.
- Response time chart API: `GET /api/monitors/[id]/response-times?range=24h|7d|30d` returns buckets of 5 minutes, 1 hour, or 6 hours with `avg` and `p95` per region. Use a single SQL query with `date_bin`.
## Status page subscribers
- `POST /api/status-pages/[slug]/subscribe` with an email. Upsert the subscriber, send a confirmation email with `/s/[slug]/confirm?token=`. Confirming sets `confirmed_at`. Every subscriber email has an unsubscribe link with `unsubscribe_token`.
- When an incident opens or resolves on a monitor that appears on a public status page, queue one email per confirmed subscriber of that page. Batch sends of 100 with Resend's batch endpoint. Don't email for `is_maintenance` incidents.
- Rate limit subscribe to 5 per IP per hour.
## Non-goals
No voice calls, on-call rotations or escalation policies, page speed audits, dependency or third-party status feeds, UDP checks, mobile app, or billing. Design the schema so `escalation_policy_id` on monitors can be added later.
## Acceptance criteria
1. A user signs in, creates a workspace, adds an HTTP monitor for a test server, and within `interval_seconds` sees results from both `us-east` and `eu-west` on the monitor detail page.
2. The test server returns 500 for requests from one region only. After four checks, no incident exists and the monitor is still `up`.
3. The test server returns 500 for everyone. After the second consecutive failure from each of two regions, exactly one incident exists with `cause = "HTTP 500"` and `first_failed_region` set, and each attached channel has exactly one `down` delivery with `sent_at` set. A third failing check creates nothing new.
4. The test server recovers. When both regions report ok, the incident has `resolved_at`, `duration_seconds` matches the timestamps, and each channel has exactly one `up` delivery.
5. Keyword `present` for a word not on the page fails with `"Keyword 'x' not found"`; keyword `absent` for a word on the page fails with `"Keyword 'x' found"`; `max_response_ms = 1` fails with a `"Slow response"` cause carrying the measured milliseconds.
6. A TCP monitor on an open port is ok; on a closed port it fails with `"Connection refused"`; on a blackholed address it fails with a timeout after `timeout_ms`.
7. A DNS monitor with `expected_values = ["203.0.113.5"]` against a name resolving elsewhere fails, and `detail.answers` lists the real answers.
8. An SSL monitor against a certificate with 10 days left and `warn_days = 14` fails with `"Certificate expires in 10 days"`; an expired certificate fails with the `authorizationError`.
9. A heartbeat monitor with `interval_seconds = 120` and `grace_seconds = 60` that receives no ping opens an incident on the first evaluate run after 180 seconds; one `GET /api/hb/[token]` resolves it. `?status=fail` opens an incident immediately.
10. With an active maintenance window, failures from both regions open an incident with `is_maintenance = true`, zero deliveries, and 30-day uptime stays at 100.000. A weekly window from 02:00 to 03:00 `America/New_York` is active at 06:30 UTC in January and 06:30 UTC in July.
11. Seeded incidents produce exact uptime numbers: one 72-minute non-maintenance incident inside a 30-day window on a monitor older than 30 days gives 99.833.
12. When `us-east` hasn't reported for more than 300 seconds, settings shows it as stale, and three consecutive failures from `eu-west` alone confirm down.
13. `/s/[slug]` renders the banner, one row per monitor with a 90-day bar and percentage, and the last 30 days of incidents; subscribing sends a confirmation, confirming enables incident emails, and the unsubscribe link removes the subscriber.
14. A webhook receiver can verify `X-Uptime-Signature` with the channel secret, and a receiver returning 500 causes three attempts with backoff before the delivery is marked failed.
## Deliverables
- The app, migrations, and a seed script: one workspace, six monitors (one per type) pointing at a bundled test server in `apps/testserver/` whose behavior you can toggle with `POST /control { status, delay_ms, body }`, two alert channels, one status page, one maintenance window, and 30 days of synthetic `daily_uptime` and incidents.
- `apps/prober/` with a Dockerfile, `fly.toml`, and a README section showing `fly launch`, then `fly machine run` once in `iad` and once in `ams`, and how `FLY_REGION` maps to region names.
- `.github/workflows/prober.yml` that runs `node apps/prober --once` every 5 minutes with `REGION=gha`.
- `lib/evaluate.ts`, `lib/maintenance.ts`, and `lib/uptime.ts` as pure modules with table-driven tests.
- README covering environment variables (`DATABASE_URL`, `APP_URL`, `PROBER_SECRET`, `CRON_SECRET`, `RESEND_API_KEY`, `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM`), webhook signature verification, custom domain setup for status pages, and Vercel Cron versus the scheduler process.
Build the evaluator and its tests first, then the prober checks against the bundled test server, then the results endpoint, then the screens, then alerts, then status pages. Run the app and both probers locally with `REGION=us-east` and `REGION=eu-west` before calling anything done.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit. The evaluator state machine and the prober need tests, and Claude Code can write the fly.toml and deploy the second region while it's at it.
- ReplitBuild and host in the browser
Replit hosts the app and database and can run the prober as a second process, but it's one region. Add the GitHub Actions workflow for the second region.
- ChatGPT / CodexPlan, then build with Codex
Use ChatGPT to trim the monitor types to the ones you'll actually use and pick your two regions, then hand the spec to Codex.
- LovablePrompt-to-app with Supabase built in
Good for the dashboard and status page. The prober has to live somewhere else (Fly.io) since Lovable can't run a long-lived process.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Create an HTTP monitor for a URL you control and confirm results arrive from both regions within one interval.
- Return a 500 from that URL for one region only (block by IP) and confirm no incident opens.
- Return a 500 for everyone and confirm exactly one incident opens after the second failed check from each region, with cause 'HTTP 500'.
- Fix the URL and confirm the incident resolves with the right duration and every channel gets one recovery message.
- Set a keyword that isn't on the page and confirm the cause says the keyword is missing.
- Set max_response_ms to 1 and confirm the check fails with the measured time in the cause.
- Create a TCP monitor on a closed port and confirm it fails with 'connection refused'.
- Create a DNS monitor with a wrong expected value and confirm the detail lists the actual answers.
- Point an SSL monitor at expired.badssl.com and confirm it fails.
- Create a heartbeat monitor with a 2-minute interval, don't ping it, and confirm an incident opens after interval plus grace. Ping it and confirm it resolves.
- Schedule a maintenance window for now, break the URL, and confirm no alerts go out and 30-day uptime doesn't drop.
- Stop one prober for 6 minutes and confirm the dashboard shows the region as stale.
- Open the status page from a browser set to another timezone and confirm incident times shift and the zone label is right.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
Write automated tests for the uptime monitor in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the evaluator, maintenance, uptime math, prober checks, and API routes, and Playwright for the dashboard and public status page. Run the bundled test server from `apps/testserver/` inside the test suite so HTTP, TCP, and keyword checks hit something real. Mock Resend, Twilio, Slack, and Discord at the module boundary and assert on call counts and payloads. Run two prober instances in-process with `REGION=us-east` and `REGION=eu-west` against the app for the integration flows.
## Acceptance criteria to cover
1. A signed-in user creates an HTTP monitor and, after one interval, the detail page shows results from both `us-east` and `eu-west`.
2. The test server fails for one region only (key on a per-region header the prober sends in test mode). After four checks there is no incident and status is `up`.
3. The test server fails for everyone. After the second consecutive failure from each region there is exactly one incident with `cause = "HTTP 500"`, and each attached channel has exactly one `down` delivery. A third failure creates no new incident or delivery.
4. Recovery from both regions sets `resolved_at`, computes `duration_seconds` correctly, and creates exactly one `up` delivery per channel.
5. Keyword `present` missing, keyword `absent` found, and `max_response_ms = 1` each fail with the documented cause strings.
6. TCP: open port ok, closed port `"Connection refused"`, blackholed address times out at `timeout_ms` (use `10.255.255.1` with a 1000 ms timeout).
7. DNS with a wrong `expected_values` entry fails and `detail.answers` contains the real answers (use a local resolver stub or a fixture for `dns.promises.Resolver`).
8. SSL: a self-signed cert with 10 days left and `warn_days = 14` fails with `"Certificate expires in 10 days"`; an expired cert fails with the `authorizationError`. Generate the certs in a test fixture with `openssl` or `node-forge`.
9. Heartbeat: no ping for `interval + grace` opens an incident on the next evaluate run; a ping resolves it; `?status=fail` opens one immediately; an unknown token returns 404.
10. Maintenance: failures during an active window open an `is_maintenance` incident with zero deliveries and 30-day uptime stays 100.000. `isInMaintenance` is true at 06:30 UTC in both January and July for a weekly 02:00 to 03:00 `America/New_York` window, and correct for a 23:30 to 00:30 `Asia/Kolkata` window on both sides of midnight.
11. Uptime math: one 72-minute incident in 30 days on an old monitor gives 99.833; an open incident counts up to `now`; a monitor created 10 days ago uses a 10-day denominator; maintenance seconds leave the denominator.
12. Stale region: with `us-east` silent for 301 seconds, settings shows it stale and three consecutive `eu-west` failures confirm down. Two failures alone do not.
13. Public status page renders banner, per-monitor bars, and percentages; subscribe sends a confirmation; confirming enables incident emails; unsubscribe removes the row and stops emails.
14. Webhook deliveries carry a valid `X-Uptime-Signature`; a receiver that returns 500 gets three attempts with backoff, then the delivery is marked failed with `last_error`.
15. Two probers polling the same region at the same time never receive the same monitor in the same interval (assert on the SKIP LOCKED claim with `Promise.all`).
16. Retention deletes `check_results` older than `retention_days` and leaves incidents and `daily_uptime` alone.
## Layout
- `tests/unit/evaluate.test.ts`: table-driven cases for 2, 3, 4, 9, 12. Each case is `{ name, monitor, regionStates, now, maintenance, expectEffects }`.
- `tests/unit/maintenance.test.ts` and `tests/unit/uptime.test.ts`: criteria 10 and 11 with an injected `now`.
- `tests/unit/checks/*.test.ts`: criteria 5, 6, 7, 8 against the test server and fixtures.
- `tests/integration/`: API routes with a real Postgres test database (Testcontainers or `DATABASE_URL_TEST`) and in-process probers for 1, 2, 3, 4, 9, 12, 14, 15, 16.
- `tests/e2e/`: Playwright for 1 and 13.
## Rules
- Name every test after its criterion: `test("AC3: two regions with two consecutive failures open one incident")`.
- Freeze time with `vi.useFakeTimers()` or an injected clock. Never depend on the real date, and never sleep for a real interval; drive the prober loop by calling its `tick()` function directly.
- Never call real Resend, Twilio, Slack, Discord, or public DNS. Every network dependency other than the bundled test server is a mock or a fixture.
- Add `pnpm test` and a GitHub Actions workflow with a Postgres service container that runs it on push.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass or fail and what changed.UptimeRobot is a trademark of UptimeRobot s.r.o.. This page is independent research and is not affiliated with or endorsed by UptimeRobot s.r.o.. Last updated 2026-09-10.