Skip to main content

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
);

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

IssueSolution
PKCE token exchange failsEnsure code_verifier is stored between screens (use useRef)
Push tokens not persistingCheck expo-notifications permissions; test on physical device
Offline cache staleImplement pull-to-refresh with refetchOnWindowFocus in React Query
401 after token refreshCheck refresh token expiry in Authentik token settings
Deep links not workingRun uri-scheme open "ecosire://" to test locally

Next Steps