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.
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:
- Cron trigger fires the Worker at 09:00 CET
- Worker calls
/company-events/searchwithdate_from=yesterday - For each event, check if its
company_numberis in the watchlist KV - 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
COUNTRIESto"ES,FR,GB". The same code loops across countries. - Telegram instead of Slack: swap the
postSlackfunction 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_typeinto 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.
Related
- Counterparty monitoring use case
- Verify a Spanish NIF in Python
- What is BORME?
- Companies House API alternative — when monitoring crosses to UK