
20 Oct 2026 · 12 min read

A small script that checks the savings account you hold once a day, and messages you when its rate drops or a better account turns up. It runs on a laptop, a Raspberry Pi, a Google Sheet or Home Assistant, uses the DepositScout UK savings rates API, and costs about £6 a month.
Everything lives under https://depositscout.com/api/developer/v1 and comes back as savings rates JSON. Four endpoints:
GET /rates: every UK savings account we list, highest AER first. Narrow it with type, provider, amount (your balance in pounds) and access, and limit gives you the top results that match.GET /providers: the banks and building societies we cover, with their slugs.GET /switch-offers: current account switching bonuses.GET /history: every rate change for one account. It's on the Enterprise plan, so the tracker below keeps its own history instead.type takes easy-access, fixed-rate, notice, cash-isa, regular-saver, junior-isa, lifetime-isa, stocks-shares-isa, childrens-savings or all. So it's a cash ISA rates API too: type=cash-isa.
There's also GET /rates/{sourceKey}, which returns a single account by its permanent ID (sourceKey). That's the cheapest way to watch the account you already have.
ds_test_… keys are free and never charged, but return sample data: use one to check your code works. ds_live_… keys return real rates and spend credits.Is it a free savings rates API? Not quite. Test keys are free, and the 20 starter credits cover about ten days of the tracker below. After that, £1 buys 10 credits, top-ups start at £5, and credits never expire.
What matters is that /rates charges 1 credit per row it returns, not per call. /rates/{sourceKey} is 1 credit. The tracker makes two calls a day: one for your account and one for the single best rival (limit=1). That's 2 credits a day, about 60 a month, or roughly £6.
How often you check makes a big difference:
| How you check | Credits a month | Cost a month |
|---|---|---|
| Once a day: your account plus the best rival | about 60 | about £6 |
| Once a day: the top 5 | about 150 | about £15 |
| Every hour: the top 5 | about 3,600 | about £360 |
Rates change at most once a day, so checking every hour gets you nothing extra.
Put your key in an environment variable, then ask for the top five easy access accounts:
export DS_API_KEY="ds_live_your_key_here"
curl "https://depositscout.com/api/developer/v1/rates?type=easy-access&limit=5" \
-H "Authorization: Bearer $DS_API_KEY"
That costs 5 credits. The response, trimmed to the useful fields (the values here are an example):
{
"data": [
{
"sourceKey": "chase-saver",
"provider": "Chase UK",
"accountName": "Chase Saver",
"accountType": "Easy Access",
"rate": "4.50",
"interestPaid": "Monthly",
"minBalance": 0,
"fscsDetails": "Protected up to £120,000 by the FSCS",
"depositscoutUrl": "https://depositscout.com/banks/chase/chase-saver",
"lastCheckedAt": "2026-10-20T07:12:00.000Z",
"updatedAt": "2026-10-07T06:00:00.000Z"
}
],
"meta": {
"creditsCharged": 5,
"creditsBalance": 15,
"attribution": "Rates powered by DepositScout — depositscout.com",
"nextCursor": null
}
}
Three things to know:
rate is the AER as a string ("4.50"), so convert it before comparing.sourceKey is the account's permanent ID. Store that, not the name, because banks rename accounts.depositscoutUrl is the account's page on DepositScout. Put it in your alert so you can check the details before you move.All three routes lead to the same tracker. Pick the one you already use.
import os, requests
res = requests.get(
"https://depositscout.com/api/developer/v1/rates",
params={"type": "easy-access", "limit": 5},
headers={"Authorization": f"Bearer {os.environ['DS_API_KEY']}"},
timeout=30,
)
res.raise_for_status()
for p in res.json()["data"]:
print(f"{p['rate']}% {p['provider']} {p['accountName']} ({p['sourceKey']})")
IMPORTDATA can't send an Authorization header, so use Apps Script. In your sheet, go to Extensions → Apps Script. Then open Project Settings → Script Properties, add DS_API_KEY with your key, and paste this in:
function refreshRates() {
const key = PropertiesService.getScriptProperties().getProperty("DS_API_KEY");
const res = UrlFetchApp.fetch(
"https://depositscout.com/api/developer/v1/rates?type=easy-access&limit=5",
{ headers: { Authorization: "Bearer " + key } }
);
const body = JSON.parse(res.getContentText());
const rows = body.data.map(p => [p.provider, p.accountName, Number(p.rate), p.sourceKey, p.lastCheckedAt]);
const book = SpreadsheetApp.getActive();
const sheet = book.getSheetByName("Rates") || book.insertSheet("Rates");
sheet.clearContents();
sheet.appendRow(["Provider", "Account", "AER %", "sourceKey", "Last checked"]);
if (rows.length) sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows);
sheet.getRange(rows.length + 3, 1).setValue(body.meta.attribution);
}
Run it once from the editor to give it permission, then add a trigger: Triggers → Add trigger → refreshRates → Time-driven → Day timer. Use a trigger rather than a custom =DS_RATES() formula. Sheets can recalculate a formula whenever it likes, and every recalculation spends credits.
Add your key to secrets.yaml:
depositscout_auth: "Bearer ds_live_your_key_here"
Then add a rest sensor to configuration.yaml:
rest:
- resource: "https://depositscout.com/api/developer/v1/rates?type=easy-access&limit=1"
scan_interval: 86400
headers:
Authorization: !secret depositscout_auth
sensor:
- name: "Best easy access rate"
unique_id: depositscout_best_easy_access
value_template: "{{ value_json.data[0].rate | float }}"
unit_of_measurement: "%"
json_attributes_path: "$.data[0]"
json_attributes:
- provider
- accountName
- sourceKey
Don't skip scan_interval: 86400 (once a day). Home Assistant's default is every 30 seconds. That would hit the 1,000-calls-a-day limit by mid-morning, after spending £100 of credits. Each Home Assistant restart also refreshes the sensor, which costs a credit.
This is the payoff: a savings rate alert that only speaks up when it matters.
The rule, in plain English. Ping me if:
Each better account should only trigger one message, not one every day.
Your bank's slug is in its DepositScout page address (depositscout.com/banks/<slug>), or you can get it from /providers. List that bank's accounts and copy the sourceKey of yours:
curl "https://depositscout.com/api/developer/v1/rates?provider=chase&limit=5" \
-H "Authorization: Bearer $DS_API_KEY"
You track one account with /rates/{sourceKey}, and compare it with the top result from /rates for the same type and balance. The script keeps the rate it saw last time in a small JSON file, so it knows when yours has dropped. That's your own rate history, with no need for /history.
#!/usr/bin/env python3
"""Daily savings rate alert. Rates powered by DepositScout."""
import json, os, pathlib, requests
API = "https://depositscout.com/api/developer/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DS_API_KEY']}"}
MY_ACCOUNT = "your-source-key" # from step 1
ACCOUNT_TYPE = "easy-access" # the same type as your account
BALANCE = 20000 # pounds: only compare accounts that take it
GAP = 0.25 # percentage points before it's worth moving
STATE = pathlib.Path(__file__).with_name("tracker-state.json")
def get(path, **params):
res = requests.get(f"{API}{path}", headers=HEADERS, params=params, timeout=30)
res.raise_for_status()
return res.json()["data"]
def notify(text):
requests.post(
f"https://api.telegram.org/bot{os.environ['TELEGRAM_BOT_TOKEN']}/sendMessage",
json={"chat_id": os.environ["TELEGRAM_CHAT_ID"], "text": text},
timeout=30,
).raise_for_status()
mine = get(f"/rates/{MY_ACCOUNT}") # 1 credit
rivals = get("/rates", type=ACCOUNT_TYPE, amount=BALANCE, limit=1) # 1 credit
if not rivals:
raise SystemExit("No accounts matched that type and balance.")
best = rivals[0]
my_rate = float(mine["rate"])
best_rate = float(best["rate"])
state = json.loads(STATE.read_text()) if STATE.exists() else {}
messages = []
last_rate = state.get("my_rate")
if last_rate is not None and my_rate < last_rate:
messages.append(
f"Your {mine['provider']} {mine['accountName']} rate dropped "
f"from {last_rate:.2f}% to {my_rate:.2f}%."
)
if (
best["sourceKey"] != MY_ACCOUNT
and best_rate - my_rate > GAP
and state.get("told_about") != best["sourceKey"]
):
messages.append(
f"{best['provider']} {best['accountName']} pays {best_rate:.2f}%, "
f"{best_rate - my_rate:.2f} points more than yours. {best['depositscoutUrl']}"
)
state["told_about"] = best["sourceKey"]
if messages:
notify("\n\n".join(messages) + "\n\nRates powered by DepositScout")
state["my_rate"] = my_rate
STATE.write_text(json.dumps(state))
To set up Telegram, message @BotFather, send /newbot, and copy the token it gives you. Send your new bot any message, then open https://api.telegram.org/bot<token>/getUpdates and copy the chat.id from the reply.
Put your secrets in a .env file next to the script:
export DS_API_KEY="ds_live_your_key_here"
export TELEGRAM_BOT_TOKEN="123456:your-bot-token"
export TELEGRAM_CHAT_ID="123456789"
Then add a line to your crontab (crontab -e). Most rate changes land in the morning, so 11am catches them the same day:
0 11 * * * cd /home/pi/rate-tracker && . ./.env && /usr/bin/python3 tracker.py >> tracker.log 2>&1
Here's the same rule as an Apps Script that emails you. Keep DS_API_KEY in Script Properties as before:
const MY_ACCOUNT = "your-source-key";
const ACCOUNT_TYPE = "easy-access";
const BALANCE = 20000;
const GAP = 0.25;
function dsGet(path) {
const key = PropertiesService.getScriptProperties().getProperty("DS_API_KEY");
const res = UrlFetchApp.fetch("https://depositscout.com/api/developer/v1" + path, {
headers: { Authorization: "Bearer " + key },
});
return JSON.parse(res.getContentText()).data;
}
function checkMyRate() {
const props = PropertiesService.getScriptProperties();
const mine = dsGet("/rates/" + encodeURIComponent(MY_ACCOUNT));
const best = dsGet("/rates?type=" + ACCOUNT_TYPE + "&amount=" + BALANCE + "&limit=1")[0];
if (!best) return;
const myRate = Number(mine.rate);
const bestRate = Number(best.rate);
const lastRate = props.getProperty("MY_RATE");
const messages = [];
if (lastRate !== null && myRate < Number(lastRate)) {
messages.push("Your " + mine.provider + " " + mine.accountName + " rate dropped from " +
Number(lastRate).toFixed(2) + "% to " + myRate.toFixed(2) + "%.");
}
if (best.sourceKey !== MY_ACCOUNT && bestRate - myRate > GAP && props.getProperty("TOLD_ABOUT") !== best.sourceKey) {
messages.push(best.provider + " " + best.accountName + " pays " + bestRate.toFixed(2) + "%. " + best.depositscoutUrl);
props.setProperty("TOLD_ABOUT", best.sourceKey);
}
if (messages.length) {
MailApp.sendEmail(Session.getEffectiveUser().getEmail(), "Savings rate alert",
messages.join("\n\n") + "\n\nRates powered by DepositScout");
}
props.setProperty("MY_RATE", String(myRate));
}
Add a daily trigger for checkMyRate: Triggers → Add trigger → Time-driven → Day timer → 11am to midday.
Add a second sensor for your own account. It goes in the same rest: list as before, under the first entry:
- resource: "https://depositscout.com/api/developer/v1/rates/your-source-key"
scan_interval: 86400
headers:
Authorization: !secret depositscout_auth
sensor:
- name: "My savings rate"
unique_id: depositscout_my_rate
value_template: "{{ value_json.data.rate | float }}"
unit_of_measurement: "%"
Then add this automation to automations.yaml. The template trigger only fires when the gap first goes past 0.25 points, so you hear about it once:
- alias: "Savings rate alert"
triggers:
- trigger: state
entity_id: sensor.my_savings_rate
id: mine
- trigger: template
value_template: >
{{ states('sensor.best_easy_access_rate') | float(0)
- states('sensor.my_savings_rate') | float(0) > 0.25 }}
id: better
conditions:
- condition: template
value_template: >
{{ trigger.id == 'better' or (
trigger.to_state.state | float(0) > 0 and
trigger.from_state.state | float(0) > trigger.to_state.state | float(0)) }}
actions:
- action: notify.notify
data:
title: "Savings rate alert"
message: >
Your rate: {{ states('sensor.my_savings_rate') }}%.
Best easy access: {{ state_attr('sensor.best_easy_access_rate', 'provider') }}
{{ state_attr('sensor.best_easy_access_rate', 'accountName') }}
at {{ states('sensor.best_easy_access_rate') }}%.
Rates powered by DepositScout.
429 with a Retry-After header.limit small. You pay per row, so limit=1 for "the best one" and limit=5 for a short list.ETag header. Send it back as If-None-Match, and if nothing has changed you get a free 304 with no body.meta.attribution). In alerts and messages, say it in words. The examples above already do.secrets.yaml, and never commit it. Add .env and tracker-state.json to .gitignore. If a key leaks, rotate it in the dashboard: that cancels the old key and gives you a new one.Partly. Test keys are free and return sample data, and new accounts get 20 free credits. After that, £1 buys 10 credits, top-ups start at £5, and credits don't expire. The daily tracker in this guide costs about £6 a month.
Yes: 60 requests a minute and 1,000 a day on a live Hobby key, and 10 a minute on a test key. Going over returns a 429 with a Retry-After header telling you when to try again.
Yes. Use type=cash-isa, or junior-isa and lifetime-isa. Add access=fixed with minTermMonths and maxTermMonths for fixed-rate ISAs of a particular length.
We check rates every day, and most changes appear in the morning. Each account has lastCheckedAt (when we last confirmed the rate on the provider's website) and updatedAt (when the record last changed).
Yes. The Data Licence allows the data in free or paid products, sites, apps and spreadsheets, as long as you show the attribution and don't resell or redistribute the raw data. Read the licence before you launch anything.
Want pagination, error codes and the full field list? The API tutorial and the API docs cover them. Would rather not write code at all? Rate alerts email you about better rates for free.
Looking for the best Cash ISA in the UK right now? Here’s a clear, data-driven comparison of the top rates.
Cash ISAs let your savings grow tax-free up to a £20,000 annual allowance. Here’s how they work in 2025, who they suit, how transfers and “flexible” ISAs work, and what protections apply.
How the FSCS temporary high balance limit works in the UK. Enjoy up to £1.4m in protection for 6-months.
Tell us what you're building and we'll get back to you about API access and commercial licensing.
[email protected]