OpenCart Mobile App Developer Guide, Part 3: The OpenCart Extension and the Mobile App
Inside the OpenCart mobile_api extension and the React Native app: request flow, sessions, the API client, caching, navigation, theming and screens.

This is part 3 of 5 of the OpenCart Mobile App Developer Guide. New to the project? Start with Part 1: Overview, Architecture, Repository Layout and Languages.
In this post
8. The backend: the mobile_api OpenCart extension
8.1 How a request reaches an endpoint
The app calls URLs such as:
GET {baseUrl}index.php?route=extension/mobile_api/api/product.info&product_id=40&language=en-gb¤cy=USD
POST {baseUrl}index.php?route=extension/mobile_api/api/cart.add (JSON body)
Headers: X-Api-Key: <key> X-Session-Token: <token> Content-Type: application/json
OpenCart’s router maps extension/mobile_api/api/product.info to the class Opencart\Catalog\Controller\Extension\MobileApi\Api\Product in extension/mobile_api/catalog/controller/api/product.php, and calls its info() method. The part after the dot is the method name.
8.2 The base class: system/library/api_controller.php
Every endpoint class extends Opencart\System\Library\Extension\MobileApi\ApiController. It provides:
| Method | What it does |
|---|---|
guard(): bool | Must be the first call in every public method. Fails with 503 if the module is disabled and 401 if X-Api-Key doesn’t match, using constant-time hash_equals. Then it merges a JSON body into $this->request->post. |
guardCustomer(): bool | guard(), plus 401 “Please log in to continue.” when no customer is logged in. Used by account, address and order endpoints. |
parseJsonBody() | Reads php://input when Content-Type is application/json and runs it through $this->request->clean(), the same HTML escaping OpenCart applies to form posts. Core code that reads $this->request->post then works unchanged. |
post($key, $default), query($key, $default) | Read POST or GET values. |
ok(array $data, string $message = '') | Sends {"success":true,"data":{…},"message":"…"}. An empty array is sent as {}, so clients always get an object. |
fail(string $message, array $errors = [], int $status = 400) | Sets the HTTP status and sends {"success":false,"error":"…","errors":{"field":"message"}}. |
output() (private) | Adds Content-Type: application/json, Cache-Control: no-store and X-Session-Token (the session actually used) to every response. |
decode($s), t($key) | OpenCart stores text HTML-escaped (&). Native clients need plain text, so every outgoing string is decoded. t() reads a language key and decodes it. |
canSeePrice() | False when “Login to see prices” (config_customer_price) is on and nobody is logged in. Every price output respects it. |
formatPrice($value, $tax_class_id) | Adds tax according to the store’s display setting and formats it in the session currency. |
image($path, $w, $h) | Absolute URL of a resized image (cached by OpenCart), or the placeholder image. The default size comes from the module settings. |
productCard($row) | The single compact product shape used by every list: home, category, search, wishlist and related products. |
validateAddress(), buildSessionAddress(), addressPayload() | Shared address rules (the same as account/address), the address array that checkout keeps in the session, and the address shape sent to the client. |
Response envelope
// success
{ "success": true, "data": { "cart": "…" }, "message": "Success: You have added …" }
// failure (HTTP 4xx/5xx)
{ "success": false, "error": "E-Mail Address does not appear to be valid!",
"errors": { "email": "E-Mail Address does not appear to be valid!", "telephone": "…" } }
Status codes used: 400 bad request, 401 bad key or not logged in, 403 not allowed (disabled feature, 2FA on, account not approved), 404 not found, 409 conflict (empty cart, already logged in), 422 validation errors, 429 too many login attempts, 500 order creation failed, 503 API disabled.
8.3 The startup: catalog/controller/startup/mobile_api.php
Why it exists. When a request arrives, OpenCart’s core startups have already started a session from the OCSESSID cookie. A phone sends no cookie, so that would be a brand-new, empty session on every request: no cart and no login. The startup fixes this by reopening the session the app asks for.
What it does, in order (and only for routes starting with extension/mobile_api/api/):
- Returns early if the module is disabled.
- Sends CORS headers if a CORS Origin is configured. This is only needed for Expo web. It answers
OPTIONSpreflight requests with204. - Takes the token from
X-Session-Token. Forcheckout.payonly, it takes it from thesession_tokenquery parameter instead, because a WebView’s first page load can’t send custom headers. - If the token matches
^[a-zA-Z0-9]{26,52}$and differs from the current id, it calls$this->session->start($token). - If
?currency=is a valid enabled currency, it stores it in the session. - If anything changed, it re-runs
startup/customer,startup/currencyandstartup/tax(the same order assystem/config/catalog.php) and replaces thecartobject. The logged-in customer, currency and tax rates then match the restored session.
The startup is registered by install() in the admin controller and removed by uninstall(). You can see it under System → Settings → Startups (code mobile_api).
8.4 Endpoint files
| File | Methods | Auth |
|---|---|---|
common.php | init: store info, theme colours, settings, currencies, languages, customer, session token | key |
catalog.php | home, categories, category, manufacturers, informations, information | key |
product.php | index (list, search, filter, sort, paginate), info (full detail), reviews, review (write) | key |
cart.php | index, add, edit, remove, clear, coupon | key |
wishlist.php | index, add, remove (session list for guests, database for customers) | key |
account.php | login, logout, register, forgotten, profile, edit, password, newsletter | key; profile, edit, password and newsletter need login |
address.php | index, save (create or update), delete | login |
order.php | index, info, reorder | login |
localisation.php | countries, zones | key |
checkout.php | index, guest, shippingAddress, paymentAddress, shippingMethods, shippingMethod, paymentMethods, paymentMethod, confirm, pay (HTML), complete | key; checkout steps need a customer (logged in or guest) in the session |
The full parameter list is in the README’s API reference.
8.5 The admin module: admin/controller/module/mobile_api.php
index()renders the settings form (mobile_api.twig). It generates a 40-character API key if none exists and shows the API base URL for testing.save()checks permission, validates the key (^[a-zA-Z0-9]{20,64}$) and each colour (^#[0-9a-fA-F]{6}$or empty), normalises the featured product IDs, and saves everything under the setting codemodule_mobile_api.editSettingreplaces the whole group, so every field must be posted together. The form already does this.install()registers the startup and writes default settings (status off and a random key).uninstall()removes the startup and allmodule_mobile_api_*settings.
Errors are returned as {"error": {"color_primary": "…"}}. OpenCart’s admin JavaScript highlights #input-color-primary and fills #error-color-primary (underscores become dashes).
9. The mobile app
9.1 Layers
Screens (src/screens) what the user sees; compose components; call hooks
│
Components (src/components) reusable UI: Button, TextField, ProductCard, AddressForm, …
│
Hooks + Context React Query hooks (useCart, useWishlist, inline useQuery/useMutation)
│ StoreContext (init data, customer, login/logout, currency)
Endpoints (src/api/endpoints) one typed function per server endpoint
│
Client (src/api/client) fetch, headers, token storage, envelope unwrap, ApiError
Rules that keep this clean:
- Screens never call
fetch. They callapi.*through React Query. - Only
client.tsknows about headers, tokens and the envelope. - Types live in
api/types.tsand mirror the PHP output exactly. Change both together.
9.2 Start-up sequence (App.tsx)
App
└─ SafeAreaProvider
└─ QueryClientProvider (staleTime 60 s, retry 1)
└─ StoreProvider calls api.common.init() once on mount
└─ Bootstrap
loading → BootScreen (spinner)
error → BootScreen (message + Try again → reload())
ready → applyTheme(init.theme)
require('./src/navigation/RootNavigator') ← loaded only now
<NavigationContainer theme={navTheme}>
<StatusBar style=light|dark />
<RootNavigator/>
Why the navigator is require()d late: each screen builds its styles with StyleSheet.create() when its module first loads, and that copies the colour values at that moment. The store’s colours must be in colors before any screen module loads. App.tsx therefore deliberately imports nothing that creates styles; even the boot screen uses inline styles. Don’t add imports of screens or components to App.tsx. The side effect is that a colour change in the admin shows up on the next app launch, not live.
9.3 API client (src/api/client.ts)
request<T>(route, {method, query, body})is the only way the app talks to the server.- Adds
Accept,X-Api-Key,X-Session-Token(if one is stored) andContent-Type: application/jsonfor POST. - Builds the URL with
buildUrl():route=extension/mobile_api/api/<route>,language, the storedcurrency, and any query parameters (empty values are dropped). - Sends
credentials: 'omit', so no cookies are used; the header is the only session carrier. - Reads
X-Session-Tokenfrom every response and stores it if it changed. This is how a new install gets its first session, and how it recovers when the server’s session expired and a new one was created. - Unwraps the envelope. On
success:falseit throwsApiError(message, status, errors). A network failure becomesApiError(…, 0). A non-JSON response (a PHP error, or the extension not installed) gets a helpful message.
- Adds
- Storage:
expo-secure-storeon device,localStorageon web. Keys areoc_session_tokenandoc_currency. The values are loaded once (ensureLoaded) and then kept in memory.
9.4 Endpoints and types
src/api/endpoints.ts exports one object:
api.cart.add({ product_id: 40, quantity: 1, option: { 218: '5' } }) // Promise<Cart>
api.checkout.confirm({ comment: '', agree: true }) // Promise<ConfirmResult>
Grouping by resource mirrors the PHP files, so api.cart.* maps to cart.php. The return types come from src/api/types.ts.
9.5 State management
| Kind of state | Where it lives | Examples |
|---|---|---|
| Server state (the large majority) | React Query cache | catalog, product, cart, wishlist, orders, addresses, checkout state |
| App-wide session state | StoreContext | init (store, settings, currencies, theme), customer, isLoggedIn, login(), logout(), changeCurrency(), reload() |
| Screen-local UI state | useState | form fields, selected options, quantity, search text, image carousel index |
| Persisted | SecureStore | session token, currency |
Query keys in use (keep new ones consistent):
| Key | Data |
|---|---|
['home'] | Home sections |
['categories', parentId], ['category', id] | Category tree and details |
['products', query] | Product lists (infinite) |
['product', id] | Product details |
['reviews', productId] | Reviews (infinite) |
['cart'] (cartKey) | Cart |
['wishlist'] (wishlistKey) | Wishlist |
['profile'], ['addresses'], ['orders'], ['order', id] | Account |
['countries'], ['zones', countryId] | Localisation (staleTime: Infinity) |
['informations'], ['information', id] | Information pages |
['checkout', 'state'], ['checkout', 'shipping', addr], ['checkout', 'payment', method, addr] | Checkout |
Cache rules used across the app:
- Mutations that return the full cart (
add,edit,remove,coupon) write the response straight into['cart']withsetQueryData. There’s no extra refetch, and the tab badge updates instantly. They also invalidate['checkout'], because the totals changed. - After login,
queryClient.invalidateQueries()refetches everything, because prices, cart (merged with the account cart) and wishlist can all change. - After logout,
queryClient.clear()drops all cached data, so nothing from the previous customer stays visible. - After currency change, init reloads and everything is invalidated, so prices refetch in the new currency.
- After an order is placed,
['cart']is invalidated and every['checkout', …]query is removed.
9.6 Navigation
src/navigation/RootNavigator.tsx:
Stack (native-stack)
├─ Tabs (bottom-tabs, header hidden on the stack)
│ ├─ Home (own header inside the screen)
│ ├─ Categories
│ ├─ Cart (badge: cart count)
│ ├─ Wishlist (badge: wishlist count)
│ └─ Account
├─ Product, ProductList, Search, Reviews
├─ WriteReview, Login, Register (presentation: 'modal')
├─ ForgotPassword
├─ Checkout, Payment (no swipe-back), OrderSuccess (no back)
├─ Orders, OrderDetail, Addresses, AddressEdit, EditProfile, ChangePassword
└─ Information
Every route’s parameters are typed in src/navigation/types.ts (RootStackParamList, TabParamList). Screens get typed props with NativeStackScreenProps<RootStackParamList, 'Product'>. Components use useNavigation<NativeStackNavigationProp<RootStackParamList>>(). A typo in a route name or a missing parameter fails tsc.
Header and tab bar colours come from colors.tertiary and colors.onTertiary. The active tab uses colors.primary (white on a dark tertiary).
9.7 Theme (src/theme.ts)
colors,spacing,radiusandfontare the only places style constants should come from. Don’t hard-code hex values in screens.applyTheme(init.theme)overrides colours from the admin and derives the rest:
| Admin setting | colors fields set | Derived |
|---|---|---|
| Primary | primary | primaryDark (25 % black), primarySoft (90 % white), onPrimary (contrast text) |
| Secondary | secondary | onSecondary |
| Tertiary | tertiary | onTertiary, tertiaryIsDark (controls the status bar style) |
| Font | text | textMuted (35 % lighter); also onTertiary on light headers |
| Button | button (falls back to primary) | n/a |
| Button text | onButton (falls back to automatic contrast) | n/a |
The contrast choice (readableOn) uses the WCAG relative-luminance formula and picks whichever of white or near-black has the higher contrast ratio. accent (red) is reserved for errors and destructive actions and can’t be changed from the admin, so errors always look like errors.
9.8 Components (src/components)
| Component | Use |
|---|---|
ui.tsx | Button (variants: primary, secondary, outline, ghost, danger; loading, icon, small), TextField (label, error), Card, Row, SectionHeader, Chip, Checkbox, RadioRow, ListItem, Badge |
States.tsx | Loading, ErrorState (with retry), EmptyState (with action) |
Price.tsx | Price (normal and special price), Rating (stars) |
ProductCard.tsx | Grid card: image, sale badge, wishlist heart, price |
ProductGrid.tsx | Two-column FlatList with infinite-scroll footer |
ProductResults.tsx | useInfiniteQuery around api.product.list plus a sort bar; used by ProductList and Search |
ProductOptions.tsx | Renders every OpenCart option type (select, radio, checkbox, text, textarea, date, time, datetime). File options show a notice instead. |
QuantityStepper.tsx | − / + control with a minimum |
SelectModal.tsx | Searchable picker in a modal (countries, zones, option values) |
AddressForm.tsx | Address fields with country and zone pickers (loads zones when the country changes) |
HtmlView.tsx | Renders store HTML in an auto-sized WebView; links open in the system browser |
9.9 Screens (src/screens)
| Screen | Data | Notes |
|---|---|---|
| Home | ['home'] | Logo or name, search box, banner carousel, category row, featured, specials, latest, bestsellers |
| Categories | ['categories', 0] | Expandable tree → ProductList |
| ProductList | ProductResults | Category, manufacturer or specials, with sort |
| Search | ProductResults | 400 ms debounce before querying |
| Product | ['product', id] | Gallery, price and discounts, options, subscriptions, quantity, add to cart, wishlist, attributes, description, related products, reviews link |
| Reviews, WriteReview | ['reviews', id] | Infinite list; the write form validates on the server |
| Cart | useCart | Edit quantity, remove, coupon, totals, stock warnings, checkout button |
| Wishlist | useWishlist | Grid of saved products |
| Account | StoreContext, ['profile'] | Login prompt or profile, newsletter switch, links, currency picker, information pages, logout |
| Login, Register, ForgotPassword | n/a | Modal screens; can redirect back to Checkout |
| Checkout | ['checkout', …] | Step-by-step on one screen (see §12.7) |
| Payment | n/a | WebView for online gateways |
| OrderSuccess | n/a | Confirmation; the back stack is reset |
| Orders, OrderDetail | ['orders'], ['order', id] | History, status timeline, reorder an item |
| Addresses, AddressEdit | ['addresses'] | Address book with set-default |
| EditProfile, ChangePassword | n/a | Account forms |
| Information | ['information', id] | CMS pages (About, Terms…) |
← Previous: Part 2: Tools and Libraries, Why Expo, and the Development Environment
Next: Part 4: Design Patterns, Hooks and End-to-End Flows →
The series
- Part 1: Overview, Architecture, Repository Layout and Languages
- Part 2: Tools and Libraries, Why Expo, and the Development Environment
- Part 3: The OpenCart Extension and the Mobile App (you are reading this part)
- Part 4: Design Patterns, Hooks and End-to-End Flows
- Part 5: Configuration, Security, Extending, Testing, Releasing and Troubleshooting




