REST API vs Webhooks - Should You Poll for RBA Rates or Get Notified?
If your app needs to know when new exchange rates are available, you have two options. You can poll - ask the REST API "are they ready yet?" on a schedule - or you can use a webhook and let the API tell you the moment the rates land.
For most integrations against Reserve Bank of Australia data, polling is wasteful. RBA rates publish once per business day, late afternoon Sydney time. Yet polling apps hammer the /latest endpoint every few minutes all day long, burning through their request quota to discover that nothing has changed 99% of the time.
That's why we've added rate-published webhooks for Professional, Business, and Enterprise plans. This article explains the difference between the two models, walks through the request-cost math, and shows you how to build a webhook receiver that verifies our signatures - in both Python and Node.js.
Webhooks are available on Professional and above. If you're on the Free or Starter tier and want them, you'll need to upgrade your plan. The rest of this article works for everyone as a guide to the trade-offs.
The Two Models in One Picture
POLLING (pull) WEBHOOKS (push)
───────────── ───────────────
Your app Our API
│ GET /latest ──────► API │
│ ◄──── "same as before" │ (rates land at ~4-5pm AEST)
│ ...wait... │
│ GET /latest ──────► API ▼
│ ◄──── "same as before" POST /your-webhook ──────► Your app
│ ...wait... │ ◄──── 200 OK
│ GET /latest ──────► API │
│ ◄──── "NEW RATES!" (finally) │ (you already have the data)
With polling, your application drives every interaction. You decide how often to ask, and you pay (in requests, latency, and code complexity) for the privilege of asking repeatedly. With webhooks, the data source drives the interaction. You register a URL once, and we send you an HTTP POST with the new rates the instant they're stored.
How REST Polling Works
A polling integration is the default starting point for almost everyone, because it's simple and uses the same /latest endpoint you already know.
import requests
import time
API_KEY = "your_api_key"
BASE_URL = "https://api.exchangeratesapi.com.au"
def poll_for_new_rates(poll_interval_seconds=600):
"""Poll /latest and act when the rate date changes."""
last_seen_date = None
while True:
response = requests.get(
f"{BASE_URL}/latest",
headers={"Authorization": f"Bearer {API_KEY}"},
)
data = response.json()
if data.get("success"):
current_date = data["date"]
# Only do work when the date actually advances
if current_date != last_seen_date:
print(f"New rates for {current_date}!")
handle_new_rates(data["rates"])
last_seen_date = current_date
else:
print(f"No change (still {current_date})")
time.sleep(poll_interval_seconds)
def handle_new_rates(rates):
# Your business logic - update prices, recalc invoices, etc.
print(f"USD: {rates.get('USD')}, EUR: {rates.get('EUR')}")
This works. But look closely at what it's doing: most loops fetch data your app already has, just to compare the date and throw it away. You're trading request quota for the chance of being reasonably up to date.
The Polling Trade-offs
You can never be both cheap and fast. Polling forces a choice:
- Poll often (every 5 minutes) → you catch new rates quickly, but you make ~288 requests/day per currency view, most of them redundant.
- Poll rarely (every few hours) → you save requests, but you might not see new rates for hours after they publish.
The request math is unforgiving. RBA rates change once per business day. Consider a 5-minute polling loop running around the clock:
288 requests/day × ~22 business days ≈ 6,336 requests/month
...to detect ~22 actual updates.
That's 99.7% wasted requests. On the Starter plan's 5,000 requests/month, an aggressive poller exhausts the entire quota just checking - leaving nothing for actual conversions. (This is one more reason businesses move up from scraping and naive polling - see the hidden costs of DIY exchange rate data.)
You own the scheduling complexity. A robust poller has to handle:
- Backoff and retries when a request fails
- A persistent "last seen date" so a restart doesn't reprocess yesterday's rates
- The RBA publication window drifting (sometimes 4pm, sometimes 6pm Sydney, and shifting with daylight saving)
- Weekends and public holidays when no new rate is published at all
You end up rebuilding, badly, the exact detection logic we already run on our side.
How Webhooks Work
A webhook inverts the relationship. Instead of your app asking, our infrastructure does the watching and calls you when there's something worth knowing.
Here's the lifecycle of our rates.published webhook:
- You register a URL once. Tell us an
https://endpoint to call (plus, optionally, which currencies you care about). - Our cron detects fresh RBA data. We poll the RBA across the publication window so you don't have to. The moment genuinely new rates for the day are stored, we act.
- We POST the payload to your URL - within roughly 10 minutes of the RBA publishing - signed so you can verify it came from us.
- Your endpoint returns
2xx. Done. If it doesn't, we retry with backoff automatically.
You make zero polling requests and find out about new rates faster than a conservative poller ever would.
The Payload
We send a JSON body like this:
{
"event": "rates.published",
"date": "2026-06-18",
"base": "AUD",
"rates": {
"USD": 0.6543,
"EUR": 0.6021,
"GBP": 0.5134,
"JPY": 104.21
},
"delivery_id": "9f2c1e7a-3b44-4f0e-9a1d-2c6b8e0f1a23",
"timestamp": 1782086400
}
| Field | Meaning |
|---|---|
event |
rates.published for real deliveries, rates.test when you trigger a test |
date |
The rate date these values apply to (the actual RBA publication date) |
base |
Always AUD - rates are expressed as foreign currency per Australian dollar |
rates |
The new rates. Filtered to your chosen currencies if you set any, otherwise all of them |
delivery_id |
Unique ID for this delivery - use it for idempotency (more below) |
timestamp |
Unix timestamp for the rate date |
Why the indirect convention? Each value is how many units of foreign currency one Australian dollar buys -
"USD": 0.6543means 1 AUD = 0.6543 USD. That mirrors how the RBA itself publishes. If that's unfamiliar, see how RBA exchange rates are calculated.
The Request Headers
Every delivery carries these headers:
Content-Type: application/json
X-ExchangeRates-Event: rates.published
X-ExchangeRates-Signature: sha256=3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
X-ExchangeRates-Delivery: 9f2c1e7a-3b44-4f0e-9a1d-2c6b8e0f1a23
User-Agent: ExchangeRatesAPI-Webhook/1.0
The X-ExchangeRates-Signature header is what makes a webhook trustworthy. Let's use it.
Building a Webhook Receiver
A webhook receiver is just an HTTP endpoint that accepts a POST. The only thing that makes it secure is verifying the signature before you trust the body.
Why You Must Verify the Signature
Your webhook URL is, by necessity, a public endpoint on the internet. Anyone who learns it could send it a fake "new rates" payload. If you act on unverified requests, an attacker could feed your pricing engine bogus exchange rates.
We solve this by signing every request with a per-subscription signing secret that only you and we know. We compute an HMAC-SHA256 of the exact raw request body using that secret, and put the result in X-ExchangeRates-Signature. You recompute the same HMAC on your side - if it matches, the request is authentic and untampered.
Keep the raw body. The signature is computed over the exact bytes we send. If your framework parses JSON and re-serializes it before you hash, the bytes can differ (key order, whitespace) and the signature won't match. Always hash the raw request body, then parse.
Python (Flask)
import hashlib
import hmac
from flask import Flask, request, abort
app = Flask(__name__)
# The signing secret shown once when you created the subscription.
# Load it from an environment variable or secrets manager - never hardcode.
SIGNING_SECRET = "your_signing_secret"
def verify_signature(raw_body: bytes, signature_header: str) -> bool:
"""Constant-time compare of our HMAC against the header."""
if not signature_header or not signature_header.startswith("sha256="):
return False
expected = hmac.new(
SIGNING_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
received = signature_header.split("=", 1)[1]
# hmac.compare_digest avoids timing side-channels
return hmac.compare_digest(expected, received)
@app.route("/webhooks/rba-rates", methods=["POST"])
def rba_rates_webhook():
raw_body = request.get_data() # raw bytes, BEFORE json parsing
signature = request.headers.get("X-ExchangeRates-Signature", "")
if not verify_signature(raw_body, signature):
abort(401) # reject anything we can't verify
payload = request.get_json()
# Respond fast (2xx), then do heavy work asynchronously if needed.
if payload["event"] == "rates.published":
handle_new_rates(payload["date"], payload["rates"], payload["delivery_id"])
return "", 200
def handle_new_rates(date, rates, delivery_id):
# Idempotency: skip if you've already processed this delivery_id
if already_processed(delivery_id):
return
print(f"Rates for {date}: USD={rates.get('USD')}")
# ...update prices, recalc invoices, fire alerts...
mark_processed(delivery_id)
Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
// Load from an env var / secret store, never hardcode.
const SIGNING_SECRET = process.env.RBA_SIGNING_SECRET;
// IMPORTANT: capture the RAW body so the signature check sees the exact bytes.
app.use(
"/webhooks/rba-rates",
express.raw({ type: "application/json" })
);
function verifySignature(rawBody, signatureHeader) {
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
return false;
}
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(rawBody)
.digest("hex");
const received = signatureHeader.slice("sha256=".length);
// timingSafeEqual needs equal-length buffers
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/rba-rates", (req, res) => {
const signature = req.get("X-ExchangeRates-Signature");
if (!verifySignature(req.body, signature)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString("utf8"));
if (payload.event === "rates.published") {
handleNewRates(payload.date, payload.rates, payload.delivery_id);
}
// Acknowledge quickly; offload slow work to a queue/background job.
res.sendStatus(200);
});
function handleNewRates(date, rates, deliveryId) {
if (alreadyProcessed(deliveryId)) return; // idempotency guard
console.log(`Rates for ${date}: USD=${rates.USD}`);
// ...your business logic...
markProcessed(deliveryId);
}
app.listen(3000, () => console.log("Listening for RBA webhooks on :3000"));
Registering a Webhook
Webhook subscriptions are managed from your dashboard at app.exchangeratesapi.com.au, or via the management API using your API key - the same Authorization: Bearer key you use for the rest of the API. Create one with a POST:
curl -X POST https://auth.exchangeratesapi.com.au/api/webhook-subscriptions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourcompany.com.au/rba-rates",
"currencies": ["USD", "EUR", "JPY"],
"description": "Production pricing engine"
}'
A successful response returns the signing secret once:
{
"success": true,
"signingSecret": "a1b2c3d4e5f6...64 hex chars...",
"warning": "Store this signing secret securely. It will not be shown again.",
"subscription": {
"id": "7c9e...",
"url": "https://hooks.yourcompany.com.au/rba-rates",
"currencies": ["USD", "EUR", "JPY"],
"description": "Production pricing engine"
}
}
Copy that signingSecret into your secrets manager immediately - we only ever store a way to use it, never a way to show it to you again. List or delete subscriptions with GET /api/webhook-subscriptions and DELETE /api/webhook-subscriptions/:id (the secret is masked everywhere after creation).
A Few Rules Worth Knowing
https://only. Plaintexthttp://URLs are rejected, as are loopback and internal/private hosts (a guard against SSRF). Your receiver must be reachable on the public internet over TLS.- Optional currency filtering. Pass a
currenciesarray to receive only those rates; omit it to get all 20 quoted currencies (every rate the RBA publishes against AUD). This keeps payloads lean if you only price in a handful of currencies. - Plan caps. Professional allows 3 subscriptions, Business 10, Enterprise 50. Free and Starter can't create webhooks.
Send a Test Delivery
Before pointing a webhook at production, fire a test. It sends a rates.test event (same shape as a real delivery, built from the current rates and your subscription's currency filter) to your URL so you can confirm your receiver verifies the signature and returns 2xx:
curl -X POST https://auth.exchangeratesapi.com.au/api/webhook-subscriptions/{id}/test \
-H "Authorization: Bearer YOUR_API_KEY"
Tools like webhook.site are handy for inspecting the raw request and signature before you've finished your receiver.
Reliability: What Happens When Delivery Fails
A push model is only useful if it's dependable. Here's how we make deliveries robust:
- Automatic retries with backoff. If your endpoint doesn't return
2xx(it's down, slow, or returns an error), we retry several times with increasing delays. A brief outage on your side won't make you miss the day's rates. - Dead-letter queue. Deliveries that exhaust all retries are parked rather than silently dropped, so nothing disappears without a trace.
- Delivered-once guarantee per day. Each subscription gets at most one successful
rates.publisheddelivery per rate date, enforced at the database level. Retries can't double-fire your business logic for the same day. - Auto-disable on persistent failure. If a subscription fails 7 deliveries in a row, we mark it inactive so we're not endlessly POSTing to a dead URL. You'll see the failure count and last-success timestamp when you list your subscriptions - fix the endpoint and re-create the subscription to resume.
Still: Make Your Receiver Idempotent
Retries and at-least-once delivery semantics are normal for webhooks across the industry. Even with our once-per-day guard, the safest receiver treats delivery_id as a deduplication key: record the IDs you've handled and ignore repeats. Both code samples above show this with already_processed() / alreadyProcessed(). It's a few lines that turn "probably fine" into "provably safe."
When to Use Which
Webhooks are the better fit for the common case, but polling still has its place.
Use webhooks when:
- You want to react to new rates - update prices, recalculate invoices, trigger downstream jobs - as soon as they publish.
- You want to stop wasting requests polling for data that changes once a day.
- You're on Professional or above and can host an
https://endpoint.
Polling (or a direct GET /latest) is still right when:
- You only need a rate on demand - e.g. converting a value when a user loads a page. Just call the API at that moment; there's nothing to be notified about.
- You can't expose a public HTTPS endpoint (some locked-down environments).
- You're doing historical or batch work over past dates, where there's no "new data" event to wait for.
A lot of mature integrations use both: a webhook to know when fresh rates land, and ordinary REST calls (including historical and time-series endpoints) for everything else. If you're building richer automation on top of this, our roundup of innovative things to build with an exchange rate API shows where event-driven rate updates pay off.
Summary
| Polling (REST) | Webhooks | |
|---|---|---|
| Who initiates | Your app, repeatedly | Our API, once per update |
| Requests to detect a daily update | Hundreds (mostly wasted) | Zero |
| Time to know about new rates | As good as your poll interval | ~10 min of RBA publishing |
| Scheduling/retry logic | You build it | We handle it |
| Best for | On-demand lookups, batch jobs | Reacting to new rates |
| Availability | All plans | Professional, Business, Enterprise |
RBA rates publish once a business day - so an event-driven model fits the data far better than a clock-driven one. If your integration spends its day asking "anything new yet?", a webhook will make it cheaper, faster, and simpler all at once.
Ready to try it? Create a webhook in your dashboard or read the full webhook reference in the docs. And if you're weighing this against rolling your own rate pipeline, the true cost of scraping RBA data is worth a look first.
Data sourced from Reserve Bank of Australia. We are not affiliated with or endorsed by the Reserve Bank of Australia.
For API documentation, visit Exchange Rates API Docs