Artificial Intelligence6 min read
Integrating AI APIs into a Flutter App
Integrate an AI API into Flutter using lessons from AI Humanizer: Provider request state, timeouts, retries, streaming, and keys kept off the device.
- #flutter
- #ai
- #api-integration
- #provider
- #rest-api

Almost every app brief I see today has the same line in it: "and it should use AI somewhere." Text rewriting, summaries, chat, smart suggestions — shipping an AI feature is quickly becoming table stakes for a mobile product. We went through exactly that with AI Humanizer, an app that rewrites AI-generated text so it reads naturally while preserving the original meaning. Its stack is a pattern I now reuse on every AI project: a Flutter frontend with Provider for state, a Node.js backend that holds the AI API keys, and Firebase for auth and infrastructure.
Integrating a third-party AI API looks deceptively simple: one HTTP call and you are done. In production it is a different animal. Network calls are slow and flaky, API keys cannot live in the app binary, long responses block your UI for seconds, and every failure mode needs a message the user can act on. This guide walks through the integration pattern that survived production traffic in AI Humanizer, where text processing completes in under two seconds and the bypass rate holds at 99 percent.
Never call the AI API directly from the phone
The first architectural decision is the most important one: the Flutter app never talks to the AI provider directly. The API key lives server-side, in a small Node.js service, and the app calls that service. Firebase Authentication issues the token that proves the user is allowed to call it.
There are three reasons for this, and all three have bitten real apps:
- A key shipped in an APK or IPA is not a secret. App binaries can be decompiled in minutes. If your OpenAI or custom-provider key is embedded in the app, someone will extract it and run up their bill on your account.
- The backend is where rate limiting and quotas live. A user on a flaky network taps "Humanize" three times in frustration. The backend deduplicates and throttles; the provider bills you once.
- You can change providers without an app release. When we adjusted the humanizing pipeline behind AI Humanizer, the Flutter app did not change at all. The endpoint contract stayed the same.
Your Flutter code therefore only knows about your own endpoint:
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiRepository {
AiRepository({required this.baseUrl});
final String baseUrl;
Future<String> processText(String input, String idToken) async {
final response = await http.post(
Uri.parse('$baseUrl/v1/humanize'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $idToken',
},
body: jsonEncode({'text': input}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body)['result'] as String;
}
throw AiApiException(response.statusCode, response.body);
}
}
This is the same separation I describe in repository pattern for Flutter: the widget never knows where the data comes from, which makes everything below testable.
Model the request lifecycle as state
An AI request is not a boolean loading flag. It has a lifecycle: idle, loading, success, error. If your UI only tracks "loading or not," you cannot show an inline error with a retry button, disable the submit button mid-request, or distinguish a fresh screen from a failed one. In AI Humanizer we used Provider, and a small ChangeNotifier models the whole lifecycle:
import 'package:flutter/foundation.dart';
enum AiRequestState { idle, loading, success, error }
class AiController extends ChangeNotifier {
AiController({required AiRepository repository}) : _repository = repository;
final AiRepository _repository;
AiRequestState state = AiRequestState.idle;
String result = '';
String? errorMessage;
bool get isBusy => state == AiRequestState.loading;
Future<void> submit(String input, String idToken) async {
if (input.trim().isEmpty || isBusy) return;
state = AiRequestState.loading;
errorMessage = null;
notifyListeners();
try {
result = await _repository.processText(input, idToken);
state = AiRequestState.success;
} on AiApiException catch (e) {
state = AiRequestState.error;
errorMessage = _friendlyMessage(e);
} catch (_) {
state = AiRequestState.error;
errorMessage = 'Something went wrong. Check your connection and try again.';
}
notifyListeners();
}
String _friendlyMessage(AiApiException e) {
if (e.statusCode == 429) {
return 'Too many requests. Wait a moment and try again.';
}
if (e.statusCode == 401) {
return 'Your session expired. Please sign in again.';
}
return 'The AI service is unavailable right now. Try again shortly.';
}
}
class AiApiException implements Exception {
AiApiException(this.statusCode, this.body);
final int statusCode;
final String body;
}
Wire it once at the top of the screen with ChangeNotifierProvider, and every widget that cares about the request reads it with context.watch<AiController>(). The enum keeps impossible states unrepresentable: you can never be both loading and showing an old success.
Timeouts and retries, done deliberately
Mobile networks are unreliable, and AI endpoints occasionally take long enough that the user thinks the app is frozen. Every request needs a timeout, and the retry policy needs to be a conscious choice rather than wishful thinking.
The rules that work:
- Timeout after a fixed budget — 20 to 30 seconds for a blocking AI call is a sane default.
- Retry only what might succeed on retry — timeouts, connection resets, HTTP 429 (rate limited), and HTTP 5xx. Never retry a 400 or 401; those will fail identically every time.
- Back off exponentially — wait 2, then 4, then 8 seconds. A hammering retry loop during a provider outage is how a small incident becomes a large bill.
- Cap attempts at three — after that, the correct UX is an error message, not another silent attempt.
import 'dart:async';
import 'dart:io';
Future<http.Response> postWithRetry({
required Uri uri,
required Map<String, String> headers,
required String body,
}) async {
const maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
try {
final response = await http
.post(uri, headers: headers, body: body)
.timeout(const Duration(seconds: 25));
// Retry only on rate limits and server errors.
if (response.statusCode == 429 || response.statusCode >= 500) {
throw _RetryableStatus(response.statusCode);
}
return response;
} on TimeoutException {
// fall through to retry below
} on SocketException {
// fall through to retry below
} on _RetryableStatus {
// fall through to retry below
}
if (attempt == maxAttempts) rethrow;
await Future.delayed(Duration(seconds: attempt * 2));
}
throw StateError('Retry loop exited unexpectedly');
}
class _RetryableStatus implements Exception {
_RetryableStatus(this.statusCode);
final int statusCode;
}
Notice the deliberate choice of exceptions: TimeoutException and SocketException are retryable, while an AiApiException carrying a 400 is not. Baking that distinction into the code is what stops your app from re-sending a malformed request four times.
Stream long responses instead of blocking
Blocking calls feel broken the moment a response takes more than two seconds. AI Humanizer holds a sub-two-second processing time in part because long generations stream: the backend forwards the provider's token stream, and the Flutter app renders text as it arrives. Even if your totals are the same, perceived latency drops to zero.
Most AI providers expose a server-sent-events stream. On the Flutter side, http.Client.send gives you the raw byte stream, and the rest is plumbing:
class StreamingAiRepository {
StreamingAiRepository({required this.baseUrl});
final String baseUrl;
http.Client _client = http.Client();
void cancel() => _client.close();
Stream<String> streamProcessText(String input, String idToken) async* {
// Cancel any in-flight request before starting a new one.
_client.close();
_client = http.Client();
final request = http.Request('POST', Uri.parse('$baseUrl/v1/humanize-stream'))
..headers['Content-Type'] = 'application/json'
..headers['Authorization'] = 'Bearer $idToken'
..body = jsonEncode({'text': input});
final streamed = await _client.send(request);
if (streamed.statusCode != 200) {
throw AiApiException(streamed.statusCode, 'stream failed');
}
final buffer = StringBuffer();
await for (final line in streamed.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.startsWith('data: ')) {
final chunk = line.substring(6).trim();
if (chunk == '[DONE]') break;
buffer.write(chunk);
yield buffer.toString();
}
}
}
}
Two details matter here. First, cancel() closes the current http.Client, which aborts the in-flight request — this is how a "Stop" button or a new submit kills the old stream without leaking. Second, parsing is defensive: SSE frames can arrive split across chunks, so a production implementation buffers partial lines. The LineSplitter approach above is correct for well-formed streams and a good starting point, but if you see truncated text in testing, the buffer is where the bug is.
Rendering partial text in the UI is then trivial with the controller pattern from the previous section: yield each accumulated string, update result on every chunk, and call notifyListeners(). Users see words appearing. That single behavior changes the app's feel more than any spinner ever will.
Debounce input and cancel in-flight requests
A text box wired directly to an AI endpoint is a billing accident waiting to happen. Every keystroke fires a request, and because networks reorder, an older response can overwrite a newer one. Two one-line habits fix this:
import 'dart:async';
Timer? _debounce;
void onTextChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 600), () {
// Only now send the request.
});
}
Combine this with the cancel-on-submit pattern from the streaming section: starting a new request closes the previous one, so there is exactly one live request at any moment. Old responses can never overwrite new ones, because old requests are dead. This is the pattern that keeps both your API bill and your race conditions under control.
Keep costs and latency under control
Every AI API bills per token and answers in proportion to what you send. Three guardrails keep both the bill and the latency sane:
- Trim the payload on the client. Strip whitespace, cap input length before sending, and send only the fields the endpoint needs. Never ship the whole document when the endpoint summarizes the first page.
- Set
max_tokensserver-side, never client-side. The limit belongs in the backend where the user cannot change it. This is one more reason the key and the request shaping live behind your own endpoint. - Cache aggressively. Humanize results, summaries, and translations are deterministic for identical input. A short-lived cache keyed by an input hash turns repeat requests into instant answers and costs nothing.
These are not optimizations to add later. On a consumer app where a single user can trigger dozens of AI calls a session, they are the difference between a sustainable feature and a surprise invoice.
Putting it together
The pattern is small enough to state in one paragraph: the app calls your own backend, the backend calls the AI provider, the key never leaves the server. The Flutter side models the request as explicit state with Provider, retries only what deserves a retry, streams long answers, cancels stale requests, and speaks to the user in plain language when something fails.
If you are building the REST layer this pattern sits on, start with Flutter REST API integration: from JSON to UI. The full case study for the app this approach shipped in is AI Humanizer.
Get in touch and let's talk about your project.
Frequently asked questions
- Should a Flutter app call an AI API directly?
- No. The API key lives server-side in a backend service, and the app calls that service. A key embedded in an APK or IPA can be extracted in minutes, so direct calls put your account and billing at risk.
- How should I model an AI request in Flutter state?
- Use an explicit state machine — idle, loading, success, error — in a Provider ChangeNotifier rather than a single loading boolean. This lets the UI disable submit buttons mid-request and show inline errors with a retry action.
- Which AI API failures are safe to retry?
- Timeouts, connection resets, HTTP 429 rate limits, and HTTP 5xx server errors are retryable with exponential backoff. Never retry 400 or 401 responses, since those will fail identically on every attempt.
- How do I cancel an in-flight AI request in Flutter?
- Keep the current http.Client in a field and call close on it before creating a new one. Closing the client aborts the pending request, so starting a new request automatically kills the old stream without leaking.


