FX Rates

How exchange rates are fetched, cached, and applied to payments.

How Rates Work

Each partner has a fiat currency (e.g. USD, BRL, COP) and a stored FX rate expressed as fiat per USDC. When a merchant creates a payment request for a fiat amount, JoPay divides the fiat amount by this rate to calculate the USDC equivalent:

usdcAmount = fiatAmount / fx_rate_fiat_per_usdc

The rate is stored on the partner record with high precision (up to 18 decimal places) to minimize rounding errors.

Rate Sources

JoPay uses two FX rate sources, with automatic fallback:

  1. Primary: ExchangeRate-API — the default source. JoPay calls v6.exchangerate-api.com with your FX_API_KEY_EXCHANGERATE_API to fetch USD-based rates for all supported currencies. The partner's fiat currency rate is extracted from the conversion_rates response.
  2. Fallback: Trails Token Prices — if the primary source fails (network error, bad response), JoPay falls back to Sequence Trails GetTokenPrices to get USDC pricing data. The fx_source field records which source was used.
Both sources are tried with a 10-second timeout and up to 2 attempts each. If both sources fail, the rate is not updated and the existing rate remains in use (subject to staleness rules).

Refresh Mechanisms

Admin Manual Refresh

A JoPay admin can trigger a rate refresh from the admin panel by calling the POST /api/admin/fx-refresh endpoint. This updates all partners (or a specific partner if partner_id is passed). The action is rate-limited to 1 refresh per 10-minute window per partner and is logged in the admin audit trail.

Cron Refresh

A scheduled cron job can call the same endpoint on a regular interval (e.g. every 30 minutes) to keep rates fresh without manual intervention. This is the recommended approach for production environments.

Dry Run

Pass ?dry_run=1 to the refresh endpoint to see which partners would be updated and what rates would be applied, without actually writing to the database.

Caching and Staleness

Request-Time Cache

When merchants create payment requests, the FX rate lookup uses an in-memory cache with a 5-minute TTL. If the cache expires and the FX API is temporarily unreachable, JoPay serves the stale cached rate for up to 30 minutes to prevent request creation from failing during transient outages.

If the cache is older than 30 minutes and the API is still down, rate resolution returns null and the request cannot be created.

Recurring Billing Staleness

The recurring billing cron job applies a stricter rule: if the partner's fx_last_refreshed_at is older than 24 hours, the charge is rejected. This prevents recurring charges from using severely outdated rates.

Display vs. Payment

JoPay supports a concept where the display currency shown to the customer can differ from the partner's base currency. For example, a partner may operate in USD, but a merchant can set their display currency to EUR. In this case:

  • The payment page shows the amount in the merchant's display currency (EUR).
  • The USDC conversion uses the display currency's rate, not the partner's base rate.
  • If the display currency matches the partner currency, no cross-rate calculation is needed.

Stored Fields

FieldDescription
fx_rate_fiat_per_usdcDecimal rate: how many fiat units per 1 USDC (e.g. 1.00 for USD, 4850.00 for COP).
fx_sourceWhich source provided the rate: exchangerate-api or trails-token-prices.
fx_last_refreshed_atTimestamp of the last successful rate update.

Frozen Rate on Requests

When a payment request is created, the FX rate at that moment is frozen onto the request record as fx_rate_fiat_per_usdc_frozen along with fx_timestamp. This ensures the USDC amount shown to the customer does not change after the request is created, even if the live rate moves.