Flutter HTTP Requests: GET, POST, and JSON With the http Package

Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter HTTP requests guide, with a GET and a POST request, a status code, and a JSON response body.
Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter HTTP requests guide, with a GET and a POST request, a status code, and a JSON response body.

Almost every real app talks to a server, and in Flutter the official http package is the simplest way to do it. The shape is always the same: build a Uri, await a request, check the status code, then decode the body. Once you have that loop for a GET, a POST is the same call with a body and a header. This guide covers adding the package, making GET and POST requests, reading the status code and body, sending and decoding JSON, headers and timeouts, and turning a response into a typed model, with paste-ready code.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Every snippet below is paste-ready against current stable Flutter. Each request returns a Future you await, so a working grasp of Dart's async/await will make the rest read naturally.

Follow me on Instagram@sagnikteaches

We'll add the package, fetch data with a GET, decode the JSON, send data with a POST, handle headers and failures, and end by mapping the response into a model.

Connect on LinkedInSagnik Bhattacharya

If you'd rather watch a full feed screen wired to a live API, the channel builds these networking flows end to end in real apps.

Subscribe on YouTube@codingliquids

Add the http package

http is not part of the SDK, so add it as a dependency. The cleanest way is the CLI, which writes the right version into pubspec.yaml for you.

flutter pub add http

That adds a line under dependencies in pubspec.yaml:

dependencies:
  http: ^1.2.0

Then import it wherever you make requests. The conventional alias keeps call sites readable:

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

On Android, remember the internet permission is included by default in release builds; on web, the server you call must allow CORS. Those aside, the API is the same everywhere.

A GET request

A GET fetches data. Build a Uri, await http.get, and you receive a Response with a statusCode and a body string.

Future<String> fetchTodo() async {
  final url = Uri.parse('https://jsonplaceholder.typicode.com/todos/1');
  final response = await http.get(url);

  if (response.statusCode == 200) {
    return response.body;          // raw JSON string
  } else {
    throw Exception('Failed: ${response.statusCode}');
  }
}

Always check statusCode before trusting body. A 200 means success; anything else is an error path you should surface, not parse as data.

Decode the JSON

The body is a String. To work with it, decode it using jsonDecode from the built-in dart:convert library — no extra package needed.

import 'dart:convert';

final response = await http.get(url);
final Map<String, dynamic> data = jsonDecode(response.body);
print(data['title']);

jsonDecode returns dynamic: a Map<String, dynamic> for an object, or a List<dynamic> for an array. Cast or destructure it into the shape you expect, then read fields by key.

The Complete Flutter Guide course thumbnail

Wire your app to a real backend

The Complete Flutter Guide covers REST calls, JSON, and Firebase inside real, shipped apps.

Enrol now

A POST request

A POST sends data. It is the same call shape with two additions: a body and a Content-Type header so the server reads it as JSON. Encode your map with jsonEncode.

Future<Map<String, dynamic>> createPost(String title) async {
  final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final response = await http.post(
    url,
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'title': title, 'userId': 1}),
  );

  if (response.statusCode == 201) {  // 201 Created
    return jsonDecode(response.body);
  }
  throw Exception('Create failed: ${response.statusCode}');
}

A successful create usually returns 201 rather than 200, and the body often contains the new resource — including a server-assigned id. The same pattern covers put and delete.

Headers, timeouts, and status codes

Headers carry auth tokens and content negotiation. A bearer token and an Accept header look like this:

final response = await http.get(
  url,
  headers: {
    'Authorization': 'Bearer $token',
    'Accept': 'application/json',
  },
).timeout(const Duration(seconds: 10));

Add .timeout so a slow network does not hang the request forever — it throws a TimeoutException you can catch. Wrap the whole call in try/catch, because network errors and timeouts throw before you ever see a status code. Treat anything outside the 2xx range as a failure and show the user a message rather than a half-parsed error body.

try {
  final response = await http.get(url).timeout(const Duration(seconds: 10));
  if (response.statusCode >= 200 && response.statusCode < 300) {
    // success
  } else {
    // handle 4xx / 5xx
  }
} on Exception catch (e) {
  // network failure or timeout
}

From response to model

Reading raw maps everywhere is fragile. The durable pattern is a model class with a fromJson factory, so the rest of your app works with typed objects instead of string keys.

class Todo {
  final int id;
  final String title;
  final bool completed;
  Todo({required this.id, required this.title, required this.completed});

  factory Todo.fromJson(Map<String, dynamic> json) => Todo(
        id: json['id'] as int,
        title: json['title'] as String,
        completed: json['completed'] as bool,
      );
}

Future<Todo> fetchTodo() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/todos/1'),
  );
  if (response.statusCode == 200) {
    return Todo.fromJson(jsonDecode(response.body));
  }
  throw Exception('Failed: ${response.statusCode}');
}

Now your widgets render a Todo, not a Map. Dedicating the parsing to the model keeps networking and UI code clean, and it is exactly where a FutureBuilder takes over to display the result.

Build the URL: query parameters

Real endpoints take query parameters — pages, filters, search terms. Build them with Uri.https (or Uri.http) and a map rather than concatenating strings, so every value is percent-encoded for you.

final url = Uri.https('jsonplaceholder.typicode.com', '/comments', {
  'postId': '1',
  'q': 'great post',   // the space is encoded automatically
});
final response = await http.get(url);

Each value must be a String (or an Iterable<String> for a repeated key), so convert numbers with toString(). Building the Uri this way avoids the classic bug where an un-encoded space or ampersand silently breaks the request.

Decode a JSON list

Collection endpoints return a JSON array, so jsonDecode hands you a List<dynamic>. Map each element through your model's fromJson to get a typed list.

Future<List<Todo>> fetchTodos() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/todos'),
  );
  if (response.statusCode != 200) {
    throw Exception('Failed: ${response.statusCode}');
  }
  final List<dynamic> data = jsonDecode(response.body);
  return data
      .map((item) => Todo.fromJson(item as Map<String, dynamic>))
      .toList();
}

The .map(...).toList() step is where one malformed element will throw, so keep fromJson strict and let the error surface instead of returning half a list.

PUT, PATCH, and DELETE

Updating and deleting use the same shape as POST. PUT replaces a resource, PATCH updates part of it, and DELETE removes it.

const id = 1;
final base = 'https://jsonplaceholder.typicode.com/posts/$id';
final headers = {'Content-Type': 'application/json'};

await http.put(Uri.parse(base),
    headers: headers, body: jsonEncode({'title': 'Replaced'}));

await http.patch(Uri.parse(base),
    headers: headers, body: jsonEncode({'title': 'Patched'}));

final res = await http.delete(Uri.parse(base));
// A successful DELETE is usually 200 or 204 (No Content).

Treat 204 No Content as success with an empty body — do not try to jsonDecode it.

Reuse one Client

The top-level http.get helpers open and close a connection on every call. When a screen makes several requests, create one http.Client, reuse it, and close it when you are done — it keeps the connection alive and is noticeably faster.

final client = http.Client();
try {
  final a = await client.get(Uri.parse('$base/posts/1'));
  final b = await client.get(Uri.parse('$base/posts/2'));
  // ...use a and b
} finally {
  client.close();   // always close it
}

Wrap it in a reusable API service

Loose functions scattered across widgets do not scale. The durable pattern is a small service that owns the base URL, the shared headers, the single Client, and one place to decode and to fail. Every screen then calls typed methods instead of repeating Uri.parse and status checks.

import 'dart:async';   // TimeoutException
import 'dart:convert';
import 'dart:io';       // SocketException (mobile/desktop)
import 'package:http/http.dart' as http;

class ApiException implements Exception {
  final String message;
  final int? statusCode;
  ApiException(this.message, {this.statusCode});
  @override
  String toString() => 'ApiException($statusCode): $message';
}

class ApiClient {
  ApiClient({http.Client? client, this.baseUrl = 'https://jsonplaceholder.typicode.com'})
      : _client = client ?? http.Client();

  final http.Client _client;
  final String baseUrl;

  Map<String, String> get _headers => {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        // 'Authorization': 'Bearer $token',
      };

  Future<dynamic> _send(Future<http.Response> Function() run) async {
    try {
      final res = await run().timeout(const Duration(seconds: 15));
      if (res.statusCode >= 200 && res.statusCode < 300) {
        return res.body.isEmpty ? null : jsonDecode(res.body);
      }
      throw ApiException('Request failed', statusCode: res.statusCode);
    } on SocketException {
      throw ApiException('No internet connection');
    } on TimeoutException {
      throw ApiException('The request timed out');
    } on FormatException {
      throw ApiException('Bad response format');
    }
  }

  Future<List<Todo>> getTodos() async {
    final data = await _send(() => _client.get(
          Uri.parse('$baseUrl/todos'),
          headers: _headers,
        ));
    return (data as List)
        .map((e) => Todo.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  Future<Todo> createTodo(String title) async {
    final data = await _send(() => _client.post(
          Uri.parse('$baseUrl/todos'),
          headers: _headers,
          body: jsonEncode({'title': title, 'completed': false}),
        ));
    return Todo.fromJson(data as Map<String, dynamic>);
  }

  void close() => _client.close();
}

Now every status check, timeout, and decode lives in _send, and the rest of the app works with Todo objects and a single ApiException it can show the user. On web there is no dart:io, so drop the SocketException branch there and catch a generic error instead.

Test the client without a network

Because the service accepts an http.Client, you can inject a fake one in tests. The http package ships MockClient for exactly this, so tests never hit the real network and run in milliseconds.

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

final mock = MockClient((request) async {
  return http.Response('[{"id":1,"title":"Test","completed":false}]', 200);
});
final api = ApiClient(client: mock);
final todos = await api.getTodos();   // returns the mocked list, no network

Common mistakes

  • Skipping the status check. Always verify statusCode before decoding body.
  • Forgetting the Content-Type header on POST. Without application/json many servers misread the payload.
  • No timeout or try/catch. Network failures throw; wrap calls and add .timeout.
  • Passing a raw String URL. http.get takes a Uri; use Uri.parse.
  • Parsing in the widget. Map the JSON into a model so the UI stays typed and simple.
  • Opening a new Client per call. For several requests, reuse one http.Client and close() it.
  • Decoding an empty body. A 204 No Content has no JSON — guard before jsonDecode.

Frequently asked questions

How do I make an HTTP request in Flutter?

Add the http package, import it, and await http.get(Uri.parse(url)). Check the returned statusCode, then decode response.body with jsonDecode.

How do I send JSON in a POST request?

Encode your map with jsonEncode, pass it as body to http.post, and set a Content-Type: application/json header so the server reads it correctly.

How do I handle a non-200 status code?

Check response.statusCode and treat anything outside 2xx as a failure path. Wrap the call in try/catch too, since timeouts and network errors throw before a status arrives.

Should I use http or Dio?

Start with the official http package — it covers GET, POST, headers, and JSON. Move to Dio only when you need interceptors, retries, cancellation, or upload progress.

Further reads

Keep going with the tutorials that pair with this guide:

Sources: Flutter documentation — networking cookbook (Fetch data, Send data); the http package (docs.flutter.dev, pub.dev/packages/http). Verified against current stable Flutter.