Mobile App Integration Guide
This guide covers integrating the ECOSIRE API into a mobile application — covering authentication via PKCE, push notifications, offline-capable data fetching, and infinite scroll patterns used in ECOSIRE's own mobile app.
Introduction
ECOSIRE's mobile app is built with React Native (Expo / EAS). The patterns in this guide are extracted from the production app and adapted for general use. The same techniques apply to Flutter apps with minor syntax changes.
Core integrations covered:
- Authentication (OAuth2 PKCE — no client secret on mobile)
- Paginated data with infinite scroll
- Push notifications on order events
- Offline-first data caching
Prerequisites
- React Native with Expo (SDK 52+) or Flutter 3.16+
- ECOSIRE API key or OAuth2 client configuration
- Expo Push Notification credentials (for push notifications)
Step 1 — Authentication with PKCE
Mobile apps must use PKCE (Proof Key for Code Exchange) — never embed a client secret.
import * as AuthSession from 'expo-auth-session';
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
const discovery = {
authorizationEndpoint: 'https://auth.ecosire.com/application/o/authorize/',
tokenEndpoint: 'https://auth.ecosire.com/application/o/token/',
revocationEndpoint: 'https://auth.ecosire.com/application/o/revoke/',
};
const CLIENT_ID = 'ecosire-mobile'; // Register this in Authentik
const REDIRECT_URI = AuthSession.makeRedirectUri({ scheme: 'ecosire' });
export function useEcosireAuth() {
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: CLIENT_ID,
scopes: ['openid', 'profile', 'email'],
redirectUri: REDIRECT_URI,
usePKCE: true,
},
discovery
);
useEffect(() => {
if (response?.type === 'success') {
const { code } = response.params;
exchangeCodeForTokens(code, request!.codeVerifier!);
}
}, [response]);
return { request, promptAsync };
}
async function exchangeCodeForTokens(code: string, verifier: string) {
const res = await fetch('https://auth.ecosire.com/application/o/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
}).toString(),
});
const tokens = await res.json();
await SecureStore.setItemAsync('access_token', tokens.access_token);
await SecureStore.setItemAsync('refresh_token', tokens.refresh_token);
}
Step 2 — API Client with Token Refresh
import * as SecureStore from 'expo-secure-store';
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
let token = await SecureStore.getItemAsync('access_token');
const attempt = async (accessToken: string) =>
fetch(`https://api.ecosire.com/api${path}`, {
...options,
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...options?.headers },
});
let res = await attempt(token!);
if (res.status === 401) {
// Refresh the token
const refreshToken = await SecureStore.getItemAsync('refresh_token');
const refreshRes = await fetch('https://auth.ecosire.com/application/o/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken!,
client_id: CLIENT_ID,
}).toString(),
});
const newTokens = await refreshRes.json();
await SecureStore.setItemAsync('access_token', newTokens.access_token);
token = newTokens.access_token;
res = await attempt(token!);
}
if (!res.ok) throw new Error(`API error ${res.status}`);
if (res.status === 204) return undefined as T;
return res.json();
}
Step 3 — Infinite Scroll with TanStack Query
import { useInfiniteQuery } from '@tanstack/react-query';
import { FlatList } from 'react-native';
function useOrders(status?: string) {
return useInfiniteQuery({
queryKey: ['orders', status],
queryFn: ({ pageParam = 1 }) =>
apiFetch(`/orders?page=${pageParam}&limit=20${status ? `&status=${status}` : ''}`),
getNextPageParam: (lastPage: any) =>
lastPage.meta.page < lastPage.meta.totalPages ? lastPage.meta.page + 1 : undefined,
initialPageParam: 1,
});
}
function OrderList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useOrders('confirmed');
const orders = data?.pages.flatMap(p => p.data) ?? [];
return (
<FlatList
data={orders}
keyExtractor={item => item.id}
renderItem={({ item }) => <OrderCard order={item} />}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.3}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
);
}
Step 4 — Push Notifications on Order Events
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
async function registerForPushNotifications() {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return null;
const token = (await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
})).data;
// Register token with ECOSIRE API
await apiFetch('/users/push-token', {
method: 'POST',
body: JSON.stringify({ token, platform: Platform.OS }),
});
return token;
}
// Configure notification handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
ECOSIRE sends push notifications via Expo Push API when:
- A new order is confirmed
- An invoice is paid
- A support ticket is updated
- A license is about to expire
Step 5 — Offline-First Data Caching
import AsyncStorage from '@react-native-async-storage/async-storage';
async function fetchWithCache<T>(key: string, fetcher: () => Promise<T>, ttlMs = 300_000): Promise<T> {
const cached = await AsyncStorage.getItem(key);
if (cached) {
const { data, expiresAt } = JSON.parse(cached);
if (Date.now() < expiresAt) return data as T;
}
try {
const data = await fetcher();
await AsyncStorage.setItem(key, JSON.stringify({ data, expiresAt: Date.now() + ttlMs }));
return data;
} catch (err) {
// Offline — return stale cache if available
if (cached) return JSON.parse(cached).data;
throw err;
}
}
// Usage
const orders = await fetchWithCache(
'orders_confirmed',
() => apiFetch('/orders?status=confirmed&limit=50'),
5 * 60 * 1000 // 5 minute TTL
);
Step 6 — Deep Links
Configure deep links for order detail and license management:
// app.json
{
"expo": {
"scheme": "ecosire",
"intentFilters": [
{
"action": "VIEW",
"data": [{ "scheme": "https", "host": "ecosire.com", "pathPrefix": "/dashboard" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
import * as Linking from 'expo-linking';
const linking = {
prefixes: ['ecosire://', 'https://ecosire.com'],
config: {
screens: {
Dashboard: 'dashboard',
Orders: 'dashboard/orders',
OrderDetail: 'dashboard/orders/:id',
Licenses: 'dashboard/licenses',
},
},
};
Troubleshooting
| Issue | Solution |
|---|---|
| PKCE token exchange fails | Ensure code_verifier is stored between screens (use useRef) |
| Push tokens not persisting | Check expo-notifications permissions; test on physical device |
| Offline cache stale | Implement pull-to-refresh with refetchOnWindowFocus in React Query |
| 401 after token refresh | Check refresh token expiry in Authentik token settings |
| Deep links not working | Run uri-scheme open "ecosire://" to test locally |
Next Steps
- Authentication API — Full auth flow reference
- Orders API — Order data for mobile dashboards
- SSO Setup — Enterprise SSO configuration
- Webhooks Reference — Real-time events for push triggers