Back to Blog

Cross-Border Payroll Currency Conversion: A Developer's Guide to Real-Time FX for Global Payroll (2026)

V
Vlado Grigirov
May 14, 2026
Currency API Exchange Rates Payroll Cross-Border Payments Developer Guide Fintech Finexly

If you ship payroll software in 2026, you are no longer building a single-country tool. Deel, Remote, Rippling, and a long tail of vertical HRIS platforms route salaries to contractors and employees across 90+ countries every month, and every one of those payouts hides the same boring-but-expensive question: which exchange rate do we use, when do we lock it, and how do we prove to the employee (and the auditor) that we did the right thing? Cross-border payroll currency conversion sounds like a back-office detail until you realize that getting it wrong by 50 basis points on a $4,000 monthly salary costs that employee $20 a month — every month — and your support inbox fills up by Friday.

This guide is for engineers building payroll, contractor-pay, EOR, or HRIS systems that settle in multiple currencies. We'll walk through the architectural decisions that matter — rate source, lock dates, mid-market vs spread, idempotency, audit trails, rounding — and write production code for each in Node.js, Python, and PHP using the Finexly API. By the end you'll have a payroll FX layer that holds up to a SOC 2 review and a 2am Slack ping after USD/JPY moves 1.5% overnight.

Why Payroll FX Is Harder Than It Looks

The naive approach to payroll currency conversion is one line of code: amount_local = amount_usd * rate. That works fine for a currency converter on a marketing site. It is not enough for payroll, for six reasons that all bite at once:

  1. The rate must be reproducible. When an employee or auditor asks why March salary was ¥608,243 instead of ¥609,118, you need to point at a specific rate, timestamp, and source. "Whatever Stripe quoted at payout time" is not an answer that survives an audit.
  2. The lock date is a policy decision, not a bug. Run date? Period end? The 25th of the month for monthly payroll? Each option has different implications for FX risk, employee predictability, and tax reporting. Your code has to encode whichever policy the CFO picked — and let them change it without a deploy.
  3. Mid-market and "payment rate" are different things. Mid-market rate is the midpoint between bid and ask — what Google or Bloomberg shows. The rate that actually moves money through SWIFT, a local rail, or a stablecoin bridge always has a spread. Display the mid-market reference clearly and track the rate the payment provider actually used, so reconciliation works.
  4. Idempotency matters. Payroll runs get retried — job timeouts, queue redeliveries, operators clicking twice. If your FX lookup isn't idempotent per (employee, pay period), retries quote different rates and produce different gross-to-net.
  5. Weekends, holidays, and jurisdiction rules. FX markets close from late Friday New York to Sunday Sydney; payroll runs don't. Naive code silently uses stale cache. And some jurisdictions (Brazil PTAX, Argentina BCRA, India RBI for non-residents) require central-bank reference rates that override mid-market — your rate layer has to support per-jurisdiction overrides.

Get those six right and you have a payroll FX layer. Get any of them wrong and you have an outage waiting to happen.

The Reference Architecture

The working examples below assume three layers with strict boundaries:

Layer 1 — Rate Layer. Pulls mid-market rates from a real-time provider (Finexly), caches them, snapshots once per day for the audit trail. Nothing else in the platform talks to the FX provider directly.

Layer 2 — FX Policy. Pure functions that take (employee, pay period, source amount, source currency, target currency, policy) and return (converted amount, rate, timestamp, source). Encodes "lock on the 25th" or "use central bank reference for BRL". Calls Layer 1; never the provider.

Layer 3 — Payment Execution. Whatever moves the money (Stripe Connect, Wise Platform, a banking rail, a stablecoin bridge). Reports the rate the provider actually settled at, logged alongside the Layer 2 reference rate.

This split is the single biggest decision that keeps the codebase maintainable as you add countries — and it makes testing tractable, since Layer 1 can be stubbed with fixed rates.

Choosing a Rate Source: Mid-Market with an Audit Trail

For Layer 1, the rate source you want is mid-market — the midpoint between bid and ask, refreshed at least once per minute, with the ability to query historical rates by date. That gives you a clean reference for everything else.

Finexly returns mid-market rates aggregated from major liquidity providers, with both live and historical endpoints. A first call to confirm everything is wired:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY,INR,BRL,PHP,MXN" \
  -H "Authorization: Bearer YOUR_API_KEY"

You'll get back a JSON payload with rates, a base, and a timestamp. The two fields that matter for payroll are timestamp (the moment that rate snapshot was taken, in UTC) and the individual currency values. Always log both — never just the rate.

For more context on choosing a rate provider, the free currency API comparison and the Finexly vs Open Exchange Rates vs Fixer comparison cover the tradeoffs in depth.

Locking the Rate: Three Policies Worth Implementing

The "when do we lock" decision is the heart of payroll FX. Three policies cover almost every real customer:

Policy A — Lock on payroll run date. Simple, defensible, easy to explain on the payslip. Same-day rate; whatever the market is doing when you press Run is what the employee sees. Best default for contractor-style payments and any customer that doesn't have a strong opinion.

Policy B — Lock on a fixed day of the month. A customer with monthly payroll might want the rate locked on the 25th — that way payslips can be cut and previewed by the 27th, while payment actually moves on the 1st. Removes day-of-run volatility from the employee's experience.

Policy C — Period-end average. For long pay periods (semi-monthly, monthly) some customers prefer the average of mid-market rates across the period. Smooths out volatility, requires you to query historical rates for every business day in the window.

Here's a TypeScript implementation of all three. Layer 1 calls have been stubbed as rateService.getRate(...) so you can see the policy logic clearly:

// Layer 2: payroll FX policy
type Policy = "run_date" | "fixed_day" | "period_avg";

interface LockedRate {
  rate: number;
  source: "finexly_mid";
  policy: Policy;
  policyInputs: Record<string, string | number>;
  lockedAt: string;        // ISO8601 UTC
  rateTimestamp: string;   // ISO8601 UTC, from provider
}

async function lockPayrollRate(
  base: string,
  quote: string,
  payPeriodStart: Date,
  payPeriodEnd: Date,
  payrollRunAt: Date,
  policy: Policy,
  fixedDay: number = 25
): Promise<LockedRate> {
  switch (policy) {
    case "run_date": {
      const r = await rateService.getRate(base, quote, payrollRunAt);
      return {
        rate: r.rate,
        source: "finexly_mid",
        policy,
        policyInputs: { runAt: payrollRunAt.toISOString() },
        lockedAt: new Date().toISOString(),
        rateTimestamp: r.timestamp,
      };
    }
    case "fixed_day": {
      const lockDate = new Date(payPeriodEnd);
      lockDate.setUTCDate(fixedDay);
      // If the fixed day is a weekend, snap back to Friday
      const snapped = snapToBusinessDay(lockDate);
      const r = await rateService.getRate(base, quote, snapped);
      return {
        rate: r.rate,
        source: "finexly_mid",
        policy,
        policyInputs: { fixedDay, snappedTo: snapped.toISOString() },
        lockedAt: new Date().toISOString(),
        rateTimestamp: r.timestamp,
      };
    }
    case "period_avg": {
      const days = businessDaysBetween(payPeriodStart, payPeriodEnd);
      const rates = await Promise.all(
        days.map(d => rateService.getRate(base, quote, d))
      );
      const avg = rates.reduce((s, r) => s + r.rate, 0) / rates.length;
      return {
        rate: avg,
        source: "finexly_mid",
        policy,
        policyInputs: {
          start: payPeriodStart.toISOString(),
          end: payPeriodEnd.toISOString(),
          dayCount: rates.length,
        },
        lockedAt: new Date().toISOString(),
        rateTimestamp: rates[rates.length - 1].timestamp,
      };
    }
  }
}

The shape of the returned LockedRate object is the contract with the rest of your payroll engine. Every downstream calculation — gross-to-net, tax withholding, displayed payslip amount, exported payment file — references that single locked rate. Never re-quote.

Querying Historical Rates from Finexly (Python)

Policies B and C need historical rates — Finexly's /historical endpoint takes an ISO date. A Python implementation for Policy C with retry, backoff, and idempotent caching:

import os
import time
import json
import hashlib
import requests
from datetime import date, timedelta
from typing import List
import redis

API_KEY = os.environ["FINEXLY_API_KEY"]
BASE_URL = "https://api.finexly.com/v1"
r = redis.from_url(os.environ["REDIS_URL"])

def _cache_key(base: str, quote: str, on: date) -> str:
    return f"fx:{base}:{quote}:{on.isoformat()}"

def get_historical_rate(base: str, quote: str, on: date) -> dict:
    """Return mid-market rate for a base/quote pair on a given UTC date."""
    key = _cache_key(base, quote, on)
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    url = f"{BASE_URL}/historical"
    params = {"base": base, "symbols": quote, "date": on.isoformat()}
    headers = {"Authorization": f"Bearer {API_KEY}"}

    for attempt in range(4):
        try:
            resp = requests.get(url, params=params, headers=headers, timeout=10)
            resp.raise_for_status()
            data = resp.json()
            payload = {
                "base": data["base"],
                "quote": quote,
                "rate": data["rates"][quote],
                "timestamp": data["timestamp"],
                "date": on.isoformat(),
            }
            # Historical rates are immutable — cache them for 30 days
            r.setex(key, 30 * 24 * 3600, json.dumps(payload))
            return payload
        except (requests.HTTPError, requests.ConnectionError, requests.Timeout):
            if attempt == 3:
                raise
            time.sleep(2 ** attempt)

def business_days(start: date, end: date) -> List[date]:
    days, cur = [], start
    while cur <= end:
        if cur.weekday() < 5:  # Mon-Fri
            days.append(cur)
        cur += timedelta(days=1)
    return days

def period_average_rate(base: str, quote: str, start: date, end: date) -> dict:
    days = business_days(start, end)
    if not days:
        raise ValueError("No business days in period")
    rates = [get_historical_rate(base, quote, d)["rate"] for d in days]
    avg = sum(rates) / len(rates)
    return {
        "base": base,
        "quote": quote,
        "rate": avg,
        "policy": "period_avg",
        "day_count": len(days),
        "first_day": days[0].isoformat(),
        "last_day": days[-1].isoformat(),
    }

Two things in that code are non-obvious but matter. First, historical rates are immutable — Finexly's rate for USD/EUR on 2026-04-03 is the same forever — so a 30-day cache is safe and cuts call volume by 95%+ for any payroll system with recurring contractors. Second, the retry loop uses exponential backoff because payroll batches typically run during the same Sunday-night window across thousands of customers, and the FX provider is a shared resource.

For deeper coverage of the caching and retry patterns, see the currency API caching and error handling guide.

Idempotency and the Audit Trail (PHP)

The single most underrated thing a payroll FX layer can do is store the locked rate against an idempotency key, so retried payroll runs reuse the same rate. Here's a PHP implementation that wraps Finexly behind an idempotent service backed by Postgres:

<?php
declare(strict_types=1);

class PayrollFx {
    public function __construct(
        private \PDO $db,
        private string $apiKey,
        private string $baseUrl = "https://api.finexly.com/v1"
    ) {}

    public function lockOnRunDate(
        string $idempotencyKey,
        string $base,
        string $quote,
        \DateTimeImmutable $runAt
    ): array {
        // 1. Have we already locked this key?
        $stmt = $this->db->prepare(
            "SELECT rate, rate_timestamp, source FROM payroll_fx_locks
             WHERE idempotency_key = :k"
        );
        $stmt->execute([":k" => $idempotencyKey]);
        $existing = $stmt->fetch(\PDO::FETCH_ASSOC);
        if ($existing) {
            return $existing + ["replay" => true];
        }

        // 2. Fetch fresh mid-market from Finexly
        $url = sprintf(
            "%s/latest?base=%s&symbols=%s",
            $this->baseUrl, urlencode($base), urlencode($quote)
        );
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $this->apiKey],
            CURLOPT_TIMEOUT => 10,
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($code !== 200) {
            throw new \RuntimeException("Finexly returned $code");
        }
        $data = json_decode($body, true);
        $rate = $data["rates"][$quote] ?? null;
        if ($rate === null) {
            throw new \RuntimeException("Missing $quote in response");
        }

        // 3. Persist atomically (UNIQUE on idempotency_key)
        $ins = $this->db->prepare(
            "INSERT INTO payroll_fx_locks
                (idempotency_key, base_currency, quote_currency, rate,
                 rate_timestamp, source, locked_at, run_at)
             VALUES (:k, :b, :q, :r, :rt, 'finexly_mid', NOW(), :ra)
             ON CONFLICT (idempotency_key) DO NOTHING
             RETURNING rate, rate_timestamp, source"
        );
        $ins->execute([
            ":k" => $idempotencyKey,
            ":b" => $base,
            ":q" => $quote,
            ":r" => $rate,
            ":rt" => $data["timestamp"],
            ":ra" => $runAt->format("c"),
        ]);
        $row = $ins->fetch(\PDO::FETCH_ASSOC);
        if (!$row) {
            // Race: another worker won. Re-read.
            $stmt->execute([":k" => $idempotencyKey]);
            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        }
        return $row + ["replay" => false];
    }
}

A natural idempotency key is payroll_run_id:employee_id:base:quote — collisions are impossible inside a single run, and retries (whether from a queue redelivery or an operator clicking twice) get the exact same rate back without a second API call.

For more PHP integration patterns, the currency API PHP integration guide covers the same shape applied to e-commerce.

Handling Weekends, Holidays, and Closed Markets

FX markets close 5 PM Friday New York to 5 PM Sunday Sydney. Three sensible policies inside that window: roll back to last close (the right default for payroll — stable and explainable), roll forward to next open (forward-looking previews), or refuse to quote (high-stakes one-off transfers).

The Finexly historical endpoint snaps to the most recent business day automatically — query a Saturday date, get Friday's close, with the timestamp pointing at Friday. Always trust the returned timestamp, not the date you requested. Local banking holidays (Brazilian Carnival, Indian Diwali, Chinese New Year) need their own table — the FX market may be open, but the destination rail isn't, so flag funds-arrival-date separately.

Rounding, Display, and Payslip Math

After you have a rate, arithmetic still has to be right. Three rules that prevent support tickets:

  1. Multiply at full precision, then round once. Compute amount_local = amount_usd * rate using a decimal type with at least 10 significant digits, then round to the ISO 4217 minor units for the destination currency. JPY rounds to 0 decimals; USD/EUR to 2; KWD/BHD to 3; CLF to 4.
  2. Round half-to-even (banker's rounding). Reduces cumulative bias when you process thousands of payslips per cycle. JavaScript's default Math.round is half-away-from-zero — use a decimal library (decimal.js, bignumber.js).
  3. Display the rate to enough precision. Show the rate used on the payslip with at least 6 significant digits (0.911234 not 0.91). Employees who copy it into a calculator should be able to reproduce the local amount to the cent.

A Worked Example, End to End

A US fintech runs monthly contractor pay. Maria, in Mexico City, is contracted at USD 4,800/month. Customer policy: "lock on the 25th, period-end-aligned, mid-market reference, settle on the 1st via Stripe Connect."

On 2026-04-25 the payroll FX layer is called with idempotency_key="run_2026_04:contractor_847:USD:MXN". It queries Finexly historical for USD→MXN on that date, gets 17.8642, locks it. The payslip shows "USD 4,800.00 → MXN 85,748.16 at 17.8642 USD/MXN (mid-market, Finexly, 2026-04-25 21:00 UTC)."

On 2026-05-01 Stripe Connect settles. Stripe's payout API returns its own exchange_rate — say, 17.8201 (mid-market minus a 25 bps spread). Both rates land in the audit table. The 1099 export uses the locked rate; GL reconciliation uses the settlement rate; the delta books to an FX cost-of-goods account. That is what good looks like.

Common Mistakes to Avoid

Patterns we see repeatedly in payroll-system code reviews:

  • Re-quoting on retry. Different rates on retry mean different gross-to-net when nothing about the run changed. Always cache by idempotency key.
  • Date.now() as the rate timestamp. That's your clock, not the provider's. Log the provider's timestamp.
  • Silent fallback to stale cache. If you fall back to cached data during an outage, label it on the payslip — never present stale data as live.
  • Floating-point money. 4800 * 17.8642 is not the same on every machine. Use a decimal library for anything that touches money.
  • One global rate per run. Different employees may need different policies (a Brazilian contractor on PTAX, an Indian contractor on RBI reference). Resolve per-employee.

Frequently Asked Questions

What's the best exchange rate to use for cross-border payroll? Mid-market — the midpoint between bid and ask — is the standard reference. It's what employees can verify on Google or Bloomberg, and it's what most contracts mean when they say "fair market rate." The actual payment rail will use mid-market plus a spread; log both, display the mid-market on the payslip.

Should I lock the rate on payroll run date or on payment date? Whatever your customer's policy is — both are defensible. Locking on run date gives employees a fixed preview before payment; locking on payment date matches the underlying rail's behavior. The important thing is to encode the policy explicitly in code, not implicitly in whatever date your job happens to call the FX API.

How do I handle weekend or holiday payroll runs? Use the last close. The Finexly historical endpoint snaps to the most recent business day automatically; trust the timestamp field. For destination-currency banking holidays (when the recipient's bank is closed), flag the payslip's funds-arrival date but use the FX rate as normal.

Do I need to use the central bank reference rate for any countries? For some, yes — Brazil (PTAX), India for non-resident reporting (RBI reference rate), and Argentina (BCRA) all have published reference rates that local tax filings require. Your rate service should accept a per-jurisdiction override and fall through to mid-market for the rest.

How precise do exchange rates need to be on a payslip? At least 6 significant digits when displaying — 17.8642 not 17.86. When computing, use a decimal type at 10+ significant digits and round only at the end. Employees absolutely will plug the rate into a calculator to verify the math.

Can I use a free currency API for production payroll? Free tiers can work for very low volume, but most have limits (e.g., daily-only rates, no historical, 1,000 requests/month) that break at the first international hire. Compare options in the free vs paid currency API guide.

Wrapping Up

Cross-border payroll currency conversion looks like one multiplication and turns out to be six interlocking decisions: source policy, lock date, idempotency, rounding, weekend handling, and audit trail. Get the three-layer architecture right — Rate Layer, FX Policy, Payment Execution — and each decision becomes a small, testable function instead of a stored procedure no one wants to touch.

Ready to wire real-time mid-market rates into your payroll engine? Get your free Finexly API key — no credit card required. 1,000 requests/month is enough to test every code sample above, and paid plans scale through every contractor you'll ever onboard. Check the Finexly API documentation or compare Finexly with other providers.

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 →