Adding a New Provider
Checklist for adding a new exchange rate data provider. Each step references an existing provider as a pattern to follow.
Before You Start
- Identify the API endpoint and authentication requirements
- Verify the API is accessible — make a test request and confirm you get a 200 response with valid data. If the API returns 403, times out, or is otherwise inaccessible, stop here. Do not proceed with a hand-crafted cassette or fake data.
- Read the API docs — understand pagination, date filtering, and rate limiting. Some APIs require specific params for date ranges (e.g., HKMA needs
choose=end_of_dayforfrom/toto work). Getting this right avoids downloading the entire dataset on every request. - Confirm the base currency and available quote currencies
- Check the publish schedule (timezone, frequency, days of week)
- Determine the earliest available date for historical data (goes in
coverage_startin the seed file)
Go/no-go: does the provider backfill meaningfully?
Before writing code, confirm the provider has a usable historical archive. If not, don't implement it — forward-only providers accumulate permanent gaps each year and the maintenance cost outweighs the value.
Stop and skip the provider if either of these is true:
- Live page only, no historical endpoint. Every year (or whenever the scheduler misses a window) leaves a permanent hole. Even a fresh deploy is forward-only from day one.
- Archive exists but lags badly. An archive that's months behind the live page means anything between the last archive entry and "now" is permanently lost if we don't catch it live.
If the provider has a real archive (downloadable history reaching back years, not just the current snapshot), proceed. Note coverage_start from the earliest archive date and continue with the checklist.
Licence: when terms block a provider
Find the source's terms or legal page and record it as terms_url (null if there is none). Read it. A licence blocks a provider only when it explicitly forbids what Frankfurter does: free redistribution of published official rates, with attribution, by a non-commercial open-source service.
Proceed on any of these:
- Attribution-only terms.
- Non-commercial-only clauses. Frankfurter has no paid tier and no API key, so "may be used for non-commercial purposes with acknowledgement" describes us rather than excludes us.
- Generic "no reproduction or distribution without written permission" boilerplate. Nearly every state-affiliated site carries it; it is aimed at commercial resale of market data, and a published reference rate is an official announcement, not a data product.
In the last case, ship with attribution and send a short courtesy notice to the institution's general contact: who we are, what we republish, and that we will remove it on request. Do not hold the PR on a reply. Quote the clause in the PR's License section so the call is on record.
Stop only when the terms single out what we do (free or non-commercial redistribution, aggregators, APIs, automated access) or when the institution has already asked us to stop.
A takedown request is handled like any other provider loss: remove the adapter, seed, cassette and rows, and note it on the provider's issue.
Implementation Checklist
1. Adapter class — lib/provider/adapters/<key>.rb
Inherit from Provider::Adapters::Adapter. See any existing adapter for the pattern (e.g. lib/provider/adapters/boi.rb).
Required:
fetch(after: nil, upto: nil)— fetches from the source API, returns an array of records- Each record:
{ date:, base:, quote:, rate: }(noprovider:— the Provider model stamps that during import) - Rate direction: match the provider's native convention.
pivot_currencymay appear as eitherbaseorquotedepending on the source — don't invert.- ECB publishes
1 EUR = X foreign, pivot EUR goes inbase(seelib/provider/adapters/ecb.rb). - NBG and BBK publish
1 foreign = X pivot, pivot goes inquote(seelib/provider/adapters/nbg.rbandlib/provider/adapters/bbk.rb). - Store what the provider returns. Inverting in the adapter invites direction bugs and diverges from the blender's expectations.
- Direction is per row, not per provider. A single feed may mix both orientations, and that is fine: record each row as published rather than normalizing the odd ones to match the majority. SARB quotes USD, GBP and EUR as ZAR-per-foreign but everything else as foreign-per-ZAR; CBK publishes KES-per-foreign except for its East African cross rates; FRED and CBC carry a per-series
[quote, base]map.BaseConversioninverts and cross-converts at query time, andRateScopesmatches the pivot on either side, so nothing downstream needs a uniform orientation. - Normalizing costs real precision.
1/xalmost never terminates, so inverting at ingest turns an exact published figure into a repeating decimal and stores it. It also defeats the passthrough contract inRateQuery#emit_records, which echoes stored digits for pairs the provider published and rounds everything else: the stored orientation is the provenance, so a computed reciprocal stored as a row inherits echo privileges it has not earned.
- ECB publishes
Optional class methods (inside class << self):
backfill_range = N— if the API needs chunked requests (e.g. max 100 results per call). The base classfetch_eachuses this to iterate in windows.def api_key = ENV["X_API_KEY"] || raise("no API key")— if the API requires authentication. This is not a blocker — implement the adapter regardless. It activates when the key is configured at deploy time.
Notes:
- Adapters have no
keyorname— Provider model owns identity. The adapter class name must match the provider key (e.g.,Provider::Adapters::ECBfor key"ECB"). - Keys never contain hyphens. When the bare acronym would collide with another provider, smush in a country-code suffix instead of hyphenating. Hyphens break Ruby constant naming and complicate file paths.
- The
baseandquotein each record are determined by the data, not a class method parseis a convention (not enforced by the base class) — most adapters define aparsemethod for unit-testable parsing, called fromfetch- Handle unit multipliers (per-100, per-1000) by dividing to normalize to per-1-unit rates. Guard against zero units before dividing.
- If the source publishes buy and sell prices instead of a reference rate, coerce them with the base class's
midpoint(buy, sell), not(buy + sell) / 2.0. The mid is our own synthesis, so it has no published digits to echo, and float arithmetic leaves noise in the low ones that single-provider responses show verbatim (#579). - Do not rescue errors — let HTTP errors, timeouts, parse failures, and other exceptions bubble up. The scheduler handles retries; swallowing errors silently hides broken providers.
- Per-day APIs: Some APIs only return rates for a single date per request. A full backfill from e.g. 2000 means ~6,800 requests. Use
backfill_rangeto chunk into small windows (e.g. 30 days) and add asleepbetween requests to be polite. The base classfetch_eachhandles the iteration loop. Seelib/provider/adapters/nbg.rbfor a working example.
The http client
The base class provides a private http method (an HTTP::Client from the http gem): call http.get(url) or http.post(url, ...) rather than reaching for Net::HTTP or another client. It's pre-configured with a User-Agent, connect/write/read timeouts, and retriable 429s (Retry-After is honored automatically, so adapters never need to handle rate limiting themselves).
- Non-2xx raises. Any response outside the 2xx range raises
HTTP::StatusError, including redirects to a moved or retired page. There's no silent empty-array fallback: a bad response must fail the fetch, not look like a genuine no-data day. - Tolerate specific statuses at the call site, not by rescuing broadly. See
lib/provider/adapters/nbp.rb, which expects 404 for date ranges with no working days:def fetch_rates(table_url, start_date, end_date) parse(http.get("#{table_url}/#{start_date}/#{end_date}/?format=json").to_s) rescue HTTP::StatusError => e raise unless e.response.code == 404 [] end - Semantic failures (the response is 200 but doesn't contain what the adapter expects: a missing download link, an empty workbook) raise
RuntimeErrorwith a message that names the provider and what went wrong, e.g.raise "no workbook link on #{DATA_URL}"(seelib/provider/adapters/cbs.rb). - Timeouts: the shared client sets connect 10s, write 60s, read 120s. The read deadline is per socket read (it resets on every chunk), so slow-but-steady downloads never trip it; only a server silent for over two minutes does. No per-adapter tuning.
- Exotic patterns: reach for these only when a provider needs them:
- Custom TLS trust: pass a per-request
ssl_context(seelib/provider/adapters/boa.rb,rbv.rb). http.persistent(BASE_URL) { |client| ... }for endpoints that misbehave across separate connections, or where you want exact parity with a legacy single-connection flow (seelib/provider/adapters/bota.rb).- Cookie-based login legs: read
response.headers.get("Set-Cookie")off the first response and forward it on the next request (seelib/provider/adapters/cbe.rb,mas.rb,bi.rb,nbc.rb).
- Custom TLS trust: pass a per-request
Redenominated and relabelled currencies
Archives often label a currency's whole history with its current ISO code. Before trusting a code, dump one file per year and look for a 1000x-plus jump in a value at a known redenomination date. Two flavours, handled differently:
- Restated series. The source converted old values into the successor unit. ECB and TCMB publish pre-2005 TRY as TRL divided by a million, so 2004 reads
EUR/TRY 1.829. Relay as published: it is what the issuing bank itself reports, and the series is continuous. - Relabelled only. The values are the predecessor's magnitudes under the successor code. CBAR's 2005-12-30 file quotes
1 USD = 4593 "AZN", old manat; LB's AZN series is the same. Map the code back to the predecessor by date in the adapter with aPREDECESSORStable (seelib/provider/adapters/cbar.rb,lb.rb):
Key each entry on the source's switch date, which can trail the official one (LB kept quoting old manat until 2006-01-09), and verify it against the rows either side. Check the nominal at the same time: CBAR's TRL rows say Nominal 1 but price 1000 TRL.PREDECESSORS = { "AZN" => ["AZM", Date.new(2006, 1, 9)] }.freeze
Two more things the relabel needs:
Current databases also have blended_weekly_rates and blended_monthly_rates. For new repair migrations, invalidate complete affected grouped buckets in the same transaction as provider rollup changes, including old bucket dates that disappear. Startup population or a subsequent rake blend:rebuild fills the gaps. Insert-driven refresh cannot repair omitted dates. Follow AGENTS.md's "Replacing provider history" procedure for delete-and-refetch repairs; do not delete only the three provider tables.
db/seeds/currency_patches.jsonmust know the predecessor, orRateValidation::UnknownCurrencydrops the rows silently. The Money gem lacks some (AZM, RUR); add a full entry.Rows already stored under the wrong code stay put: the insert is
ON CONFLICT DO NOTHINGand the corrected rows have a different key. Relabel them in place with a migration (seedb/migrate/027_relabel_lb_old_manat.rb), which runs itself at container start. No re-backfill: the values were right, only the code was wrong. The migration has four parts, because three tables derive fromrates:UPDATE ratesscoped to provider, code and date range.- Rollups: delete the provider's
weekly_ratesandmonthly_ratesfor both codes and re-insert fromrateswithBucket.week/Bucket.month, the wayProvider#refresh_rollupdoes. A bucket straddling the cutover holds both codes. - Summaries:
currenciesandcurrency_coveragesonly ever widen on insert, so recompute both codes fromrates. - Blend:
blended_ratesrefreshes on insert only. Where the provider was the sole contributor for the code,UPDATEthe quote; the stored value is a pure function of those rows and a recompute gives the same bytes. Where other providers already quote the successor,BlendedRate.refreshthe window plus the 14-day carry-forward lookback past the provider's last old-unit row. Check contributor sets withSELECT provider, MIN(date) FROM rates WHERE base = ? OR quote = ? GROUP BY provider.
Verify against a prod backup: apply the migration to a copy, recompute the blend from scratch over the affected years on a second copy, and diff
blended_rates. Zero rows either way, or the migration is wrong.
db/seeds/nascent_currencies.json is not the tool for this. It rejects every row dated before a currency's inception, restated series included, so it is reserved for the euro, where no pre-1999 series is wanted from anyone.
Non-ISO labels (SDR for XDR) go through an ALIASES map rather than the predecessor table.
2. Tests — spec/provider/adapters/<key>_spec.rb
Follow the pattern in spec/provider/adapters/boi_spec.rb or spec/provider/adapters/bccr_spec.rb:
- VCR cassette setup in
before/afterblocks - Integration test:
adapter.fetch(after:, upto:), assert dataset is non-empty and has expected structure - Parse unit tests: call
parsedirectly with inline fixture data - Test edge cases: unit multipliers, empty values, invalid data
VCR cassettes (spec/vcr_cassettes/<key>.yml) are auto-created on the first live test run. Pin dates in tests — never use Date.today with VCR. Never hand-craft or fabricate cassettes — they must be recorded from a live API response. Use narrow date ranges in integration tests (3-5 days) to keep cassettes small and test runs fast.
Avoiding time bombs: Always pass explicit upto: dates in tests, even when the provider defaults to Date.today. If upto is omitted, the fetch will reach into unrecorded months and hit VCR errors on the 1st of the next month. Similarly, avoid assertions with hardcoded bounds on date counts (e.g. <= 13 months) that break at month boundaries.
3. Seed provider metadata — db/seeds/providers/<key>.json
Create a single JSON file (not an array) with: key, name, description, pivot_currency, data_url, terms_url (nullable), publish_schedule (5-field cron expression in UTC, e.g. "*/30 14-16 * * 1-5" for daily Mon-Fri with a 3-hour polling window starting at 14:00 UTC; null for providers without a recurring cadence), publish_cadence (one of "daily", "weekly", "monthly", or null for historical-only providers; dispatches publishes_missed to the right algorithm — per-fire-day count for daily, ISO-week bucket for weekly, year-month bucket for monthly), coverage_start (earliest date for historical data, or null if unknown). Each provider has its own file — no shared file to conflict on.
The adapter class is auto-discovered from lib/provider/adapters/ — no need to edit any wiring files.
4. Verify
APP_ENV=test bundle exec rake spec # All tests pass
APP_ENV=test bundle exec rake rubocop # No lint issues
bundle exec rake db:seed # Provider appears in seed data
bundle exec rake backfill[<key>] # Live backfill works
Dry-run the backfill before shipping. VCR tests only cover narrow date ranges. A real backfill exercises chunked iteration, API rate limits, and date range constraints that specs won't catch. Test at least one full backfill_range chunk against the live API to confirm the adapter works end-to-end — especially to verify the API's maximum allowed date range matches your backfill_range setting.
5. Sanity-check rates (before deploy)
After local backfill, compare the new provider's rates against an independent source before pushing or deploying. This catches direction bugs (base/quote swapped), unit errors (per-100 not normalized), or stale data before they reach production.
Quick check — cross-reference with ECB rates in the local DB:
# In a console or one-liner: compare a sample of the new provider's rates against ECB
new_rates = Rate.where(provider: "<KEY>").where(date: Date.today - 7..Date.today).all
ecb_rates = Rate.where(provider: "ECB").where(date: Date.today - 7..Date.today).all
# Rebase both to EUR and compare overlapping quotes
External check — use the wise-api skill to compare against Wise mid-market rates. Sample a few major currency pairs (EUR/USD, EUR/GBP, EUR/JPY) and check deviation:
| Deviation | Assessment |
|---|---|
| < 0.5% | Good — normal institutional vs real-time spread |
| 0.5-1% | Acceptable for less-liquid pairs |
| > 1% | Investigate — possible direction or unit error |
| > 5% | Almost certainly a bug (e.g. base/quote inverted) |
What to look for:
- Rates that are the reciprocal of expected (base/quote swapped) — see 'Rate direction' principle above
- Rates that are 10x or 100x off (unit multiplier not normalized)
- Rates that match another provider exactly but on wrong dates (date parsing bug)
Extending an existing adapter
When you widen an existing adapter to emit new record shapes (a new currency, a new pair, a new report block), Provider#backfill resumes from last_synced — so already-synced environments only fetch the new shape from the current date forward. To populate history, hand-backfill once at deploy:
Provider["KEY"].backfill(after: Date.new(YYYY, M, D))
A fresh DB doesn't need this — it starts from coverage_start.