Exchange Rate API Alerts: Build a Currency Rate Alert in Python
A currency rate alert is one of the most useful small things you can build on an exchange rate API. "Tell me when USD/EUR goes above 0.95." "Tell the finance channel when GBP/USD drops below 1.25." This guide builds one twice: as a Python script you can run from cron or GitHub Actions, and as a Cloudflare Worker with a Cron Trigger. Both send to Slack, Telegram or email, both remember what they already sent, and both use one API request per run.
How the alert works
Every run does five things:
- Fetch the latest rates for every currency in your rules, in one request.
- Work out which "zone" each rule is in: above the upper threshold, below the lower threshold, or inside.
- Compare with the zone stored from the previous run.
- Send a message only when a rule enters a new zone outside the band.
- Save the new zones.
Step 3 is what stops the alert from firing every 15 minutes for as long as the rate stays above your line. Without stored state, a threshold check is a spam generator.
The exchange rate API call
Put every target currency in one call. The response is a JSON array, and a multi-target call costs one request.
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://allratestoday.com/api/v1/rates?source=USD&target=EUR,GBP,JPY" [
{"rate":0.9215,"source":"USD","target":"EUR","time":"2026-04-03T12:00:00Z"},
{"rate":0.7893,"source":"USD","target":"GBP","time":"2026-04-03T12:00:00Z"},
{"rate":151.42,"source":"USD","target":"JPY","time":"2026-04-03T12:00:00Z"}
] Keep all rules on one base. If you want an alert on EUR/GBP, compute it from the same response: rate[GBP] / rate[EUR]. That way ten rules still cost one request per run. The rates are mid-market rates refreshed every 60 seconds.
Request budget: frequency against plan quota
Because each run is one request, the monthly cost is simply runs per month. For a 30-day month:
| Schedule | Runs per 30 days | Fits |
|---|---|---|
| Every minute | 43,200 | Large (100,000/mo) |
| Every 5 minutes | 8,640 | Medium (10,000/mo) |
| Every 15 minutes | 2,880 | Small (5,000/mo) |
| Hourly | 720 | Small (5,000/mo) |
Every 15 minutes is a good default for a threshold alert: 96 runs a day, 2,880 requests a month, which leaves room on the Small plan's 5,000 for other uses. A free API key is fine for building and testing the alert. The free allowance is a one-off evaluation allowance that does not reset, so a job that runs every day needs a paid plan; Small is €4.99 a month. Details are on the pricing page.
Python version
The script below reads rules, fetches rates, keeps state in a JSON file and sends notifications to whichever channels you configure. It needs only requests.
# fx_alert.py (pip install requests)
import json
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path
import requests
API_KEY = os.environ["ALLRATES_API_KEY"]
STATE_FILE = Path(os.environ.get("STATE_FILE", "alert_state.json"))
# Pairs are always USD/<code>; use "cross" for pairs computed from two USD rates.
RULES = [
{"name": "USD/EUR", "target": "EUR", "above": 0.95},
{"name": "USD/JPY", "target": "JPY", "below": 140.0},
{"name": "EUR/GBP", "cross": ("EUR", "GBP"), "above": 0.88, "below": 0.82},
]
def fetch_rates():
targets = set()
for rule in RULES:
targets.update(rule.get("cross", ()) or [rule["target"]])
resp = requests.get(
"https://allratestoday.com/api/v1/rates",
params={"source": "USD", "target": ",".join(sorted(targets))},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=15,
)
resp.raise_for_status()
return {row["target"]: row["rate"] for row in resp.json()} # array -> dict
def value_for(rule, rates):
if "cross" in rule:
base, quote = rule["cross"]
return rates[quote] / rates[base]
return rates[rule["target"]]
def zone(rule, value):
if "above" in rule and value > rule["above"]:
return "above"
if "below" in rule and value < rule["below"]:
return "below"
return "inside"
def notify(text):
if url := os.environ.get("SLACK_WEBHOOK_URL"):
requests.post(url, json={"text": text}, timeout=10)
if token := os.environ.get("TELEGRAM_BOT_TOKEN"):
requests.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": os.environ["TELEGRAM_CHAT_ID"], "text": text},
timeout=10,
)
if host := os.environ.get("SMTP_HOST"):
msg = EmailMessage()
msg["Subject"] = "Exchange rate alert"
msg["From"] = os.environ["ALERT_FROM"]
msg["To"] = os.environ["ALERT_TO"]
msg.set_content(text)
with smtplib.SMTP_SSL(host, 465) as smtp:
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
smtp.send_message(msg)
def main():
state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}
rates = fetch_rates()
for rule in RULES:
value = value_for(rule, rates)
new = zone(rule, value)
old = state.get(rule["name"], "inside")
if new != old and new != "inside":
limit = rule[new]
notify(f"{rule['name']} is {new} {limit}: now {value:.4f} (mid-market)")
state[rule["name"]] = new
STATE_FILE.write_text(json.dumps(state))
if __name__ == "__main__":
main() The state file maps each rule to its last zone. An alert fires when the zone changes to above or below. When the rate comes back inside the band, the zone resets to inside silently, which re-arms the alert. If a rate hovers right at the threshold, add a small buffer before re-arming, for example require it to come back 0.2% inside the line.
Run it from cron on any server:
*/15 * * * * cd /opt/fx-alert && ALLRATES_API_KEY=YOUR_API_KEY /usr/bin/python3 fx_alert.py Running it on GitHub Actions
GitHub Actions can run the same script on a schedule. The state file has to survive between runs, so the workflow restores it from the Actions cache and saves a new copy each time:
name: fx-alert
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: alert_state.json
key: fx-alert-state-${{ github.run_id }}
restore-keys: fx-alert-state-
- run: pip install requests && python fx_alert.py
env:
ALLRATES_API_KEY: ${{ secrets.ALLRATES_API_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} A few caveats. GitHub's scheduled workflows are best effort: runs can start late, sometimes by much more than the interval, and can be skipped when GitHub is busy. Scheduled workflows are also disabled after 60 days without repository activity on public repositories. And on a private repository, every run uses Actions minutes. For an alert where timing matters, a dedicated scheduler is more reliable.
Cloudflare Workers Cron Trigger version
A Worker with a Cron Trigger runs on schedule without a server, and KV holds the state. wrangler.toml:
name = "fx-alert"
main = "src/index.js"
compatibility_date = "2026-11-01"
[triggers]
crons = ["*/15 * * * *"]
[[kv_namespaces]]
binding = "STATE"
id = "YOUR_KV_NAMESPACE_ID" Create the namespace with npx wrangler kv namespace create STATE and set secrets with npx wrangler secret put ALLRATES_API_KEY and npx wrangler secret put SLACK_WEBHOOK_URL.
// src/index.js
const RULES = [
{ name: "USD/EUR", target: "EUR", above: 0.95 },
{ name: "USD/JPY", target: "JPY", below: 140 },
{ name: "EUR/GBP", cross: ["EUR", "GBP"], above: 0.88, below: 0.82 },
];
const valueFor = (rule, rates) =>
rule.cross ? rates[rule.cross[1]] / rates[rule.cross[0]] : rates[rule.target];
const zoneOf = (rule, v) =>
rule.above !== undefined && v > rule.above ? "above"
: rule.below !== undefined && v < rule.below ? "below"
: "inside";
async function run(env) {
const targets = [...new Set(RULES.flatMap((r) => r.cross ?? [r.target]))].sort();
const res = await fetch(
`https://allratestoday.com/api/v1/rates?source=USD&target=${targets.join(",")}`,
{ headers: { Authorization: `Bearer ${env.ALLRATES_API_KEY}` } }
);
if (!res.ok) { console.log("rates request failed", res.status); return; }
const rows = await res.json(); // an array
const rates = Object.fromEntries(rows.map((r) => [r.target, r.rate]));
const state = JSON.parse((await env.STATE.get("zones")) ?? "{}");
for (const rule of RULES) {
const value = valueFor(rule, rates);
const zone = zoneOf(rule, value);
if (zone !== "inside" && zone !== (state[rule.name] ?? "inside")) {
await fetch(env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `${rule.name} is ${zone} ${rule[zone]}: now ${value.toFixed(4)} (mid-market)` }),
});
}
state[rule.name] = zone;
}
await env.STATE.put("zones", JSON.stringify(state));
}
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(run(env));
},
}; To send to Telegram instead, replace the Slack fetch with a POST to https://api.telegram.org/bot<token>/sendMessage with chat_id and text, exactly as in the Python version. Test locally with npx wrangler dev --test-scheduled and then request /__scheduled.
Cron Triggers run in UTC. A 15-minute schedule writes to KV 96 times a day, which is well within KV limits.
Practical details
- Do not alert on errors as if they were prices. If the request fails, log it and skip the run. A 401 means a bad key; a 429 means you are over the monthly quota. Rejected 401, 403 and 429 calls are not charged.
- Say what the number is. Include "mid-market" in the message. A bank or card rate will differ; see mid-market vs retail rates.
- Use official rates for compliance triggers. If the alert drives an accounting or tax process, check the central bank's own rate with the central bank rates API instead.
- Faster checks rarely help. Rates refresh every 60 seconds, but a threshold alert every minute costs 15 times more than every 15 minutes. The article on how often exchange rates are updated covers what refresh frequency actually buys you.
If you are still picking a data provider, the free currency API comparison lists update frequencies and quotas.
FAQ
How do I get an alert when an exchange rate crosses a threshold?
Run a scheduled job that fetches the latest rate from an exchange rate API, compares it with your threshold, and sends a Slack, Telegram or email message when the rate crosses the line. Store the last state so it only alerts once per crossing.
How do I stop the alert from repeating every run?
Save the zone (above, below or inside) for each rule after every run, and alert only when the zone changes to above or below. When the rate returns inside the band, the rule re-arms.
How many API requests does a rate alert use?
One per run if you put every currency in one multi-target call. Every 15 minutes is 2,880 requests in 30 days, which fits the Small plan's 5,000 requests a month.
Are GitHub Actions schedules reliable for rate alerts?
They are best effort. Scheduled runs can start late or be skipped under load. For time-sensitive alerts, use a server cron or Cloudflare Workers Cron Triggers.
Can I alert on a cross rate like EUR/GBP?
Yes. Fetch both against USD in the same call and divide: EUR/GBP equals the USD/GBP rate divided by the USD/EUR rate. It costs no extra request.
Start your rate alert
One multi-target request per run. Every 15 minutes is 2,880 requests in 30 days, inside the Small plan's 5,000 a month.
See plans