How I Built a Live Monitor for 77 Free APIs (Build-in-Public)
· meta
Astro static + Cloudflare Workers + D1 + GitHub Actions. Total cost: $0/month. Here's the architecture and the bugs I hit.
FreeAPI.watch runs on Cloudflare's free tier and costs $0/month to operate. The architecture is a static Astro site deployed to Cloudflare Pages, with a Cloudflare Worker doing the hourly pings and writing results to a D1 (SQLite) database. GitHub Actions handles the daily site rebuild.
I built this because I kept running into the same problem: blog posts listing 'free APIs' from 2022 or 2023 with no indication that half the APIs were dead. The solution was to actually check them.
Architecture: The Three-Tier Stack
The stack has three components that interact through shared data:
Tier 1 — The pinger Worker: A Cloudflare Worker scheduled via `crons` in wrangler.toml fires every hour. It queries D1 for all active API health URLs, fires HTTP requests (GET or HEAD per API config), and writes results back. On status change, it inserts an event record.
Tier 2 — D1 database: Stores the API seed data, latest check results, 30-day rollup data (pre-computed hourly uptime percentages), and the events table. D1 is Cloudflare's serverless SQLite — free tier includes 5GB storage and 25M row reads/day, more than enough.
Tier 3 — Astro static site: Built once per day by GitHub Actions, the Astro site fetches a JSON snapshot from the Worker (`/snapshot.json` endpoint), renders all pages statically, and deploys to Cloudflare Pages. The site itself has no server-side logic — it's pure HTML.
The Bugs That Hurt
The Workers 50-subrequest limit was the first major wall. Cloudflare Workers on the free tier are limited to 50 outbound subrequests per invocation. With 77 APIs to ping, a naive implementation hits the limit every time. The fix: batch the pings across multiple invocations. The cron fires once per hour; rather than pinging all 77 APIs at once, I split them across multiple invocation slots using modulo arithmetic on the current minute.
The `Illegal invocation` error was the most confusing. When you call `fetch()` inside a Cloudflare Worker and pass a `Request` object constructed outside the current module scope, you can get an `Illegal invocation` error that looks completely unrelated to what you're doing. The fix was constructing the Request object fresh in each fetch call rather than caching it.
Direct-upload Pages doesn't support deploy hooks. I assumed I could trigger a Cloudflare Pages build via their API after the Worker updates the database. This only works for git-connected deployments — direct upload projects (which is what you get when you deploy from GitHub Actions using wrangler) have no programmatic trigger. The solution: GitHub Actions runs on a daily cron (`0 6 * * *`), fetches the snapshot, and builds+deploys with `wrangler pages deploy dist/`.
The JSON API
The `/api` endpoint (see /api for documentation) exposes the full snapshot as JSON. It's the same data the static build uses, available for anyone who wants to build on it: API names, slugs, categories, free tier info, latest check results, and uptime rollups.
Using this yourself: `curl https://freeapi.watch/snapshot.json` returns the full dataset. It's updated hourly by the Worker. The schema is documented at /api.
Cost: $0
Cloudflare Workers free tier: 100,000 requests/day, which covers 77 hourly pings (= 77×24 = 1,848/day) with room to spare. D1 free tier: 5GB storage, 25M row reads/day, 50K row writes/day — the database is a few MB and the writes are 77/hour = 1,848/day. Cloudflare Pages: unlimited bandwidth on the free tier. GitHub Actions: 2,000 free minutes/month, the daily build takes about 3 minutes.
The only potential cost is if traffic scales dramatically and Cloudflare starts charging for bandwidth — but static HTML compresses well and the free tier is generous.