Open, ZAR-anchored exchange rates — the open way.
Docs · Quickstart · API · Configuration · Go library · 15 languages · Graph model · Accuracy · Changelog · Roadmap
Current release: v0.1.8
MIT OR Apache-2.0 · Go 1.25+ · no API key · self-hostable
openrate is an open-source exchange-rate engine, and a Go library first.
Import it and it computes; add a Refresher and it fetches; add serve and it
answers HTTP too, with an embedded, dependency-free UI. It ingests rates "the
open way" — from central-bank reference files and free public venue feeds, not
by reselling a paid API — and models every currency as a graph rather than
picking a single canonical base. The self-hosted binary is one deployment of
the library, not the only way to run it: import it. The server is optional.
The UI is optional.
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.
import (
"context"
"github.com/vul-os/openrate"
"github.com/vul-os/openrate/fxsource"
)
e := openrate.NewEngine(openrate.EngineOptions{}) // starts nothing, opens no socket
c, err := e.Convert("USD", "ZAR", 100) // ErrUnknownPair until fed
r := openrate.NewRefresher(e, openrate.RefreshOptions{
Sources: fxsource.Build("ecb,coinbase"),
})
go r.Run(context.Background()) // the ONLY line above that talks to the network
c, err = e.Convert("USD", "ZAR", 100) // now answers from what r fetchedNewEngine on its own is inert: no goroutine, no socket, no environment read,
ever. A host can construct one behind a feature flag that is off and be
certain the process is unchanged — see
the Engine-without-a-Refresher example.
That is not a claim, it is a counted number with a control — see
docs/zero-network.md.
Add a Refresher only when — and exactly when — the process should fetch, and
serve.New (below) only when it should also answer HTTP. Full guide:
docs/library.md.
openrate ships a package for bun, C, C++, Deno, .NET, Elixir, Go, Java, Kotlin, Node, PHP, Python, Ruby, Rust and Swift — fifteen. All fifteen give you the sidecar; all but Elixir also give you an in-process path, behind the same API, so choosing again later is a constructor change rather than a rewrite.
| What it is | The right default for | |
|---|---|---|
| Sidecar | openrate runs as its own process on 127.0.0.1; the package starts it, waits until it is ready, and stops it for you |
.NET · bun · Deno · Elixir · Java · Kotlin · Node · PHP · Python |
| Direct | the engine runs inside your process, over a C shared library — no port, no process, no socket | C · C++ · Go |
| depends | Ruby, where the answer turns on whether your server forks; Rust and Swift, whose packages present both as equally supported | Ruby · Rust · Swift |
Elixir is the one language with no direct mode, deliberately: a crash inside a NIF takes the BEAM down with it. Go is the one with no FFI at all — it imports the package.
Sidecar, in Python. The package spawns the process and returns only once it is ready, not merely alive:
import openrate
from openrate import Client
openrate.start(sources="ecb,coinbase", base_currency="ZAR")
with Client() as client:
answer = client.convert("USD", "ZAR", 100)
print(answer["result"], answer["rate"]["quality"]["grade"])Direct, in C — the shape every other in-process binding wraps. Nothing here opens a socket, and nothing can be configured to:
char *err = NULL;
uint64_t eng = openrate_new("{\"base\":\"ZAR\"}", &err); /* no thread, no socket */
openrate_free(openrate_call(eng, "load", edges_json, &err));
char *out = openrate_call(eng, "convert",
"{\"from\":\"USD\",\"to\":\"ZAR\",\"amount\":100}", &err);
puts(out);
openrate_free(out);
openrate_close(eng);Both return the same JSON document /api/v1 publishes — one wire format for
both modes, held by a test that asks one engine the same questions over each and
requires the answers to be equal by value.
The split is enforced at the ABI, and that — not speed — is direct mode's
argument. openrate_new builds an engine: no thread, no socket, no
environment read. A refresher is a separate, explicit openrate_refresher_new,
and an engine handle refuses "refresh" — checked in Go and again in C,
through a really-dlopen'd library. "The feature is off, therefore nothing is
sent" stops being a promise in a comment and becomes a property of the handle
you hold — in all thirteen non-Go packages that offer a direct mode, because
all thirteen call these same two constructors.
Direct mode is also faster — 3.7 µs per conversion against 33.5 µs over
loopback, about 9× — but read that as a floor with an asterisk: if a conversion
sits behind a database query or a model call, 30 µs is not an argument for
anything. The costs are real and ffi/README.md leads with
them: the Go runtime and its signal handlers move into your process, the library
is not fork-safe (Python multiprocessing, uWSGI, Unicorn), building it needs
cgo and a per-target C toolchain, and the artifact is 6–8 MB. Of the prebuilt
targets only darwin/arm64 has ever been executed and no Windows DLL exists,
so on Windows the sidecar is not a fallback — it is the mode. The sidecar needs
only the openrate binary and has no such matrix.
Start at sdks/README.md — the index, with the reasoning
behind each language's default. docs/quickstart.md
has the one command that runs a working example in each of the fifteen;
docs/c-abi.md is the ABI they are built on, and
docs/deployment-modes.md puts both beside the other
ways to run openrate.
There is deliberately no streaming API and no openrate_stream: a
conversion is a graph lookup and a multiplication, complete the moment it is
asked for. That is a design statement, not a gap.
Importing the library is the primary way in; running the binary is the
deployment option for when you want a standalone JSON API and web console
instead of an in-process engine. Both are the same three pieces
(Engine/Refresher/serve) — the binary just wires them together for you:
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.1.8/scripts/verify.sh
bash verify.sh --tag v0.1.8 --attest openrate_0.1.8_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.
openrate ships as sovereign, self-contained infrastructure — run it however much of it you need, all fully open, keyless, and free:
| Shape | How |
|---|---|
| Compute only | openrate.NewEngine(...) — no fetching, no serving; feed it with Load |
| Compute + fetch | add openrate.NewRefresher(...) — still no HTTP, in-process only |
| Compute + fetch + HTTP | add serve.New(...) — the JSON API, and the UI if Options.UI is set |
| Self-hosted binary | go run ./cmd/openrate — all three, wired together, keyless, hourly refresh |
| Sidecar, from any language | the same binary on 127.0.0.1, spawned and supervised by your language package |
| In-process, from any language | libopenrate loaded over the C ABI — the first two rows, outside Go |
The binary is the fourth row, not a different thing. There is also
openrate.Start(...), the original all-in-one embedding call — it still
works, but it is deprecated in favour of the three explicit pieces above; see
docs/library.md for the migration.
| 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 — the process is up |
GET /readyz |
Readiness — a conversion would succeed; 503 carries the source errors |
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 fxsource.Source and registering it in
fxsource/registry.go. Full catalog + freshness notes: SOURCES.md.
The interface is a single hand-written HTML document (serve/web/ui.html)
embedded via go:embed and mounted at / when Options.UI (binary) or
serve.Options{UI: true} (library) is set — inline CSS and JS, vanilla, no
build step, no npm, no bundler. A build tagged noui compiles the embed out
of the binary entirely instead of merely not serving it; see
docs/library.md for the measured size
difference. 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 serve/web/ui.html directly; there is nothing to
regenerate. See docs/web-ui.md.
openrate.go public package: Engine (computes) + Refresher (fetches); Start (deprecated)
fx the pure core: currency graph, snapshot, accuracy model, Describe
fxsource pluggable FX sources (ecb, coinbase, luno, sarb, … all live) + the only os.Getenv
cmd/openrate entrypoint: wires Engine + Refresher + serve (+ UI) together
serve the optional HTTP shell: JSON API, rate limiting, hardening
serve/web serve/web/ui.html — the embedded UI; compiled out entirely under -tags noui
serve/interest policy-rate endpoints, /api/v1/interest/*
serve/ratelimit the rate limiter (buckets by network prefix: /64 v6, /32 v4)
ffi the C shared library other languages load (its own Go module,
named openrate-ffi so the internal/ wall applies to it too)
sdks fifteen language packages (bun, c, cpp, deno, dotnet, elixir, go,
java, kotlin, node, php, python, ruby, rust, swift). All offer the
sidecar; all but elixir also offer direct. sdks/README.md is the index
embedtest a second module that proves the library is embeddable from outside
internal/rates, the interest-rate engine's own stack (rates, sources, store,
ratesources, quality) — serve-only, not part of the importable surface
ratestore,
ratequality
internal/redact strips API keys that net/http echoes back into fetch errors
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 ./fx |
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) |
node scripts/check-contrast.mjs --selftest |
every muted text tier clears WCAG AA against all three page backgrounds in both themes, and every exception names a claim it still has to meet | 6 mutations, including a stale exception and a scanner that stops matching |
node scripts/check-contrast-rendered.mjs --selftest |
the same floor measured from composited pixels — the only gate that sees opacity, rgba() and alpha-resolving color-mix(), which the token reader above is blind to by construction |
7 deliberate breakages, opening with an opacity fade that leaves every token untouched; plus a positive control, because a gate that refuses everything also refuses every mutation |
bash scripts/check-ffi.sh --selftest |
the C ABI really loads, converts, and refuses a stale library; the shared library carries no console bytes | 4 deliberate defects, each rebuilt via go build -overlay and required to be caught |
Full documentation lives in docs/.
Start here
| Guide | What's inside |
|---|---|
| Quickstart | Five starting points: see it work, run it as a service, embed it in Go, call it from another language, or bring your own rates |
| Which mode should I choose | Library, CLI, sidecar or C ABI — the decision, with measured cost and size for each |
| Troubleshooting | Symptoms in the order they happen, and what each one actually means |
Embedding
| Guide | What's inside |
|---|---|
| Go library | Import Engine/Refresher directly — compute, fetch and serve as separate, opt-in steps |
| Language packages | The index for all fifteen — which of the two modes each language should default to, and why |
| Use it from another language | The C ABI those packages are built on, its honest costs, and what is actually prebuilt |
| Proving it sends nothing | An Engine constructed with the feature off sends zero packets — counted, with a control, and held across the ABI too |
Everything else
| Guide | What's inside |
|---|---|
| API reference | Every endpoint, params, and full response shapes |
| Configuration | Flags, env vars, and the source spec |
| 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 |
| Interest rates | The optional policy/reference-rate engine — internal/ and serve-only |
| 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 (serve/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 atserve/web/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

