Putting a Geoapify app online safely: the SiteScout demo proxy
The public demo runs live Geoapify analyses for anyone, with no sign-up and no key in the page. One Cloudflare Worker on the free plan does this. It is two…
On this page
This page covers the architecture, why the key stays secret, the limits, what a visitor sees when a limit is reached, the cache and what it saves, the code, and how to deploy your own copy.
Production check, 2026-11-04: after the deploy that day, the status endpoint
reports creditCap: 2400 and tileCap: 2500, matching the source below. Section 5 has the dated response
and the daily totals.
1. Architecture
flowchart LR
B["Browser<br/>Leaflet UI + SiteScout engine"] -->|"/geo/* (API calls)"| W["Cloudflare Worker<br/>key = secret GEOAPIFY_KEY"]
B -->|"/tiles/z/x/y.png"| W
B -->|"/api/plan, /api/status, /api/share"| W
W -->|"x-api-key header"| G["api.geoapify.com"]
W <--> C[("Edge cache<br/>Cache API")]
W <--> D[("D1 (SQLite)<br/>daily counters")]
W --> AI["Workers AI<br/>planner only"]The SiteScout engine runs in the browser. It is the same code as the CLI (src/engine/), pointed at the
Worker with new GeoapifyApi({ baseUrl: \${location.origin}/geo` }) in [web/src/app.ts`](https://github.com/ducvuathuduc/sitescout-agent/blob/main/web/src/app.ts).
Every Geoapify request goes to the Worker on the same origin, so no CORS setup is needed.
| Route | What the Worker does | Geoapify credits |
|---|---|---|
/geo/<geoapify path> | Checks the allow-list and the HCMC scope, answers from cache, or checks the caps, adds the key and forwards the call | per API (see §3) |
/tiles/z/x/y.png | Geoapify positron raster tiles for the HCMC area, cached 30 days | 0.25 per tile |
/api/status | Today's usage against the caps, plus cache statistics. The UI shows it as the quota bar | 0 |
/api/plan | Optional LLM planner on Workers AI (Llama 3.3 70B). The browser validates its JSON before use | 0 |
/api/share | Stores a finished run in D1 and returns a short link id | 0 |
| anything else | Static files of the web app, embedded in the Worker script by web/build.mjs | 0 |
2. Why the key never reaches the browser
- The key is a Worker secret. It is set once during the setup in section 7. It is not in the
repository, in
wrangler.toml, or in the static files. - Only the Worker adds it.
proxyGeo()andproxyTile()put it in anx-api-keyrequest header on the call toapi.geoapify.com. The browser only ever talks to/geo/*and/tiles/*. - An allow-list limits what the key can be used for.
ALLOWnames 9 Geoapify endpoints and their HTTP method. Anything else gets a 404. - A scope check limits where.
scopeError()rejects coordinates outside a Greater Ho Chi Minh City bounding box, Places requests without a filter or with more than 3 categories, isolines longer than 30 minutes or with more than 3 ranges, route matrices larger than 5 × 10, and so on. A stolen proxy URL can only run small HCMC queries. - The trace never contains it. The engine sends its key (CLI) in a header too, so the endpoints in
trace.jsonland in the demo's Evidence tab are safe to show.tests/engine.test.tschecks that noapiKey=reaches a trace.
Check it yourself: open the demo, open DevTools → Network and run an analysis. Requests go to
sitescout.huynhthanh.workers.dev/geo/..., and no URL or header contains a key.
If an app must call Geoapify from the browser directly, Geoapify lets you restrict a key by HTTP referrer, origin or IP address in MyProjects (Geoapify MCP docs, Quick start). A proxy avoids putting any key in the page.
3. Limits and daily counters
The limits
| Limit | Value | Where |
|---|---|---|
| Daily budget, live API calls and map tiles together | 2,400 credits (DAILY_CREDIT_CAP) | checkCaps() in budget.ts |
| Map tiles inside that budget | 2,500 tiles a day (TILE_CAP) = 625 credits | proxyTile() |
| Live calls per visitor | 180 a day (PER_IP_CALL_CAP); cache hits do not count | checkCaps() |
| AI planner | 300 plans a day for the whole demo | plan() |
| Share links | 20 per visitor a day; run text limited to 1,500,000 JavaScript string code units | share() |
| One live run in the browser | stops at 250 credits | creditCap: 250 in web/src/app.ts |
Why 2,400. The Geoapify free plan gives 3,000 credits a day (pricing). The plan belongs to the account, so other work on the same account (for us: the CLI and the address benchmark) uses the same credits. 2,400 is 80 %. The other 600 credits are headroom for that work and for the few calls that can pass the cap check at the same moment (see §8).
Why tiles are in the same budget. Geoapify counts a map tile request as 0.25 credit (4 tiles = 1 credit,
pricing details, checked 2026-11-04). Until 2026-11-04 production had a 1,500-credit API
cap and a separate 2,500-tile cap: 1,500 + 2,500 × 0.25 = 2,125 credits on paper, but two numbers that nobody could
read as one budget. Now one number covers both: spent = credits + tiles × 0.25.
How credits are counted
The Worker estimates the cost of each successful call with Geoapify's published rules
(pricing details, MCP pricing,
checked 2026-11-04). The browser engine shares these rules except for routing (see table; estimateCredits() in
src/engine/geoapify.ts), and a test checks that both agree.
| Request | Credits |
|---|---|
| Places | max(1, ⌈features ÷ 20⌉) |
| Route Matrix | max(sources, targets) × min(sources, targets, 10) |
| Isoline (time) | 1 for each 5 minutes; several ranges are added up (5/10/15 min = 1 + 2 + 3 = 6) |
| Boundaries (part-of) | max(1, ⌈results ÷ 20⌉), plus 1 per result when polygons are returned (SiteScout asks for them) |
| Map tile | 0.25 |
| Geocoding, autocomplete, reverse; routing (Worker estimate) | 1; engine routing uses max(1, waypoints - 1), so the demo's 2-waypoint route costs 1 |
| Place Details | 1, plus 1 for each 5 minutes of an isochrone feature such as drive_5 |
Three rules were corrected on 2026-10-31: isolines, boundaries with polygons, and Place Details features. Before, the demo counted each of these calls as 1 credit (a 10-minute walk is really 2). Adding up the ranges of a multi-range isoline request is our cautious reading of the rule. The Geoapify dashboard is the final word on what a key really spent.
The counters (D1)
CREATE TABLE IF NOT EXISTS totals (day TEXT PRIMARY KEY, credits INTEGER NOT NULL DEFAULT 0, tiles INTEGER NOT NULL DEFAULT 0, plans INTEGER NOT NULL DEFAULT 0, calls INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS ip_usage (day TEXT NOT NULL, ip TEXT NOT NULL, calls INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, ip));
CREATE TABLE IF NOT EXISTS cache_stats (day TEXT PRIMARY KEY, hits INTEGER NOT NULL DEFAULT 0, tile_hits INTEGER NOT NULL DEFAULT 0, saved REAL NOT NULL DEFAULT 0);- One row per day.
dayis the UTC date (new Date().toISOString().slice(0, 10)), so the limits reset at 00:00 UTC, which is 07:00 in Vietnam. - No raw IP is stored.
visitor()storessha256(ip | day | "sitescout"), cut to 24 hex characters. The salt includes the day, so the same visitor gets a new id every day and ids cannot be linked across days. - Per call: a cache miss reads both counters (
usage()), compares them with the caps (checkCaps()), forwards the call, then adds the call and its credits in one D1 batch after the response is sent (ctx.waitUntil(bump(…))). A cache hit touches neither counter. It only adds tocache_stats, which lives in its own table so that a statistics problem can never break the caps.
4. What the visitor sees when a limit is reached
The Worker answers HTTP 429 with a body the UI understands and a marker header:
HTTP/1.1 429 Too Many Requests
content-type: application/json
x-sitescout-fallback: quota
{"error":"daily live quota reached","fallback":true,"used":2400,"cap":2400}The per-visitor limit gives "error":"per-visitor daily limit reached" with used/cap of 180. The engine does
not retry a 429 that carries x-sitescout-fallback (ordinary Geoapify 429s get up to 2 retries, 3 attempts total).
In the app (web/src/app.ts):
- Before a run, the quota bar under the examples shows "N of C live credits left today · a live run uses
~85–140". Both N and C are derived from
/api/status; C is 2400. - When a cap stops a live question, an amber banner explains which limit was hit, and the closest of the three
recorded runs opens instead, so the visitor still gets a full analysis:
- daily budget: "Today's shared live budget is used up, so this question could not run live. Showing the closest recorded run instead — live runs reset at 07:00 (UTC+7)."
- per visitor: "You have used today's live-call limit for one visitor, so this question could not run live. Showing the closest recorded run instead — the limit resets at 07:00 (UTC+7)."
- Reach explorer (5/10/15-minute bands): a toast says "Live quota used up — bands unavailable today".
- Map tiles: tiles already in the edge cache keep loading. New tiles get a 429, and a toast says "Map tiles are paused: today's shared demo budget is used up. Recorded runs still work."
- Cached answers keep working after the cap, because the cache is checked before the caps.

5. Caching
| What | Cache key | Kept for | Why |
|---|---|---|---|
API answers (/geo/*, status 200 only) | the upstream URL without the key; for the one POST (Route Matrix) the URL + # + SHA-256 of the body truncated to 24 hex characters | 24 h (cache-control: public, max-age=86400) | POIs and addresses change slowly; the same question within a day costs nothing |
| Map tiles | the upstream tile URL | 30 days (max-age=2592000) | Basemap tiles barely change |
| Static app files | — | 5 min; fonts, icons and examples 1 day | Normal web caching |
The Worker uses the Cache API (caches.default).
Its content stays in the Cloudflare data center that served the request and is not copied to other data centers.
A visitor in another region starts with an empty cache.
A cache hit carries x-sitescout-cache: HIT, x-sitescout-credits: 0 and x-sitescout-saved: <credits the original call cost>. A miss carries x-sitescout-cache: MISS and its estimated credits.
What it saves: measured
This is a single controlled test, not production history. We ran the same live question twice through the public proxy on 2026-10-31 with
scripts/measure-cache.mjs. Raw per-call results:
docs/demo-proxy/cache-test-2026-10-31.json.
| "Where should I open a pharmacy in Bình Thạnh?" | Run 1 | Run 2, right after |
|---|---|---|
| Geoapify calls in the analysis | 56 | 56 |
| Answered by Geoapify (MISS) | 53 | 0 |
| Answered from the edge cache (HIT) | 3 | 56 |
| Credits spent (SiteScout estimate) | 84 | 0 |
| Time | 54 s | 4 s |
- In this test the repeated question used no additional credits in the same data center and ran about 15 times faster (54.210 s / 3.735 s). The API cache lifetime is 24 hours.
- Different questions share work too. The 3 hits in run 1 are the geocodes of the drive-time origins (Chợ Bến Thành, Bến xe Miền Đông, Crescent Mall), which an earlier coffee-shop run that day had already fetched. That saved 3 credits on a question nobody had asked before.
- The three example questions on the start screen are pre-recorded and never call Geoapify.
During this test the deployed Worker's counter went from 77 to 150 credits (+73). That Worker still used the old estimate. With the corrected rules (§3) the same 53 calls are 84 credits: +7 for the 7 walking isolines, +3 for the 3 boundary polygons, +1 for the drive-time feature of Place Details.
For tiles, Geoapify's own estimate is about 14 tiles per map view and 50 per session (pricing details). That is about 12.5 credits for a session that misses the cache. A tile already in the cache costs nothing for 30 days.
Production status and totals. GET the status endpoint, checked at 2026-11-04T11:40:12Z (UTC), right after the deploy that day, returned the JSON below with the per-visitor usage field omitted. Only aggregate usage and configuration are shown:
{"day":"2026-11-04","spent":0,"creditCap":2400,"credits":0,"tiles":0,"tileCap":2500,"yourCap":180,"cache":{"hits":0,"tileHits":0,"savedCredits":0},"liveKey":true}npm run demo:migrate created the cache_stats table on 2026-11-04 (all statements are CREATE TABLE IF NOT EXISTS;
no data was changed). npm run demo:totals was then run at 11:40:13 UTC, using Wrangler 4.157.0.
It selects only daily aggregates, for the inclusive date range 2026-10-22 through 2026-11-04.
The exact query in package.json is:
SELECT d.day, t.credits, t.tiles, s.hits, s.tile_hits, ROUND(s.saved, 2) AS saved FROM (SELECT day FROM totals UNION SELECT day FROM cache_stats) d LEFT JOIN totals t ON t.day = d.day LEFT JOIN cache_stats s ON s.day = d.day WHERE d.day BETWEEN '2026-10-22' AND '2026-11-04' ORDER BY d.dayThe command returned 5 rows. Days without a row had no recorded live usage. Cache statistics start on
2026-11-04, when the cache_stats table was created, so hits, tile_hits and saved are empty for every
earlier day ("not recorded", not zero). The current UTC day is partial and had no row at the time of the query.
Credits and saved credits are SiteScout estimates, not Geoapify billing.
| day | credits | tiles | hits | tile_hits | saved |
|---|---|---|---|---|---|
| 2026-10-22 | 370 | 298 | not recorded | not recorded | not recorded |
| 2026-10-23 | 68 | 83 | not recorded | not recorded | not recorded |
| 2026-10-24 | 0 | 44 | not recorded | not recorded | not recorded |
| 2026-10-26 | 68 | 180 | not recorded | not recorded | not recorded |
| 2026-10-31 | 150 | 1 | not recorded | not recorded | not recorded |
| Sum, 2026-10-22 through 2026-11-03 | 656 | 606 | not recorded | not recorded | not recorded |
The 606 tiles are 151.5 credits, so the shared budget would have counted 807.5 credits over those 13 days. The busiest day, 2026-10-22, is 370 + 298 × 0.25 = 444.5 credits, about 19 % of the 2,400 budget. The 2026-10-31 figure includes the cache test above, counted with the old estimate.
6. The code
Allow-list and scope (from web/worker/index.ts, shortened):
const ALLOW: Record<string, "GET" | "POST"> = {
"/v1/geocode/search": "GET",
"/v1/geocode/autocomplete": "GET",
"/v1/geocode/reverse": "GET",
"/v2/places": "GET",
"/v2/place-details": "GET",
"/v1/isoline": "GET",
"/v1/routing": "GET",
"/v1/boundaries/part-of": "GET",
"/v1/routematrix": "POST",
};
// Greater Ho Chi Minh City
const BBOX = { minLat: 10.35, maxLat: 11.2, minLon: 106.35, maxLon: 107.05 };
/** Reject requests that would use the key outside the demo's scope. */
function scopeError(path: string, q: URLSearchParams, body: unknown): string | null {
const num = (k: string): number => Number(q.get(k));
if (q.has("lat") && q.has("lon") && !inHcmc(num("lat"), num("lon"))) return "coordinates outside Ho Chi Minh City";
// … per-endpoint rules for places, isoline, routing, routematrix, geocoding text length
return null;
}One budget for API calls and tiles (from web/worker/budget.ts):
export const TILE_CREDITS = 0.25;
/** Credits spent today, API calls and tiles together. */
export function spent(u: { credits: number; tiles: number }): number {
return u.credits + u.tiles * TILE_CREDITS;
}
/** May one more live API call start? Tiles only check the daily budget (pass ipCalls = 0). */
export function checkCaps(
u: { credits: number; tiles: number; ipCalls: number },
caps: { daily: number; perIp: number },
): CapDecision {
const s = spent(u);
if (s >= caps.daily) return { ok: false, reason: "daily", used: s, cap: caps.daily };
if (u.ipCalls >= caps.perIp) return { ok: false, reason: "visitor", used: u.ipCalls, cap: caps.perIp };
return { ok: true };
}Cache first, then caps, then Geoapify (proxyGeo(), shortened):
const upstream = `${UPSTREAM}${path}${url.search}`;
const cacheKey = new Request(method === "GET" ? upstream : `${upstream}#${await sha(JSON.stringify(body))}`, { method: "GET" });
const cache = caches.default;
const hit = await cache.match(cacheKey);
if (hit) {
// A hit costs no Geoapify credit; the stored header holds what the original call cost.
const saved = Number(hit.headers.get("x-sitescout-credits") ?? 0);
ctx.waitUntil(bumpStats(env, { hits: 1, saved }));
return new Response(hit.body, { status: hit.status, headers: { ...Object.fromEntries(hit.headers), "x-sitescout-cache": "HIT", "x-sitescout-credits": "0", "x-sitescout-saved": String(saved) } });
}
const ip = await visitor(req);
const u = await usage(env, ip);
const c0 = caps(env);
const gate = checkCaps(u, c0);
if (!gate.ok) return capped(gate.reason === "daily" ? "daily live quota reached" : "per-visitor daily limit reached", gate.used, gate.cap);
const res = await fetch(upstream, {
method,
headers: { "x-api-key": env.GEOAPIFY_KEY, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
// … count credits, then:
const out = new Response(text, { status: res.status, headers: { "content-type": "application/json", "cache-control": "public, max-age=86400", "x-sitescout-cache": "MISS", "x-sitescout-credits": String(c) } });
if (res.status === 200) ctx.waitUntil(cache.put(cacheKey, out.clone()));
return out;Map tiles (proxyTile(), shortened):
if (!tileInHcmc(z, x, y)) return new Response(null, { status: 204 });
const upstream = `${UPSTREAM}/v1/tile/positron/${z}/${x}/${y}${m[4] ?? ""}.png`;
const cache = caches.default;
const key = new Request(upstream);
const hit = await cache.match(key);
if (hit) {
ctx.waitUntil(bumpStats(env, { tileHits: 1, saved: TILE_CREDITS }));
return hit;
}
// Tiles draw from the same daily credit budget as API calls (0.25 credit each),
// plus their own ceiling so a map-browsing spike cannot starve live analyses.
const u = await usage(env, "tiles");
const c0 = caps(env);
if (!checkCaps({ ...u, ipCalls: 0 }, c0).ok || u.tiles >= c0.tiles) return new Response(null, { status: 429, headers: { "x-sitescout-fallback": "tiles" } });
const res = await fetch(upstream, { headers: { "x-api-key": env.GEOAPIFY_KEY } });
if (!res.ok) return new Response(null, { status: res.status });
const out = new Response(res.body, { headers: { "content-type": "image/png", "cache-control": "public, max-age=2592000" } });
ctx.waitUntil(cache.put(key, out.clone()));
ctx.waitUntil(bump(env, "tiles", { tiles: 1 }));
return out;In the browser, the tile layer is a plain Leaflet layer on the Worker route:
L.tileLayer("/tiles/{z}/{x}/{y}{r}.png", { maxZoom: 18, minZoom: 10, tileSize: 256 }).
7. Deploy your own copy
You need a free Cloudflare account, a free Geoapify key and Node.js 22 (the pinned Wrangler's runtime requirement). Root devDependencies pin Wrangler 4.157.0 exactly. Run all commands from the repository root.
git clone https://github.com/ducvuathuduc/sitescout-agent.git && cd sitescout-agent
npm ci
npx wrangler login
npx wrangler d1 create sitescout-demo # put the returned database_id in web/wrangler.toml
npx wrangler secret put GEOAPIFY_KEY -c web/wrangler.toml
npm run demo:migrate # creates tables in your own remote database
npm run demo:dry-run # builds and checks the bundle without uploading
npm run demo:deploy # builds and publishes your own Worker
npm run demo:totals # reads daily aggregates; update the date range as neededBefore the secret, migration, or deploy steps, edit web/wrangler.toml to make it yours:
name: the Worker name, which is part of the URL.database_id: the id fromd1 create.[vars]:DAILY_CREDIT_CAP,PER_IP_CALL_CAP,TILE_CAP. KeepDAILY_CREDIT_CAPbelow your Geoapify plan's daily credits, minus what you use yourself with the same account.- Remove the
[ai]block if you do not want the LLM planner; the app then uses the rule-based planner.
To serve another city, change BBOX in web/worker/index.ts (and the map's start view in web/src/app.ts).
To allow another Geoapify API, add its path to ALLOW and a rule to scopeError().
npm run demo:dry-run, demo:migrate and demo:deploy ran with Wrangler 4.157.0 on 2026-11-04. No lifecycle hook calls these demo scripts.
web/deploy.sh is an alternative that uploads through the Cloudflare
API without wrangler (for CI); it keeps the GEOAPIFY_KEY secret from the previous deployment.
Free-plan limits that matter here (Cloudflare Workers limits, D1 pricing, checked 2026-11-04):
| Resource | Free limit | Demo use |
|---|---|---|
| Worker requests | 100,000 a day | 1 per API call, tile, static file |
| D1 rows written | 100,000 a day | 2 logical counter upserts per forwarded call (totals + ip_usage), 1 statistics upsert per cache hit; billed rows can include index work |
| D1 rows read | 5 million a day | 2 counter SELECTs per cache miss; not a measured billed-row count |
| Geoapify (free) | 3,000 credits a day, 5 requests a second | shared 2,400-credit budget (80 %) |
8. Known limits
- The cap check is not atomic. Counters are read before a call and written after it. When several calls run at the same moment near the cap, a few can pass. The 600-credit headroom is a cushion, not a bound on overshoot.
- The cache is per data center (see §5).
- Credits are estimates from the published rules. Check real usage in the Geoapify dashboard.
- The proxy does not enforce Geoapify's 5 requests a second. One live run sends its calls one after another
(
runScout()insrc/engine/scout.tsawaits each call), but several visitors at once could go over. Geoapify's limits are soft (pricing FAQ), and the engine retries a Geoapify 429 with backoff. - Tiles outside the HCMC box return 204 (empty), so the map is blank outside the demo area.
Two servers side by side
SiteScout's own server does the multi-step analysis; the official Geoapify MCP server (https://api.geoapify.com/v1/mcp, streamable HTTP) gives the model raw…
Provenance
All application code in src/ is original, written for this project (MIT). No copyleft and no unlicensed copy-paste. Third-party components: