Fetch today's government procurement opportunities, classify them with an LLM against your plain-English rules, and post matches to Microsoft Teams or Slack.
Each alert includes a relevance score (1-10), a plain-English summary, and direct links to both the original notice and Open Opportunities.
- Open Opportunities API credentials - Expert tier
- Google Gemini API key - Get one at aistudio.google.com
- A Microsoft Teams or Slack webhook URL (see setup instructions below)
git clone https://github.com/spendnetwork/alert-router
cd alert-router
pip install -r requirements.txtcp config.yaml.example config.yamlOpen config.yaml and fill in:
- Your Spend Network API credentials (email + password)
- Your Gemini API key
- Your webhook URLs
- Your routing rules (plain English descriptions of what each channel should receive)
- Optionally, a relevance gate to filter out false positives
python run.py # live run
python run.py --dry-run # classify but don't post
python run.py --limit 5 # fetch only 5 records
python run.py --lookback 7 # look back 7 days
python run.py --config /path/to/config.yaml # custom config pathThe procurement database updates twice a day. Making repeated requests (e.g. every few minutes) is pointless — the data won't have changed. Running the router once or twice a day is all you need.
Excessive API usage will trigger rate limits and your account may be suspended. Please be respectful:
- Run once or twice a day — a morning and evening run catches everything
- Don't poll in a loop — there's no new data between updates
- Use search filters — always filter by keyword, category, or buyer. Don't fetch unfiltered data
- Use
max_records— cap the number of records per run while testing - Test with
--dry-run— saves webhook quota and is easier to iterate on
[DRY RUN] Would post to: bd-south (teams)
Title: Cyber Security Services 3 (DPS) Stage 1 Capability Assessment
Buyer: METROPOLITAN POLICE SERVICE
Value: Not published
Rule: bd-south
Relevance: 9/10
Summary: The Metropolitan Police Service is conducting a capability
assessment for their Cyber Security Services 3 DPS...
Every run prints a summary:
--- Run complete ---
Records fetched: 42
Already processed: 12
Classified: 30
Matched: 8
Posted: 8
Unmatched: 22
Errors: 0
Duration: 43s
crontab -eAdd this line:
0 7 * * * cd /path/to/alert-router && python run.py >> logs/run.log 2>&1
Create the logs directory first: mkdir -p logs
Microsoft Teams now uses Power Automate Workflows for webhooks:
- In Microsoft Teams, go to the channel where you want alerts
- Click the ... menu next to the channel name
- Click Manage channel or Workflows
- Choose "Post to a channel when a webhook request is received"
- Follow the setup — it gives you a URL
- Copy the webhook URL — paste it into your
config.yaml
The router automatically detects Power Automate Workflows URLs and formats the payload accordingly.
- Go to api.slack.com/apps
- Click Create New App > From scratch
- Name it "Procurement Alerts", select your workspace
- Go to Incoming Webhooks > toggle it On
- Click Add New Webhook to Workspace
- Select the channel for alerts
- Copy the webhook URL — paste it into your
config.yaml
The relevance gate is an optional first-pass filter that runs before any routing rules. If a record fails the gate, it scores 0 and isn't routed anywhere — regardless of the routing rules.
This is useful when your search keywords are broad and pull in false positives. For example, searching for "security" will match both cyber security and physical security (guards, CCTV, patrols). The gate lets the LLM filter out the false positives before routing.
relevance_gate: >
This opportunity must be genuinely related to CYBER SECURITY.
Physical security (guards, CCTV, patrols, keyholding) should FAIL.Remove or leave blank to disable the gate.
Every classified record gets a relevance score from 1-10:
| Score | Meaning |
|---|---|
| 9-10 | Excellent match — directly on topic |
| 7-8 | Good match — clearly relevant |
| 4-6 | Partial match — some relevance |
| 1-3 | Marginal — keyword appeared but weak fit |
| 0 | Failed the relevance gate |
The score appears on every Teams and Slack card with colour coding (green/amber/red).
A single record can match multiple routing rules and will be posted to all matched destinations. For example, an NHS cyber security tender in Manchester would match:
bd-north(buyer in North of England)consulting(professional services)health-opps(NHS buyer)
- description: >
All cyber security consulting, penetration testing, SOC services,
security architecture, threat intelligence, and red team exercises
destination: cyber-team- description: >
The buying organisation is located in London, South East England,
South West England, or East of England.
Also includes UK-wide central government departments based in London.
destination: bd-south- description: >
The BUYER ORGANISATION NAME is a health sector body.
Match ONLY if the buyer name contains NHS, Health, ICB, UKHSA, etc.
DO NOT match based on the subject — only the buyer name matters.
destination: health-opps- description: >
Any IT services or software development contract worth more than
£500,000 from any UK public sector buyer
destination: large-it-dealsTips:
- Be specific — include synonyms, alternative names, and examples
- Clarify what should NOT match (e.g., "physical security" vs "cyber security")
- For buyer-based rules, say explicitly "match on the buyer name, not the subject"
- Test with
--dry-runto refine your rules before going live
┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Spend Network │────>│ Gemini │────>│ Teams/Slack │
│ Procurement API │ │ (classify) │ │ (webhooks) │
└──────────────────┘ └──────────────┘ └──────────────┘
Fetch daily records Relevance gate Post formatted
with your filters + routing rules alert cards
+ relevance score
- Fetch — Queries the Spend Network API for recent opportunities matching your filters
- Deduplicate — Skips records already posted in previous runs (14-day rolling window)
- Gate — Checks each record against the relevance gate (if configured)
- Classify — Sends each record to Gemini with your routing rules; returns matched destinations, relevance score, summary, and reason
- Route — Posts branded alert cards to the matched Teams or Slack channels
This script demonstrates what's possible with the Open Opportunities API. If you'd rather have managed alerts without running code:
Open Opportunities includes API access, daily alerts, and more — so your team can focus on winning contracts, not maintaining scripts.
This entire project — every line of Python, the config structure, the routing logic, and this README — was built using Claude Code to demonstrate how quickly you can build useful tools on top of the Open Opportunities API. It works, we use it internally, and we're sharing it as a starting point.
That said: this is AI-generated code shared as a practical example, not a production-grade product. Please review it, test it in your own environment, and adapt it to your needs before relying on it. We welcome contributions and feedback.
MIT