Tutorial/Python alert tutorial

Build a Python exchange-rate alert with USD/IDR

Build a Python standard-library monitor that alerts once when USD/IDR crosses a percentage threshold, persists state safely, and respects rate freshness and API limits.

ERexchangerate.dev·Sep 8, 2026·8 min read

A useful alert compares each newly effective USD/IDR observation with the last one, not each HTTP poll with the last poll. The example suppresses the first observation, ignores stale data and errors, and prints a console alert when the move crosses the configured threshold.

Key points
Use sources["IDR"] and effective_at["IDR"] to judge the currency being monitored.
The first observation establishes a baseline and never emits an alert.
Persist state with an atomic replace and file mode 0600; never log the API key.
A stale observation, network failure, 401, or 429 retains the previous state and cannot create or re-arm an alert.
The default five-minute poll is about 8,640 calls in 30 days; a 60-second poll is about 43,200, so account for the shared monthly quota before choosing it.

Model the per-currency observation

The envelope source and data_updated_at fields summarize all requested currencies. An alert for IDR should use sources["IDR"] and effective_at["IDR"] instead, so another currency cannot make the monitored observation look fresher or older than it is.

python · observation modelcopy
from decimal import Decimal

def idr_observation(payload: dict) -> dict:
    return {
        "rate": Decimal(str(payload["rates"]["IDR"])),
        "source": payload["sources"]["IDR"],
        "effective_at": payload["effective_at"]["IDR"],
        "market_session": payload["market_session"],
    }

Define a crossing policy

The first valid observation is only a baseline. When a new effective observation moves at least the threshold from the previous one, emit one alert and disarm. Do not emit another alert until a later valid observation returns below the threshold; then re-arm. This deduplicates repeated polls of the same crossing.

SituationState changeAlert
First valid observationStore baselineNo
New observation, move below thresholdSet armed=trueNo
New observation crosses threshold while armedSet armed=falseOne
Repeated/stale observation or request errorKeep stateNo

Persist state safely

Write a complete JSON object to a temporary file in the same directory, set its mode to 0600, then replace the state path with os.replace. A process interruption cannot leave a half-written baseline. Store the last rate, effective timestamp, source, market session, and whether the threshold is armed.

python · atomic state writecopy
import json
import os
import tempfile
from pathlib import Path

def save_state(path: Path, state: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
    try:
        os.fchmod(fd, 0o600)
        with os.fdopen(fd, "w") as handle:
            json.dump(state, handle, sort_keys=True)
            handle.write("\n")
        os.replace(temporary, path)
    except Exception:
        try:
            os.unlink(temporary)
        except OSError:
            pass
        raise

Bound the network and quota

Use a ten-second request timeout and never follow redirects while an Authorization header is present. Keep the key in EXCHANGERATE_API_KEY; it does not belong in a URL or log line. The script defaults to one request every five minutes (about 8,640 calls in 30 days). A 60-second interval stays under the 12 requests-per-minute burst limit but uses about 43,200 monthly calls, so it can exceed a shared monthly quota. On 429, wait for Retry-After when present and preserve the last state.

Errors cannot create false alerts
A timeout, 401, 429, 5xx response, malformed JSON response, missing per-currency freshness field, or stale effective timestamp leaves the previous baseline untouched. A closed-market response can still be valid; keep source and market_session beside it.

Run the complete monitor

Download the complete Python alert script, make it executable, and set the threshold, maximum age, and state path outside source control. The default freshness window is one hour; configure a longer FX_ALERT_MAX_AGE_SECONDS if a closed-market or daily-reference observation is intentional. It prints console notifications only; adding email, Slack, or another external destination requires a separate integration and secret policy.

shell · run itcopy
chmod 700 python-exchange-rate-alerts.py
export EXCHANGERATE_API_KEY="your-key"
export FX_ALERT_THRESHOLD_PCT="1.0"
export FX_ALERT_POLL_SECONDS="300"
export FX_ALERT_STATE_PATH="$HOME/.local/state/fx-alert.json"
python3 python-exchange-rate-alerts.py

Test the policy without waiting for the market

Unit-test the state transition with Decimal values: first observation, a move below threshold, a crossing, a repeated effective timestamp, a stale response, a return below threshold, and a second crossing. Mock the network boundary rather than fabricating market data in the script. KeyboardInterrupt stops the loop cleanly and does not delete the baseline.

ER
exchangerate.dev
Integration guides for developers building with FX data.

Keep reading

TutorialHow to get exchange rates in PythonRead ReferenceReading source and market_sessionRead GuideHistorical FX rates and time series in one callRead
More ComparisonsFixer vs exchangerate.devOpen Exchange Rates vs exchangerate.devCurrencylayer vs exchangerate.devCurrencylayer vs ExchangeRate-API
LearnReading source and market_session in your pipelineIndicative vs executable FX rates: what a rates API actually gives youECB reference rates, explained
Live RatesEUR/USDGBP/USDUSD/JPY