Estimating order charges
Quote the exact wallet debit before committing - across vouchers, topups, and eSIM
Each vertical has its own charges endpoint that returns the precise wallet impact of a hypothetical order — face value, discount applied, any FX conversion, and the final amount that would leave the wallet. Use it to:
- Show a real price to the end-user before they confirm.
- Pre-check the wallet balance before placing bulk orders.
- Price-monitor across currencies.
- Surface max-quantity limits (vouchers only) without bumping into them.
The three endpoints follow the same response shape, so the integration logic is the same — only the request body differs.
| Vertical | Endpoint | Identifier | Amount field |
|---|---|---|---|
| Vouchers | POST /api/v1/products/:id/charges | :id in path | denomination + quantity in body |
| Topups | POST /api/v1/topups/charges | product_id in body | amount (single-order) |
| eSIM | POST /api/v1/esim/charges | product_id in body | amount (single-order, quantity always 1) |
Vouchers
POST /api/v1/products/:id/charges
curl -X POST "$HOST/api/v1/products/123/charges" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"denomination": 50.00,
"quantity": 5,
"wallet_id": 1
}'Voucher charges support multi-quantity (a single quote covers quantity units of the same denomination). The response includes max_quantity — the per-product ceiling the wallet is allowed to order at once.
Topups
POST /api/v1/topups/charges
curl -X POST "$HOST/api/v1/topups/charges" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": 4218,
"amount": 4.99,
"wallet_id": 1
}'Topup orders are 1-at-a-time, so there is no quantity field and no max_quantity in the response. The variant Octopus Cards picks for (product_id, amount) is reported back via charges_details (and used directly by POST /topups/orders).
eSIM
POST /api/v1/esim/charges
curl -X POST "$HOST/api/v1/esim/charges" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": 712,
"amount": 4.50,
"wallet_id": 1
}'eSIM orders always represent a single eSIM — there is no quantity field and no max_quantity in the response. amount must equal one of the variant amounts returned by GET /esim/products/:id/variants.
The shared response shape
All three endpoints return the same shape. The field that matters most is total_payable — that is what leaves the wallet if the order is placed. Everything else exists to explain how it was computed.
{
"non_discounted_total": 250.00,
"discount_amount": 8.75,
"total_amount": 241.25,
"discount": 3.5,
"total_payable": 241.25,
"charges_details": {
"source_currency": "USD",
"destination_currency": "USD",
"forex_rate": null,
"conversion_fee": null
}
}| Field | Meaning |
|---|---|
non_discounted_total | Face value (denomination × quantity for vouchers, amount for topup/eSIM). |
discount_amount | Absolute discount applied for your client. |
total_amount | After-discount amount, before any FX conversion. |
discount | Discount percentage applied. |
total_payable | Final amount debited from the wallet (after FX + fees, when applicable). |
max_quantity | (Vouchers only) Per-product per-call ceiling. Returning 400 if exceeded. |
net_amount | (Cross-currency only) Wallet-currency amount before conversion fee. |
handling_fee_amount | (Cross-currency only) Fixed handling fee added on top of the FX-converted amount. |
charges_details.source_currency | The variant's currency (what non_discounted_total is denominated in). |
charges_details.destination_currency | The wallet's currency (what total_payable is denominated in). |
charges_details.forex_rate | Admin-managed rate used. null when source = destination. |
charges_details.conversion_fee | FX conversion fee percentage. null when source = destination. |
Cross-currency orders
When the wallet's currency differs from the variant's currency, Octopus Cards looks up an admin-managed forex rate and the response carries the conversion fields:
{
"non_discounted_total": 250.00,
"discount_amount": 8.75,
"total_amount": 241.25,
"net_amount": 222.31,
"handling_fee_amount": 0.50,
"total_payable": 222.81,
"charges_details": {
"source_currency": "USD",
"destination_currency": "EUR",
"forex_rate": 0.9214,
"conversion_fee": 0.5
}
}total_payable = net_amount + handling_fee_amount, always in the wallet's destination_currency. If no rate exists for the source → destination pair, the request rejects with 400 "Exchange rate not available for the wallet currency". Full walkthrough: Cross-currency orders.
When to call it
Server caches for 5 minutes
The charges endpoint caches its response server-side for 5 minutes per (product, amount/denomination, quantity, wallet, client) tuple. Don't burn rate-limit calling it in a tight loop — call it once per logical quote and reuse the result.
Good places to call it:
- Right before an order. Use the returned
total_payableto pre-check the wallet balance. - When rendering a checkout/pricing UI. Call once when the user opens the step; re-call if they change amount / denomination / quantity.
- When planning a bulk campaign. Estimate total cost before committing, without holding any inventory.
Bad places:
- Every tick of a loop — the cache returns the same answer anyway, and rate-limit hits are easy.
- As a proxy for placing the order. Charges is a read-shaped calculation; it doesn't reserve anything. Only the create-order endpoint actually creates the order.
Pre-flight: check balance first
Pattern is identical across verticals — quote, fetch wallet, gate on balance. Example using the voucher charges endpoint:
func canAfford(host, token string, productID uint64, denom float64, qty int, walletID uint64) (bool, error) {
// 1. Quote
q, err := voucherCharges(host, token, productID, voucherChargesReq{
Denomination: denom,
Quantity: qty,
WalletID: &walletID,
})
if err != nil {
return false, err
}
// 2. Read wallet balance
w, err := getWallet(host, token, walletID)
if err != nil {
return false, err
}
// 3. Gate the order on sufficient funds
return w.Amount >= q.TotalPayable, nil
}This avoids the INSUFFICIENT_FUNDS error path on the order endpoint, especially useful for bulk workflows where fast failure matters.
Example: pricing a user-facing checkout
type Quote struct {
DisplayPrice float64
Currency string
Discount float64
FaceValue float64
MaxQuantity int // 0 when not applicable (topup/eSIM)
}
func PriceForCheckout(host, token string, productID uint64, denom float64, qty int) (*Quote, error) {
c, err := voucherCharges(host, token, productID, voucherChargesReq{
Denomination: denom,
Quantity: qty,
})
if err != nil {
return nil, err
}
return &Quote{
DisplayPrice: c.TotalPayable,
Currency: c.ChargesDetails.DestinationCurrency,
Discount: c.DiscountAmount,
FaceValue: c.NonDiscountedTotal,
MaxQuantity: c.MaxQuantity,
}, nil
}Render displayPrice + currency to the user. If they change quantity (vouchers) or amount (topup/eSIM), call again.
Errors worth handling
| Error | What it means | Action |
|---|---|---|
400 / "Denomination not available" (vouchers) | The denomination isn't in the product's ranges | Re-fetch the product and pick a valid denomination |
400 / "Quantity exceeds maximum" (vouchers) | Above the product's max_quantity | Reduce quantity or request a limit uplift |
400 / "No variant available for this product and amount" (topup/eSIM) | No variant matches (product, amount) | Pick an amount from the variant catalogue (/topups/products/:id/variants or /esim/products/:id/variants) |
400 / "Appropriate wallet not found" | No wallet exists in the variant's currency, and no wallet_id was supplied | Specify wallet_id to trigger FX, or open a wallet in the variant currency |
400 / "Exchange rate not available for the wallet currency" | Cross-currency requested but no admin rate is configured for the pair | Use a wallet in the variant currency, or contact your account manager to add the rate |
404 / NOT_FOUND | Product doesn't exist or isn't enabled for your client | Re-fetch the catalogue |
Vertical references for the full error list:
Summary
- Always quote before committing.
total_payableis what will actually be paid. - Three endpoints, one response shape — choose by vertical (
/products/:id/charges,/topups/charges,/esim/charges). - Cache hits are server-side and last 5 minutes — don't hammer.
- Pre-check the wallet balance against
total_payablebefore bulk voucher orders or whenever a clean failure mode is wanted. charges_details.forex_ratereveals whether an FX conversion is about to happen.