API Integration Without AI: Proving Your Fundamentals in Interviews
The LinkedIn Post That Exposed 4-5 Year 'Senior' Devs Who Can't Call a REST API Without ChatGPT
Open interactive version (quiz + challenge)Real-world analogy
What is it?
API integration without AI is the fundamental skill of connecting a Flutter app to a REST API using only your knowledge of HTTP, Dart, and Flutter — without relying on AI tools to generate the code. It means you understand every line: how HTTP requests work, how JSON maps to Dart objects, how errors propagate, and how to secure credentials. The viral LinkedIn post exposed that many 'senior' Flutter devs with 4-5 years experience cannot perform this basic task without AI assistance, and worse, some share API keys and tokens with AI tools. This lesson makes you bulletproof against that criticism by drilling the fundamentals until they are second nature.
Real-world relevance
A fintech startup interviews a Flutter developer with 5 years experience. The interviewer gives them a REST API for fetching transaction history and says 'build a screen that shows the user their transactions — you have 30 minutes, no internet except the API.' The candidate stares at the screen. They open Chrome to go to ChatGPT — blocked. They try to remember the http package syntax — blank. They can't write fromJson without json_serializable. They don't know how to handle a 401. They get rejected. The next candidate — with only 2 years experience — writes the model, service, error handling, and UI in 25 minutes. They get the offer at a higher salary. The difference wasn't talent. It was that one practiced fundamentals while the other outsourced thinking to AI.
Key points
- REST API Fundamentals You MUST Know Cold — HTTP methods: GET (read), POST (create), PUT (replace), PATCH (update), DELETE (remove). Status codes: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable Entity, 429 Too Many Requests, 500 Internal Server Error. Headers: Content-Type (application/json), Authorization (Bearer token), Accept. Request/response cycle: client sends request with method + URL + headers + optional body, server processes and returns status code + headers + body. If an interviewer asks 'what's the difference between 401 and 403?' and you hesitate, you've already lost.
- Building API Integration Step by Step — No AI, No Code Gen — Step 1: Create a model class with fromJson/toJson. Step 2: Write an API service class using http or dio. Step 3: Make the HTTP request with proper headers. Step 4: Check status code FIRST, then parse body. Step 5: Handle errors at every step. Step 6: Return typed data to your UI layer. Step 7: Display with FutureBuilder or state management. This is the flow you must be able to do from an empty file in under 30 minutes. Practice it until it's muscle memory.
- Manual JSON Parsing — fromJson and toJson Without Code Gen — Yes, freezed and json_serializable exist. Yes, they save time. But in an interview, you MUST prove you understand what those tools generate. A fromJson factory constructor takes a Map and maps each key to a typed field. A toJson method returns a Map. Handle nested objects by calling NestedClass.fromJson(json['key']). Handle lists with (json['items'] as List).map((e) => Item.fromJson(e)).toList(). Handle nullable fields with null checks. If you can't write this from memory, you don't understand your own data layer.
- Security Hygiene — NEVER Share Secrets with AI Tools — This is a CAREER-ENDING mistake that the LinkedIn post highlighted. NEVER paste API keys, Bearer tokens, .env file contents, user data, database credentials, or proprietary business logic into ChatGPT, Copilot, Claude, or any AI tool. These inputs may be logged, used for training, or stored on third-party servers. Use placeholders like 'YOUR_API_KEY_HERE' or 'Bearer '. If your company finds out you shared production credentials with an AI tool, that's a fireable offense. In interviews, mention this proactively — it shows security awareness that separates seniors from juniors.
- Error Handling That Shows Maturity — Junior devs: try-catch and show 'Something went wrong.' Senior devs: handle EVERY failure mode differently. SocketException → no internet connection, show retry button. TimeoutException → server slow, suggest trying later. HttpException with 401 → token expired, trigger refresh. 403 → user lacks permission, show access denied. 404 → resource deleted, remove from local cache. 422 → validation failed, show field-specific errors. 429 → rate limited, implement exponential backoff. 500 → server error, log and show generic message. FormatException → malformed JSON, log for debugging. Create a custom ApiException class hierarchy. Wrap http calls in a try-catch that maps each failure to a specific, user-friendly response.
- The 'Build It Live' Interview Test — 30 Minutes, No AI, No Google — Many companies now hand you a laptop with VS Code, a REST API endpoint, and say 'build a Flutter screen that fetches and displays this data.' No internet (except the API), no AI, no Stack Overflow. You have 30 minutes. They're watching your process: Do you start with the model? Do you check the API response shape first? Do you handle errors? Do you know the http package API by heart? Practice this weekly: pick a free public API (jsonplaceholder, reqres.in, pokeapi), set a 30-minute timer, close all browser tabs, and build from scratch. This single practice habit will put you ahead of 90% of candidates.
- Common API Patterns You Must Implement From Memory — Pagination: track current page, append results to list, detect last page by empty response or total count. Token refresh: intercept 401, call refresh endpoint, retry original request with new token. Retry with exponential backoff: wait 1s, 2s, 4s, 8s with max retries. Response caching: store responses with timestamps, serve cache if fresh, fetch if stale. Request cancellation: use CancelToken (dio) to cancel in-flight requests when user navigates away. Request deduplication: prevent identical concurrent requests. These patterns are what separate 'I can call an API' from 'I can build production-ready networking.'
- Debugging API Issues Without AI — Step 1: Test the API in Postman or Insomnia FIRST to confirm the endpoint works. Step 2: Check the exact request your app sends — use dio interceptors to log URL, headers, body. Step 3: Compare your app's request to the working Postman request — find the difference. Step 4: Read the error message. Actually read it. Most devs skip this and paste into ChatGPT. A 'type String is not a subtype of type int' tells you EXACTLY which field has wrong type in your fromJson. Step 5: Check the JSON response structure — is 'data' an object or array? Is 'id' a String or int? Mismatched types are the #1 parsing bug.
- AI as Accelerator, Not Crutch — The Right Way — WRONG: 'Hey ChatGPT, write me an API integration for this endpoint' → paste result → deploy without understanding. RIGHT: Build it yourself first. Get it working. Understand every line. THEN use AI to review your code, suggest optimizations, or generate boilerplate for similar endpoints. The test: if the AI tool disappears tomorrow, can you still do your job? If the answer is no, you're not a developer — you're a prompt engineer pretending to be one. Interviewers can tell the difference in 5 minutes.
- Security Red Flags Interviewers Watch For — Hardcoded API keys in source code — use --dart-define or .env files with flutter_dotenv. Tokens stored in SharedPreferences (plain text) — use flutter_secure_storage which uses Keychain (iOS) and EncryptedSharedPreferences (Android). No HTTPS certificate pinning — man-in-the-middle attacks can intercept all traffic. Logging sensitive data — never print tokens, passwords, or user data to console in production. No token expiry handling — tokens should refresh automatically, not require re-login. API keys in version control — add .env to .gitignore, use CI/CD secrets. If you mention even 3 of these proactively in an interview, you've demonstrated security maturity.
- The Fundamentals Litmus Test — Can You Explain These From Memory? — HTTP: a stateless protocol for client-server communication using request-response pairs over TCP. REST: an architectural style using resources (URLs), HTTP methods (verbs), and stateless requests. JSON: JavaScript Object Notation, a lightweight text format for data exchange using key-value pairs. async/await: syntactic sugar over Futures, letting you write asynchronous code that reads like synchronous code. Future vs Stream: Future resolves once, Stream emits multiple values over time. try-catch-finally: try runs code, catch handles errors by type, finally always executes for cleanup. If you stumbled on any of these, study them until you can explain each in one clear sentence without thinking.
- The Dio Interceptor Pattern for Production Apps — Interceptors are middleware that run before every request (onRequest), after every response (onResponse), and on every error (onError). Use onRequest to attach auth tokens automatically, log outgoing requests, and add common headers. Use onResponse to log responses and transform data. Use onError to handle 401 by refreshing tokens and retrying, implement retry logic, and map server errors to custom exceptions. This pattern keeps your API service classes clean — they just make requests while the interceptor handles cross-cutting concerns. In interviews, explaining this architecture shows you think in systems, not just screens.
Code example
// === COMPLETE API INTEGRATION FROM SCRATCH ===
// No freezed, no code gen, no AI — pure fundamentals
// ---- 1. CONFIGURATION (NEVER hardcode secrets) ----
// Use: flutter run --dart-define=API_BASE_URL=https://api.example.com
// Use: flutter run --dart-define=API_KEY=your_key_here
class ApiConfig {
// Read from environment at compile time
static const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://jsonplaceholder.typicode.com',
);
// NEVER do this:
// static const apiKey = 'sk-1234567890abcdef'; // CAREER ENDING
// NEVER paste this into ChatGPT/Copilot either!
}
// ---- 2. MODEL CLASS (Manual fromJson/toJson) ----
class User {
final int id;
final String name;
final String email;
final Address? address; // Nullable nested object
const User({
required this.id,
required this.name,
required this.email,
this.address,
});
// Factory constructor — know this by heart
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
address: json['address'] != null
? Address.fromJson(json['address'] as Map<String, dynamic>)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'address': address?.toJson(),
};
}
}
class Address {
final String street;
final String city;
const Address({required this.street, required this.city});
factory Address.fromJson(Map<String, dynamic> json) {
return Address(
street: json['street'] as String,
city: json['city'] as String,
);
}
Map<String, dynamic> toJson() => {'street': street, 'city': city};
}
// ---- 3. CUSTOM EXCEPTIONS (Not just generic catch-all) ----
sealed class ApiException implements Exception {
final String message;
final int? statusCode;
const ApiException(this.message, [this.statusCode]);
}
class NetworkException extends ApiException {
const NetworkException() : super('No internet connection');
}
class TimeoutException extends ApiException {
const TimeoutException() : super('Request timed out');
}
class UnauthorizedException extends ApiException {
const UnauthorizedException() : super('Session expired', 401);
}
class ForbiddenException extends ApiException {
const ForbiddenException() : super('Access denied', 403);
}
class NotFoundException extends ApiException {
const NotFoundException(String resource)
: super('$resource not found', 404);
}
class ServerException extends ApiException {
const ServerException() : super('Server error. Try later.', 500);
}
class ParseException extends ApiException {
const ParseException(String detail)
: super('Failed to parse response: $detail');
}
// ---- 4. API SERVICE (with proper error handling) ----
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
class ApiService {
final http.Client _client;
final String _baseUrl;
String? _authToken;
ApiService({
http.Client? client,
String? baseUrl,
}) : _client = client ?? http.Client(),
_baseUrl = baseUrl ?? ApiConfig.baseUrl;
void setAuthToken(String token) => _authToken = token;
// Core request method — handles ALL error cases
Future<dynamic> _request(
String method,
String path, {
Map<String, dynamic>? body,
Map<String, String>? queryParams,
}) async {
final uri = Uri.parse('$_baseUrl$path').replace(
queryParameters: queryParams,
);
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
if (_authToken != null) 'Authorization': 'Bearer $_authToken',
};
try {
late http.Response response;
switch (method) {
case 'GET':
response = await _client
.get(uri, headers: headers)
.timeout(const Duration(seconds: 15));
case 'POST':
response = await _client
.post(uri, headers: headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15));
case 'PUT':
response = await _client
.put(uri, headers: headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15));
case 'DELETE':
response = await _client
.delete(uri, headers: headers)
.timeout(const Duration(seconds: 15));
default:
throw ArgumentError('Unsupported HTTP method: $method');
}
return _handleResponse(response);
} on SocketException {
throw const NetworkException();
} on TimeoutException {
throw const TimeoutException();
} on ApiException {
rethrow; // Don't wrap our own exceptions
} catch (e) {
throw ParseException(e.toString());
}
}
dynamic _handleResponse(http.Response response) {
switch (response.statusCode) {
case 200 || 201:
try {
return jsonDecode(response.body);
} on FormatException catch (e) {
throw ParseException(e.message);
}
case 204:
return null;
case 401:
throw const UnauthorizedException();
case 403:
throw const ForbiddenException();
case 404:
throw const NotFoundException('Resource');
case >= 500:
throw const ServerException();
default:
throw ApiException(
'Request failed: ${response.statusCode}',
response.statusCode,
);
}
}
// Public API methods — clean and simple
Future<List<User>> getUsers({int page = 1, int limit = 20}) async {
final json = await _request('GET', '/users', queryParams: {
'_page': page.toString(),
'_limit': limit.toString(),
});
return (json as List).map((e) => User.fromJson(e)).toList();
}
Future<User> getUser(int id) async {
final json = await _request('GET', '/users/$id');
return User.fromJson(json);
}
Future<User> createUser(User user) async {
final json = await _request('POST', '/users', body: user.toJson());
return User.fromJson(json);
}
void dispose() => _client.close();
}
// ---- 5. DIO INTERCEPTOR (Token Refresh Pattern) ----
// For production apps using dio package:
/*
class AuthInterceptor extends Interceptor {
final Dio dio;
final AuthRepository authRepo;
AuthInterceptor(this.dio, this.authRepo);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = authRepo.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
try {
// Refresh the token
final newToken = await authRepo.refreshToken();
// Retry the original request with new token
final options = err.requestOptions;
options.headers['Authorization'] = 'Bearer $newToken';
final response = await dio.fetch(options);
handler.resolve(response);
return;
} catch (e) {
// Refresh failed — force logout
authRepo.logout();
}
}
handler.next(err);
}
}
*/
// ---- 6. USAGE IN UI ----
// Using FutureBuilder (simple) or state management (production)
/*
class UsersScreen extends StatefulWidget {
@override
State<UsersScreen> createState() => _UsersScreenState();
}
class _UsersScreenState extends State<UsersScreen> {
late final ApiService _api;
late Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_api = ApiService();
_usersFuture = _api.getUsers();
}
@override
void dispose() {
_api.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: FutureBuilder<List<User>>(
future: _usersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
final error = snapshot.error;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_getErrorMessage(error)),
ElevatedButton(
onPressed: () {
setState(() => _usersFuture = _api.getUsers());
},
child: const Text('Retry'),
),
],
),
);
}
final users = snapshot.data!;
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
},
),
);
}
String _getErrorMessage(Object? error) {
if (error is NetworkException) return 'No internet. Check connection.';
if (error is TimeoutException) return 'Server slow. Try again.';
if (error is UnauthorizedException) return 'Please log in again.';
if (error is ServerException) return 'Server error. Try later.';
return 'Something went wrong.';
}
}
*/Line-by-line walkthrough
- 1. ApiConfig uses String.fromEnvironment to read secrets at compile time via --dart-define flags. This means API keys NEVER appear in source code — they're injected during the build. The defaultValue is only for development with public APIs.
- 2. The User model class has typed final fields and a required const constructor. Every field has an explicit type. The address field is nullable (Address?) because the API might not always include it.
- 3. User.fromJson is a factory constructor that takes Map — the standard type that jsonDecode returns. Each field is explicitly cast (json['id'] as int) which gives clear error messages if the API returns unexpected types instead of a vague runtime crash.
- 4. The nested Address object is parsed by checking for null first, then calling Address.fromJson. This is the pattern for every nested object — always null-check before parsing to prevent null reference errors on optional fields.
- 5. toJson returns a Map with the ?. operator on nullable address — if address is null, the map value is null rather than crashing. This mirrors the fromJson null handling.
- 6. The sealed class ApiException hierarchy creates specific exception types for each failure mode. Using sealed means the compiler can check you've handled all cases in a switch statement — no missed error scenarios.
- 7. ApiService takes an optional http.Client parameter — this is dependency injection for testing. In tests, you pass a MockClient. In production, it creates a real client. This is a pattern interviewers love to see.
- 8. The _request method builds the URI, constructs headers with conditional auth token spread, and uses a switch expression on the HTTP method. The .timeout(Duration(seconds: 15)) ensures requests don't hang forever.
- 9. _handleResponse uses pattern matching on status codes. The key insight: check the status code BEFORE trying to parse the body. A 401 response might have HTML error page as body — parsing it as JSON would throw a confusing FormatException instead of the real 'unauthorized' error.
- 10. The SocketException catch maps to NetworkException and TimeoutException maps to our custom TimeoutException. The 'on ApiException rethrow' line prevents wrapping our own exceptions — a subtle but important detail that prevents error message corruption.
- 11. The public API methods (getUsers, getUser, createUser) are clean one-liners that call _request and map the response to typed objects. Pagination parameters are passed as query params with toString() conversion since query params must be strings.
- 12. The AuthInterceptor (dio pattern) intercepts 401 errors, refreshes the token, and retries the original request transparently. This means API service classes never need to handle auth — it's a cross-cutting concern handled in one place.
Spot the bug
class Post {
final int id;
final String title;
final String body;
Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
class ApiService {
static const _baseUrl = 'https://api.example.com';
static const _apiKey = 'sk_live_a1b2c3d4e5f6g7h8i9j0';
Future<List<Post>> getPosts() async {
final response = await http.get(
Uri.parse('$_baseUrl/posts'),
headers: {'X-API-Key': _apiKey},
);
final List data = jsonDecode(response.body);
return data.map((json) => Post.fromJson(json)).toList();
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter HTTP Networking — Official Cookbook (Flutter Docs)
- The http Package — Dart Official Documentation (pub.dev)
- Dio Package — Advanced HTTP Client for Dart (pub.dev)
- JSON Serialization in Flutter — Official Guide (Flutter Docs)
- Flutter Secure Storage — Secure Token Storage (pub.dev)
- REST API Integration in Flutter — From Scratch (Rivaan Ranawat)
- OWASP Mobile Security Testing Guide (OWASP)