Open, ZAR-anchored exchange rates — the open way.
Docs · API · Configuration · Go library · Graph model · Accuracy · Changelog · Roadmap
Current release: v0.2.0
MIT OR Apache-2.0 · Go 1.25+ · no API key · self-hostable
openrate is an open-source exchange-rate engine. It ingests rates "the open way" — from central-bank reference files and free public venue feeds, not by reselling a paid API — models every currency as a graph rather than picking a single canonical base, and serves an all-pairs JSON API plus a single hand-written, dependency-free embedded UI from a single Go binary.
Most rate APIs hand you a number and ask you to trust it. openrate hands you the walk it took through the currency graph, the individual source quotes behind every hop, how far apart those sources are, and a grade for the lot:
Both screenshots are real captures of the running engine against live source data. More on the site.
Most rate APIs pick one base currency (usually EUR/USD) and derive everything through it. openrate keeps each source's quotes in their native base (ECB in EUR, SARB in ZAR, …) as edges in a currency graph. Any pair is the product of rates along the shortest path between them, so:
- ZAR is the anchor for free — it's just the default presentation base, a
view over the same graph (
?base=ZAR, or any other). - Directly quoted pairs win — BFS reaches a pair by the fewest hops first, so a direct quote always beats a triangulated cross.
- No single point of contamination — a bad edge only affects paths through it, not every pair.
- Provenance on every number — each rate carries
hops,as_of, andage, so consumers see exactly how stale it is (it matters: fiat is frozen on weekends). - The working is checkable — a pair's
rateis the product of itslegsexactly, bit for bit, so you can recompute it from the response. That holds at full precision only: round the legs and the rate for display and they stop agreeing in the last place, which is why the UI prints the residual rather than implying there isn't one. See the graph model.
go run ./cmd/openrate # serves :8080, base ZAR, hourly refresh
# or
go build -o openrate ./cmd/openrate && ./openrate -addr :8080 -base ZAR -refresh 1hConfig via flags or env: OPENRATE_ADDR, OPENRATE_BASE, OPENRATE_REFRESH,
OPENRATE_SOURCES, OPENRATE_RATELIMIT. Full reference:
docs/configuration.md.
With Docker:
docker build -t openrate . && docker run -p 8080:8080 openrateReleases publish a source archive plus a SHA256SUMS manifest covering every
published asset, and a sigstore build-provenance attestation minted from the
release workflow's OIDC identity (no long-lived signing key exists, so there is
none to leak or rotate). scripts/verify.sh is what you run against them:
curl -fsSLO https://raw.githubusercontent.com/vul-os/openrate/v0.2.0/scripts/verify.sh
bash verify.sh --tag v0.2.0 --attest openrate_0.2.0_source.zipIt fetches the manifest, looks up the exact entry for the asset (names are
matched as strings, not as regexes) and compares digests. It has two outcomes:
verified, or non-zero with a diagnostic naming what was wrong — a missing or
malformed manifest, a missing entry, a truncated download, a digest mismatch, an
HTML error page served where bytes were expected. There is no --skip-verify,
and a SHA256SUMS that 404s is a failure, never "nothing to check".
--attest additionally verifies the provenance (needs the gh CLI); leave it
off and the script says out loud that provenance was not checked, so a pass
never implies more than it checked.
bash scripts/verify.sh --selftest runs 24 synthetic-origin cases asserting
that each refusal still fires; CI runs it on every push.
Instead of running the binary, import the root package and run the engine
in-process — no subprocess, same store/sources/API/hardening as cmd/openrate:
import "github.com/vul-os/openrate"
local, err := openrate.Start(openrate.Options{}) // ZAR base, hourly refresh, ephemeral port
if err != nil {
log.Fatal(err)
}
defer local.Close()
resp, _ := http.Get(local.APIBaseURL() + "/rates") // or local.BaseURL + "/healthz"Options mirrors the binary's flags (Addr, Base, Refresh, Sources,
RateLimit, ServeUI). Start returns once /healthz is serving. The engine's
building blocks stay under internal/; this package is the supported public API.
openrate ships as sovereign, self-contained infrastructure — run it two ways, both fully open, keyless, and free:
| Shape | How |
|---|---|
| Self-hosted binary | go run ./cmd/openrate — keyless, all sources, hourly refresh |
| Embedded Go library | openrate.Start(...) in-process — the same engine, no subprocess |
| Endpoint | Description |
|---|---|
GET /api/v1/rates?base=ZAR |
All currencies vs base; rate reads "1 base = rate CCY" |
GET /api/v1/convert?from=USD&to=ZAR&amount=100 |
Convert, with rate provenance |
GET /api/v1/meta |
Sources, freshness, currency list |
GET /healthz |
Liveness |
Every rate includes hops, as_of, age_sec, the path and sources, plus a
quality block (grade A–D + confidence) — see below. Full request/response
shapes: docs/api.md.
A separate, flat time-series engine (no currency graph) for central-bank policy
and reference rates worldwide. Enable with -interest-sources (binary) or
Options{Interest: true} (library). Served alongside the FX API:
| Endpoint | Description |
|---|---|
GET /api/v1/interest/rates?area=US&type=policy |
Latest value per series + confidence grade |
GET /api/v1/interest/series?id=us.policy |
One series with full history (timeseries) |
GET /api/v1/interest/meta |
Areas covered, series catalogue, source status |
Out of the box (bis,sarbrates, no keys) this covers 48 central banks' policy
rates with daily history plus the South African ZARONIA family; set
OPENRATE_FRED_API_KEY to auto-enable US benchmark series. Each series carries an
interest-tuned quality grade. See docs/interest-rates.md.
Every price carries a quality assessment so you know how much to trust it:
"quality": {
"grade": "B", "confidence": 0.89,
"freshness": "realtime", "directness": "direct", "source_class": "exchange",
"corroboration": { "sources": 4, "spread_bps": 29, "agree": true }
}The grade combines freshness (edge age), directness (hop count), source authority (official > exchange > aggregator > unofficial), cross-source agreement (spread in bps), and per-currency caveats (e.g. NGN/EGP/CNY official-vs-parallel-rate flags). Full model: ACCURACY.md. The web UI shows the grade badge in the converter and, per row, a "show the working" panel with the path, sources and spread that produced it — there is no separate in-app Accuracy page.
Selectable with -sources (or OPENRATE_SOURCES). Default: ecb,coinbase,luno,sarb.
| Source | Default | Cadence | Notes |
|---|---|---|---|
| ECB daily file | ✅ | daily | EUR-base, ~30 currencies, ~16:00 CET |
| Coinbase | ✅ | real-time | free/no-auth fiat (incl. ZAR) + crypto — best open intraday source |
| Luno | ✅ | real-time | SA exchange, live BTC/ETH/USDT vs ZAR; bridges to fiat via BTC |
| SARB | ✅ | daily | authoritative ZAR (per USD/GBP/EUR/JPY); slow host → bounded dialer + retries |
| Frankfurter | opt-in | daily | clean JSON ECB mirror |
| open.er-api | opt-in | daily incl. weekends | fills the ECB Fri→Mon gap |
| fawazahmed0 | opt-in | daily | ~400 currencies, dual-CDN, no limits |
| Bank of Canada | opt-in | daily | Valet REST, independent cross-check |
| Yahoo Finance | opt-in | ~1 min | unofficial, ToS-prohibited, rate-limited — last resort |
Four further sources are key-gated and auto-enable when their API key is
present: Open Exchange Rates, Twelve Data, Polygon.io and TraderMade. They are
not open data, so they are off unless you bring a key — see
.env.example and SOURCES.md.
Because the graph prefers the freshest direct edge, USD→ZAR resolves to the
live Coinbase quote (~seconds old) while EUR/GBP/JPY→ZAR resolve to SARB's
authoritative direct quotes — each chosen automatically, no special-casing.
Add a source by implementing sources.Source and registering it in
internal/sources/registry.go. Full catalog + freshness notes: SOURCES.md.
The interface is a single hand-written HTML document (web/ui.html) embedded
in the binary via go:embed — inline CSS and JS, vanilla, no build step, no
npm, no bundler. It has the converter (with the live grade badge and a "show
the working" panel: graph path, hops, sources, spread, and — on a triangulated
pair — the displayed legs multiplied out against the displayed rate, residual
included) and a sortable,
filterable board of every pair with its grade, in a dark theme and a light
one that follow the system preference or a manual toggle. There is no
in-binary docs viewer and no policy/interest-rate UI — /api/v1/interest/*
still works, it just has no page of its own right now.
go run ./cmd/openrate # then open http://localhost:8080 — the UI is served at "/"Editing the UI means editing web/ui.html directly; there is nothing to
regenerate. See docs/web-ui.md.
openrate.go public package: embed the engine in-process (Start/Close)
cmd/openrate entrypoint: wires sources -> store -> api + UI
internal/graph currency graph, BFS all-pairs materialization
internal/sources pluggable FX sources (ecb, coinbase, luno, sarb, … all live)
internal/store ingest loop + snapshot store
internal/quality the grade/confidence model attached to every rate
internal/api JSON read endpoints
internal/ratesapi policy-rate endpoints, /api/v1/interest/*
web web/ui.html — the embedded UI (plain HTML/CSS/JS, no build step, embedded via go:embed)
site the static site: landing, docs viewer, generated site/docs
site/gen regenerates site/docs from the canonical docs (CI-gated), and
re-derives the landing's cross-rate arithmetic from its own digits
scripts release verifier + the landing's screenshot gate (both self-testing)
Every check below fails closed and each has a mode that proves it still refuses, because a guard that has quietly stopped failing looks exactly like a healthy build:
| Gate | Protects | Selftest |
|---|---|---|
bash scripts/verify.sh --selftest |
a release artifact is the published bytes | 24 synthetic-origin failures |
go test ./site/gen |
site/docs is generated and link-clean; the landing's §02 arithmetic is what its own printed digits produce, residual included | coverage floors on every scan |
go test ./internal/graph |
a pair's rate is the product of its legs bit-for-bit, and the displayed legs agree only within display rounding | fails if the fixture drifts to values where rounding happens to land |
node scripts/check-shots.mjs --selftest |
every capture the landing displays is its display box's shape, and at least 2x sampled | 5 deliberate breakages (crop, blur, 404, stale attrs, empty selector) |
Full documentation lives in docs/.
| Guide | What's inside |
|---|---|
| API reference | Every endpoint, params, and full response shapes |
| Configuration | Flags, env vars, and the source spec |
| Go library | Embed the engine in-process with Start/Close |
| Graph model | Why currencies are a graph, not a base |
| Accuracy & quality | The grade/confidence model behind every rate |
| Sources | Full source catalog, cadence, and provenance |
| Web UI | The embedded, dependency-free HTML UI (converter + rates board) |
The mark in brand/ is the source of truth. Every icon this repo
ships — favicon, PWA and app icons, the mark in the README and on the site — is
rendered from brand/logo.svg rather than redrawn, so there is one approved
drawing and no second copy to drift.
Copy it outward, never edit a derived copy, and never edit brand/ to match
something downstream.
MIT OR Apache-2.0 — © VulOS. openrate is a VulOS project; source and issues at github.com/vul-os/openrate.
openrate redistributes third-party software: the Go standard library and any Go
modules compiled into the binary, plus the browser assets vendored — as
committed files, not npm packages — into the marketing site: the Geist Sans
and Geist Mono webfonts under site/assets/fonts/ (whose OFL-1.1 licence
must travel with the shipped .woff2 files) and the highlight.js/marked
bundles under site/assets/vendor/. The embedded UI (web/ui.html) ships zero
npm-derived code and no webfonts — system font stacks only — so there is
nothing of its own to attribute. Their licences (MIT, BSD, Apache-2.0, OFL-1.1)
require the copyright notice and licence text to accompany every copy.
- THIRD-PARTY-NOTICES.txt — name, version, licence and
full text for every component. Generated from the real dependency graph by
scripts/gen-notices.sh(Go: go-licence-detector; fonts and vendored JS: read directly off the committed files), never hand-edited. - Both the binary and the marketing site serve it at
/licenses.txt: the binary embeds a physical copy atweb/THIRD-PARTY-NOTICES.txt(web.Licenses, linked from the UI topbar; kept byte-identical to the root file by a test), and the site serves its own copy the same way (linked from its footer). - Vendored site bundles carry their upstream licence next to them, e.g.
site/assets/vendor/marked.umd.js.LICENSE.
![]()
vulos — open by design

