E-commerce integrations

GCC currency conversion for Shopify and WooCommerce

Fetch a dated AED exchange rate and display equivalent prices in AED, SAR, QAR, BHD, KWD, or OMR. Keep the API key on your server and show shoppers when a quote is cached or unavailable.

1. Request an exchange rate

Call GET /api/v1/finance/currency/exchange?from=AED&to=SAR from your backend. The endpoint requires the finance:exchange scope and returns data.rate (target currency per one AED), data.date, and data.source. Rates are dated reference quotes, not a streaming feed or a guaranteed checkout/settlement rate. Confirm the date and source in your storefront and apply your own margin/rounding policy.

For example, an AED 100.00 item at an illustrative rate of 1 AED = 1.02 SAR displays as SAR 102.00 before destination tax. Never assume a fixed peg or hard-code an exchange rate.

Create a key with only finance:exchange, store it in a server environment variable named KHALEEJI_API_KEY, and send it in the Authorization header using Bearer authentication. See the cloud accounting guide for another server-side example using this endpoint.

2. Shopify: App Proxy and storefront currency switcher

Configure a Shopify App Proxy (for example, /apps/khaleeji/rate) to your app backend. Verify Shopify's proxy signature before accepting a request, allowlist only the six supported currencies, and never let the browser supply an upstream URL or path. The app secret verifies the proxy request; the separate KhaleejiAPI key authenticates your server's rate lookup.

javascript
// Shopify app backend (Node.js / Express); configure an App Proxy to this route.
// Keep both secrets in server-only environment variables.
import crypto from "node:crypto"
import express from "express"
const app = express()
const currencies = new Set(["AED", "SAR", "QAR", "BHD", "KWD", "OMR"])
function isValidAppProxy(query) {
const { signature, ...params } = query
if (!process.env.SHOPIFY_API_SECRET || typeof signature !== "string" ||
!/^[a-f0-9]{64}$/.test(signature)) return false
const message = Object.keys(params).sort()
.map((key) => `${key}=${[].concat(params[key]).join(",")}`)
.join("")
const expected = crypto.createHmac("sha256", process.env.SHOPIFY_API_SECRET)
.update(message).digest("hex")
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
}
app.get("/apps/khaleeji/rate", async (req, res) => {
if (!isValidAppProxy(req.query)) return res.status(401).json({ error: "bad signature" })
if (!process.env.KHALEEJI_API_KEY) return res.status(503).json({ error: "rate service is not configured" })
const to = String(req.query.to ?? "").toUpperCase()
if (!currencies.has(to)) return res.status(400).json({ error: "unsupported currency" })
const url = new URL("/api/v1/finance/currency/exchange", "https://khaleejiapi.dev")
url.searchParams.set("from", "AED")
url.searchParams.set("to", to)
try {
const response = await fetch(url, {
headers: { Authorization: ["Bearer", process.env.KHALEEJI_API_KEY].join(" ") },
signal: AbortSignal.timeout(5000),
})
res.status(response.status).json(await response.json())
} catch {
res.status(502).json({ error: "exchange rate unavailable" })
}
})
html
<label for="gcc-currency">Display currency</label>
<select id="gcc-currency" name="display_currency">
<option value="AED">AED</option>
<option value="SAR">SAR</option>
<option value="QAR">QAR</option>
<option value="BHD">BHD</option>
<option value="KWD">KWD</option>
<option value="OMR">OMR</option>
</select>
<p id="gcc-rate-status" aria-live="polite"></p>
<!-- In an AED-based Shopify theme, add this attribute to product prices: -->
<span class="js-gcc-price" data-price-aed="{{ product.price | divided_by: 100.0 }}"></span>

Add the following selector and status markup, then a data-price-aed attribute to each AED product price. Update that attribute when a shopper selects another product variant. The browser code uses session storage for 15 minutes, can fall back to a cached quote for up to 24 hours during an API error, and otherwise restores AED prices with an unavailable message. Avoid caching personalized App Proxy responses in a shared CDN.

javascript
const supported = new Set(["AED", "SAR", "QAR", "BHD", "KWD", "OMR"])
const select = document.querySelector("#gcc-currency")
const status = document.querySelector("#gcc-rate-status")
const prices = document.querySelectorAll(".js-gcc-price[data-price-aed]")
const memoryCache = new Map()
let requestGeneration = 0
function readCachedRate(currency) {
try {
const value = JSON.parse(sessionStorage.getItem(`gcc-rate-${currency}`) ?? "null")
return value && Number.isFinite(value.rate) ? value : memoryCache.get(currency) ?? null
} catch {
return memoryCache.get(currency) ?? null
}
}
function saveCachedRate(currency, value) {
memoryCache.set(currency, value)
try {
sessionStorage.setItem(`gcc-rate-${currency}`, JSON.stringify(value))
} catch {
// Continue with the in-memory cache when browser storage is unavailable.
}
}
async function getRate(currency) {
const cached = readCachedRate(currency)
if (cached && Date.now() - cached.savedAt < 15 * 60 * 1000) return cached
try {
const response = await fetch(`/apps/khaleeji/rate?to=${encodeURIComponent(currency)}`)
if (!response.ok) throw new Error("rate request failed")
const { data } = await response.json()
if (!Number.isFinite(data?.rate) || data.rate <= 0) throw new Error("invalid rate")
const value = { rate: data.rate, date: data.date, savedAt: Date.now() }
saveCachedRate(currency, value)
return value
} catch (error) {
if (cached && Date.now() - cached.savedAt < 24 * 60 * 60 * 1000) {
return { ...cached, stale: true }
}
throw error
}
}
async function displayCurrency(currency) {
if (!supported.has(currency)) return
const generation = ++requestGeneration
try {
const quote = currency === "AED" ? { rate: 1, date: "base currency" } : await getRate(currency)
if (generation !== requestGeneration) return
for (const element of prices) {
const base = Number(element.dataset.priceAed)
if (!Number.isFinite(base)) continue
element.textContent = new Intl.NumberFormat(undefined, {
style: "currency", currency,
}).format(base * quote.rate)
element.dataset.rateDate = quote.date
element.dataset.rateStale = String(quote.stale === true)
}
document.documentElement.dataset.exchangeRateStatus = quote.stale ? "stale" : "current"
if (status) {
status.textContent = quote.stale
? `Showing cached ${currency} prices from ${quote.date}.`
: `Showing ${currency} prices; rate date: ${quote.date}.`
}
} catch {
if (generation !== requestGeneration) return
for (const element of prices) {
const base = Number(element.dataset.priceAed)
if (Number.isFinite(base)) {
element.textContent = new Intl.NumberFormat(undefined, {
style: "currency", currency: "AED",
}).format(base)
}
}
if (select instanceof HTMLSelectElement) select.value = "AED"
if (status) status.textContent = "Exchange rate unavailable; showing store currency (AED)."
document.documentElement.dataset.exchangeRateStatus = "unavailable"
}
}
select?.addEventListener("change", () => void displayCurrency(select.value))
if (select instanceof HTMLSelectElement) void displayCurrency(select.value)

This theme example is for product display in an AED-based catalog, not a hosted checkout extension. Shopify Markets or a supported multi-currency checkout integration must own the transaction currency and final order total; do not imply that changing theme text changes the amount Shopify charges.

3. WooCommerce: cached server-side display conversion

Put the API key in the server environment and expose it to PHP through wp-config.php, not a theme script or a public option. This example uses WordPress transients for a 15-minute fresh cache and a separate one-day stale fallback, and uses woocommerce_get_price_html for simple-product display only. Add your selector to set the allowlisted display-currency cookie; then vary or bypass full-page caches for that cookie so one shopper does not see another currency.

php
// wp-config.php: use a server environment variable; do not put this in theme JavaScript.
define( 'KHALEEJI_API_KEY', getenv( 'KHALEEJI_API_KEY' ) );
function khaleeji_gcc_exchange_rate( $currency ) {
static $memory = array();
$allowed = array( 'AED', 'SAR', 'QAR', 'BHD', 'KWD', 'OMR' );
if ( ! in_array( $currency, $allowed, true ) ) return null;
if ( 'AED' === $currency ) return array( 'rate' => 1.0, 'date' => 'base currency', 'stale' => false );
if ( array_key_exists( $currency, $memory ) ) return $memory[ $currency ];
$key = 'khaleeji_fx_' . strtolower( $currency );
$cached = get_transient( $key );
if ( is_array( $cached ) && isset( $cached['rate'] ) && is_numeric( $cached['rate'] ) ) {
return $memory[ $currency ] = array(
'rate' => (float) $cached['rate'], 'date' => $cached['date'] ?? '', 'stale' => false,
);
}
$api_key = defined( 'KHALEEJI_API_KEY' ) ? KHALEEJI_API_KEY : getenv( 'KHALEEJI_API_KEY' );
if ( ! $api_key ) {
$stale = get_transient( $key . '_stale' );
return $memory[ $currency ] = is_array( $stale ) && isset( $stale['rate'] ) && is_numeric( $stale['rate'] )
? array( 'rate' => (float) $stale['rate'], 'date' => $stale['date'] ?? '', 'stale' => true ) : null;
}
$url = add_query_arg(
array( 'from' => 'AED', 'to' => $currency ),
'https://khaleejiapi.dev/api/v1/finance/currency/exchange'
);
$response = wp_remote_get( $url, array(
'timeout' => 5,
'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
) );
$body = is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response )
? null : json_decode( wp_remote_retrieve_body( $response ), true );
$rate = is_array( $body ) ? ( $body['data']['rate'] ?? null ) : null;
if ( is_numeric( $rate ) && (float) $rate > 0 ) {
$value = array( 'rate' => (float) $rate, 'date' => $body['data']['date'] ?? '' );
set_transient( $key, $value, 15 * MINUTE_IN_SECONDS );
set_transient( $key . '_stale', $value, DAY_IN_SECONDS );
$value['stale'] = false;
return $memory[ $currency ] = $value;
}
$stale = get_transient( $key . '_stale' );
return $memory[ $currency ] = is_array( $stale ) && isset( $stale['rate'] ) && is_numeric( $stale['rate'] )
? array( 'rate' => (float) $stale['rate'], 'date' => $stale['date'] ?? '', 'stale' => true ) : null;
}
add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {
if ( is_admin() || ! $product instanceof WC_Product || ! $product->is_type( 'simple' ) ) return $html;
$requested = isset( $_COOKIE['khaleeji_display_currency'] ) && is_string( $_COOKIE['khaleeji_display_currency'] )
? sanitize_text_field( wp_unslash( $_COOKIE['khaleeji_display_currency'] ) ) : '';
$currency = strtoupper( '' === $requested ? 'AED' : $requested );
if ( 'AED' === $currency ) return $html;
$quote = khaleeji_gcc_exchange_rate( $currency );
if ( null === $quote ) {
return $html . ' <small class="khaleeji-rate-notice">Exchange rate unavailable; showing store currency.</small>';
}
// This example assumes a tax-exclusive AED catalog price; native checkout tax remains authoritative.
$base = (float) $product->get_price();
$digits = in_array( $currency, array( 'BHD', 'KWD', 'OMR' ), true ) ? 3 : 2;
$converted = wc_price( round( $base * $quote['rate'], $digits ), array(
'currency' => $currency, 'decimals' => $digits,
) );
if ( $quote['stale'] ) {
$converted .= ' <small class="khaleeji-rate-notice">Approximate price; cached rate dated ' .
esc_html( $quote['date'] ) . '.</small>';
}
return $converted;
}, 20, 2 );
// Display only: this filter does not change cart totals, order currency, or payment capture.

Reuse the selector markup above. Set the preference and reload the page so WooCommerce renders the selected currency on the server. This sample assumes tax-exclusive AED catalog prices; configure destination VAT separately in WooCommerce. Stale and unavailable rates are explicitly labeled in the returned price HTML:

javascript
// A currency preference is not secret. The PHP filter still allowlists its value.
const allowed = new Set(["AED", "SAR", "QAR", "BHD", "KWD", "OMR"])
const select = document.querySelector("#gcc-currency")
const saved = document.cookie.split("; ").find((item) => item.startsWith("khaleeji_display_currency="))
const preferred = saved?.split("=")[1]
if (select && allowed.has(preferred)) select.value = preferred
select?.addEventListener("change", (event) => {
const currency = event.target.value
if (!allowed.has(currency)) return
document.cookie = `khaleeji_display_currency=${currency}; Path=/; SameSite=Lax; Secure; Max-Age=2592000`
window.location.reload()
})

The PHP filter deliberately skips variable products and does not convert cart or order totals. For live multi-currency checkout, choose an integration that consistently sets product prices, cart totals, order currency, gateway amount, refunds, and reporting; test all of those paths before launch.

4. Calculate destination VAT after conversion

Determine the destination taxable amount in the destination currency first, then apply the merchant's configured destination VAT rules. For illustration only: AED 100.00 × 1.02 SAR/AED = SAR 102.00 net; at an illustrative 15% Saudi VAT rate, VAT is SAR 15.30 and the total is SAR 117.30. The tax rate, taxability, exemptions, and rounding must come from the applicable jurisdiction and your platform's tax configuration—not from the FX API. Confirm tax treatment with a qualified adviser.

Keep the exchange quote date and rate with your price/tax audit data. Do not apply VAT to the pre-conversion AED amount and then convert the tax separately; apply tax to the converted taxable base and use your platform's native tax calculation for the charge.

5. Caching, precision, and failure handling

  • Cache each base/target pair for 5–15 minutes at the integration layer; the API itself also caches upstream rates.
  • Keep the returned rate date/source, round at the final display boundary, and use three fractional digits for BHD, KWD, and OMR.
  • Set a short upstream timeout. If refresh fails, use a clearly labelled cached rate only within your chosen staleness window.
  • If no acceptable cached quote exists, retain the original store currency and do not block checkout or change the payable amount.
  • Use per-currency cache keys and rate limits; do not fetch once per product tile or expose a general-purpose API proxy.
  • Monitor 401/403 scope errors, 429 rate limits, and 5xx failures; never log API keys or send them to the browser.

Official references