Skip to content

API Integration

Zelta Chat requires consistent API communication to:

  • Authenticate all requests with session tokens
  • Transform request/response data between camelCase and snake_case
  • Handle errors uniformly across the app
  • Automatically construct API URLs with account context
  • Support self-hosted Zelta Chat installations with dynamic base URLs

Why Axios:

  • Interceptor support for request/response transformation
  • Promise-based API
  • Request/response transformation
  • Automatic JSON parsing
  • TypeScript support
Component/Action
APIService.get/post/put/delete
Request Interceptor
├─ Inject auth headers from Redux
├─ Set baseURL from settings
├─ Add account ID to URL
└─ Transform payload (camelCase → snake_case)
Axios HTTP Request → Zelta Chat Server
Response Interceptor
├─ Check for 401 (auto logout)
├─ Transform response (snake_case → camelCase)
└─ Handle errors
Return Data to Caller

Singleton Pattern (src/services/APIService.ts):

class APIService {
private static instance: APIService;
private api = axios.create();
private constructor() {
this.setupInterceptors();
}
public static getInstance(): APIService {
if (!APIService.instance) {
APIService.instance = new APIService();
}
return APIService.instance;
}
// HTTP methods
public async get<T>(url: string, config?: AxiosRequestConfig) {
return this.api.get<T>(url, config);
}
public async post<T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) {
return this.api.post<T>(url, data, config);
}
public async put<T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) {
return this.api.put<T>(url, data, config);
}
public async delete<T>(url: string, config?: AxiosRequestConfig) {
return this.api.delete<T>(url, config);
}
}
export const apiService = APIService.getInstance();

Non-Account Routes:

Routes that don’t require account context:

  • profile
  • profile/availability
  • notification_subscriptions
  • profile/set_active_account

Account-Scoped Routes:

Most API endpoints are automatically scoped to current account:

Original: apiService.get('conversations')
Transformed: {baseURL}/api/v1/accounts/{accountId}/conversations
Component Dispatches Action
Action Calls Service Method
Service Calls apiService.get('endpoint')
Request Interceptor:
1. Get auth headers from Redux (access-token, uid, client)
2. Get baseURL from settings.installationUrl
3. Get accountId from auth.user.account_id
4. Transform URL: 'conversations' → 'api/v1/accounts/{accountId}/conversations'
5. Add headers to request
HTTP Request Sent
Server Response
Response Interceptor:
1. Check status code
2. If 401 → Dispatch logout action
3. If error → Show toast notification
4. Return response/error
Action Processes Response
Dispatch Redux Action to Update State
private setupInterceptors() {
this.api.interceptors.request.use(
async (config: AxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
const headers = this.getHeaders();
const store = getStore();
const state = store.getState();
// Set base URL from user's installation
config.baseURL = state.settings?.installationUrl;
// Add account ID to URL
const accountId = state.auth.user?.account_id;
if (accountId && config.url && !nonAccountRoutes.includes(config.url)) {
config.url = `api/v1/accounts/${accountId}/${config.url}`;
} else if (nonAccountRoutes.includes(config.url || '')) {
config.url = `api/v1/${config.url}`;
}
return {
...config,
headers: {
...config.headers,
...headers, // Auth headers
},
} as InternalAxiosRequestConfig;
},
(error: AxiosError) => Promise.reject(error),
);
}
private getHeaders() {
const store = getStore();
const state = store.getState();
const headers = state.auth.headers;
if (!headers) return {};
return {
'access-token': headers['access-token'],
uid: headers.uid,
client: headers.client,
};
}
this.api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Automatic logout on unauthorized
const store = getStore();
store.dispatch({ type: 'auth/logout' });
} else {
// Show generic error toast
showToast({ message: I18n.t('ERRORS.COMMON_ERROR') });
}
return Promise.reject(error);
},
);

Request Transformation (camelCase → snake_case):

import snakecaseKeys from 'snakecase-keys';
export const conversationService = {
async updateStatus(conversationId: number, status: string) {
const payload = { conversationId, statusType: status };
// Transform to snake_case before sending
const response = await apiService.post(
`conversations/${conversationId}/toggle_status`,
snakecaseKeys(payload)
);
return response.data;
},
};

Response Transformation (snake_case → camelCase):

import camelcaseKeys from 'camelcase-keys';
export const conversationService = {
async fetchConversations(params: FetchParams) {
const response = await apiService.get('conversations', {
params: snakecaseKeys(params),
});
// Transform to camelCase after receiving
return camelcaseKeys(response.data, { deep: true });
},
};

✅ Good Patterns:

// Type-safe service methods
interface UpdateConversationPayload {
conversationId: number;
status: string;
}
export const conversationService = {
async updateStatus(payload: UpdateConversationPayload) {
const response = await apiService.post<Conversation>(
`conversations/${payload.conversationId}/toggle_status`,
snakecaseKeys(payload)
);
return camelcaseKeys(response.data, { deep: true });
},
};
// Handle errors in thunks
export const fetchConversations = createAsyncThunk(
'conversations/fetch',
async (params, { rejectWithValue }) => {
try {
return await conversationService.fetchConversations(params);
} catch (error) {
return rejectWithValue(handleApiError(error));
}
}
);

❌ Anti-Patterns:

// DON'T: Bypass APIService
axios.get('https://api.Zelta Chat.com/conversations'); // ❌ No auth headers
// DON'T: Hardcode base URLs
const response = await fetch('https://app.Zelta Chat.com/api/conversations'); // ❌
// DON'T: Ignore errors
try {
await apiService.get('conversations');
} catch (error) {
console.log(error); // ❌ User sees no feedback
}
// DON'T: Store response objects in Redux
dispatch(setConversations(response)); // ❌ Store response.data only

Implement caching for frequently accessed data:

const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export const cachedGet = async <T>(url: string): Promise<T> => {
const cached = cache.get(url);
const now = Date.now();
if (cached && now - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const response = await apiService.get<T>(url);
cache.set(url, { data: response.data, timestamp: now });
return response.data;
};

Prevent duplicate simultaneous requests:

const pendingRequests = new Map<string, Promise<any>>();
export const deduplicatedGet = async <T>(url: string): Promise<T> => {
if (pendingRequests.has(url)) {
return pendingRequests.get(url);
}
const request = apiService.get<T>(url)
.finally(() => pendingRequests.delete(url));
pendingRequests.set(url, request);
return (await request).data;
};
jest.mock('@/services/APIService', () => ({
apiService: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
},
}));
describe('conversationService', () => {
it('should fetch conversations with transformed params', async () => {
const mockData = { conversations: [] };
apiService.get.mockResolvedValue({ data: mockData });
const result = await conversationService.fetchConversations({
assigneeType: 'me'
});
expect(apiService.get).toHaveBeenCalledWith('conversations', {
params: { assignee_type: 'me' }, // Transformed to snake_case
});
});
});
import axiosRetry from 'axios-retry';
class APIService {
private constructor() {
this.setupInterceptors();
this.setupRetry();
}
private setupRetry() {
axiosRetry(this.api, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) => {
return (
axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status === 429 // Rate limit
);
},
});
}
}
public async get<T>(url: string, config?: AxiosRequestConfig) {
return this.api.get<T>(url, {
...config,
timeout: config?.timeout || 30000, // 30s default
});
}
public async uploadFile(
url: string,
file: File,
onProgress?: (progress: number) => void
) {
const formData = new FormData();
formData.append('file', file);
return this.api.post(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const progress = (progressEvent.loaded / progressEvent.total!) * 100;
onProgress?.(progress);
},
});
}
import axios, { CancelTokenSource } from 'axios';
const cancelTokens = new Map<string, CancelTokenSource>();
export const cancelableGet = async <T>(
url: string,
cancelKey: string
): Promise<T> => {
// Cancel previous request with same key
if (cancelTokens.has(cancelKey)) {
cancelTokens.get(cancelKey)!.cancel('Request cancelled');
}
// Create new cancel token
const source = axios.CancelToken.source();
cancelTokens.set(cancelKey, source);
try {
const response = await apiService.get<T>(url, {
cancelToken: source.token,
});
return response.data;
} finally {
cancelTokens.delete(cancelKey);
}
};