Octopus Cards
Best Practices

Reference Data

Best practices for downloading, caching, and refreshing reference data

Overview

Reference data — countries, currencies, categories, and subcategories — changes infrequently. Download it once after authentication and cache it locally. This avoids redundant API calls, reduces latency, and ensures your application works smoothly even during brief network disruptions.

What to Download

EndpointDataChanges
GET /api/v1/countriesCountry names, ISO codes, dialing prefixesRarely (new countries are exceptional)
GET /api/v1/currenciesCurrency names, ISO codes, decimal precisionRarely (new currencies are uncommon)
GET /api/v1/categoriesProduct category namesOccasionally (when new product verticals are added)
GET /api/v1/subcategoriesProduct subcategory namesOccasionally

All four endpoints return the full dataset in a single response — there is no pagination to handle.

Download Once at Startup

Fetch all reference data immediately after a successful login. This is a one-time cost of 4 API calls that gives your application everything it needs for mapping IDs to display names, validating user input, and building product filters.

type ReferenceData struct {
    Countries     []Country
    Currencies    []Currency
    Categories    []Category
    SubCategories []SubCategory
    FetchedAt     time.Time
}

func FetchReferenceData(host, token string) (*ReferenceData, error) {
    rd := &ReferenceData{FetchedAt: time.Now()}

    // Fetch all four in parallel
    g, _ := errgroup.WithContext(context.Background())

    g.Go(func() error {
        countries, err := fetchJSON[[]Country](host+"/api/v1/countries", token)
        rd.Countries = countries
        return err
    })
    g.Go(func() error {
        currencies, err := fetchJSON[[]Currency](host+"/api/v1/currencies", token)
        rd.Currencies = currencies
        return err
    })
    g.Go(func() error {
        categories, err := fetchJSON[[]Category](host+"/api/v1/categories", token)
        rd.Categories = categories
        return err
    })
    g.Go(func() error {
        subcategories, err := fetchJSON[[]SubCategory](host+"/api/v1/subcategories", token)
        rd.SubCategories = subcategories
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return rd, nil
}

Build Lookup Maps

Raw arrays are fine for listing, but most operations need a lookup by ID. Build maps once after download:

type RefLookup struct {
    CountryByID     map[int]Country
    CountryByAlpha2 map[string]Country
    CurrencyByID    map[int]Currency
    CurrencyByCode  map[string]Currency
    CategoryByID    map[int]Category
}

func BuildLookups(rd *ReferenceData) *RefLookup {
    rl := &RefLookup{
        CountryByID:     make(map[int]Country, len(rd.Countries)),
        CountryByAlpha2: make(map[string]Country, len(rd.Countries)),
        CurrencyByID:    make(map[int]Currency, len(rd.Currencies)),
        CurrencyByCode:  make(map[string]Currency, len(rd.Currencies)),
        CategoryByID:    make(map[int]Category, len(rd.Categories)),
    }

    for _, c := range rd.Countries {
        rl.CountryByID[c.ID] = c
        rl.CountryByAlpha2[c.Alpha2] = c
    }
    for _, c := range rd.Currencies {
        rl.CurrencyByID[c.ID] = c
        rl.CurrencyByCode[c.Currency] = c
    }
    for _, c := range rd.Categories {
        rl.CategoryByID[c.ID] = c
    }

    return rl
}

This lets you resolve a country_id from a product response into a full country name without an extra API call.

Refresh Schedule

Reference data is served with a 24-hour cache header and a 10-minute stale-while-revalidate window. Match this on your side:

StrategyWhen to use
Daily refreshLong-running services — refresh once every 24 hours in a background goroutine
On restartShort-lived processes — download fresh data each time the application starts
Manual triggerAdmin dashboards — add a "Refresh Reference Data" button for on-demand updates
func (rd *ReferenceData) IsStale() bool {
    return time.Since(rd.FetchedAt) > 24*time.Hour
}

You do not need to poll these endpoints. A daily refresh is more than sufficient. The server returns cache headers (Cache-Control: public, max-age=86400) confirming the data is stable for 24 hours.

Local Storage

For applications that restart frequently (serverless functions, CLI tools, CI jobs), avoid re-downloading on every cold start by persisting reference data to disk:

func SaveToFile(rd *ReferenceData, path string) error {
    data, err := json.Marshal(rd)
    if err != nil {
        return err
    }
    return os.WriteFile(path, data, 0600)
}

func LoadFromFile(path string) (*ReferenceData, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var rd ReferenceData
    if err := json.Unmarshal(data, &rd); err != nil {
        return nil, err
    }

    // Re-download if stale
    if rd.IsStale() {
        return nil, fmt.Errorf("cached data is stale")
    }
    return &rd, nil
}

Error Handling

Reference data endpoints can fail due to auth issues or transient errors. Handle this gracefully:

  • On startup failure — retry with exponential backoff (3 attempts max). If all attempts fail, exit with a clear error. Your application cannot function without reference data.
  • On refresh failure — log the error and continue with the existing cached data. Try again on the next refresh cycle.
  • Feature-gated endpoints — categories and subcategories require the vouchers feature to be enabled for your client. If you receive a 400 INVALID_FEATURE error, skip those endpoints and proceed without them.

Checklist

  • Download all reference data after login, in parallel
  • Build ID and code lookup maps for fast access
  • Refresh daily for long-running services, or on each restart for short-lived ones
  • Persist to disk if your application has frequent cold starts
  • Handle feature-gated endpoints (categories, subcategories) gracefully
  • Never call reference endpoints per-request — always use your local cache

On this page