DEV Community

Oleksandr
Oleksandr

Posted on

I built a currency & VAT monitoring system with Cloudflare Workers

Three streams shipped this week:

  1. 100 document parsers with one command
  2. 100 finance validators with one command
  3. 100 monitoring pages — and this one needs a Worker with cron, KV, and webhooks

Let me show you how the monitoring one works.

What it does

Watch monitors two things daily:

  • Currency rates — NBU official rates (44 currencies) + ECB via Frankfurter
  • EU VAT rates — 27 member states, standard and reduced rates

When a value changes beyond the threshold, it fires a webhook + email alert. No dashboards to check.

Live landing: https://boring-saas-infra.pages.dev/watch

Example page: https://boring-saas-infra.pages.dev/watch/vat-watch-germany

Why monitoring is harder than validation

The first two streams were stateless: request → response → forget.

Monitoring is stateful:

  • A cron must run daily even when nobody is using the API
  • We need to remember yesterday's value to detect changes
  • We need to store subscriptions (who watches what)
  • We need to deliver alerts (webhook + email)

That's exactly what Cloudflare Workers + KV + Cron Triggers give you.

The architecture

One Worker, one KV namespace, one cron job.

watch-engine.boring-saas-infra.workers.dev
  ├── GET  /currency/nbu              ← live NBU rates
  ├── GET  /currency/frankfurter      ← live ECB rates
  ├── GET  /vat/:country              ← VAT snapshot for one country
  ├── GET  /watch/list                ← list all watches
  ├── POST /watch/create              ← create a watch
  ├── DELETE /watch/:id               ← delete a watch
  └── GET  /cron/run                  ← manual cron trigger (for testing)

Cron: 0 6 * * *                       ← runs every day at 06:00 UTC
Enter fullscreen mode Exit fullscreen mode

The wrangler.toml

name = "watch-engine"
main = "src/index.js"
compatibility_date = "2026-10-08"

[[kv_namespaces]]
binding = "WATCH_KV"
id = "021f939116ae416bb35703daa1e2c74d"
preview_id = "021f939116ae416bb35703daa1e2c74d"

[triggers]
crons = ["0 6 * * *"]
Enter fullscreen mode Exit fullscreen mode

That's the entire infrastructure. No database, no message queue, no VPS.

The data sources

For currency — three of them:

// NBU — official Ukrainian rates
async function fetchNBU() {
  const r = await fetch("https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange?json");
  const data = await r.json();
  const rates = {};
  for (const row of data) rates[row.cc] = { rate: row.rate, name: row.txt };
  return { source: "NBU", base: "UAH", rates };
}

// Frankfurter — free ECB wrapper
async function fetchFrankfurter(base = "USD") {
  const r = await fetch(`https://api.frankfurter.app/latest?from=${base}`);
  const data = await r.json();
  return { source: "Frankfurter", base: data.base, rates: data.rates };
}
Enter fullscreen mode Exit fullscreen mode

Both are free, no API key, stable JSON.

For VAT — a snapshot map of 27 countries:

const VAT_SNAPSHOT = {
  updated: "2026-10-08",
  countries: {
    DE: { standard: 19, reduced: [7] },
    FR: { standard: 20, reduced: [2.1, 5.5, 10] },
    IT: { standard: 22, reduced: [4, 5, 10] },
    // ... 24 more
  }
};
Enter fullscreen mode Exit fullscreen mode

The cron compares the current snapshot with yesterday's stored value. If a rate changed, it fires an alert. VAT rates change rarely (a few times per year per country), so this is cheap and effective.

Change detection

The whole point of a monitoring system is to not alert on every run. Only when something actually changes.

async function runCron(env) {
  const alerts = [];

  // Currency: compare with previous snapshot
  const nbu = await fetchNBU();
  const prev = await getSnapshot(env, "currency:NBU");

  if (prev) {
    for (const [cc, cur] of Object.entries(nbu.rates)) {
      const old = prev.rates[cc];
      if (old && Math.abs(old.rate - cur.rate) / old.rate > 0.005) {
        alerts.push({
          type: "CURRENCY_CHANGE",
          currency: cc,
          from: old.rate,
          to: cur.rate,
          change_pct: ((cur.rate - old.rate) / old.rate * 100).toFixed(2),
        });
      }
    }
  }

  await saveSnapshot(env, "currency:NBU", nbu);

  // VAT: only fires when the snapshot's `updated` date changes
  // ...

  return { alerts };
}
Enter fullscreen mode Exit fullscreen mode

Threshold for currency: 0.5%. Anything smaller is noise.

The webhook alert format

{
  "type": "VAT_CHANGE",
  "country": "DE",
  "from": "19%",
  "to": "20%",
  "effective": "2026-01-01",
  "source": "EU Commission TEDB",
  "ts": "2026-10-08T14:21:33.181Z"
}
Enter fullscreen mode Exit fullscreen mode

POSTed to whatever URL you configured. Works with:

  • Slack incoming webhooks
  • Discord webhooks
  • n8n / Zapier / Make.com
  • Your own backend

The 100 SEO pages

Same pattern as the previous two streams.

products-watch.json (excerpt):

[
  {"slug":"currency-watch-uah-usd","name":"UAH/USD Currency Watch","type":"currency","category":"Currency","pair":"UAH/USD","source":"NBU"},
  {"slug":"vat-watch-germany","name":"German VAT Watch","type":"vat","category":"VAT","country":"DE","country_name":"Germany"},
  {"slug":"vat-watch-eu-standard","name":"EU Standard VAT Watch","type":"vat","category":"VAT","country":null,"country_name":"EU"}
]
Enter fullscreen mode Exit fullscreen mode

100 entries:

  • 50 currency pairs — UAH pairs (25) + ECB/Frankfurter pairs (25)
  • 50 VAT watches — 27 countries + 23 EU-wide categories

generator-watch.cjs reads the JSON, renders templates/watch-detail.html, writes 100 folders, updates sitemap.xml and llms.txt.

node generator-watch.cjs
Enter fullscreen mode Exit fullscreen mode

→

Generated 100 pages in output/watch/
sitemap.xml updated — 628 URLs total
llms.txt updated — 100 monitors
Enter fullscreen mode Exit fullscreen mode

Each page has a live demo hitting /currency/nbu or /vat/:country — so visitors see real data, not a mock.

Pricing

  • Free — 1 active watch, daily check, email alerts
  • Pro ($29.99/mo) — 100 watches, webhooks, priority checks

Different price from the previous streams because monitoring is recurring value — customers stay subscribed.

Stack

  • Cloudflare Workers (API + cron)
  • Cloudflare KV (state)
  • Cloudflare Pages (static pages)
  • Node.js generators (no deps)
  • Suby (payments)
  • IndexNow (search ping)

Total monthly cost so far: $0.

What's next

  • Tariff Watch — US ITC / EU TARIC / UK Trade Tariff changes
  • Sanctions Watch — OFAC / EU / UK OFSI lists
  • HS Code Watch — WCO Harmonized System updates

Same architecture, just more data sources.

Try it

If you're building KYC, tax, or finance tooling and need alerts on rate/rate changes — the free tier is genuinely free.

Happy to answer questions about Cloudflare Workers + KV + cron, or the programmatic SEO side.

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

The VAT path has a useful distinction to make explicit: as shown here, it's watching changes to a bundled snapshot, rather than discovering changes at the source. What refreshes VAT_SNAPSHOT, and how would a subscriber know that refresh stopped?

I'd add two fixtures: bump updated without changing any rates (no VAT_CHANGE), then change one country's rate without bumping updated (detect the value change or reject the inconsistent snapshot). Keeping source-checked time separate from deployment/edit time would let a successful daily cron report stale source data rather than imply a fresh check. I haven't run the Worker; this is based on the snapshot and date-gated comparison described in the post.

Collapse
 
alexcodebytes profile image
Oleksandr •

This is incredibly solid architectural feedback. Handling stale source data is one of those classic edge cases that completely breaks currency monitors if left unchecked. Separating the source-checked timestamp from the internal deployment time is a textbook best practice. Definitely worth adding those test fixtures to ensure the Cloudflare Worker fails gracefully instead of reporting stale data as fresh.