Exchange rates in your shop's base currency, for a Vendure shop that prices in one currency and shows or charges in others. Rates come from a source you choose — the European Central Bank, Frankfurter, Monobank, the National Bank of Ukraine, fixed rates or your own — are re-based onto your currency, refreshed on a schedule, served on the Shop API, and can be overridden per currency in the dashboard.
The plugin stores and serves rates; it does not touch Vendure prices or orders — your storefront, feed or payment code converts with them. Vendure's own per-currency variant prices are unaffected.

Install · Use cases · Sources · Base currency · Options · Dashboard · GraphQL API · In your own code · Event · Database · Limitations
Rates are stored in the default channel's currency; see Base currency to pin another.
@vendure/core, @nestjs/* and typeorm are peers. The dashboard pages need @vendure/dashboard (an
optional peer): its Vite plugin discovers the extension — restart the dashboard dev server after
installing.
First deploy. Generate and run a migration for the new currency_exchange_rate table. On the API
server's first boot the plugin fills the empty table from the source (startup waits for it, up to the
source's timeout); if the source is unreachable then, the table stays empty until the next scheduled
refresh. Every currency arrives disabled — enable the ones you sell in under Settings → Currency
exchange rates. Until you do, the storefront sees no rates and stays in your base currency.
Show prices in the visitor's currency. The storefront reads the enabled rates once:
This assumes both currencies have 1/100 minor units. When they differ (JPY has none, KWD and BHD have 1/1000), multiply the result by 10^(target digits − base digits).
Charge in a foreign currency. A PaymentMethodHandler gets the service in init and converts the
amount it is asked to collect:
Price a product feed in another currency (Google Merchant, Meta, …) even if the storefront does not
offer it: getRate(ctx, CurrencyCode.EUR, { requireEnabled: false }).
Pin a rate. Turn on Use custom rate for a currency: the storefront and your code use your number until you turn it off, while the fetched rate keeps updating underneath.
Fixed rates only. source: new StaticExchangeRateSource({ base: CurrencyCode.USD, rates: { EUR: 1.12 } }).
Keep the sync on, so a number you change in config is picked up on the next refresh; pin any currency in
the meantime with Use custom rate.
| Source | Base | Rates |
|---|---|---|
EcbExchangeRateSource | EUR | The ECB's euro reference rates: ~30 major currencies, every working day around 16:00 CET. Free, no key. |
FrankfurterExchangeRateSource | EUR, or base | Frankfurter: the same ECB rates as JSON. Free, no key, can be self-hosted (apiUrl). |
StaticExchangeRateSource | yours | The numbers you pass: { base, rates }. In e2e tests, set source.config before a sync — no network. |
MonobankExchangeRateSource | UAH | Ukrainian bank rates, buy/sell/mid. |
NbuExchangeRateSource | UAH | Ukrainian official rates, ~40 currencies. |
The network sources take apiUrl and timeout (default 10 s). Whatever a source returns, only Vendure
CurrencyCodes with a positive finite rate are stored — metals, the SDR and garbage are dropped. If the
source fails or returns nothing usable, the stored rates stay as they are.
Cross rates. A source that quotes against another base is re-based onto yours: with the ECB and a USD
shop, GBP = (EUR per GBP) / (EUR per USD), and EUR itself is added. This needs the source to quote your
base currency — the ECB does not quote UAH, for instance, so a UAH shop uses a Ukrainian source. Rates are
stored per unit with 8 decimals, so a weak currency against a strong base keeps few significant digits
(LBP in USD: 0.00001117); a rate that does not fit decimal(19, 8) is dropped.
MonobankExchangeRateSource — Monobank's public rates. side: 'buy' (default) is what the bank pays
you for the currency — the lower rate, so a UAH price converted with it never comes up short; or
'sell', 'mid'. Rate-limited; cached 5 minutes.NbuExchangeRateSource — the National Bank of Ukraine's official rate, set once per business day.Both quote against UAH, so they suit a UAH shop. They also work for a shop in a currency they quote (USD, EUR, …), but the ECB is the more natural choice there.
A source returns its base and quotes { currencyCode, rate }, rate being base units per one unit;
the plugin re-bases them onto the shop's currency. Throw on failure — never return an empty list to mean
"failed" — and give the request a timeout, since the API server waits for the first fetch on boot. If the
source needs services, implement init(injector) (and destroy()); the plugin calls init before the
first fetch.
When a source rate-limits or is down, fall back to another — the first one that answers wins:
To add a currency a source does not quote, merge the quotes of several sources in the same base (a
StaticExchangeRateSource last); when two quotes share a code, the last one wins. Forward init/destroy
if the inner sources need them.
Rates are stored and served in one base currency: baseCurrency if you set it, otherwise the default
channel's defaultCurrencyCode, read on every sync and lookup.
When the base changes, the Shop API and getRate return nothing until a sync re-bases the table. The API
server does that on boot, so changing the option needs nothing more; after changing the default channel's
currency, run the task (see Options) or restart. The sync moves every currency to the new
base, keeps whether it is enabled, drops its custom rate (it was in the old base) and deletes the
currencies the new base does not cover.
| Option | Type | Default |
|---|---|---|
source | ExchangeRateSource — required | — |
baseCurrency | CurrencyCode | the default channel's currency |
sync | { schedule?: ScheduledTaskConfig['schedule'] } | false | { schedule: '40 2-23/3 * * *' } |
sync registers the scheduled task currency-exchange-rate-updater (SYNC_TASK_ID, every 3 hours). It
needs a scheduler plugin such as DefaultSchedulerPlugin, and runs in the worker — so the worker needs the
plugin in its config too. sync: false leaves it out; you can still call CurrencyExchangeRateSyncService.syncRates(ctx).
To refresh now, run it under System → Scheduled tasks (or
runScheduledTask(id: "currency-exchange-rate-updater")); its last run there tells you the sync is alive.
A sync moves a row's updatedAt only when its rate changes. A currency the source stops quoting keeps its
last rate; if it is enabled, every sync logs a warning.
Settings → Currency exchange rates lists every stored currency with its rate and base, whether it is enabled and whether a custom rate is in use. A currency's page shows the fetched rate and lets you enable it and set a custom rate.

Rates are shop settings: listing needs ReadSettings, editing UpdateSettings — no extra role setup.
Shop (public)
rate is the custom rate while one is in use, otherwise the fetched one.
Admin
In the Admin API rate is always the fetched rate; useCustomRate and customRate are separate fields.
Turning on useCustomRate without a positive customRate is rejected.
| Export | Use |
|---|---|
CurrencyExchangeRateService | getRate(ctx, code, { requireEnabled }) → base units per unit or undefined; getBaseCurrency(ctx); findAll, findOne, update |
CurrencyExchangeRateSyncService | syncRates(ctx) — refresh now |
effectiveRate(row) | The rate to convert with for a row you loaded yourself, or undefined if it is not usable |
requireEnabled is your policy: true for anything the customer is billed in, false for internal uses.
CurrencyExchangeRateEvent is published once the transaction has committed: type: 'synced' when a sync
changed anything, or when the plugin adopts rows without a base on boot, with every rate now stored in the
base currency; 'updated' with the one rate an admin saved. Use it to drop caches that embed the rates —
subscribe in onModuleInit, which runs before any plugin's bootstrap, so the boot event is not missed.
Events stay in the process that published them: a scheduled sync publishes in the worker, the boot adoption
in whichever process adopted the rows. Register the subscriber in both, and keep a cache it must drop
shared between them (e.g. Redis), or let it expire:
One table, currency_exchange_rate:
| Column | Type |
|---|---|
code | varchar, unique |
baseCurrency | varchar — the currency rate is counted in¹ |
rate | decimal(19, 8) — the fetched one |
enabled | boolean, default false |
useCustomRate | boolean, default false |
customRate | decimal(19, 8), nullable |
¹ Nullable so that a generated migration can add it to a filled table. On the next boot (and in every sync) the plugin gives rows without one the current base, keeping their custom rates — it assumes they are already in that base, so change the base only after the upgrade has booted once.
A sync updates rate and inserts new currencies, in one transaction; on a base change it
also rewrites baseCurrency, clears custom rates and deletes the currencies the new base does not cover.
baseCurrency).UpdateSettings in any channel can
change them.rate use the fetched rate, while the returned rate is the effective one.@uplab/vendure-plugin-unified-slug). It is a Vendure bug, fixed in 3.7.4
(vendure#5459); production builds are fine.See CHANGELOG.md.
MIT
#43 c1a7435 Thanks @brmk! - Keep the grey badges grey on the Vendure 3.8 dashboard. Its Badge no longer has a secondary variant and shows such a badge as plain text. That hit the «System change» badge in the change history, and the «Disabled» and «Fetched rate» badges on the exchange-rate pages. They look the same as before on 3.7.
#43 c7d7775 Thanks @brmk! - Install alongside Vendure 3.8: the @nestjs/common and @nestjs/core peers now accept ^11 || ^12, and @nestjs/graphql accepts ^13 || ^14. Vendure 3.8 moved to NestJS 12, so npm install failed with ERESOLVE against the old ^11 / ^13 ranges. Nothing changes on Vendure 3.7.
#33 c586872 Thanks @brmk! - Rows from before the baseCurrency column are now also adopted when MySQL filled the column with '', and until they are, the Admin API lists them with the base they will get instead of failing the whole list on a null.
An adoption publishes a synced event; to see the one on boot, subscribe in onModuleInit (the README example now does).
The worker adopts such rows on bootstrap too, so it no longer depends on the server booting first, and the boot backfill no longer throws on a database error.
a1899fe Thanks @brmk! - The baseCurrency column is nullable, so a generated migration adds it to a table that already has rows without hand edits. On boot (and in every sync) rows without a base are given the current one and keep their custom rates.d132726 Thanks @brmk! - Initial release: exchange rates in your shop's base currency from a pluggable source (the ECB, Frankfurter, Monobank, the NBU, fixed rates or your own), cross rates derived automatically, refreshed on a schedule, served on the Shop API and overridable per currency in the dashboard.