GuidesWalkthroughs

Cross-currency orders

Pay from a wallet whose currency differs from the product's - end-to-end across vouchers, topups, and eSIM

When a wallet's currency differs from the product's currency, Octopus Cards applies FX conversion automatically. The wallet's wallet_id is the trigger: pass it on the charges and order endpoints, and the response carries the FX breakdown.

The pattern is identical across the three verticals — only the endpoint URLs and the request bodies differ. The same response shape applies everywhere.

When FX kicks in

By default — when no wallet_id is supplied — Octopus Cards looks up a wallet whose currency matches the variant's currency and debits from it. No FX, no conversion fee.

FX is triggered explicitly, not implicitly: pass a wallet_id whose currency differs from the variant's, and Octopus Cards converts.

The response shape

Every charges endpoint returns the same shape when FX applies:

{
  "non_discounted_total": 250.00,
  "discount_amount": 8.75,
  "total_amount": 241.25,
  "net_amount": 282.26,
  "handling_fee_amount": 0.00,
  "total_payable": 282.26,
  "charges_details": {
    "source_currency": "GBP",
    "destination_currency": "EUR",
    "forex_rate": 1.1700,
    "conversion_fee": 0.0
  }
}
FieldCurrencyMeaning
non_discounted_totalsource_currency (variant)Face value — denomination × quantity for vouchers, amount for topup/eSIM.
discount_amountsource_currencyAbsolute client discount applied.
total_amountsource_currencyAfter-discount amount, before FX.
net_amountdestination_currency (wallet)total_amount × forex_rate. The converted amount before the handling fee.
handling_fee_amountdestination_currencyFixed conversion fee (currently 0).
total_payabledestination_currencyWhat leaves the wallet = net_amount + handling_fee_amount.
charges_details.source_currencyThe variant's currency.
charges_details.destination_currencyThe wallet's currency.
charges_details.forex_rateMultiplier applied to total_amount to produce net_amount. Source → destination.
charges_details.conversion_feeConversion-fee percentage applied on top of FX (currently 0).

In words: total_payable is the only number that matters for budgeting. Everything else is showing how it was computed.

Finding wallets

Every cross-currency order needs a wallet_id. List wallets to find one:

curl "$HOST/api/v1/wallets" -H "Authorization: Bearer $TOKEN"

Response:

[
  { "id": 1, "currency_id": 1, "amount": 5000.00, "type": "PREPAID" },
  { "id": 7, "currency_id": 3, "amount": 2300.50, "type": "PREPAID" }
]

Resolve currency_id to an ISO 4217 code via GET /api/v1/currencies (cache the result — currency data changes rarely).

End-to-end: vouchers

Buy a GBP-priced gift card from an EUR wallet.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "os"
)

type chargesResp struct {
    NonDiscountedTotal float64 `json:"non_discounted_total"`
    TotalAmount        float64 `json:"total_amount"`
    NetAmount          float64 `json:"net_amount,omitempty"`
    HandlingFeeAmount  float64 `json:"handling_fee_amount,omitempty"`
    TotalPayable       float64 `json:"total_payable"`
    ChargesDetails     struct {
        SourceCurrency      string  `json:"source_currency"`
        DestinationCurrency string  `json:"destination_currency"`
        ForexRate           float64 `json:"forex_rate"`
    } `json:"charges_details"`
}

func crossCurrencyVoucherOrder(host, token string) error {
    const productID, walletID uint64 = 123, 7
    const denomination, quantity = 50.00, 5

    // 1. Preview the FX
    body, _ := json.Marshal(map[string]any{
        "denomination": denomination,
        "quantity":     quantity,
        "wallet_id":    walletID,
    })
    url := fmt.Sprintf("%s/api/v1/products/%d/charges", host, productID)
    req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, _ := http.DefaultClient.Do(req)
    var c chargesResp
    _ = json.NewDecoder(resp.Body).Decode(&c)
    _ = resp.Body.Close()

    fmt.Printf("Quoted %.2f %s for %.2f %s of product\n",
        c.TotalPayable, c.ChargesDetails.DestinationCurrency,
        c.NonDiscountedTotal, c.ChargesDetails.SourceCurrency)

    // 2. Gate on a budget (in destination currency)
    const budgetEUR = 300.00
    if c.TotalPayable > budgetEUR {
        return errors.New("over budget")
    }

    // 3. Place the order with the same wallet
    body, _ = json.Marshal(map[string]any{
        "product_id":   productID,
        "denomination": denomination,
        "quantity":     quantity,
        "wallet_id":        walletID,
        "client_reference": "CAMPAIGN_EUR_GBP_001",
    })
    req, _ = http.NewRequest("POST", host+"/api/v1/orders", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, _ = http.DefaultClient.Do(req)
    defer func() { _ = resp.Body.Close() }()

    body2, _ := io.ReadAll(resp.Body)
    fmt.Printf("Order placed; debited %.2f %s. Response:\n%s\n",
        c.TotalPayable, c.ChargesDetails.DestinationCurrency, body2)
    _ = os.Stderr // silence unused
    return nil
}

End-to-end: topups

Buy a USD-priced gaming top-up from a USD wallet — but quote against an INR wallet to show the FX path. (Topups use product_id + amount in the body; no denomination / quantity.)

func crossCurrencyTopupOrder(host, token string) error {
    const productID, walletID uint64 = 4218, 12 // INR wallet
    const amount = 4.99

    // 1. Preview the FX
    body, _ := json.Marshal(map[string]any{
        "product_id": productID,
        "amount":     amount,
        "wallet_id":  walletID,
    })
    req, _ := http.NewRequest("POST",
        host+"/api/v1/topups/charges", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, _ := http.DefaultClient.Do(req)
    var c chargesResp
    _ = json.NewDecoder(resp.Body).Decode(&c)
    _ = resp.Body.Close()

    fmt.Printf("Quoted %.2f %s for %.2f %s top-up\n",
        c.TotalPayable, c.ChargesDetails.DestinationCurrency,
        c.NonDiscountedTotal, c.ChargesDetails.SourceCurrency)

    // 2. Place the order with the same wallet
    body, _ = json.Marshal(map[string]any{
        "product_id": productID,
        "amount":     amount,
        "input_data": map[string]string{
            "player_id": "5123456789",
            "server":    "asia",
        },
        "wallet_id":        walletID,
        "client_reference": "FX_TOPUP_001",
    })
    req, _ = http.NewRequest("POST",
        host+"/api/v1/topups/orders", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, _ = http.DefaultClient.Do(req)
    defer func() { _ = resp.Body.Close() }()
    return nil
}

End-to-end: eSIM

Buy a USD-priced Japan eSIM from an EUR wallet.

func crossCurrencyEsimOrder(host, token string) error {
    const productID, walletID uint64 = 712, 7 // EUR wallet
    const amount = 4.50

    // 1. Preview the FX
    body, _ := json.Marshal(map[string]any{
        "product_id": productID,
        "amount":     amount,
        "wallet_id":  walletID,
    })
    req, _ := http.NewRequest("POST",
        host+"/api/v1/esim/charges", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, _ := http.DefaultClient.Do(req)
    var c chargesResp
    _ = json.NewDecoder(resp.Body).Decode(&c)
    _ = resp.Body.Close()

    fmt.Printf("Quoted %.2f %s for %.2f %s eSIM\n",
        c.TotalPayable, c.ChargesDetails.DestinationCurrency,
        c.NonDiscountedTotal, c.ChargesDetails.SourceCurrency)

    // 2. Place the order with the same wallet
    body, _ = json.Marshal(map[string]any{
        "product_id": productID,
        "amount":     amount,
        "quantity":   1,
        "wallet_id":  walletID,
        "client_reference": "FX_ESIM_TOKYO_001",
    })
    req, _ = http.NewRequest("POST",
        host+"/api/v1/esim/orders", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, _ = http.DefaultClient.Do(req)
    defer func() { _ = resp.Body.Close() }()
    return nil
}

FX rate semantics

  • Rates are admin-managed. Pairs that aren't configured return 400 "Exchange rate not available for the wallet currency" — contact your account manager to add one.
  • The rate quoted is the rate billed, provided the order is placed within the charges cache window (~5 minutes). Outside that window, expect minor drift.
  • No client-side hedging. The rate at the moment of order placement is what's used. Don't pre-quote hours in advance — by the time the order fires, the rate may have moved.
  • Conversion fee is currently 0, but the field exists in the response so the integration is forward-compatible.

Error: FX rate not available

{
  "error": {
    "name": "BadRequestError",
    "code": "FX_UNAVAILABLE",
    "message": "Exchange rate not available for the wallet currency"
  }
}

Two ways out:

  1. Open or use a wallet in the variant's currency — pay directly, no FX.
  2. Contact your account manager to enable the missing pair in Octopus Cards's forex_values table.

When not to use FX

  • If a wallet in the variant's currency exists, paying directly is cheaper (no conversion fee). Reserve cross-currency for genuine shortfalls.
  • For micro-orders, the conversion fee (when non-zero) can dominate the total. Check handling_fee_amount / total_amount and decide.
  • For high-frequency / high-volume flows, hold balances in the currencies most products are priced in. Cross-currency is a convenience, not a cost-free swap.

Summary

  • FX is triggered by passing a wallet_id whose currency differs from the variant's.
  • The pattern is the same across all three verticals: preview with /charges, gate on total_payable, place the order with the same wallet_id.
  • source_currency is the variant's currency; destination_currency is the wallet's (debited) currency.
  • total_payable = net_amount + handling_fee_amount, in the wallet's currency.
  • Missing rate pairs return 400 — fix by adding the rate or by using a same-currency wallet.

On this page