← All episodes
July 3, 2026 · EPISODE 5

How I built a no-KYC, crypto-paid uptime monitor on Base

A technical build log on exact-amount USDC payments, SQLite, and a one-minute pinger.

How I built a no-KYC, crypto-paid uptime monitor on Base

Disclosure: sunwatch is a project I work on. This post walks through the architecture and trade-offs, not just the pitch.

Most uptime monitors want your email, your credit card, and a 12-month subscription before they ping your first URL. I wanted the opposite: open the dashboard, add a URL, and either use three monitors free or pay per monitor with stablecoins — no account, no KYC, no lock-in.

The result is sunwatch, a tiny uptime monitor that takes USDC on Base and sends webhook alerts when a site goes down or back up. Here's how it works under the hood.

The architecture in one breath

  • Node.js 20 + Express serves the API and static dashboard.
  • SQLite (better-sqlite3, WAL mode) stores monitors, checks, and payment records.
  • node-cron runs two jobs: the pinger (every minute) and the payment poller (every minute).
  • viem reads USDC Transfer events on Base to verify payments.
  • Caddy + systemd keep the hosted instance alive on a small VPS.

The whole thing is intentionally boring on the infrastructure side. The interesting part is the payment flow.

Payment verification without accounts or invoices

The trick is the exact payment amount. When you create a monitor past the free tier, sunwatch asks for exactly 1.00 USDC. Because every monitor starts in the pending state, the payment poller can scan recent USDC transfers to the receiving wallet and activate the oldest pending monitor when it sees a 1.00 USDC transfer.

// src/payments.js — simplified
const TRANSFER_EVENT = parseAbiItem(
  'event Transfer(address indexed from, address indexed to, uint256 value)'
);

const logs = await client.getLogs({
  address: USDC_BASE,
  event: TRANSFER_EVENT,
  args: { to: RECEIVING_WALLET },
  fromBlock: currentBlock - LOOKBACK_BLOCKS,
  toBlock: currentBlock,
});

For each transfer, it checks:

  1. Is the human-readable amount exactly 1.00?
  2. Has this transaction hash already been recorded as a payment?
  3. Is there a pending monitor waiting to be activated?

If yes, it inserts a payment row and flips the monitor from pending to active for 30 days.

const humanAmount = formatUnits(rawAmount, 6);
if (humanAmount !== '1.00') return null;
if (db.prepare('SELECT 1 FROM payments WHERE tx_hash = ?').get(log.transactionHash)) return null;

const monitor = pending[0]; // oldest pending
const paidUntil = nowSec + 30 * 24 * 60 * 60;

db.prepare(
  'INSERT INTO payments (monitor_id, tx_hash, amount, currency, block_number, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(monitor.id, log.transactionHash, humanAmount, 'USDC', Number(log.blockNumber), nowSec);

db.prepare(
  "UPDATE monitors SET state = 'active', paid_until = ? WHERE id = ?"
).run(paidUntil, monitor.id);

This is wallet-to-wallet activation. No Stripe, no checkout session, no user table. The downside is that the current implementation assumes one pending monitor at a time; if two people pay simultaneously, the oldest pending monitor gets activated first. That's acceptable for a side-project experiment, but it's a known ceiling.

The pinger and webhook alerts

The pinger is equally minimal. Every minute it pulls all active or down monitors, does a GET with a 15-second timeout, and records the result.

// src/pinger.js — simplified
const res = await fetch(monitor.url, {
  method: 'GET',
  redirect: 'follow',
  signal: controller.signal,
  headers: { 'User-Agent': 'sunwatch-pinger/0.1' },
});
up = res.status === Number(monitor.expected_status);

A monitor is marked down if the status code doesn't match or the response time exceeds a user-defined threshold. To avoid spam, the webhook only fires when the state changes compared to the previous check:

const previous = db
  .prepare('SELECT up FROM checks WHERE monitor_id = ? ORDER BY checked_at DESC LIMIT 2')
  .all(monitor.id);

const lastState = previous.length > 1 ? previous[1].up : 1;
if (Number(lastState) !== Number(up ? 1 : 0)) {
  await dispatchWebhook(monitor, up, { statusCode, responseMs, error });
}

The webhook payload is just JSON, so you can route it to Slack, Discord, PagerDuty, or a custom handler:

{
  "monitor_id": 1,
  "url": "https://example.com",
  "state": "down",
  "status_code": 500,
  "response_ms": 245,
  "error": null,
  "timestamp": "2026-07-03T12:34:56.789Z"
}

Why SQLite?

I picked SQLite because the workload is tiny (a few hundred checks per hour, a handful of monitors) and SQLite in WAL mode handles concurrent reads and writes without a separate database process. For a single-tenant VPS, that's one less moving part.

CREATE TABLE IF NOT EXISTS monitors (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  url TEXT NOT NULL,
  interval_sec INTEGER NOT NULL DEFAULT 300,
  expected_status INTEGER NOT NULL DEFAULT 200,
  response_ms_threshold INTEGER,
  webhook_url TEXT,
  state TEXT NOT NULL DEFAULT 'pending',
  payment_amount TEXT,
  payment_currency TEXT,
  payment_tx_hash TEXT,
  paid_until INTEGER,
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);

If the project ever outgrows one box, the schema is small enough to port to Postgres without much pain.

Self-hosting decisions

The hosted version runs on a cheap VPS with systemd unit files for the server and pinger. HTTPS is handled by Caddy + Let's Encrypt. This keeps monthly costs low and avoids platform lock-in.

Self-hosting is intentionally simple:

git clone https://github.com/ryan-knowone/sunwatch.git
cd sunwatch
npm install
npm run migrate
npm start       # API + dashboard
npm run pinger  # in another terminal or service

If you self-host, you keep the revenue (your own wallet), the data, and the uptime responsibility.

What I'd do differently next time

  1. Payment matching by memo / reference. Using only the exact amount is fragile. A better design would generate a unique receiving address per monitor or use a smart contract that emits a reference ID.
  2. Graceful retries for RPC failures. Right now a failed Base RPC call logs an error and tries again in a minute. For production, I'd add exponential backoff and a fallback RPC.
  3. Paid tier with variable durations. Currently every payment buys exactly 30 days. A real SaaS would support annual discounts or top-ups.

Try it

If you want to see the flow in action, the hosted version is live at sunwatch.sunfamily.xyz. You can create up to 3 monitors for free with no signup — just add a URL and webhook. If you want more, the fourth monitor costs $1/mo paid in USDC on Base.

The repo is open source at github.com/ryan-knowone/sunwatch. Issues and PRs welcome.

— Ryan