Skip to content

API Integration & Data Handling


Effective API integration is essential for modern Flutter applications that rely on backend services. Implementing secure, efficient, and resilient communication patterns ensures your app delivers a seamless user experience while protecting sensitive data.


Flutter offers several libraries for API communication, each with distinct advantages. The two most popular options are:

Flutter’s basic HTTP client package.

Advantages:

  • Simple API with minimal boilerplate
  • Part of Flutter’s core packages
  • Lightweight with few dependencies

Limitations:

  • Basic functionality only
  • Manual configuration for advanced features
  • Limited built-in error handling
// pubspec.yaml
dependencies:
http: ^1.1.0

Implementing a structured API client is essential for maintainable code. We recommend using Dio with a repository pattern:

  • Directorylib
    • Directorycore
      • Directoryapi
        • api_client.dart # Core API client with Dio configuration
        • api_endpoints.dart # Constant endpoint definitions
        • api_interceptors.dart # Custom interceptors
    • Directoryfeatures
      • Directoryfeature_1
        • Directorydata
          • Directoryrepositories
            • feature_1_repository.dart # Feature-specific API calls
API Client Implementation
// lib/core/api/api_client.dart
import 'package:dio/dio.dart';
import '../../core/api/api_interceptors.dart';
import '../config/app_config.dart';
class ApiClient {
late Dio _dio;
// Singleton instance
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() => _instance;
ApiClient._internal() {
_dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: Duration(seconds: AppConfig.apiTimeoutSeconds),
receiveTimeout: Duration(seconds: AppConfig.apiTimeoutSeconds),
sendTimeout: Duration(seconds: AppConfig.apiTimeoutSeconds),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
// Add interceptors
_dio.interceptors.add(LoggingInterceptor());
_dio.interceptors.add(AuthInterceptor());
_dio.interceptors.add(ErrorInterceptor());
}
// Get Dio instance
Dio get dio => _dio;
// Generic GET method
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
T Function(dynamic)? parser,
}) async {
try {
final response = await _dio.get(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
if (parser != null) {
return parser(response.data);
} else {
return response.data;
}
} catch (e) {
throw _handleError(e);
}
}
// Generic POST method
Future<T> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
T Function(dynamic)? parser,
}) async {
try {
final response = await _dio.post(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
if (parser != null) {
return parser(response.data);
} else {
return response.data;
}
} catch (e) {
throw _handleError(e);
}
}
// Error handling
Exception _handleError(dynamic error) {
if (error is DioException) {
return ApiFailure.fromDioException(error);
}
return ApiFailure.unexpected(error.toString());
}
}
API Endpoints
// lib/core/api/api_endpoints.dart
class ApiEndpoints {
// Auth endpoints
static const String login = '/auth/login';
static const String register = '/auth/register';
static const String refreshToken = '/auth/refresh';
// User endpoints
static const String userProfile = '/users/profile';
static const String updateUser = '/users/update';
// Content endpoints
static const String getProducts = '/products';
static const String getProductDetails = '/products/{id}';
// Helper method for path parameter replacement
static String replacePathParameters(String endpoint, Map<String, dynamic> parameters) {
String result = endpoint;
parameters.forEach((key, value) {
result = result.replaceAll('{$key}', value.toString());
});
return result;
}
}
Feature Repository
// lib/features/products/data/repositories/product_repository.dart
import 'package:dio/dio.dart';
import '../../../../core/api/api_service.dart';
import '../../../../core/api/api_client.dart';
import '../models/product_model.dart';
class ProductRepository {
late final ApiService _apiService;
ProductRepository() {
final apiClient = ApiClient();
_apiService = ApiService(apiClient.dio);
}
// Clean, readable API calls with automatic parameter injection
Future<List<Product>> getProducts({
int page = 1,
int pageSize = 20,
String? category,
}) async {
try {
return await _apiService.getProducts(page, pageSize, category);
} catch (e) {
throw ApiFailure.fromException(e);
}
}
Future<Product> getProductDetails(String productId) async {
try {
return await _apiService.getProductDetails(productId);
} catch (e) {
throw ApiFailure.fromException(e);
}
}
Future<Product> createProduct(Product product) async {
try {
return await _apiService.createProduct(product);
} catch (e) {
throw ApiFailure.fromException(e);
}
}
Future<Product> updateProduct(String productId, Product product) async {
try {
return await _apiService.updateProduct(productId, product);
} catch (e) {
throw ApiFailure.fromException(e);
}
}
Future<void> deleteProduct(String productId) async {
try {
await _apiService.deleteProduct(productId);
} catch (e) {
throw ApiFailure.fromException(e);
}
}
}

Dio’s interceptor system enables powerful request/response processing and global error handling:

API Interceptors
// lib/core/api/api_interceptors.dart
import 'package:dio/dio.dart';
import '../auth/secure_storage_service.dart';
import 'dart:developer' as developer;
import 'api_failure.dart';
// Authentication Interceptor
class AuthInterceptor extends Interceptor {
final SecureStorageService _secureStorage = SecureStorageService();
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Get access token from secure storage
final token = await _secureStorage.getAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// Handle 401 Unauthorized errors (token expired)
if (err.response?.statusCode == 401) {
// Get refresh token
final refreshToken = await _secureStorage.getRefreshToken();
if (refreshToken != null && refreshToken.isNotEmpty) {
try {
// Create a new Dio instance to avoid interceptor loops
final refreshDio = Dio();
final refreshResponse = await refreshDio.post(
'${AppConfig.apiBaseUrl}/auth/refresh',
data: {'refresh_token': refreshToken},
);
// Extract new tokens
final newAccessToken = refreshResponse.data['access_token'];
final newRefreshToken = refreshResponse.data['refresh_token'];
// Save new tokens
await _secureStorage.saveAccessToken(newAccessToken);
await _secureStorage.saveRefreshToken(newRefreshToken);
// Retry the original request with new token
final requestOptions = err.requestOptions;
requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
final response = await refreshDio.fetch(requestOptions);
return handler.resolve(response);
} catch (e) {
// Refresh token is also invalid, logout user
await _secureStorage.deleteAllTokens();
// TODO: Notify auth state listeners about logout
}
}
}
// For other errors, continue with the error
return handler.next(err);
}
}
// Logging Interceptor
class LoggingInterceptor extends Interceptor {
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
developer.log(
'REQUEST[${options.method}] => PATH: ${options.path}',
name: 'API',
);
return handler.next(options);
}
@override
void onResponse(
Response response,
ResponseInterceptorHandler handler,
) {
developer.log(
'RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}',
name: 'API',
);
return handler.next(response);
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
developer.log(
'ERROR[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}',
name: 'API',
error: err.message,
);
return handler.next(err);
}
}
// Error Interceptor using Freezed union classes
class ErrorInterceptor extends Interceptor {
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
// Convert DioExceptions to ApiFailure using Freezed union classes
final apiFailure = ApiFailure.fromDioException(err);
// You can log the structured error
developer.log(
'API Error: ${apiFailure.runtimeType}',
name: 'API',
error: apiFailure.toString(),
);
// Continue with the original DioException
// The repository will convert it to ApiFailure
return handler.next(err);
}
}

Robust error handling is crucial for a good user experience. Implement these strategies:

API Error Handling
// lib/core/api/api_error.dart
class ApiException implements Exception {
final String message;
final int? statusCode;
final String? errorCode;
final dynamic data;
ApiException({
required this.message,
this.statusCode,
this.errorCode,
this.data,
});
@override
String toString() => 'ApiException: $message';
}
// In your API client
Exception _handleError(dynamic error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return ApiException(
message: 'Connection timeout. Please check your internet connection.',
statusCode: error.response?.statusCode,
);
case DioExceptionType.badResponse:
return ApiException(
message: error.error?.toString() ?? 'Server error occurred',
statusCode: error.response?.statusCode,
data: error.response?.data,
errorCode: error.response?.data is Map ?
error.response?.data['code'] : null,
);
case DioExceptionType.cancel:
return ApiException(message: 'Request was cancelled');
case DioExceptionType.connectionError:
return ApiException(
message: 'No internet connection',
statusCode: error.response?.statusCode,
);
default:
return ApiException(
message: 'Unexpected error occurred',
statusCode: error.response?.statusCode,
);
}
}
return ApiException(message: error.toString());
}
Retry Interceptor
// lib/core/api/retry_interceptor.dart
import 'package:dio/dio.dart';
import 'dart:math' as math;
import 'dart:async';
class RetryInterceptor extends Interceptor {
final Dio dio;
final int retries;
final Duration retryDelay;
final List<int> retryStatusCodes;
final bool useExponentialBackoff;
RetryInterceptor({
required this.dio,
this.retries = 3,
this.retryDelay = const Duration(seconds: 1),
this.retryStatusCodes = const [408, 500, 502, 503, 504],
this.useExponentialBackoff = true,
});
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final extraData = err.requestOptions.extra;
// Get current retry count (default 0)
final retryCount = extraData['retryCount'] ?? 0;
// Check if should retry based on error type and retry count
final shouldRetry = _shouldRetry(err, retryCount);
if (shouldRetry) {
// Calculate delay with optional exponential backoff
final delay = useExponentialBackoff
? Duration(milliseconds: retryDelay.inMilliseconds * math.pow(2, retryCount).toInt())
: retryDelay;
await Future.delayed(delay);
try {
// Create a new request with the same options
final options = Options(
method: err.requestOptions.method,
headers: err.requestOptions.headers,
);
// Update retry count
options.extra = {
...err.requestOptions.extra,
'retryCount': retryCount + 1,
};
// Execute the request again
final response = await dio.request<dynamic>(
err.requestOptions.path,
cancelToken: err.requestOptions.cancelToken,
data: err.requestOptions.data,
queryParameters: err.requestOptions.queryParameters,
options: options,
);
// If successful, resolve with the new response
return handler.resolve(response);
} catch (e) {
// If retry failed, continue with the error
return handler.next(err);
}
}
// If we shouldn't retry, continue with the error
return handler.next(err);
}
bool _shouldRetry(DioException err, int retryCount) {
// Don't retry if we've hit the maximum retry count
if (retryCount >= retries) {
return false;
}
// Retry on timeout errors
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.sendTimeout) {
return true;
}
// Retry on specific status codes
if (err.response != null &&
retryStatusCodes.contains(err.response!.statusCode)) {
return true;
}
return false;
}
}
Adding Retry to API Client
// In your API client constructor
ApiClient._internal() {
_dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com/v1',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
// Add interceptors
_dio.interceptors.add(LoggingInterceptor());
_dio.interceptors.add(AuthInterceptor());
// Add retry interceptor
_dio.interceptors.add(RetryInterceptor(
dio: _dio,
retries: 3,
retryStatusCodes: [408, 500, 502, 503, 504],
useExponentialBackoff: true,
));
_dio.interceptors.add(ErrorInterceptor());
}
UI Error Handling
// In your UI layer
FutureBuilder<List<Product>>(
future: _productRepository.getProducts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
final error = snapshot.error;
// Handle ApiFailure with pattern matching
if (error is ApiFailure) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getErrorIcon(error),
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
error.userFriendlyMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() {}),
child: const Text('Try Again'),
),
// Show retry button only for certain errors
if (_shouldShowRetryButton(error))
TextButton(
onPressed: _showErrorDetails,
child: const Text('Show Details'),
),
],
),
);
}
// Fallback for other errors
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Something went wrong'),
ElevatedButton(
onPressed: () => setState(() {}),
child: const Text('Try Again'),
),
],
),
);
}
final products = snapshot.data!;
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductItem(products[index]),
);
},
);
// Helper methods for UI error handling
IconData _getErrorIcon(ApiFailure failure) {
return failure.when(
serverError: (_, __, ___) => Icons.error,
connectionTimeout: (_) => Icons.wifi_off,
sendTimeout: (_) => Icons.upload,
receiveTimeout: (_) => Icons.download,
badRequest: (_, __) => Icons.warning,
unauthorized: (_) => Icons.lock,
forbidden: (_) => Icons.block,
notFound: (_) => Icons.search_off,
conflict: (_) => Icons.warning,
internalServerError: (_) => Icons.error,
noInternetConnection: (_) => Icons.wifi_off,
requestCancelled: (_) => Icons.cancel,
unexpected: (_) => Icons.error_outline,
);
}
bool _shouldShowRetryButton(ApiFailure failure) {
return failure.when(
serverError: (_, __, ___) => true,
connectionTimeout: (_) => true,
sendTimeout: (_) => true,
receiveTimeout: (_) => true,
badRequest: (_, __) => false,
unauthorized: (_) => false,
forbidden: (_) => false,
notFound: (_) => true,
conflict: (_) => false,
internalServerError: (_) => true,
noInternetConnection: (_) => true,
requestCancelled: (_) => true,
unexpected: (_) => true,
);
}

pubspec.yaml
// pubspec.yaml
dependencies:
flutter_secure_storage: ^8.0.0
Secure Storage Service
// lib/core/auth/secure_storage_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorageService {
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
// Key constants
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';
static const String _apiKeyKey = 'api_key';
// Token management
Future<void> saveAccessToken(String token) async {
await _storage.write(key: _accessTokenKey, value: token);
}
Future<String?> getAccessToken() async {
return await _storage.read(key: _accessTokenKey);
}
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: _refreshTokenKey, value: token);
}
Future<String?> getRefreshToken() async {
return await _storage.read(key: _refreshTokenKey);
}
Future<void> saveUserId(String userId) async {
await _storage.write(key: _userIdKey, value: userId);
}
Future<String?> getUserId() async {
return await _storage.read(key: _userIdKey);
}
// API key management
Future<void> saveApiKey(String apiKey) async {
await _storage.write(key: _apiKeyKey, value: apiKey);
}
Future<String?> getApiKey() async {
return await _storage.read(key: _apiKeyKey);
}
// Delete all tokens (logout)
Future<void> deleteAllTokens() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
await _storage.delete(key: _userIdKey);
// Don't delete API key as it's usually app-specific, not user-specific
}
// Delete everything
Future<void> deleteAll() async {
await _storage.deleteAll();
}
}
Using API Keys from Secure Storage
// In your API client
class ApiClient {
late Dio _dio;
final SecureStorageService _secureStorage = SecureStorageService();
// Initialize API client with API key from secure storage
Future<void> initializeWithApiKey() async {
final apiKey = await _secureStorage.getApiKey();
if (apiKey != null) {
_dio.options.headers['X-API-Key'] = apiKey;
}
}
// Non-sensitive configuration can be set directly in the constructor
ApiClient._internal() {
_dio = Dio(BaseOptions(/* ... */));
// Add interceptors
}
}

Implementing app signature validation enhances API security by ensuring requests come from your legitimate app:

pubspec.yaml
// pubspec.yaml
dependencies:
package_info_plus: ^4.0.2
crypto: ^3.0.3
device_info_plus: ^9.0.2
App Signature Generation
// lib/core/security/app_signature.dart
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AppSignatureService {
// Get package signature for API requests
static Future<Map<String, String>> getApiSignatureHeaders() async {
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
final nonce = _generateNonce();
final signature = await _generateSignature(timestamp, nonce);
return {
'X-App-Timestamp': timestamp,
'X-App-Nonce': nonce,
'X-App-Signature': signature,
};
}
// Generate a random nonce (number used once)
static String _generateNonce() {
final random = DateTime.now().millisecondsSinceEpoch.toString() +
DateTime.now().microsecond.toString();
return base64.encode(utf8.encode(random)).substring(0, 16);
}
// Generate HMAC signature
static Future<String> _generateSignature(String timestamp, String nonce) async {
// Get app-specific information
final packageInfo = await PackageInfo.fromPlatform();
final deviceInfoPlugin = DeviceInfoPlugin();
String deviceId = '';
// Get device-specific information
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
deviceId = androidInfo.id;
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceId = iosInfo.identifierForVendor ?? '';
}
// Combine data for signature
final dataToSign = '$timestamp:$nonce:${packageInfo.packageName}:$deviceId';
// Use your API secret key to sign
// WARNING: Ideally this API_SECRET should not be stored in the app
// Consider using server-side validation or other secure methods
const API_SECRET = 'YOUR_API_SECRET'; // Bad practice to hardcode this
// Generate HMAC signature
final hmacSha256 = Hmac(sha256, utf8.encode(API_SECRET));
final digest = hmacSha256.convert(utf8.encode(dataToSign));
return digest.toString();
}
}

Proper data serialization ensures efficient conversion between API JSON data and Dart objects:

Manual JSON Serialization
// lib/features/products/data/models/product_model.dart
class Product {
final String id;
final String name;
final double price;
final String? description;
final List<String> imageUrls;
final DateTime createdAt;
Product({
required this.id,
required this.name,
required this.price,
this.description,
required this.imageUrls,
required this.createdAt,
});
// Manual fromJson constructor
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
name: json['name'],
price: (json['price'] as num).toDouble(),
description: json['description'],
imageUrls: (json['image_urls'] as List<dynamic>)
.map((url) => url as String)
.toList(),
createdAt: DateTime.parse(json['created_at']),
);
}
// Manual toJson method
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
'description': description,
'image_urls': imageUrls,
'created_at': createdAt.toIso8601String(),
};
}
}

When to Use:

  • For simple models with few fields
  • When you want full control over serialization logic
  • For learning purposes to understand how serialization works
Handling Nested Objects
// lib/features/products/data/models/product_model.dart
@JsonSerializable(explicitToJson: true)
class Product {
final String id;
final String name;
final double price;
final List<ProductVariant> variants;
final ProductCategory category;
Product({
required this.id,
required this.name,
required this.price,
required this.variants,
required this.category,
});
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}
@JsonSerializable()
class ProductVariant {
final String id;
final String name;
final double price;
ProductVariant({
required this.id,
required this.name,
required this.price,
});
factory ProductVariant.fromJson(Map<String, dynamic> json) =>
_$ProductVariantFromJson(json);
Map<String, dynamic> toJson() => _$ProductVariantToJson(this);
}
@JsonSerializable()
class ProductCategory {
final String id;
final String name;
ProductCategory({
required this.id,
required this.name,
});
factory ProductCategory.fromJson(Map<String, dynamic> json) =>
_$ProductCategoryFromJson(json);
Map<String, dynamic> toJson() => _$ProductCategoryToJson(this);
}
  1. Use Consistent Naming Conventions Clearly map API snake_case to Dart camelCase using @JsonKey.

  2. Handle Null Values Properly Make non-nullable fields required in constructors and provide defaults for nullable fields.

  3. Type Safety First Always convert dynamic values to proper types with validation.

  4. Custom Converters for Complex Types Use fromJson/toJson helpers for dates, enums, and custom types.

  5. Prefer Code Generation Use json_serializable or freezed to minimize manual serialization errors.

  • Create base model classes with common serialization patterns
  • Write unit tests for your serialization logic
  • Use QuickType to quickly generate model classes from JSON samples
  • Consider nullable fields carefully in your models
  • Validate incoming API data to prevent runtime errors

  • Use Dio for advanced API features and interceptors
  • Implement retry mechanisms for transient network failures
  • Store sensitive data in secure storage
  • Implement certificate pinning for critical APIs
  • Use code generation for data serialization
  • Handle errors gracefully in the UI
  • Structure API clients using repository pattern
  • Validate API responses before parsing
  • Implement proper token refresh logic
  • Monitor API performance using logging interceptors
  • Hardcoding API keys in source code
  • Not handling connection errors properly
  • Using too many global interceptors that slow down requests
  • Blocking the UI thread during API calls
  • Not caching API responses for frequently used data
  • Ignoring API versioning in your client code
  • Skipping proper error handling in API repositories

  1. Dio Package Documentation – Feature-rich HTTP client for Flutter
  2. Flutter Secure Storage – Secure storage for sensitive data
  3. json_serializable Package – JSON serialization through code generation
  4. freezed Package – Code generation for immutable classes