Blog
Guides

Multi-currency pricing for online stores: caching, rounding and the base-currency trap

Rate-API Team·

Showing prices in a shopper's own currency is one of the cheapest wins an online store has, and one of the easiest features to get subtly wrong. The failures are rarely crashes: product pages that call an API on every view until they're rate-limited, carts whose lines don't add up, and, worst of all, prices that look plausible and are simply wrong.

This guide covers the three places multi-currency pricing breaks: caching, rounding and the base currency. The examples are plain PHP you can drop into any store backend, and the rules carry over to any language.

First decide: display prices or charged prices?

Two different features hide behind "multi-currency":

  • Display conversion. The shopper sees "≈ $21.57" next to "€18.50" but pays in euros. An hour-old reference rate is fine, and a rounding difference is harmless because the number is explicitly approximate.
  • Charging in the shopper's currency. The shopper pays $21.57. Now the converted price is the price: it must be rounded properly, stay stable while the customer shops, and be stored with the order.

Either way, exchange-rate data is an input to your pricing, not the price itself. Reference rates are not dealing rates, so if you charge in several currencies you still decide the final price, usually with a margin for conversion costs. Everything below applies to both features; the checkout section only matters for the second.

Fetch one table, in your store's currency

Ask for rates quoted against the currency your catalogue is priced in. For a store that prices in euros:

curl -H "X-API-Key: $RATE_API_KEY" \
  "https://rate-api.com/api/v1/latest?base=EUR"

The response carries base (here "EUR"), date (the date of the rates) and rates, a map from currency code to units per 1 EUR. Always pass base explicitly: without it, the API uses the default base currency from your account's API settings, and store code shouldn't depend on a dashboard setting.

The base-currency trap

Here is the detail behind most multi-currency bugs: the base currency is not included in its own rates map. A response for base=EUR contains USD, GBP, JPY and the rest, but no "EUR": 1. Filtering doesn't change that: ?base=EUR&symbols=EUR,USD,GBP returns USD and GBP only.

Now picture the obvious conversion code, and a shopper whose currency happens to be the store's own:

$rates = $latest['rates'];        // from /latest?base=EUR
$price = 20.00;                    // EUR
$shown = $price * $rates['EUR'];   // Warning: Undefined array key "EUR"
                                   // $shown is 0.0: the product is now free

JavaScript fails differently but just as badly: 20 * rates.EUR is NaN, which renders as "NaN €". The usual quick fix is worse:

$rate = $rates[$currency] ?? 1.0;  // don't do this

That handles EUR, and also silently prices every currency your table lacks, whether a typo or a code the feed doesn't cover, at exactly one euro. No error, no log line, just wrong prices.

The right fix is narrow. Add the base, and only the base, as 1.0, taking it from the base field of the response rather than from what you asked for. Treat any other missing currency as an error. And if you ever add a second rate source as a fallback, make the same function re-quote that source's table to your store currency, because a fallback that answers in a different base produces prices that are plausible and wrong.

Caching: refresh on a schedule, never on a page view

Rate-API refreshes its rates hourly, so fetching more often gains nothing. More importantly, fetching during a page render ties your storefront to a network call and to your plan's per-minute limit: 10 requests a minute on Free, 100 on Pro. One traffic spike and product pages start failing.

Refresh on a schedule instead, and let pages read a local copy. This script runs hourly from cron, normalises the table and writes it atomically:

<?php
// refresh-rates.php - run hourly from cron; page renders only ever read the cache file
declare(strict_types=1);

const API = 'https://rate-api.com/api/v1';
const STORE_CURRENCY = 'EUR';
const CACHE = __DIR__ . '/rates-cache.json';

function api_get(string $path, array $query = []): array
{
    $ch = curl_init(API . $path . ($query ? '?' . http_build_query($query) : ''));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('RATE_API_KEY')],
        CURLOPT_TIMEOUT => 10,
        CURLOPT_ENCODING => '', // accept gzip
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $data = is_string($body) ? json_decode($body, true) : null;

    if ($status !== 200 || ($data['success'] ?? false) !== true) {
        $type = $data['error']['type'] ?? "http_$status";
        throw new RuntimeException("Rate-API $path failed: $type");
    }

    return $data;
}

/** Rates per 1 unit of $requested, with $requested itself present as 1.0. */
function normalize_rates(array $payload, string $requested): array
{
    $given = strtoupper($payload['base'] ?? $requested);
    $rates = $payload['rates'] ?? [];
    $rates[$given] ??= 1.0; // the API leaves the base out of its own map

    if ($given !== $requested) {
        $pivot = $rates[$requested] ?? 0;
        if ($pivot <= 0) {
            throw new RuntimeException("No $requested rate to re-base on");
        }
        $rates = array_map(fn ($r) => $r / $pivot, $rates);
    }

    return $rates;
}

$old = is_file(CACHE) ? json_decode(file_get_contents(CACHE), true) : [];

try {
    $latest = api_get('/latest', ['base' => STORE_CURRENCY]);

    // Minor units almost never change: refresh them weekly, not hourly.
    $stale = ($old['decimals_at'] ?? 0) < time() - 7 * 86400;
    $decimals = $stale
        ? array_map(fn ($c) => (int) $c['decimal_places'], api_get('/currencies')['currencies'])
        : $old['decimals'];

    $table = [
        'base' => STORE_CURRENCY,
        'date' => $latest['date'],
        'fetched_at' => time(),
        'rates' => normalize_rates($latest, STORE_CURRENCY),
        'decimals' => $decimals,
        'decimals_at' => $stale ? time() : $old['decimals_at'],
    ];

    // Write, then rename: a page render never sees half a file.
    file_put_contents(CACHE . '.tmp', json_encode($table));
    rename(CACHE . '.tmp', CACHE);
} catch (RuntimeException $e) {
    // Keep the last good table. Alert on age, not on a single failed run.
    $ageHours = (time() - ($old['fetched_at'] ?? 0)) / 3600;
    error_log(sprintf('%s; cached rates are %.1f h old', $e->getMessage(), $ageHours));
    exit(1);
}
# crontab: refresh at five past every hour
5 * * * * RATE_API_KEY=your-key php /path/to/refresh-rates.php

What it gets right:

  • Normalisation happens once, on write, in normalize_rates(). Every reader of the cache gets a table in which the store currency is 1.0 and nothing else is guessed.
  • Failures keep the last good table. The script logs the table's age and exits non-zero, but never overwrites good data with nothing. Alert on age, say older than a few hours, rather than on a single failed run.
  • Minor units come from the API too. /currencies returns each currency's decimal_places: 2 for EUR, 0 for JPY, 3 for KWD. It is refreshed weekly because it almost never changes.
  • The request budget is tiny. 24 refreshes a day is about 744 requests in a 31-day month, plus a handful of /currencies calls, well inside the Free plan's 2,500. See pricing if you need more headroom.

For outside monitoring, the keyless GET /api/v1/health reports status and rates_age_hours.

Page caches and CDNs

If your store caches whole HTML pages, a converted price baked into the HTML is only as fresh as the page cache, and the first visitor's currency can end up served to everyone. Either make the shopper's currency part of the cache key (a cookie or a URL segment), or render the store-currency price on the server and fill in the converted price in the browser. The WooCommerce plugin mentioned below takes the second route.

Rounding: once, per unit, in the target currency

With a clean table, conversion is one multiplication. Rounding is where carts go wrong. Three rules:

  1. Round to the target currency's minor unit, not always to two decimals. A yen price with cents doesn't exist, and a dinar price with two decimals is under-precise.
  2. Round the unit price, then multiply by the quantity. Converting the cart total instead produces a total that doesn't equal the sum of its lines.
  3. Do the arithmetic in integer minor units once prices are rounded, so sums are exact.

Rule 2 in numbers, at an illustrative rate of 1 EUR = 1.1658 USD: three mugs at €0.99 each. One mug is $1.154142, shown as $1.15, so the line reads 3 × $1.15 = $3.45. Convert the €2.97 total instead and you get $3.462426, shown as $3.46. A cent apart on a receipt is a support ticket.

These helpers are what the storefront calls. They only read the cache file:

<?php
// pricing.php - what the storefront calls; it never touches the network
declare(strict_types=1);

function rate_table(): array
{
    static $table = null;

    return $table ??= json_decode(file_get_contents(__DIR__ . '/rates-cache.json'), true);
}

/** Store-currency amount -> integer minor units of $currency (cents, yen, fils). */
function convert_to_minor(float $storeAmount, string $currency): int
{
    $table = rate_table();
    $rate = $table['rates'][$currency] ?? null;
    if ($rate === null) {
        throw new InvalidArgumentException("No rate for $currency"); // never guess 1.0
    }
    $decimals = $table['decimals'][$currency] ?? 2;

    // Round to the currency's decimals first: round(1.005, 2) is 1.01 in PHP,
    // whereas 1.005 * 100 is 100.4999... and would round down to 100.
    return (int) round(round($storeAmount * $rate, $decimals) * 10 ** $decimals);
}

/** Optional price endings: 18.73 -> 18.99 and 2013 -> 2020. Never lowers a price. */
function charm_minor(int $minor, int $decimals): int
{
    return match ($decimals) {
        0 => intdiv($minor + 9, 10) * 10,
        2 => intdiv($minor, 100) * 100 + 99,
        default => $minor, // 3-decimal currencies: keep the plain rounding
    };
}

function format_minor(int $minor, string $currency, string $locale): string
{
    $decimals = rate_table()['decimals'][$currency] ?? 2;
    $fmt = new NumberFormatter($locale, NumberFormatter::CURRENCY);

    return $fmt->formatCurrency($minor / 10 ** $decimals, $currency);
}

A cart built with them, where the lines add up by construction:

<?php
require __DIR__ . '/pricing.php';

$cart = [
    ['name' => 'Mug', 'price' => 0.99, 'qty' => 3], // store prices, in EUR
    ['name' => 'Poster', 'price' => 18.50, 'qty' => 1],
];
$currency = 'USD';
$total = 0;

foreach ($cart as $line) {
    $unit = convert_to_minor($line['price'], $currency); // round once, per unit
    $lineTotal = $unit * $line['qty'];
    $total += $lineTotal; // integers: the lines always add up to the total
    printf("%-8s %d x %s = %s\n", $line['name'], $line['qty'],
        format_minor($unit, $currency, 'en_US'), format_minor($lineTotal, $currency, 'en_US'));
}

echo 'Total: ' . format_minor($total, $currency, 'en_US') . "\n";

At that illustrative rate it prints:

Mug      3 x $1.15 = $3.45
Poster   1 x $21.57 = $21.57
Total: $25.02

charm_minor() is optional. Endings like .99 are a merchandising choice and only customary in some currencies, and the function only ever rounds up, so a charm price never undercuts the converted one. NumberFormatter, from PHP's intl extension, does what Intl.NumberFormat does in JavaScript: format_minor(123456, 'JPY', 'ja_JP') gives "¥123,456" and format_minor(1234500, 'KWD', 'en_US') gives "KWD 1,234.500".

Charging in several currencies? Lock the rate

If the shopper pays in their own currency, the price they saw on the product page must be the price at checkout, even if the hourly refresh ran in between.

  • Price a cart against one table snapshot, identified by its date and fetched_at, and keep that snapshot for the session or a fixed window such as 30 minutes.
  • Store the conversion with the order: the currency, the rate used, the table's date and the store-currency amount. Refunds and disputes need it long after the live table has moved on, so never recompute a paid order from current rates.

Or skip the code: store integrations

If what you need is display conversion on a common platform, a ready-made integration may already cover it. The three below need no API key, and all three are display tools: they show approximate prices and don't change the currency a customer is charged in.

  • WooCommerce: a plugin that adds an "≈ price in the shopper's currency" line under the price on product pages, plus a converter shortcode.
  • WordPress: a live converter as a shortcode or a block, for any post or page.
  • Shopify: a storefront converter widget added with a small Liquid snippet. No app to install, and it defaults to your store's currency.

The full list is on the integrations page.

Checklist

  • Request rates with an explicit base equal to your store currency.
  • Add the base as 1.0, taken from the response's base. Treat any other missing currency as an error, never as 1.
  • Refresh hourly on a schedule, let page views read a local copy, and keep the last good table when a refresh fails.
  • Round per unit to each currency's decimal_places, then add up integers.
  • If you charge in the shopper's currency, lock the table for the session and store the rate with the order.

The endpoints used here, /latest, /currencies and /health, are documented in the API reference, and all of them work on the Free plan. Create a free key to run the scripts against live data, and use the converter pages such as EUR to USD to sanity-check your numbers.