Tutorial · Cloudflare Workers

Building a daily BORME watchlist with Cloudflare Workers

Free-tier daily counterparty-event monitoring on Spanish BORME — capital changes, director changes, dissolutions, insolvency — with Slack alerts. ~50 lines of TypeScript.

Updated 30 June 2026

What we're building

A scheduled Cloudflare Worker that fires daily at 09:00 CET, queries the Prometiam API for yesterday's BORME events, joins them against a watchlist stored in Workers KV, and posts Slack alerts for every match. End-to-end runs on Cloudflare's free tier; the Prometiam side costs nothing during the 14-day trial and €9.99/month on Starter after it.

The flow:

  1. Cron trigger fires the Worker at 09:00 CET
  2. Worker calls /company-events/search with date_from=yesterday
  3. For each event, check if its company_number is in the watchlist KV
  4. Post Slack alerts for matches; update KV with last-seen event id to dedup

Step 1 — wrangler.toml

name = "borme-watchlist"
main = "src/index.ts"
compatibility_date = "2026-04-01"

[triggers]
crons = ["0 9 * * 1-5"]   # 09:00 UTC, Mon–Fri (CET-aware: adjust to "0 8" in summer for CEST)

[[kv_namespaces]]
binding = "WATCHLIST"
id = "<your-kv-namespace-id>"

[[kv_namespaces]]
binding = "STATE"
id = "<your-state-kv-namespace-id>"

[vars]
COUNTRIES = "ES"          # comma-separated; add "ES,FR,GB" if you want multi-country

# Set as secrets via: npx wrangler secret put PROMETIAM_API_KEY
# Set: PROMETIAM_API_KEY, SLACK_WEBHOOK_URL

Step 2 — the Worker

interface Env {
  WATCHLIST: KVNamespace
  STATE: KVNamespace
  PROMETIAM_API_KEY: string
  SLACK_WEBHOOK_URL: string
  COUNTRIES: string
}

const API = 'https://api.prometiam.com/functions/v1/risk-api'

export default {
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
    const countries = env.COUNTRIES.split(',').map(c => c.trim()).filter(Boolean)

    let totalEvents = 0, totalHits = 0
    for (const country of countries) {
      const events = await fetchEvents(env, country, yesterday)
      totalEvents += events.length
      const hits = await matchWatchlist(env, events)
      totalHits += hits.length
      if (hits.length) await postSlack(env, country, hits)
    }
    console.log(`borme-watchlist: country=${env.COUNTRIES} events=${totalEvents} hits=${totalHits}`)
  }
}

async function fetchEvents(env: Env, country: string, dateFrom: string) {
  const lastSeen = parseInt((await env.STATE.get(`lastSeen:${country}`)) || '0', 10)
  const out: any[] = []
  let cursor: string | null = null

  do {
    const url = new URL(`${API}/company-events/search`)
    url.searchParams.set('date_from', dateFrom)
    url.searchParams.set('country', country)
    url.searchParams.set('limit', '100')
    if (cursor) url.searchParams.set('cursor', cursor)

    const r = await fetch(url, { headers: { Authorization: `Bearer ${env.PROMETIAM_API_KEY}` } })
    if (!r.ok) throw new Error(`Prometiam ${r.status} ${await r.text()}`)
    const j = await r.json()
    out.push(...(j.data || []).filter((e: any) => e.id > lastSeen))
    cursor = j.pagination?.next_cursor ?? null
  } while (cursor)

  if (out.length) {
    const maxId = Math.max(...out.map(e => e.id))
    await env.STATE.put(`lastSeen:${country}`, String(maxId))
  }
  return out
}

async function matchWatchlist(env: Env, events: any[]) {
  const hits: any[] = []
  for (const e of events) {
    if (!e.company_number) continue
    const watched = await env.WATCHLIST.get(`co:${e.company_number}`)
    if (watched) hits.push({ ...e, label: watched })
  }
  return hits
}

async function postSlack(env: Env, country: string, hits: any[]) {
  const lines = hits.map(h => `• *${h.company_name}* (${h.company_number}) — ${h.event_type} on ${h.event_date} — <${h.notice_url || '#'}|notice>`)
  await fetch(env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `📊 BORME watchlist — ${hits.length} ${country} event(s) today:\n\n${lines.join('\n')}`
    })
  })
}

Step 3 — populate the watchlist

The Worker reads from the WATCHLIST KV namespace where each key is co:<company_number> and the value is a human-readable label (your internal customer name, ID, etc.). Populate it however suits your stack — one-time CLI seed or programmatic put from your CRM.

# Bulk-load via wrangler — one company at a time
npx wrangler kv:key put --binding=WATCHLIST "co:C-462208" "Inditex (account #123)"
npx wrangler kv:key put --binding=WATCHLIST "co:M-6817"   "Telefonica (account #456)"

# Or programmatically from your CRM
async function seed(env: Env, customers: { company_number: string, label: string }[]) {
  await Promise.all(customers.map(c =>
    env.WATCHLIST.put(`co:${c.company_number}`, c.label)
  ))
}

Step 4 — deploy

# Create the KV namespaces
npx wrangler kv:namespace create WATCHLIST
npx wrangler kv:namespace create STATE
# (paste the ids returned into wrangler.toml)

# Set secrets
npx wrangler secret put PROMETIAM_API_KEY     # paste your rk_live_… key
npx wrangler secret put SLACK_WEBHOOK_URL     # paste your Slack incoming webhook

# Deploy
npx wrangler deploy

Once deployed, the Worker fires automatically at 09:00 UTC Mon–Fri. Manually trigger to test:

npx wrangler tail   # follow logs
# In a second terminal:
npx wrangler dev --test-scheduled
curl 'http://localhost:8787/__scheduled?cron=0+9+*+*+1-5'

Costs

  • Cloudflare Workers free tier: 100,000 requests/day. Daily cron = 1 invocation. Free.
  • KV free tier: 1 GB storage, 100K reads/day, 1K writes/day. A 5,000-counterparty watchlist easily fits. Free.
  • Prometiam Free: 14-day trial, 500 credits. Each Worker run uses roughly 1–10 credits (with pagination on busy days). Fits a small daily watchlist.
  • Prometiam Starter (€9.99/mo): 1,000 credits/month. Headroom for paginated busy days.

Total: €0 during the 14-day trial, then €9.99/month on Starter for ~5,000 monitored counterparties.

Variations worth building

  • Multi-country: change COUNTRIES to "ES,FR,GB". The same code loops across countries.
  • Telegram instead of Slack: swap the postSlack function with the Telegram Bot API — same idea.
  • Email digest: store hits in a daily-bucket KV key, send a single morning email via Resend/MailerSend.
  • Severity tiers: classify event_type into red/amber/green and only ping Slack on red (insolvency, dissolution, liquidation, capital_change with ≥50% reduction).
  • Per-customer routing: store the customer's account-manager Slack user ID in the WATCHLIST value and DM them instead of the channel.

FAQ

How much does this cost?
€0 during the 14-day trial, then €9.99/mo on Starter for ~5,000 counterparties.

Can I use this for France or UK?
Yes — set COUNTRIES = "ES,FR,GB".

How do I avoid duplicate alerts?
The STATE KV stores the last-seen event id per country. Only events with id > last-seen are processed.

Can the Worker handle pagination?
Yes — the fetchEvents function above loops on pagination.next_cursor until null.

What if the cron fires while a deploy is mid-flight?
Cloudflare guarantees at-least-once delivery and the function is idempotent thanks to the last-seen tracking.

Frequently asked questions

How much does this cost to run?
Cloudflare Workers free tier covers 100,000 requests/day (more than enough for a daily cron). KV free tier is 1 GB storage, 100K reads/day. The Prometiam trial covers 500 credits over 14 days; Starter (€9.99) covers 1,000 credits/month after it. Total: €0 during the trial, then €9.99/month.
Can I use this for France or UK too?
Yes. The same /company-events/search endpoint accepts country=FR (BODACC) or country=GB (Companies House). The Worker code below loops across countries.
How do I avoid duplicate alerts?
Each event has a stable id. Store the last-seen event id in KV and only alert on events with id > last-seen. The full code includes this dedup logic.