HttpClient and JSON

By the Syncfusion DevTutorials team · Last updated

Call web APIs and handle JSON: HttpClient, GET, POST, PUT and DELETE, headers, responses, System.Text.Json serialization, options, attributes, typed clients and error handling.

Introduction to HttpClient

Think about ordering food through a delivery app. You send a request like "one masala dosa to this address". The restaurant sends back a response—maybe the food, or maybe "sorry, sold out". Both sides understand the same message format, so it doesn't matter that the app and the kitchen use completely different systems.

Programs talk to each other the same way, using HTTP: the language of the web. Your C# program sends an HTTP request to a web API, and the API sends back an HTTP response. Most web APIs send their data as JSON, which is just plain text that follows a simple format both sides understand.

Here is what you need to know:

  • HttpClient is the C# class that sends HTTP requests and gets responses back.
  • System.Text.Json is the C# library that turns your objects into JSON and turns JSON back into your objects.

In simple words: HttpClient talks to web APIs over the internet; System.Text.Json translates between JSON text and C# objects.

Easy Way to Remember: HttpClient carries the message. JSON is the language the message is written in.

What You Will Learn

In this lesson, you will learn:

  1. How to create and set up HttpClient.
  2. How to send GET, POST, PUT, and DELETE requests.
  3. How to add request headers (extra information with requests).
  4. How to read status codes and response bodies (the data that comes back).
  5. How to turn C# objects into JSON and turn JSON back into C# objects.
  6. How to change how JSON works using options and attributes.
  7. How to build a simple API client class that wraps an API.
  8. How to handle errors that happen when calling web APIs.

The examples you can run here use a pretend web API built into the program: a small class that answers requests the way a real server would. This way every example works without needing the internet, and you get the same result every time. The HttpClient code is exactly what you would write with a real API. When we show real code that uses the internet, we'll make it clear.

Start With a Working Example

This is how you read data from a real web API. It needs an internet connection, so this is just plain code (you can't run it here):

using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("https://api.example.com/") };

Product? product = await client.GetFromJsonAsync<Product>("products/1");
Console.WriteLine($"{product?.Name}: Rs. {product?.Price}");

record Product(int Id, string Name, decimal Price);

Now here is the same code using the pretend API built into this page. Click Run to see it work.

Get a product from an API

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FakeShopApi()) { BaseAddress = new Uri("https://api.example.com/") };

Product? product = await client.GetFromJsonAsync<Product>("products/1");
Console.WriteLine($"{product?.Name}: Rs. {product?.Price}");

record Product(int Id, string Name, decimal Price);

// Pretend server: answers requests without using the network.
class FakeShopApi : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        Console.WriteLine($"(server got {request.Method} {request.RequestUri!.PathAndQuery})");
        var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = JsonContent.Create(new { id = 1, name = "Electric Kettle", price = 1299.00m }),
        };
        return Task.FromResult(response);
    }
}

One single line, GetFromJsonAsync<Product>, did three things:

  1. Sent a GET request
  2. Checked if the response was successful
  3. Turned the JSON data into a Product object

How Does the Pretend API Work?

HttpClient doesn't talk to the network by itself. Instead, it passes each request to a message handler. The default handler opens a real internet connection. But here, FakeShopApi is a handler that answers the request right in memory instead.

This is also how professionals test code that uses HttpClient: they give it a fake handler that returns fake data. This way they can test without needing a real server.

Creating HTTP Requests

Every HTTP request has four parts:

PartExampleWhat It Does
MethodGET, POST, PUT, DELETESays what action you want
URLhttps://api.example.com/products/1Says which resource you want
HeadersAccept: application/jsonExtra info about what you're asking for
Body (optional){"name":"Pen","price":10}Data you are sending to the server

Creating HttpClient the Right Way

HttpClient is designed to be created once and used many times. If you create a new HttpClient for every request, your program uses up network connections. Eventually your program will run out of connections. This problem is called socket exhaustion.

// Wrong: a new client (and new connections) for every request
foreach (var id in ids)
{
    using var client = new HttpClient();
    await client.GetAsync($"https://api.example.com/products/{id}");
}

// Right for a console program: create one client and reuse it
private static readonly HttpClient Client = new() { BaseAddress = new Uri("https://api.example.com/") };

In ASP.NET Core and apps that use dependency injection, use IHttpClientFactory (see lesson 17). The factory manages connections automatically:

builder.Services.AddHttpClient<ProductApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.Timeout = TimeSpan.FromSeconds(10);
});

public class ProductApiClient(HttpClient http)   // the factory gives you a configured client
{
    public Task<Product?> GetAsync(int id) => http.GetFromJsonAsync<Product>($"products/{id}");
}

Building a Request by Hand

The helper methods like GetAsync, PostAsync, etc. work for most situations. But if you need more control, you can build an HttpRequestMessage yourself and send it with SendAsync:

Build a request by hand

using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new EchoServer()) { BaseAddress = new Uri("https://api.example.com/") };

var request = new HttpRequestMessage(HttpMethod.Get, "orders?status=shipped&page=2");
request.Headers.Add("X-Request-Id", "req-7781");

HttpResponseMessage response = await client.SendAsync(request);
Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}");

class EchoServer : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        Console.WriteLine($"Method: {request.Method}");
        Console.WriteLine($"Path:   {request.RequestUri!.AbsolutePath}");
        Console.WriteLine($"Query:  {request.RequestUri.Query}");
        Console.WriteLine($"Header: X-Request-Id = {string.Join(",", request.Headers.GetValues("X-Request-Id"))}");
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
    }
}

When you set BaseAddress and give a relative URL, they combine into the full address. Keep the / at the end of BaseAddress, and don't put a / at the start of the relative path.

GET Requests

GET means "give me data". GET requests never change anything on the server—they only read data.

There are three common ways to send a GET request:

MethodReturnsWhen to use
GetFromJsonAsync<T>(url)T?When you want an object back, and error status codes should throw an exception
GetStringAsync(url)stringWhen you want the raw text response
GetAsync(url)HttpResponseMessageWhen you want to check the status code and headers yourself

Get a list as JSON

using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FakeShopApi()) { BaseAddress = new Uri("https://api.example.com/") };

List<Product>? products = await client.GetFromJsonAsync<List<Product>>("products?category=kitchen");
foreach (var p in products!)
    Console.WriteLine($"{p.Id}  {p.Name,-16} Rs. {p.Price}");

string raw = await client.GetStringAsync("products?category=kitchen");
Console.WriteLine($"Raw JSON starts with: {raw[..40]}...");

record Product(int Id, string Name, decimal Price);

class FakeShopApi : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        var items = new[]
        {
            new { id = 1, name = "Electric Kettle", price = 1299m },
            new { id = 2, name = "Mixer Grinder", price = 3499m },
            new { id = 3, name = "Toaster", price = 1599m },
        };
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(items) });
    }
}

POST Requests

POST means "here is new data, please create something with it". POST requests send data to the server, usually to create a new item. The data goes in the request body.

PostAsJsonAsync(url, value) takes your object, turns it into JSON, sets the Content-Type: application/json header (so the server knows it's JSON), and sends it.

When a POST succeeds, the server usually responds with 201 Created, and often sends back the new item including its ID.

Create an order with POST

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FakeOrderApi()) { BaseAddress = new Uri("https://api.example.com/") };

var newOrder = new CreateOrder("Asha", "Electric Kettle", 2);
HttpResponseMessage response = await client.PostAsJsonAsync("orders", newOrder);

Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($"Location: {response.Headers.Location}");

Order? created = await response.Content.ReadFromJsonAsync<Order>();
Console.WriteLine($"Created {created?.Id} for {created?.Customer}: {created?.Quantity} x {created?.Item}");

record CreateOrder(string Customer, string Item, int Quantity);
record Order(string Id, string Customer, string Item, int Quantity);

class FakeOrderApi : HttpMessageHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        Console.WriteLine($"Server received: {await request.Content!.ReadAsStringAsync(token)}");
        var input = await request.Content.ReadFromJsonAsync<CreateOrder>(token);
        var order = new Order("ORD-9001", input!.Customer, input.Item, input.Quantity);

        var response = new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(order) };
        response.Headers.Location = new Uri("https://api.example.com/orders/ORD-9001");
        return response;
    }
}

Look at what the server received: {"customer":"Asha","item":"Electric Kettle","quantity":2}. The ...AsJsonAsync helpers use web conventions, which turn your property names into camelCase (first letter small, then capital letters for each new word). Most web APIs expect this format.

PUT Requests

PUT means "replace this item with what I'm sending". You send the complete updated item to its URL.

(There is also PATCH, which means "change just some fields". It works the same way as PUT but you only send the fields you're changing. Use PatchAsJsonAsync the same way you use PutAsJsonAsync.)

Update a product with PUT

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FakeProductApi()) { BaseAddress = new Uri("https://api.example.com/") };

var updated = new Product(2, "Mixer Grinder 750W", 3299m);
HttpResponseMessage response = await client.PutAsJsonAsync("products/2", updated);

Console.WriteLine($"PUT status: {(int)response.StatusCode} {response.StatusCode}");

record Product(int Id, string Name, decimal Price);

class FakeProductApi : HttpMessageHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        var product = await request.Content!.ReadFromJsonAsync<Product>(token);
        Console.WriteLine($"Server: {request.Method} {request.RequestUri!.AbsolutePath} -> {product!.Name} at Rs. {product.Price}");
        return new HttpResponseMessage(HttpStatusCode.NoContent);   // done, nothing to return
    }
}

Notice the response is 204 No Content. This means "the update worked, and there's no data body to send back". This is a common response to PUT and DELETE requests.

DELETE Requests

DELETE removes an item. It usually has no body—the URL tells the server which item to delete.

Delete an item

using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FakeCartApi()) { BaseAddress = new Uri("https://api.example.com/") };

foreach (string id in new[] { "item-7", "item-99" })
{
    HttpResponseMessage response = await client.DeleteAsync($"cart/{id}");
    Console.WriteLine($"DELETE {id}: {(int)response.StatusCode} {response.StatusCode}");
}

class FakeCartApi : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        bool exists = request.RequestUri!.AbsolutePath.EndsWith("item-7");
        return Task.FromResult(new HttpResponseMessage(exists ? HttpStatusCode.NoContent : HttpStatusCode.NotFound));
    }
}

Here's a summary of the four main HTTP methods:

MethodWhat It DoesSends a Body?Typical Success Code
GETRead dataNo200 OK
POSTCreate a new itemYes201 Created
PUTReplace an entire itemYes200 OK or 204 No Content
PATCHChange just some fieldsYes200 OK or 204 No Content
DELETERemove an itemUsually no204 No Content

Request Headers

Headers are extra information you send with a request: who you are, what format you want back, which language you prefer, etc.

You can put headers in two places:

  • client.DefaultRequestHeaders: these go with every request from that client. Good for an API key or an Accept header that you always use.
  • request.Headers on one HttpRequestMessage: these go with just that one request.

Default and per-request headers

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new HeaderPrinter()) { BaseAddress = new Uri("https://api.example.com/") };

client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "demo-token-123");
client.DefaultRequestHeaders.UserAgent.ParseAdd("MyShopApp/1.0");

var request = new HttpRequestMessage(HttpMethod.Get, "profile");
request.Headers.AcceptLanguage.ParseAdd("ta-IN");

await client.SendAsync(request);

class HeaderPrinter : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        foreach (var header in request.Headers.OrderBy(h => h.Key))
            Console.WriteLine($"{header.Key}: {string.Join(", ", header.Value)}");
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
    }
}

Never write an API key or password in your code. Read it from a settings file or a secret store (see lesson 18), and set it once on the client. Also, never include the Authorization header in log files (see lesson 23).

Response Handling

A response has three main parts that matter:

  • StatusCode: a number that tells you what happened.
  • Headers and Content.Headers: extra information, like Content-Type (what format the body is in).
  • Content: the body—the actual data, which you read as text, a stream, or JSON.

Status codes come in groups:

RangeWhat It MeansExamples
2xxSuccess200 OK, 201 Created, 204 No Content
3xxRedirect (go somewhere else)301 Moved Permanently, 304 Not Modified
4xxYour request has a problem400 Bad Request, 401 Unauthorized, 404 Not Found, 429 Too Many Requests
5xxThe server has a problem500 Internal Server Error, 503 Service Unavailable

response.IsSuccessStatusCode is true if the status is any 2xx code.

Check the status before reading the body

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FakeShopApi()) { BaseAddress = new Uri("https://api.example.com/") };

foreach (int id in new[] { 1, 42 })
{
    HttpResponseMessage response = await client.GetAsync($"products/{id}");

    if (response.IsSuccessStatusCode)
    {
        var product = await response.Content.ReadFromJsonAsync<Product>();
        Console.WriteLine($"Product {id}: {product!.Name} ({response.Content.Headers.ContentType?.MediaType})");
    }
    else if (response.StatusCode == HttpStatusCode.NotFound)
    {
        Console.WriteLine($"Product {id}: not found");
    }
    else
    {
        Console.WriteLine($"Product {id}: failed with {(int)response.StatusCode}");
    }
}

record Product(int Id, string Name, decimal Price);

class FakeShopApi : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
    {
        if (request.RequestUri!.AbsolutePath == "/products/1")
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
            { Content = JsonContent.Create(new Product(1, "Electric Kettle", 1299m)) });

        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
    }
}

When you get a large response, use using var response = await client.GetAsync(...) to clean up the HttpResponseMessage. For very big downloads, read the body as a stream with ReadAsStreamAsync, so the entire file doesn't have to fit in memory at once.

JSON Serialization

Serialization is turning a C# object into JSON text so you can send it or store it. JsonSerializer.Serialize does this.

Here's how C# types map to JSON:

C#JSON
Object or record{ "name": value, ... }
List<T>, array[ ... ]
string"text"
int, decimal, double42, 1299.5
booltrue / false
nullnull
DateTime"2025-08-15T10:30:00" (ISO 8601 format)

Turn objects into JSON

using System;
using System.Collections.Generic;
using System.Text.Json;

var order = new Order(
    Id: "ORD-2201",
    Customer: "Asha",
    PlacedOn: new DateTime(2025, 8, 15, 10, 30, 0),
    Items: new List<Line> { new("Kettle", 1, 1299m), new("Mug", 4, 199m) },
    GiftWrap: false,
    Note: null);

string json = JsonSerializer.Serialize(order);
Console.WriteLine(json);

record Line(string Product, int Qty, decimal Price);
record Order(string Id, string Customer, DateTime PlacedOn, List<Line> Items, bool GiftWrap, string? Note);

By default, the property names stay exactly the same as in C# (using PascalCase—first letter capital). The output is also on one long line. Options (explained below) can change both of these.

JSON Deserialization

Deserialization is the opposite: turning JSON text into a C# object. JsonSerializer.Deserialize<T> does this.

The serializer reads the JSON, matches the JSON names to your property names, converts the values to the right types, and builds the object. Both records with constructors and classes with settable properties work.

Turn JSON into objects

using System;
using System.Text.Json;

string json = """
{
  "Id": "ORD-3310",
  "Customer": "Ravi",
  "Total": 2450.75,
  "Paid": true,
  "Tags": ["express", "gift"],
  "ShipTo": { "City": "Chennai", "Pin": "600001" },
  "Unknown": "ignored"
}
""";

Order? order = JsonSerializer.Deserialize<Order>(json);

Console.WriteLine($"{order!.Id} for {order.Customer}, Rs. {order.Total}, paid: {order.Paid}");
Console.WriteLine($"Tags: {string.Join(", ", order.Tags)}");
Console.WriteLine($"Ship to: {order.ShipTo.City} {order.ShipTo.Pin}");

record Address(string City, string Pin);
record Order(string Id, string Customer, decimal Total, bool Paid, string[] Tags, Address ShipTo);

Notice that "Unknown" didn't cause an error—it was just ignored, because Order doesn't have an Unknown property. Properties that are missing from the JSON get their default value (0 for numbers, null for nullable types, etc.). You can change both behaviors with options.

When the JSON Doesn't Match

Bad JSON, or a value that can't be turned into the right type, throws a JsonException and tells you the line and position where the problem is:

Bad JSON throws JsonException

using System;
using System.Text.Json;

string[] samples =
{
    """{ "Name": "Pen", "Price": 10 }""",
    """{ "Name": "Pen", "Price": "ten" }""",
    """{ "Name": "Pen", "Price": 10 """,
};

foreach (string json in samples)
{
    try
    {
        var item = JsonSerializer.Deserialize<Item>(json);
        Console.WriteLine($"OK: {item!.Name} at {item.Price}");
    }
    catch (JsonException ex)
    {
        Console.WriteLine($"JsonException: {ex.Message.Split('.')[0]}.");
    }
}

record Item(string Name, decimal Price);

System.Text.Json

System.Text.Json is the built-in JSON library in .NET. It's fast, uses very little memory, and works great with HttpClient. The older Newtonsoft.Json (Json.NET) library is still used in many existing projects, but new code should use System.Text.Json.

Besides JsonSerializer, it has two more tools for when you don't have a class for the JSON shape:

  • JsonDocument: read-only and very fast. Use this when you just need to pull a few values out of a large JSON document.
  • JsonNode: an editable tree. Use this when you need to read and change the JSON.

Read and change JSON without a class

using System;
using System.Text.Json;
using System.Text.Json.Nodes;

string json = """{ "user": { "name": "Meera", "points": 120 }, "level": "silver" }""";

// JsonDocument: fast, read-only
using (JsonDocument doc = JsonDocument.Parse(json))
{
    JsonElement user = doc.RootElement.GetProperty("user");
    Console.WriteLine($"Name: {user.GetProperty("name").GetString()}, points: {user.GetProperty("points").GetInt32()}");
}

// JsonNode: you can change it
JsonNode root = JsonNode.Parse(json)!;
root["user"]!["points"] = 250;
root["level"] = "gold";
root["upgradedOn"] = "2025-09-01";

Console.WriteLine(root.ToJsonString());

JSON Options

JsonSerializerOptions changes how JSON is written and read. Here are the most useful settings:

OptionWhat It Does
PropertyNamingPolicy = JsonNamingPolicy.CamelCaseTurns CustomerName into customerName in JSON
WriteIndented = trueAdds line breaks and spaces to make JSON pretty
PropertyNameCaseInsensitive = trueReads "NAME", "name" or "Name" all into the same property
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNullDoesn't include properties that are null
Converters = { new JsonStringEnumConverter() }Writes enums as "Shipped" instead of the number 2
AllowTrailingCommas, ReadCommentHandlingAccept slightly messy JSON that has trailing commas or comments

JsonSerializerOptions.Web is a ready-made set for web APIs: camelCase names and case-insensitive reading. HttpClient and ASP.NET Core use this by default.

Options change the JSON

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

var shipment = new Shipment("SHP-77", "Kiran Kumar", Status.InTransit, Note: null);

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter() },
};

Console.WriteLine("Default:");
Console.WriteLine(JsonSerializer.Serialize(shipment));
Console.WriteLine("With options:");
Console.WriteLine(JsonSerializer.Serialize(shipment, options));

var back = JsonSerializer.Deserialize<Shipment>("""{"ID":"SHP-78","CUSTOMERNAME":"Asha","status":"Delivered"}""",
    new JsonSerializerOptions(options) { PropertyNameCaseInsensitive = true });
Console.WriteLine($"Read back: {back!.Id}, {back.CustomerName}, {back.Status}");

enum Status { Packed, InTransit, Delivered }
record Shipment(string Id, string CustomerName, Status Status, string? Note);

Create a JsonSerializerOptions object once and reuse it, such as in a static readonly field. Every new options object rebuilds an internal cache, which is slow. If you make a new one on every call, your code will be slow.

JSON Attributes

Options change behavior for all types. Attributes change behavior for one specific property or type. Attributes are in System.Text.Json.Serialization.

AttributeWhat It Does
[JsonPropertyName("first_name")]Uses a different name in the JSON
[JsonIgnore]Never write or read this property
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]Skip this property only when it is null
[JsonPropertyOrder(1)]Control which order properties appear in
[JsonRequired]Throw an error if this property is missing from the JSON
[JsonConverter(typeof(JsonStringEnumConverter))]Use a custom way to read/write this type
[JsonInclude]Include a private property or field in the JSON

Attributes shape the JSON for one type

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

var user = new UserProfile
{
    Id = 501,
    FirstName = "Priya",
    Email = "priya@example.com",
    Password = "s3cret!",
    Plan = Plan.Premium,
};

string json = JsonSerializer.Serialize(user, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);

try
{
    JsonSerializer.Deserialize<UserProfile>("""{ "first_name": "Ravi" }""");
}
catch (JsonException ex)
{
    Console.WriteLine($"Error: {ex.Message.Split('.')[0]}.");
}

enum Plan { Free, Premium }

class UserProfile
{
    [JsonPropertyOrder(-1)]
    [JsonRequired]
    [JsonPropertyName("user_id")]
    public int Id { get; set; }

    [JsonPropertyName("first_name")]
    public string FirstName { get; set; } = "";

    public string Email { get; set; } = "";

    [JsonIgnore]
    public string Password { get; set; } = "";         // never sent as JSON

    [JsonConverter(typeof(JsonStringEnumConverter))]
    public Plan Plan { get; set; }
}

Notice that the password never appears in the JSON. The Id appears first and is named user_id. If the JSON doesn't have user_id, reading fails because it's [JsonRequired].

Working with APIs

In a real app, don't scatter HttpClient calls all over your code. Instead, wrap one API in one typed client class. Its methods speak in your C# types, and only it knows about URLs and JSON details.

A typed API client

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

var http = new HttpClient(new FakeTodoApi()) { BaseAddress = new Uri("https://api.example.com/") };
var todos = new TodoClient(http);

await todos.AddAsync("Pay electricity bill");
await todos.AddAsync("Book train tickets");
await todos.CompleteAsync(1);

foreach (var t in await todos.ListAsync())
    Console.WriteLine($"[{(t.Done ? "x" : " ")}] {t.Id}. {t.Title}");

record Todo(int Id, string Title, bool Done);
record NewTodo(string Title);

class TodoClient
{
    private readonly HttpClient _http;
    public TodoClient(HttpClient http) => _http = http;

    public async Task<List<Todo>> ListAsync() =>
        await _http.GetFromJsonAsync<List<Todo>>("todos") ?? new();

    public async Task<Todo> AddAsync(string title)
    {
        var response = await _http.PostAsJsonAsync("todos", new NewTodo(title));
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<Todo>())!;
    }

    public async Task CompleteAsync(int id)
    {
        var response = await _http.PutAsJsonAsync($"todos/{id}/done", new { });
        response.EnsureSuccessStatusCode();
    }
}

// Pretend API that remembers the to-do list in memory.
class FakeTodoApi : HttpMessageHandler
{
    private readonly List<Todo> _items = new();

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken token)
    {
        string path = req.RequestUri!.AbsolutePath;
        if (req.Method == HttpMethod.Get && path == "/todos")
            return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(_items) };

        if (req.Method == HttpMethod.Post && path == "/todos")
        {
            var input = await req.Content!.ReadFromJsonAsync<NewTodo>(token);
            var todo = new Todo(_items.Count + 1, input!.Title, false);
            _items.Add(todo);
            return new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(todo) };
        }

        if (req.Method == HttpMethod.Put && path.EndsWith("/done"))
        {
            int id = int.Parse(path.Split('/')[2]);
            int index = _items.FindIndex(t => t.Id == id);
            _items[index] = _items[index] with { Done = true };
            return new HttpResponseMessage(HttpStatusCode.NoContent);
        }

        return new HttpResponseMessage(HttpStatusCode.NotFound);
    }
}

The rest of your code uses TodoClient like any other class. It never sees URLs, status codes, or JSON. If you want to switch to a different API, you only change this one class.

HTTP Error Handling

Network calls fail. Servers crash, internet connections drop, requests take too long. Good code expects this to happen and handles it.

The Errors You Will See

What HappensWhat You Get
Server returns 4xx or 5xxNo exception; GetAsync returns a response with status in StatusCode
Call EnsureSuccessStatusCode() on an errorThrows HttpRequestException with StatusCode set
GetFromJsonAsync on an error statusThrows HttpRequestException
DNS fails, connection refused, etc.Throws HttpRequestException (no status code)
Request takes longer than Timeout (default 100 seconds)Throws TaskCanceledException
Body is not valid JSONThrows JsonException

Handle each kind of failure

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new UnreliableApi())
{
    BaseAddress = new Uri("https://api.example.com/"),
    Timeout = TimeSpan.FromMilliseconds(200),
};

foreach (string path in new[] { "ok", "missing", "broken", "slow", "garbage" })
{
    try
    {
        var data = await client.GetFromJsonAsync<Reply>(path);
        Console.WriteLine($"{path,-8} -> success: {data!.Message}");
    }
    catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
    {
        Console.WriteLine($"{path,-8} -> not found (404)");
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"{path,-8} -> HTTP error {(int?)ex.StatusCode}");
    }
    catch (TaskCanceledException)
    {
        Console.WriteLine($"{path,-8} -> timed out");
    }
    catch (JsonException)
    {
        Console.WriteLine($"{path,-8} -> response was not valid JSON");
    }
}

record Reply(string Message);

class UnreliableApi : HttpMessageHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken token)
    {
        switch (req.RequestUri!.AbsolutePath)
        {
            case "/ok":
                return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new Reply("all good")) };
            case "/missing":
                return new HttpResponseMessage(HttpStatusCode.NotFound);
            case "/broken":
                return new HttpResponseMessage(HttpStatusCode.InternalServerError);
            case "/slow":
                await Task.Delay(1000, token);
                return new HttpResponseMessage(HttpStatusCode.OK);
            default:
                return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("<html>oops</html>") };
        }
    }
}

Retries

Some failures are temporary: a 503, a 429, a timeout. Waiting a bit and trying again often works. But 4xx errors like 400 or 404 will not go away no matter how many times you retry.

In real apps, use the Microsoft.Extensions.Http.Resilience package (built on Polly). It adds retries, timeouts, and circuit breakers with just one line:

builder.Services.AddHttpClient<ProductApiClient>(c => c.BaseAddress = new Uri("https://api.example.com/"))
    .AddStandardResilienceHandler();   // handles retries, timeouts, circuit breaker

Here is a simple hand-written retry to show the idea:

Retry temporary failures

using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

var client = new HttpClient(new FlakyApi()) { BaseAddress = new Uri("https://api.example.com/") };

string result = await GetWithRetryAsync(client, "rates/inr", attempts: 3);
Console.WriteLine($"Result: {result}");

static async Task<string> GetWithRetryAsync(HttpClient client, string url, int attempts)
{
    for (int attempt = 1; ; attempt++)
    {
        HttpResponseMessage response = await client.GetAsync(url);
        bool temporary = response.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.TooManyRequests;

        if (response.IsSuccessStatusCode || !temporary || attempt == attempts)
        {
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }

        int wait = 100 * attempt;    // wait longer each time
        Console.WriteLine($"Attempt {attempt}: {(int)response.StatusCode}, retrying in {wait} ms");
        await Task.Delay(wait);
    }
}

class FlakyApi : HttpMessageHandler
{
    private int _calls;
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken token)
    {
        _calls++;
        return Task.FromResult(_calls < 3
            ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
            : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("1 USD = 83.2 INR") });
    }
}

Error Handling Checklist

  1. Set a sensible timeout, or pass a CancellationToken (see lesson 3).
  2. Check the status with IsSuccessStatusCode, or call EnsureSuccessStatusCode().
  3. Catch HttpRequestException, TaskCanceledException, and JsonException where you can handle them.
  4. Only retry temporary failures like 503 or 429. Use growing wait times, and set a limit on retries.
  5. Log failures with the URL and status code (see lesson 23), but never log passwords or secrets.

Where You Use HttpClient and JSON

  • Calling third-party APIs: payment gateways, SMS and email services, maps, weather data.
  • Microservices talking to each other inside one larger system.
  • Blazor WebAssembly apps in the browser calling their backend server.
  • Mobile apps made with .NET MAUI talking to a backend server.
  • Background jobs that bring in data from other systems or sync data.

Common Mistakes

MistakeWhat Goes WrongHow to Fix It
Making a new HttpClient() for every requestRuns out of network connections under heavy loadUse one shared client, or IHttpClientFactory
Never checking the status codeTreats error pages as real dataCheck IsSuccessStatusCode or call EnsureSuccessStatusCode()
BaseAddress without a trailing /Part of the URL path gets lostEnd BaseAddress with /; don't start relative paths with /
Property names don't match the JSONProperties stay at default valuesUse PropertyNameCaseInsensitive, camelCase policy, or [JsonPropertyName]
Sending enums as numbersAPI rejects "status": 2Use JsonStringEnumConverter
Catching only HttpRequestExceptionTimeouts crash the appAlso catch TaskCanceledException
Using .Result or .Wait() on HTTP callsProgram freezes or blocks threadsUse async and await throughout
Making new JsonSerializerOptions every timeSlow serializationReuse one static options object

What to Remember

  • HttpClient sends HTTP requests. Reuse one client, or use IHttpClientFactory.
  • GET reads data. POST creates. PUT replaces. PATCH changes fields. DELETE removes.
  • Headers go on DefaultRequestHeaders for every request, or on one HttpRequestMessage.
  • Check status codes: 2xx means success, 4xx means your fault, 5xx means the server's fault.
  • GetFromJsonAsync, PostAsJsonAsync, and ReadFromJsonAsync handle JSON automatically.
  • JsonSerializer.Serialize and Deserialize<T> turn objects into JSON and JSON into objects.
  • JsonSerializerOptions changes naming, spacing, nulls, and enums. Make one and reuse it.
  • Attributes like [JsonPropertyName], [JsonIgnore], and [JsonRequired] control one property.
  • Wrap each API in its own typed client class.
  • Expect failures: check statuses, set timeouts, catch the right exceptions, and retry only temporary failures.