Reader Stacks

Calling an API in Flutter Using the http Package

http.get() returns the raw response body as a JSON string — decoding it and mapping the result to a typed Dart model are both manual steps the package deliberately leaves to the developer.

Calling an API in Flutter Using the http Package

The http package is Flutter/Dart's most commonly used way to call a REST API — it returns a raw response body as a JSON string, leaving decoding and mapping to a typed model as explicit, separate steps.

Adding the package

# pubspec.yaml
dependencies:
  http: ^1.2.0

A basic GET request

import 'package:http/http.dart' as http;
import 'dart:convert';

Future> fetchProducts() async {
  final response = await http.get(Uri.parse('https://api.example.com/products'));

  if (response.statusCode == 200) {
    final List data = jsonDecode(response.body);
    return data.map((json) => Product.fromJson(json)).toList();
  } else {
    throw Exception('Failed to load products');
  }
}

Checking response.statusCode explicitly is necessary — unlike some HTTP libraries in other languages, http.get() does not automatically throw an exception for a non-2xx response; a 404 or 500 response still returns normally, just with a different status code and body.

Defining the model with a fromJson factory

class Product {
  final int id;
  final String name;
  final double price;

  Product({required this.id, required this.name, required this.price});

  factory Product.fromJson(Map json) {
    return Product(
      id: json['id'],
      name: json['name'],
      price: (json['price'] as num).toDouble(),
    );
  }
}

Casting json['price'] through as num before calling .toDouble() handles a common inconsistency where a JSON API returns a whole-number price as an integer rather than a float — without this cast, a price like 20 (rather than 20.0) would throw a type error when the code expects a double.

A POST request with a JSON body

Future createProduct(String name, double price) async {
  final response = await http.post(
    Uri.parse('https://api.example.com/products'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'name': name, 'price': price}),
  );

  if (response.statusCode != 201) {
    throw Exception('Failed to create product');
  }
}

The Content-Type: application/json header must be set explicitly — unlike Angular's HttpClient or similar libraries in other frameworks, the http package doesn't infer or set this automatically based on the body's shape.

Adding an authorization header

final response = await http.get(
  Uri.parse('https://api.example.com/orders'),
  headers: {'Authorization': 'Bearer $token'},
);

Handling a network error (not just a bad status code)

Future> fetchProducts() async {
  try {
    final response = await http.get(Uri.parse('https://api.example.com/products'));
    if (response.statusCode == 200) {
      final List data = jsonDecode(response.body);
      return data.map((json) => Product.fromJson(json)).toList();
    }
    throw Exception('Server error: ${response.statusCode}');
  } on SocketException {
    throw Exception('No internet connection.');
  }
}

A SocketException (no internet connection, DNS failure) is a genuinely separate failure mode from a bad HTTP status code — the request never even reaches the server in this case, which is why it needs its own catch clause distinct from checking response.statusCode.

Setting a request timeout

final response = await http
    .get(Uri.parse('https://api.example.com/products'))
    .timeout(const Duration(seconds: 10));

Without an explicit timeout, a request to an unresponsive server can hang indefinitely from the app's perspective — .timeout() throws a TimeoutException after the specified duration, giving the app a bounded, predictable failure case to handle instead.