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:
HttpClientis the C# class that sends HTTP requests and gets responses back.System.Text.Jsonis 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:
- How to create and set up
HttpClient. - How to send GET, POST, PUT, and DELETE requests.
- How to add request headers (extra information with requests).
- How to read status codes and response bodies (the data that comes back).
- How to turn C# objects into JSON and turn JSON back into C# objects.
- How to change how JSON works using options and attributes.
- How to build a simple API client class that wraps an API.
- 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:
- Sent a GET request
- Checked if the response was successful
- Turned the JSON data into a
Productobject
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:
| Part | Example | What It Does |
|---|---|---|
| Method | GET, POST, PUT, DELETE | Says what action you want |
| URL | https://api.example.com/products/1 | Says which resource you want |
| Headers | Accept: application/json | Extra 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:
| Method | Returns | When to use |
|---|---|---|
GetFromJsonAsync<T>(url) | T? | When you want an object back, and error status codes should throw an exception |
GetStringAsync(url) | string | When you want the raw text response |
GetAsync(url) | HttpResponseMessage | When 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:
| Method | What It Does | Sends a Body? | Typical Success Code |
|---|---|---|---|
GET | Read data | No | 200 OK |
POST | Create a new item | Yes | 201 Created |
PUT | Replace an entire item | Yes | 200 OK or 204 No Content |
PATCH | Change just some fields | Yes | 200 OK or 204 No Content |
DELETE | Remove an item | Usually no | 204 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 anAcceptheader that you always use.request.Headerson oneHttpRequestMessage: 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.HeadersandContent.Headers: extra information, likeContent-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:
| Range | What It Means | Examples |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirect (go somewhere else) | 301 Moved Permanently, 304 Not Modified |
| 4xx | Your request has a problem | 400 Bad Request, 401 Unauthorized, 404 Not Found, 429 Too Many Requests |
| 5xx | The server has a problem | 500 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, double | 42, 1299.5 |
bool | true / false |
null | null |
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:
| Option | What It Does |
|---|---|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase | Turns CustomerName into customerName in JSON |
WriteIndented = true | Adds line breaks and spaces to make JSON pretty |
PropertyNameCaseInsensitive = true | Reads "NAME", "name" or "Name" all into the same property |
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | Doesn't include properties that are null |
Converters = { new JsonStringEnumConverter() } | Writes enums as "Shipped" instead of the number 2 |
AllowTrailingCommas, ReadCommentHandling | Accept 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.
| Attribute | What 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 Happens | What You Get |
|---|---|
| Server returns 4xx or 5xx | No exception; GetAsync returns a response with status in StatusCode |
Call EnsureSuccessStatusCode() on an error | Throws HttpRequestException with StatusCode set |
GetFromJsonAsync on an error status | Throws 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 JSON | Throws 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
- Set a sensible timeout, or pass a
CancellationToken(see lesson 3). - Check the status with
IsSuccessStatusCode, or callEnsureSuccessStatusCode(). - Catch
HttpRequestException,TaskCanceledException, andJsonExceptionwhere you can handle them. - Only retry temporary failures like 503 or 429. Use growing wait times, and set a limit on retries.
- 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
| Mistake | What Goes Wrong | How to Fix It |
|---|---|---|
Making a new HttpClient() for every request | Runs out of network connections under heavy load | Use one shared client, or IHttpClientFactory |
| Never checking the status code | Treats error pages as real data | Check IsSuccessStatusCode or call EnsureSuccessStatusCode() |
BaseAddress without a trailing / | Part of the URL path gets lost | End BaseAddress with /; don't start relative paths with / |
| Property names don't match the JSON | Properties stay at default values | Use PropertyNameCaseInsensitive, camelCase policy, or [JsonPropertyName] |
| Sending enums as numbers | API rejects "status": 2 | Use JsonStringEnumConverter |
Catching only HttpRequestException | Timeouts crash the app | Also catch TaskCanceledException |
Using .Result or .Wait() on HTTP calls | Program freezes or blocks threads | Use async and await throughout |
Making new JsonSerializerOptions every time | Slow serialization | Reuse one static options object |
What to Remember
HttpClientsends HTTP requests. Reuse one client, or useIHttpClientFactory.- GET reads data. POST creates. PUT replaces. PATCH changes fields. DELETE removes.
- Headers go on
DefaultRequestHeadersfor every request, or on oneHttpRequestMessage. - Check status codes: 2xx means success, 4xx means your fault, 5xx means the server's fault.
GetFromJsonAsync,PostAsJsonAsync, andReadFromJsonAsynchandle JSON automatically.JsonSerializer.SerializeandDeserialize<T>turn objects into JSON and JSON into objects.JsonSerializerOptionschanges 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.