חזרה לבלוג

WooCommerce Multi-Currency: How to Integrate a Real-Time Exchange Rate API (2026 Developer Guide)

V
Vlado Grigirov
May 07, 2026
WooCommerce Currency API Exchange Rates WordPress PHP E-commerce Finexly

WooCommerce powers roughly a third of the world's online stores, and the moment one of those stores wants to sell across borders the same question shows up: how do you keep prices, taxes, and payouts honest in twelve currencies without checking the FX market by hand every morning? The honest answer is to plug a WooCommerce multi-currency exchange rate API into your store — but doing it correctly is more nuanced than dropping in a plugin and walking away. You have to think about refresh cadence, cache stampedes, payment gateway compatibility, rounding, and what happens when the upstream provider returns a 503 in the middle of a checkout.

This guide walks through three production patterns developers actually use in 2026, with working PHP for each. We'll wire the Finexly API into WooCommerce three different ways: as a custom feed for an existing currency switcher plugin, as a from-scratch filter-based layer, and as an Action-Scheduler-driven refresh job with proper locking. By the end you'll have a pattern that fits whatever stack you're starting from — clean WooCommerce, WooCommerce + a switcher plugin, or a fully custom B2B build.

Why a Real-Time Exchange Rate API Matters for WooCommerce

Out of the box, WooCommerce sets a single base currency in woocommerce_currency and assumes every order, refund, and payout settles in that currency. Multi-currency support is a layer that sits on top: a switcher in the header, recalculated cart totals, and (sometimes) localized payment processing. That layer needs exchange rates from somewhere, and "somewhere" usually defaults to one of two things — a manually entered rate that nobody remembers to update, or a free provider feed that ticks once every 12–24 hours.

Both are dangerous in 2026's FX environment. Pairs like USD/JPY moved 0.95% on May 6 alone after Tokyo's intervention, and emerging-market currencies regularly swing more than 1% intraday. A store displaying a 24-hour-stale rate is selling at a 1–2% discount or premium depending on which way the wind blew — and that's before you account for the spread the gateway tacks on at settlement. A real-time WooCommerce multi-currency exchange rate API with a sane caching layer keeps your displayed prices within fractions of a percent of mid-market, every time.

The other reason this matters: payment gateway compatibility. Stripe, PayPal, and most large gateways have their own list of supported settlement currencies. If your store displays prices in 15 currencies but Stripe only settles in 6, you need to know what conversion happens, where, and at what rate — and that calculation is much easier when your layer owns the source of truth for FX, not a black-box gateway.

The Three Integration Patterns

There are three places you can plug a currency API into a WooCommerce store, and each comes with different tradeoffs:

  1. Hook into an existing switcher plugin — fastest path, works with CURCY, FOX, Aelia, VillaTheme, and most popular plugins. Code surface is tiny but you're tied to the plugin's update cadence and storefront UI.
  2. Custom filter-based layer using core WooCommerce hooks — full control over which prices get converted (regular, sale, shipping, tax), but you write more code and own the storefront UI yourself.
  3. Hybrid: plugin handles UI, your code owns rates — best of both worlds for stores already on a switcher plugin. The plugin renders the switcher; your scheduled job pushes rates into the plugin's storage.

We'll build all three.

Pattern 1: Custom Rate Feed for an Existing Switcher Plugin

Most popular WooCommerce currency plugins expose a filter or action hook for custom rate sources. VillaTheme's CURCY plugin uses wmc_get_currency_exchange_rates. FOX uses woocs_currencies_array. Aelia uses wc_aelia_cs_exchange_rates_request_args. The pattern is the same in all of them — you intercept the rates the plugin would otherwise fetch from its built-in providers and substitute your own.

Here's a complete example for CURCY (the same shape works for FOX with a different filter name). Drop this into a small mu-plugin or your theme's functions.php:

<?php
/**
 * Plugin Name: Finexly Rates for CURCY
 * Description: Pulls real-time WooCommerce multi-currency exchange rates from Finexly.
 */

add_filter( 'wmc_get_currency_exchange_rates', 'finexly_curcy_rates', 10, 1 );

function finexly_curcy_rates( $rates ) {
    $cached = get_transient( 'finexly_curcy_rates' );
    if ( false !== $cached ) {
        return wp_parse_args( $cached, $rates );
    }

    $base    = get_woocommerce_currency(); // e.g. "USD"
    $symbols = array_keys( $rates );        // e.g. ["EUR","GBP","JPY",...]
    $api_key = defined( 'FINEXLY_API_KEY' ) ? FINEXLY_API_KEY : '';

    if ( empty( $api_key ) || empty( $symbols ) ) {
        return $rates; // graceful fall-through to plugin defaults
    }

    $url = add_query_arg( array(
        'apikey'     => $api_key,
        'base'       => $base,
        'currencies' => implode( ',', $symbols ),
    ), 'https://api.finexly.com/v1/latest' );

    $response = wp_remote_get( $url, array( 'timeout' => 8 ) );

    if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
        return $rates;
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( empty( $body['rates'] ) ) {
        return $rates;
    }

    // CURCY expects an associative array of CODE => rate (relative to base).
    $fresh = array();
    foreach ( $body['rates'] as $code => $rate ) {
        $fresh[ $code ] = (float) $rate;
    }

    set_transient( 'finexly_curcy_rates', $fresh, HOUR_IN_SECONDS );
    return wp_parse_args( $fresh, $rates );
}

A few details that matter here. First, the wp_remote_get call has an 8-second timeout — long enough to absorb a slow response, short enough that PHP-FPM won't tip over if the upstream is degraded. Second, on any failure (network error, non-200, missing payload) we return the plugin's default $rates rather than an empty array. A degraded checkout is better than a broken one. Third, the transient caches successful responses for an hour, which is plenty fresh for retail e-commerce and keeps you well under any API quota.

Define your key in wp-config.php:

define( 'FINEXLY_API_KEY', 'your_api_key_here' );

That's it. CURCY's switcher renders in the header, customers pick a currency, and prices recalculate using fresh rates from your API call instead of CURCY's default 12-hour Yahoo Finance feed.

Pattern 2: From-Scratch Multi-Currency with Core WooCommerce Filters

If you don't want a plugin in the loop — or you're building a custom B2B store where the switcher is part of an account-level setting rather than a header dropdown — you can build the whole multi-currency layer with two core filters: woocommerce_currency and raw_woocommerce_price.

woocommerce_currency lets you swap the active currency code per request. raw_woocommerce_price lets you transform every price WooCommerce displays. Combine them with a session variable and you have a working currency switcher in about 60 lines of code:

<?php

class Finexly_WC_Currency {

    const SESSION_KEY = 'finexly_active_currency';
    const TRANSIENT   = 'finexly_rates';

    public function __construct() {
        add_action( 'init',                          array( $this, 'maybe_switch_currency' ) );
        add_filter( 'woocommerce_currency',          array( $this, 'active_currency' ) );
        add_filter( 'raw_woocommerce_price',         array( $this, 'convert_price' ) );
        add_filter( 'woocommerce_package_rates',     array( $this, 'convert_shipping' ), 10, 2 );
    }

    public function maybe_switch_currency() {
        if ( isset( $_GET['set_currency'] ) ) {
            $code = strtoupper( sanitize_text_field( $_GET['set_currency'] ) );
            if ( array_key_exists( $code, $this->rates() ) ) {
                WC()->session->set( self::SESSION_KEY, $code );
            }
        }
    }

    public function active_currency( $default ) {
        $code = WC()->session ? WC()->session->get( self::SESSION_KEY ) : null;
        return $code ?: $default;
    }

    public function convert_price( $price ) {
        $code  = $this->active_currency( get_option( 'woocommerce_currency' ) );
        $rates = $this->rates();
        if ( ! isset( $rates[ $code ] ) ) {
            return $price;
        }
        return (float) $price * (float) $rates[ $code ];
    }

    public function convert_shipping( $rates, $package ) {
        $code     = $this->active_currency( get_option( 'woocommerce_currency' ) );
        $fx       = $this->rates();
        if ( ! isset( $fx[ $code ] ) ) {
            return $rates;
        }
        foreach ( $rates as $rate ) {
            $rate->cost = (float) $rate->cost * (float) $fx[ $code ];
            foreach ( $rate->taxes as &$tax ) {
                $tax = (float) $tax * (float) $fx[ $code ];
            }
        }
        return $rates;
    }

    private function rates() {
        $cached = get_transient( self::TRANSIENT );
        return is_array( $cached ) ? $cached : array();
    }
}

new Finexly_WC_Currency();

The transient is populated by a separate scheduled job (next section), so this class never makes a network call inside a request — every page load is a single get_transient lookup, which in object-cache-enabled environments is ~0.1ms. That's the difference between adding 8ms to your TTFB on every uncached page and adding 800ms once per hour to one cron worker.

Two things to add for production: precision and rounding. WooCommerce stores prices as decimals; multiplying a decimal(13,4) value by a 1.10851234 rate and storing the result back as decimal(13,4) will silently drop precision. For low-priced items (sub-$1 SKUs, tip jars, micro-donations) you want at least 8 decimal places of internal precision, with rounding to the currency's natural minor unit only at the display layer. WooCommerce's wc_price() helper handles the display rounding for you; what you have to do yourself is keep the underlying floats wide enough.

Pattern 3: Action Scheduler Refresh Job with Stampede Protection

Both patterns above read from a transient. Something has to write to that transient. The right tool in WooCommerce is Action Scheduler — the job runner WooCommerce ships with — because it's reliable, retries automatically, and handles concurrency better than wp_schedule_event.

<?php

/**
 * Plugin Name: Finexly Rates Refresh
 * Description: Refreshes WooCommerce multi-currency exchange rates from Finexly hourly.
 */

add_action( 'init', function () {
    if ( false === as_next_scheduled_action( 'finexly_refresh_rates' ) ) {
        as_schedule_recurring_action(
            time() + 60,
            HOUR_IN_SECONDS,
            'finexly_refresh_rates',
            array(),
            'finexly'
        );
    }
} );

add_action( 'finexly_refresh_rates', 'finexly_do_refresh' );

function finexly_do_refresh() {
    // Stampede lock: only one worker at a time.
    $lock_key = 'finexly_refresh_lock';
    if ( get_transient( $lock_key ) ) {
        return;
    }
    set_transient( $lock_key, 1, 30 );

    try {
        $base       = get_option( 'woocommerce_currency', 'USD' );
        $supported  = apply_filters( 'finexly_currencies', array(
            'USD','EUR','GBP','JPY','CHF','CAD','AUD','CNY','INR','BRL','MXN','SEK','NOK','SGD','HKD','TRY','ZAR','PLN'
        ) );

        $url = add_query_arg( array(
            'apikey'     => FINEXLY_API_KEY,
            'base'       => $base,
            'currencies' => implode( ',', $supported ),
        ), 'https://api.finexly.com/v1/latest' );

        $response = wp_remote_get( $url, array( 'timeout' => 10 ) );
        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
            // Don't blow away existing rates on a transient upstream failure.
            error_log( 'Finexly refresh failed: ' . wp_remote_retrieve_response_code( $response ) );
            return;
        }

        $body = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( empty( $body['rates'] ) ) {
            return;
        }

        $rates = array_map( 'floatval', $body['rates'] );
        $rates[ $base ] = 1.0;

        set_transient( 'finexly_rates', $rates, 3 * HOUR_IN_SECONDS );

        // Persist to options as a durable fallback. Object cache flushes
        // would otherwise wipe transients and force a cold checkout.
        update_option( 'finexly_rates_persistent', array(
            'fetched_at' => time(),
            'base'       => $base,
            'rates'      => $rates,
        ), false );
    } finally {
        delete_transient( $lock_key );
    }
}

Three patterns worth noting. The stampede lock prevents two parallel workers from hammering the upstream when Action Scheduler runs late and queues two jobs back-to-back. The 3× transient TTL vs. 1-hour refresh interval is a deliberate buffer — if one refresh fails, the cached value survives until the next attempt. The persistent option fallback is the third tier: if the object cache flushes for any reason, your storefront still has a usable rate set from up to 24 hours ago, and you can have your rates() method fall back to it before returning empty.

If you want a deeper dive on the caching architecture itself, the patterns covered in our currency API caching and error handling guide translate directly to WooCommerce.

Testing Your Multi-Currency Integration

WooCommerce stores break in production in three predictable ways, and all three are testable. Here's a minimal PHPUnit suite using WP_Mock and Mockery:

<?php

class Finexly_WC_Currency_Test extends WP_Mock\Tools\TestCase {

    public function test_convert_price_with_known_rate() {
        WP_Mock::userFunction( 'get_transient' )
            ->with( 'finexly_rates' )
            ->andReturn( array( 'EUR' => 0.92, 'GBP' => 0.79 ) );

        WP_Mock::userFunction( 'get_option' )
            ->with( 'woocommerce_currency' )
            ->andReturn( 'USD' );

        $svc = new Finexly_WC_Currency();
        $svc->set_active_currency( 'EUR' );

        $this->assertEqualsWithDelta( 92.0, $svc->convert_price( 100.0 ), 0.0001 );
    }

    public function test_returns_original_price_when_rate_missing() {
        WP_Mock::userFunction( 'get_transient' )
            ->andReturn( array() );

        $svc = new Finexly_WC_Currency();
        $svc->set_active_currency( 'XYZ' ); // unsupported code

        $this->assertEquals( 100.0, $svc->convert_price( 100.0 ) );
    }

    public function test_refresh_handles_upstream_500() {
        WP_Mock::userFunction( 'wp_remote_get' )
            ->andReturn( array( 'response' => array( 'code' => 500 ) ) );
        WP_Mock::userFunction( 'set_transient' )->never();
        WP_Mock::userFunction( 'update_option' )->never();

        finexly_do_refresh();
        // Test passes if no fatal error and we don't overwrite cached rates.
    }
}

The third test is the one most teams skip. Almost every WooCommerce currency bug I've seen in production reduces to "the upstream returned an error and we cached the empty response." Asserting that you don't write garbage on failure is more valuable than asserting you write the right thing on success.

Common Pitfalls and How to Avoid Them

Payment gateway currency support. Just because your store displays prices in TRY doesn't mean Stripe will settle in TRY. Before showing a currency in your switcher, check WC()->payment_gateways() and filter to the intersection of currencies your active gateways support. The Stripe FX quotes API vs. dedicated currency API comparison covers the tradeoffs in detail.

Refunds and order edits. A refund issued in EUR three weeks after the original USD-base order won't use today's rate — it'll use whatever rate the gateway captures at refund time. Store the rate at order time in order meta (update_post_meta( $order_id, '_finexly_rate', $rate )) so reporting can reconcile. This is also useful for the accounting integration layer.

Rounding. Round at display, not at storage. Storing 9.99 * 0.92 = 9.1908 rounded to 9.19 and then converting that back to USD gives you 9.989..., not 9.99. Wide internal precision plus display-time rounding is the only sane pattern.

ISO 4217 validity. Don't trust user input. Validate all currency codes against the ISO 4217 list before storing them in session.

Picking the Right API: Free vs. Paid

WooCommerce's built-in providers (Open Exchange Rates free tier, the deprecated CurrencyLayer, etc.) update every 12–24 hours and cap at small request quotas. For a small store with one product and ten orders a month that's fine; for anything with traffic, you'll want a dedicated provider with at least hourly updates and a quota that won't make you ration calls. Our free vs. paid currency API comparison for 2026 walks through the actual numbers.

The Finexly API specifically targets this use case — 170+ currencies, 60-second refresh on majors, hourly on long-tail, and a free tier of 1,000 requests/month that's enough for one Action Scheduler job per hour with headroom for retries. You can compare currency APIs side-by-side or sign up for free to test it in your dev store.

Frequently Asked Questions

Does WooCommerce support multi-currency natively? Partially. WooCommerce supports a single base currency for orders and payouts. Multi-currency display and checkout require either a switcher plugin (CURCY, FOX, Aelia, etc.) or custom code using woocommerce_currency and raw_woocommerce_price filters, and either approach needs an exchange rate source you supply.

How often should I refresh exchange rates in WooCommerce? For retail e-commerce, hourly is plenty. For B2B stores with high-value invoices, every 15–30 minutes is safer. Anything more aggressive than that is rarely worth the API quota — payment gateways re-quote at settlement anyway, so the difference between a 5-minute-old rate and a 30-minute-old one is noise relative to gateway spread.

Can I integrate a custom currency API with the WooCommerce currency switcher plugin I already use? Yes. CURCY exposes wmc_get_currency_exchange_rates, FOX exposes woocs_currencies_array, Aelia has wc_aelia_cs_exchange_rates_request_args. Hook into the filter, return your custom rates, and the plugin's UI keeps working unchanged.

What happens if the exchange rate API is down during checkout? With the architecture above, nothing visible to the customer. The pricing layer reads from a transient that's populated by a background job; if the most recent refresh failed, the previous rate is still cached and the persistent option holds a 24-hour fallback. Checkout proceeds at the last-known-good rate.

How do I handle refunds across currencies? Store the exchange rate that was used at order time in order meta. When refunding, calculate the refund amount using that rate, not today's, so the customer is made whole in the currency they paid. Your accounting system can reconcile the FX P&L separately.

Wrapping Up

A solid WooCommerce multi-currency exchange rate API integration isn't really about the API call — it's about the four layers around it: caching, scheduling, fallbacks, and tests. Get those right and your store keeps prices honest in 50+ currencies without a single overnight pager alert.

Ready to integrate real-time exchange rates into your WooCommerce store? Get your free Finexly API key — no credit card required. Start with 1,000 free requests per month, drop the snippets above into a mu-plugin, and you'll have multi-currency pricing live before lunch. When you outgrow the free tier, the pricing plans scale linearly with your store, not exponentially.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →