openrate
Open exchange-rate engine

Every rate,
with its receipts.

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.

Quote docket GET /api/v1/convert

Amount
100.00 USD
Receives
1 656.67 ZAR

Rate
16.566672
Path
USD → ZAR
Hops
1 · direct

coinbase
16.566672 · 8m
sarb
16.5247 · 13h
Spread
25.4 bps
Agree
true

Confidence0.80 B

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.

43currency codes
39 fiat · 4 crypto
4sources on by default
no key, no account
0third-party Go
dependencies
1binary
no database
ecb base EUR

The European Central Bank's daily reference file. Official class — the strongest authority weight the grader gives.

sarb quotes ZAR

The South African Reserve Bank's own USD, GBP, EUR and JPY fixes against the rand. Official class, daily cadence.

coinbase base USD

A live public venue, refreshed on the minute. Exchange class — real-time, but weighted below a central bank.

luno quotes ZAR

Crypto legs quoted straight into rand, which is how BTC and ETH reach the graph without a USD detour.

01
The product

Not a mockup. The running app.

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.

localhost:8080 — openrate · Convert
Capture unavailable
A real capture of the running binary against live ECB, SARB, Coinbase and Luno data — the quote every figure on this page is drawn from
02
The model

A graph, not a base.

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.

You can see the walk

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.

  • Direct quotes win. Breadth-first search reaches a pair by the fewest hops first, so a quoted pair always beats a triangulated one.
  • Freshest breaks ties. Each node's edges are sorted newest-first, so among equal-length paths the most recent edge is taken.
  • Age is the oldest edge. A path is only as fresh as its stalest leg, and that is the age reported.
  • Any base is free. ZAR is the default view, not a privileged position — pass ?base= for any other.
  • No single point of contamination. A bad edge only affects paths that cross it, not every pair in the set.
Each source keeps its own base. Two edges here carry two independent sources apiece — that doubling is what §03 measures

And a cross-rate is just a product

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.

ZAR → AUD the capture in §05 0.060362×1.425186=0.086027 rate as displayed 0.086027 · residual 0.000000
ZAR → IDR same binary, same feed set, a pair where it does not land 0.060254×18062.729366=1088.351695 rate as displayed 1088.349413 · residual +0.002282

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.

ZAR → USD → AUD, drawn — each leg multiplied, not averaged. Both ends are read off the captures (0.060362 on the converter's inverse line in §01 and again as the USD row in §05, 0.086027 as the AUD row in §05); the middle leg is the number that joins them. The rows underneath are the working checked against itself: the cross rate is the product of the legs at full precision, rounded once, so multiplying the legs as printed agrees only to within display rounding — and the gap, when there is one, is printed rather than tidied away.
03
Dispersion

Two sources. Twenty-five basis points apart.

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.

USD → ZAR 2 direct quotes · 1 hop, direct
25.4 basis points apart
16.50 16.52 16.54 16.56 16.58 16.60

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.

What 25.4 bps is worth
coinbase
R16 566.67
sarb
R16 524.70
on 1 000 USD
R41.97

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.

Against the bands it is graded on
25.4USD→ZAR 50agree 9.6ZAR→EUR

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.

What it costs the grade
freshness ×0.90 8m — current directness ×1.00 direct authority ×0.96 exchange corroboration ×0.93 25.4 bps caveats ×1.00 none 0.80B

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.

ZAR → EUR · the same figure, drawn on a pair that clears the band

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.

0.90A

Two sources, 25 bps apart — shown, not smoothed over. Both quotes are legible in the captures in §01 and §05.

04
Accuracy

Graded, and reproducible.

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.

A≥ 0.90
Trust it

Fresh, directly quoted, official class, and corroborated by independent sources that agree inside 25 bps.

B≥ 0.78
Good

One weak link — a venue rather than a central bank, one triangulation hop, or sources that disagree.

C≥ 0.60
Use with care

Stale, multi-hop, or a lone uncorroborated quote with nothing to check it against.

D< 0.60
Flagged

Something is materially wrong with the provenance. Read the caveats before using it.

The equation, run on two real quotes

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.

Honest about its own limits

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.

05
Live board

Sort by what you can't trust.

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.

localhost:8080 — openrate · Live rates
Capture unavailable
42 pairs against the ZAR anchor, sorted by path so the eight directly quoted pairs surface first — and with them a C, an A and a B in the same eyeful. The expanded EUR row is the counterpoint quote from §03: two central banks, 9.6 bps apart, graded A
06
Central banks

Policy rates, same contract.

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.

Reference areas returned by one BIS call

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.

Flat series, not a graph — and stepped, because a policy rate holds and jumps at a meeting rather than drifting between them
07
Self-host

One binary. No database.

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.

terminal
# 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
  1. 01
    Nothing to configureThe default source set — ECB, SARB, Coinbase, Luno — is free and needs no keys. It works on first run.
  2. 02
    Nothing to vendorZero third-party Go modules. The whole engine is the standard library, so go build is the entire supply chain.
  3. 03
    Bring your own feeds if you wantDrop an OXR, Twelve Data, Polygon or TraderMade key into the environment and that source auto-enables. More overlapping sources means better corroboration, which means better grades.
  4. 04
    Fails softA source that errors keeps its previous edges rather than blanking the graph, and its error is redacted before it is logged so a key embedded in a URL never leaks.
  5. 05
    Hardened by defaultPer-IP rate limiting on /api/, a restrictive robots.txt, no-store on the API, and X-Forwarded-For trusted only for proxies you name.
main.go
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

Or skip the binary entirely

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.

Not a Go program? Fifteen packages · two ways in · same JSON
0 packets an engine can send

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.

Go · no FFI at all

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.

Sidecar · the default elsewhere

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.

In-process · when it pays

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.

Direct by default
go c cpp
Sidecar by default
Either · see the README

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.

08
The surface

Six endpoints. All read-only.

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.

PathWhat 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/metaSource 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/metaAreas, the series catalogue and interest-source status
GET /healthzLiveness probe — plain text ok, outside /api/ and outside the limiter
GET /api/v1/convert?from=USD&to=ZAR&amount=100
{
  "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.