GuidesWalkthroughs

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.

VerticalEndpointIdentifierAmount field
VouchersPOST /api/v1/products/:id/charges:id in pathdenomination + quantity in body
TopupsPOST /api/v1/topups/chargesproduct_id in bodyamount (single-order)
eSIMPOST /api/v1/esim/chargesproduct_id in bodyamount (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
  }
}
FieldMeaning
non_discounted_totalFace value (denomination × quantity for vouchers, amount for topup/eSIM).
discount_amountAbsolute discount applied for your client.
total_amountAfter-discount amount, before any FX conversion.
discountDiscount percentage applied.
total_payableFinal 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_currencyThe variant's currency (what non_discounted_total is denominated in).
charges_details.destination_currencyThe wallet's currency (what total_payable is denominated in).
charges_details.forex_rateAdmin-managed rate used. null when source = destination.
charges_details.conversion_feeFX 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_payable to 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

ErrorWhat it meansAction
400 / "Denomination not available" (vouchers)The denomination isn't in the product's rangesRe-fetch the product and pick a valid denomination
400 / "Quantity exceeds maximum" (vouchers)Above the product's max_quantityReduce 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 suppliedSpecify 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 pairUse a wallet in the variant currency, or contact your account manager to add the rate
404 / NOT_FOUNDProduct doesn't exist or isn't enabled for your clientRe-fetch the catalogue

Vertical references for the full error list:

Summary

  • Always quote before committing. total_payable is 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_payable before bulk voucher orders or whenever a clean failure mode is wanted.
  • charges_details.forex_rate reveals whether an FX conversion is about to happen.

On this page