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
}
}| Field | Currency | Meaning |
|---|---|---|
non_discounted_total | source_currency (variant) | Face value — denomination × quantity for vouchers, amount for topup/eSIM. |
discount_amount | source_currency | Absolute client discount applied. |
total_amount | source_currency | After-discount amount, before FX. |
net_amount | destination_currency (wallet) | total_amount × forex_rate. The converted amount before the handling fee. |
handling_fee_amount | destination_currency | Fixed conversion fee (currently 0). |
total_payable | destination_currency | What leaves the wallet = net_amount + handling_fee_amount. |
charges_details.source_currency | — | The variant's currency. |
charges_details.destination_currency | — | The wallet's currency. |
charges_details.forex_rate | — | Multiplier applied to total_amount to produce net_amount. Source → destination. |
charges_details.conversion_fee | — | Conversion-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:
- Open or use a wallet in the variant's currency — pay directly, no FX.
- Contact your account manager to enable the missing pair in Octopus Cards's
forex_valuestable.
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_amountand 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_idwhose currency differs from the variant's. - The pattern is the same across all three verticals: preview with
/charges, gate ontotal_payable, place the order with the samewallet_id. source_currencyis the variant's currency;destination_currencyis 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.
Sync vs. async orders
When each vertical's create-order endpoint returns the result inline vs. queues it for fulfilment - and how to handle both
Handling webhooks in production
End-to-end pattern for a production-ready webhook consumer - verification, idempotency, acking fast - across vouchers, topups, and eSIM