Most rate APIs hand you a number and ask you to trust it. openrate hands you the walk it took through the currency graph, every source quoting the pair, how far apart those sources are, and a grade for the lot — built from central-bank files and free public venues, never a resold paid API.
Import it as a Go library and it computes with no server at all — the JSON API and the web UI are both optional layers you opt into, not the only way in.
Not a mockup — every figure above is read off the capture of the running binary in §01, and reappears unchanged in §03 and the API response in §08.
The European Central Bank's daily reference file. Official class — the strongest authority weight the grader gives.
The South African Reserve Bank's own USD, GBP, EUR and JPY fixes against the rand. Official class, daily cadence.
A live public venue, refreshed on the minute. Exchange class — real-time, but weighted below a central bank.
Crypto legs quoted straight into rand, which is how BTC and ETH reach the graph without a USD detour.
Convert an amount and the grade comes with it. Expand the working and the graph path, every source quoting the pair, and the spread between them are right there — the same numbers the API returns, read off the screen instead.
Most rate APIs pick one base currency and derive everything through it, so every number inherits that one feed's problems. openrate keeps each source's quotes in its native base — ECB in EUR, SARB in ZAR, Coinbase in USD — as edges in a currency graph. Any pair is the product of the rates along the shortest path.
Open any rate and the path is drawn: each node a currency, each hop labelled with its own rate, the source that quoted it, and how old that quote is. If a number is a cross-rate, you can see exactly which two numbers it was multiplied from.
?base= for any other.There is no ZAR → AUD quote anywhere in the open feed set, so openrate walks through the dollar and multiplies. Both legs come from Coinbase's USD-based file, both are eight minutes old, and the result is the number the board reports for AUD — reproducible from the two legs printed beside it.
The multiplication happens once, at full precision. The engine multiplies the raw quotes and rounds the answer for display. The panel beside this rounds three numbers separately — each leg, then the rate — and separate roundings do not compose. So the six-decimal legs you can read off reproduce the six-decimal rate only to within that rounding: exactly on ZAR → AUD, not exactly on most crosses. The residual is printed either way, because picking the pair where it vanishes would be the same move as averaging two sources into one number.
Two hops also means nothing to corroborate: corroboration only ever looks
at quotes for the exact pair, and no source quotes ZAR → AUD directly. It returns
a neutral ×1.00 and lets the hop count do the work — which is why no
live pair is ever both multi-hop and multi-source.
2 hops · exchange class · 8m old · corroboration ×1.00 · grade B — the ZAR → AUD row on the board in §05. The IDR residual is two parts per million, and all of it is the cost of printing a 0.060254 leg to six decimals; none of it is the engine, which multiplied 0.06025387367132677 by 18062.7293655 and rounded once.
A central bank's daily fix and a live venue's tick rarely land on the same number, and the gap between them is real information about how much that number can be leaned on. openrate plots both on a scale zoomed to the quotes themselves, marks the mean it did not silently return, and grades the pair on the distance.
ZAR per USD. The mean is drawn quietly on purpose: it is the single number an
averaging API would have handed you instead of the two above it. Both quotes, the
mean and the standard deviation come back in the corroboration block
on every response — nothing on this scale is computed by this page.
Same pair, same second, two defensible sources, and forty-two rand between them on a thousand dollars. Small — until the amount isn't, and a single averaged number would never have told you the choice was there to make.
Linear, 0–100 bps. 25.4 is well inside the 50 bps agreement tolerance, so
agree comes back true — and it still misses the ≤25 band
that returns ×1.00, by four-tenths of a basis point, so corroboration is
docked to ×0.93 anyway. Past 100 bps the factor drops to ×0.85, and past
300 to ×0.72.
Tighten the spread inside 25 bps and corroboration returns ×1.00 — 0.86, still a B, because a venue is also weighted below a central bank. Both have to be true to reach an A. Below is what that looks like.
sarb 0.052599 at 13 hours old and ecb 0.052549 at 37, both official class — 9.6 bps apart. Inside the ≤25 band, so corroboration returns ×1.00 and nothing else is deducted. Two independent central banks, checked against each other, agreeing to within a thousandth of a percent. That is what an A looks like.
Two sources, 25 bps apart — shown, not smoothed over. Both quotes are legible in the captures in §01 and §05.
Every price carries a letter grade and a 0–1 confidence. The confidence is a plain product of five factors — freshness, directness, source authority, cross-source agreement and currency caveats — and each factor is returned with the rate, so you can re-derive the score instead of taking it on faith.
Fresh, directly quoted, official class, and corroborated by independent sources that agree inside 25 bps.
One weak link — a venue rather than a central bank, one triangulation hop, or sources that disagree.
Stale, multi-hop, or a lone uncorroborated quote with nothing to check it against.
Something is materially wrong with the provenance. Read the caveats before using it.
Both of these are on this page already. They are one source class and one spread apart, and that is the entire distance between a B and an A.
| Factor | USD → ZAR | ZAR → EUR |
|---|---|---|
| Freshness | ×0.90 8m, current | ×0.90 13h, current |
| Directness | ×1.00 1 hop | ×1.00 1 hop |
| Source authority | ×0.96 exchange | ×1.00 official |
| Corroboration | ×0.93 25.4 bps | ×1.00 9.6 bps |
| Caveats | ×1.00 | ×1.00 |
| Confidence | 0.80 B | 0.90 A |
USD → ZAR 0.90 × 1.00 × 0.96 × 0.93 × 1.00 = 0.80 → B
ZAR → EUR 0.90 × 1.00 × 1.00 × 1.00 × 1.00 = 0.90 → A
Confidence is a product, not an opinion. Each factor comes from a fixed set —
freshness is one of 1.00 / 0.90 / 0.72 / 0.45, corroboration one of
1.00 / 0.93 / 0.85 / 0.72 for a corroborated pair and 0.88
for a lone quote — so there is no dial to nudge, and the grade is struck from the
rounded value that gets published rather than the raw product behind it.
A lone quote, however fresh, is multiplied by 0.88 and can never reach an A — there
is nothing to check it against, which is why BTC, ETH, USDC and
USDT, each quoted by Luno alone, grade C on the board in §05 while the fiat
rows either side of them grade B.
NGN and EGP carry a parallel-market caveat and CNY a
managed-regime one, each capping confidence at ×0.7, so those pairs sit at C or D no
matter how good the feed is. And a currency that no longer trades is not quietly
dropped — HRK is multiplied by 0.2, which is a D floor no
arithmetic can climb out of. The number is still returned; it is just labelled for
what it is.
The whole board, every currency against the anchor, sortable on every column — because the useful question is rarely "what is 1 ZAR in AED", it is "which of these numbers should I not lean on". Hop counts show at a glance which rates are quoted and which are derived; sorting on them, as the capture below does, floats the eight directly quoted pairs to the top. Any row opens into the full working in place.
The same binary carries a second engine for central-bank policy rates — 48 reference areas pulled from the BIS in a single call, each with daily history, plus the South African ZARONIA family. They are flat series rather than a currency graph, so they get their own store and a slower refresh, but the identical A–D grading.
AR AT AU BE BR CA CH CL CN CO CZ DE DK ES FR GB GR HK HR HU ID IL IN IS IT JP KR KW MA MK MX MY NL NO NZ PE PH PL PT RO RS RU SA SE TH TR US XM ZA
The grade earns its keep here. Amber marks pre-euro national areas that sit in the list alongside XM, the euro area itself — a series can be complete, correctly parsed and still be historic. An explicit observation date and a letter grade on every series are what stop an old fix passing for today's.
The engine, the JSON API and the web UI are one Go binary with the interface embedded, built on the standard library and nothing else. Nothing to sign up for, no API key, no account — and no telemetry phoning home.
MIT OR Apache-2.0, at your option.
# clone & run — serves the API and the UI on :8080
$ git clone https://github.com/vul-os/openrate
$ cd openrate
$ go run ./cmd/openrate
INFO interest rates enabled sources=2 refresh=6h0m0s
INFO openrate listening addr=:8080 base=ZAR refresh=1h0m0s ui=true
# or install the command directly
$ go install github.com/vul-os/openrate/cmd/openrate@latest
$ openrate -addr :8080 -base ZAR -refresh 5m
# or in Docker
$ docker build -t openrate . && docker run -p 8080:8080 openrate
# or API-only — no console compiled in; / answers with JSON
$ go build -tags noui -o openrate ./cmd/openrate
go build is the entire supply chain./api/, a restrictive robots.txt, no-store on the API, and X-Forwarded-For trusted only for proxies you name.import (
"context"
"github.com/vul-os/openrate"
"github.com/vul-os/openrate/fxsource"
)
// Starts nothing: no goroutine, no socket, no environment read.
e := openrate.NewEngine(openrate.EngineOptions{})
c, err := e.Convert("USD", "ZAR", 100) // ErrUnknownPair until fed
// Opt in to fetching — the only line here that touches the network.
r := openrate.NewRefresher(e, openrate.RefreshOptions{
Sources: fxsource.Build("ecb,coinbase"),
})
go r.Run(context.Background())
c, err = e.Convert("USD", "ZAR", 100) // now answers from what r fetched
The engine, the fetcher and the HTTP shell are three separate types —
Engine, Refresher, serve.Server — and importing
one does not pull in the others. Construct an Engine behind a feature
flag that's off and the process is provably unchanged: nothing it does is
observable until something feeds it a snapshot.
Provably is a counted number, not a turn of phrase. Every adapter is rewired
through a transport that counts round trips, an Engine and a
Refresher over all thirteen sources are constructed, and the count has
to be zero — then the test fetches once on purpose and the number has to move, because
a counter wired to nothing reports zero forever.
How that is held.
Add a Refresher only when the program should fetch, and
serve.New only when it should also answer HTTP — the binary above is
just those three pieces, wired together for you. Full guide:
the embedding docs.
An engine handle refuses refresh. openrate_new starts
no goroutine, opens no socket and reads no environment — it answers from whatever
snapshot it holds. Fetching is a second, explicit handle, and asking an engine for it
is an error the ABI returns rather than a call it quietly makes. That refusal is
checked in Go and again in C, which is what turns “the feature is off, so nothing is
sent” from a promise in a comment into a property of the interface — and it is
why beepbite could delete its HTTP client outright.
The C ABI ·
how the zero is held.
A package import, and that is the whole story: no shared library, no
cgo, no platform matrix, nothing to install and nothing to ship
beside your binary. go get, and the engine is a type in your process.
It is the cleanest path openrate has.
Run the binary, talk to it over loopback. No toolchain, no linking, no Go runtime inside your process. Every language already has an HTTP client, and openrate stays restartable and crash-isolated on its own.
libopenrate is six C functions — JSON in, JSON out — and 3.7 µs a
conversion against 33.5 µs over a warm loopback connection. It is
not fork-safe, and the Go runtime's signal
handlers move into your process. Both facts are stated before the benchmark, not
after it.
Every one of the fifteen implements both modes behind one API, so changing your mind later is a constructor argument — the default above is only where its README starts you. Elixir is the single exception, and says so: sidecar only, because a NIF cannot be killed or timed out. The package index carries the binding each one uses · which mode should I choose.
Six JSON endpoints, CORS-enabled and rate-limited per IP, plus a plain-text liveness probe that is neither. The embedded UI is not rate-limited either.
| Path | What it returns |
|---|---|
| GET /api/v1/convert | ?from=USD&to=ZAR&amount=100 — the converted amount plus the rate, its path, per-leg calculation, contributing source quotes and quality block |
| GET /api/v1/rates | ?base=ZAR — every reachable currency against a base, each carrying the same rate object |
| GET /api/v1/meta | Source status, per-source edge counts, and the currency list |
| GET /api/v1/interest/rates | ?type=policy — the latest observation per series, graded |
| GET /api/v1/interest/series | ?id=za.policy — the full observation history for one series |
| GET /api/v1/interest/meta | Areas, the series catalogue and interest-source status |
| GET /healthz | Liveness probe — plain text ok, outside /api/ and outside the limiter |
{
"amount": 100,
"from": "USD",
"rate": {
"rate": 16.566672,
"hops": 1,
"as_of": "2026-07-31T12:21:49Z",
"age_sec": 480,
"path": ["USD", "ZAR"],
"sources": ["coinbase"],
"quality": {
"grade": "B",
"confidence": 0.8,
"freshness": "current",
"directness": "direct",
"source_class": "exchange",
"corroboration": {
"sources": 2,
"spread_bps": 25.4,
"agree": true,
"mean": 16.545686,
"stdev": 0.029678,
"stdev_bps": 17.94,
"min": 16.5247,
"max": 16.566672
}
},
"legs": [
{ "from": "USD", "to": "ZAR", "rate": 16.566672, "source": "coinbase", "age_sec": 480 }
],
"quotes": [
{ "source": "coinbase", "rate": 16.566672, "age_sec": 480 },
{ "source": "sarb", "rate": 16.5247, "age_sec": 46800 }
]
},
"result": 1656.6672,
"to": "ZAR"
}
Field names, their order and the nesting are exactly what the encoder emits — the top level is a map, so Go sorts those five keys alphabetically. The values are the quote captured in §01, with floats shown at the precision the UI displays them.