You now know how to send bytes over HTTP and how JSON represents data — this lesson is where those two things meet.
In the previous lesson, you sent a GET request and got back raw text, then manually built a JSON string for a POST body. That works, but it's exactly the kind of repetitive, error-prone plumbing a good library should handle for you. You already know System.Text.Json can turn a C# object into JSON text and back (lesson 47) — HttpClient has extension methods that fuse that directly into the request/response pipeline, so you go straight from "call this API" to "here's my strongly-typed object," with the JSON step invisible in between.
In this lesson, you'll learn how to call a JSON REST API using GetFromJsonAsync and PostAsJsonAsync, how to deserialize responses directly into your own DTO classes, and how to handle the API returning something other than success.
A DTO (Data Transfer Object) is a plain C# class whose only job is to mirror the shape of the JSON an API sends or expects — no behavior, just properties. The System.Net.Http.Json namespace adds extension methods directly onto HttpClient — GetFromJsonAsync<T> and PostAsJsonAsync among them — that combine "make the HTTP call" and "serialize/deserialize the JSON" into a single line, using a DTO as the target type.
GetFromJsonAsync<T>(string uri) sends a GET request, reads the response body, and deserializes it into an instance of T using System.Text.Json, all in one call. PostAsJsonAsync<T>(string uri, T value) does the reverse: serializes value to JSON, sets the content type to application/json, and sends it as a POST request body. Both return awaitable tasks and both throw on genuinely malformed responses, but — importantly — neither one throws just because the server returned a non-success status code like 404 or 500; that's something you still check yourself.
GetAsync → read raw string → JsonSerializer.Deserialize<T>StringContent by hand for POST bodiesGetFromJsonAsync<T>(url) → typed object, directlyPostAsJsonAsync(url, dto) → serializes for youEvery single call to a JSON API follows the same pattern: send the request, read the response body as a string, hand that string to JsonSerializer.Deserialize<T>, and (for a POST) do the reverse in the other direction — serialize an object, wrap it in StringContent with the right content type, then send it. Writing this out by hand every time is not just tedious; it's also an easy place to introduce small bugs — forgetting the content type header, forgetting to check the status before deserializing, forgetting to dispose a stream.
Because calling a JSON API and converting the payload are almost always done together, .NET provides extension methods that do both in one step, directly on HttpClient. This is exactly the same relationship as IHttpClientFactory to raw socket management: the underlying mechanism (an HTTP call, a JSON conversion) hasn't gone away — it's just no longer something you have to wire together by hand every single time.
var quote = await client.GetFromJsonAsync<PriceQuote>("quotes/latest");quotes/latestPriceQuote using System.Text.JsonPriceQuote instancequote.Symbol // "AAPL"
quote.Price // 231.50
quote.AsOfUtc // 2026-08-30T14:03:00ZCompare that to the manual approach from the previous lesson: GetAsync + ReadAsStringAsync + JsonSerializer.Deserialize<T>, written out as three separate steps every single time you need data from an API. GetFromJsonAsync<T> is those same three steps, fused into one call.
public class PriceQuote
{
public string Symbol { get; set; } = string.Empty;
public decimal Price { get; set; }
public DateTime AsOfUtc { get; set; }
}
System.Text.Json matches JSON property names to C# property names case-insensitively, so "price" in the JSON maps cleanly to Price in your class without any extra configuration.PriceQuote? quote = await client.GetFromJsonAsync<PriceQuote>("quotes/latest");
null deserializes to a C# null, which is why you should always account for a null result even when a status code check passed.var response = await client.PostAsJsonAsync("orders", newOrder);
response.EnsureSuccessStatusCode();
PostAsJsonAsync returns an HttpResponseMessage, not a deserialized object — it doesn't assume the response is something you want to parse, since APIs commonly return an empty body, a location header, or a different DTO (like the created resource with a new ID) after a write.public record WeatherForecastDto(string City, double TemperatureCelsius, string Summary);
public class WeatherClient(HttpClient httpClient)
{
public async Task<WeatherForecastDto?> GetForecastAsync(string city)
{
var response = await httpClient.GetAsync($"forecast?city={Uri.EscapeDataString(city)}");
if (!response.IsSuccessStatusCode)
{
return null; // caller decides how to handle "no forecast available"
}
return await response.Content.ReadFromJsonAsync<WeatherForecastDto>();
}
}
// Registration
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<WeatherClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.example.com/");
});
using var host = builder.Build();
var weatherClient = host.Services.GetRequiredService<WeatherClient>();
var forecast = await weatherClient.GetForecastAsync("Seattle");
if (forecast is not null)
{
Console.WriteLine($"{forecast.City}: {forecast.TemperatureCelsius}°C, {forecast.Summary}");
}
Notice this example checks IsSuccessStatusCode before calling ReadFromJsonAsync<T> (the content-level equivalent of GetFromJsonAsync, useful once you already have an HttpResponseMessage in hand) — attempting to deserialize an error response's body as if it were a WeatherForecastDto would either throw a confusing deserialization exception or, worse, silently produce a mostly-empty, misleading object.
A background notification worker calls a third-party SMS gateway: it POSTs a message request as JSON and reads back a typed result DTO — with explicit handling for the gateway rejecting the request (a 4xx, meaning the request itself was bad) versus the gateway being unavailable (a 5xx or network failure, meaning it might be worth retrying):
public record SendSmsRequest(string PhoneNumber, string Message);
public record SendSmsResult(bool Delivered, string MessageId);
public class SmsGatewayClient(HttpClient httpClient, ILogger<SmsGatewayClient> logger)
{
public async Task<SendSmsResult?> SendAsync(SendSmsRequest request)
{
HttpResponseMessage response;
try
{
response = await httpClient.PostAsJsonAsync("messages", request);
}
catch (HttpRequestException ex)
{
// Network-level failure — DNS, connection refused, TLS handshake, etc.
logger.LogError(ex, "SMS gateway unreachable while sending to {PhoneNumber}", request.PhoneNumber);
return null;
}
if (response.StatusCode is System.Net.HttpStatusCode.BadRequest)
{
// 400 — our request was malformed; retrying won't help, this needs a code fix
logger.LogWarning("SMS gateway rejected the request for {PhoneNumber}: {Status}", request.PhoneNumber, response.StatusCode);
return null;
}
if (!response.IsSuccessStatusCode)
{
// 5xx or similar — the gateway is having problems; this might succeed on retry
logger.LogWarning("SMS gateway returned {Status} for {PhoneNumber}", response.StatusCode, request.PhoneNumber);
return null;
}
return await response.Content.ReadFromJsonAsync<SendSmsResult>();
}
}
Treating "the gateway said no" (a 400) and "the gateway is down" (a 500, or the request never even completed) as the exact same kind of failure would be a mistake — one is worth retrying, and one isn't. Distinguishing them requires actually inspecting the status code rather than only catching exceptions.
GetFromJsonAsync and PostAsJsonAsync throw HttpRequestException for network-level failures (the request never reached the server, or the connection was refused) — but they do not throw just because the server responded with a non-success status code. Those two failure categories need to be handled separately, as shown above.
Calling a JSON API manually is like traveling abroad and doing your own translation: you write your request in English, translate it to the local language yourself, hand it over, get a reply in the local language, and translate that back to English before you can understand it. It works, but it's extra effort every single time, and translation mistakes are easy to make.
GetFromJsonAsync/PostAsJsonAsync are like having a fluent interpreter standing right there: you say what you want in your own language (a C# object), the interpreter handles both directions of translation, and you get a response back already in your own language. The translation is still happening — it's just no longer your job to do it by hand.
T — if the error response body isn't valid JSON matching T's shape, deserialization itself can throw a JsonException, which is a different, less clear signal than "the server said 404."HttpRequestException (or, for a timeout, TaskCanceledException) — because in those cases, there genuinely is no HTTP response to reason about at all.This is the single most important thing to internalize about GetFromJsonAsync and PostAsJsonAsync: a 404 Not Found or a 500 Internal Server Error, by itself, does not throw. If you skip an explicit status check and just try to use the returned object, you may end up working with null, a partially-populated DTO, or (worse) an exception thrown later from an unrelated line of code that assumed the data was valid — far from where the real problem occurred.
It's tempting to reuse the same class for "the shape the API sends" and "the class my business logic actually works with," but APIs change their JSON shape over time, sometimes in ways that don't map cleanly onto how your application wants to think about the data. Keeping a dedicated, API-shaped DTO — and mapping it into your own domain type when needed — insulates your core logic from a third-party API's JSON quirks.
Wrapping only a try/catch around GetFromJsonAsync<T> and assuming that's sufficient to catch "the resource wasn't found" — it isn't; a 404 with a JSON-shaped error body may deserialize without throwing anything at all, or may throw a confusing JsonException instead of a clear "not found" signal.
When you need to distinguish success from a specific status code, call GetAsync first, check response.StatusCode explicitly, and only then call ReadFromJsonAsync<T> on the content if the status indicates success.
var quote = await client.GetFromJsonAsync<PriceQuote>(url); Console.WriteLine(quote.Price); — this compiles with a nullable-reference-types warning, and throws a NullReferenceException at runtime if the body was literally null or empty.
Treat the result as genuinely nullable: check if (quote is not null) before using it, or use the null-conditional operator where appropriate.
Catching everything into one generic "the API call failed" branch and retrying blindly — retrying a malformed request (400) just repeats the same failure; only a transient failure (like a 503 or a timeout) is actually worth retrying.
Branch on the status code (or category — 4xx vs 5xx) to decide whether retrying makes sense, exactly as shown in the SMS gateway example above.
GetFromJsonAsync/PostAsJsonAsync for the common case: you trust the endpoint to return either success with a well-formed body, or a status you don't need to inspect in detail.GetAsync/PostAsync plus a manual ReadFromJsonAsync<T> call whenever you need to branch on the specific status code before deciding whether (or how) to deserialize the body — exactly the pattern used in both real-world examples in this lesson.HttpRequestException.GetFromJsonAsync<T> and PostAsJsonAsync fuse the HTTP call and the JSON conversion into one line, using System.Text.Json under the hood.GetAsync/PostAsync plus ReadFromJsonAsync<T>.HttpRequestException.You've seen how GetFromJsonAsync and PostAsJsonAsync simplify calling a JSON API. Let's confirm you understand exactly what they do — and don't — guard against.
1. An API endpoint returns a 404 Not Found with a JSON error body. What does await client.GetFromJsonAsync<OrderDto>(url) do?
Correct: B
Why B is correct: GetFromJsonAsync does not check the status code before attempting deserialization — a 404 still has a body, and the method tries to parse it as OrderDto regardless, which can produce misleading results rather than a clear "not found" signal.
Why A is incorrect: Only transport-level failures (no response received at all) throw HttpRequestException — a 404 is a complete, valid HTTP response, not a transport failure.
Why C is incorrect: There's no automatic fallback to a default object — the method genuinely attempts to parse whatever body came back.
Why D is incorrect: Neither GetFromJsonAsync nor plain HttpClient retries automatically without extra configuration.
Reinforcement: When you need to react specifically to a status code, use GetAsync and check response.StatusCode explicitly before deserializing.
2. Under what circumstance does calling GetFromJsonAsync<T> throw an HttpRequestException?
Correct: B
Why B is correct: HttpRequestException represents a failure to get an HTTP response at all — the network layer itself broke down, so there's genuinely no status code or body to reason about.
Why A is incorrect: Non-2xx status codes (404, 500, etc.) are still complete responses and do not by themselves trigger this exception.
Why C is incorrect: A null property in otherwise valid JSON deserializes fine (assuming the target type allows it) — it isn't a transport failure.
Why D is incorrect: Transport-level failures are real and do throw — this is a distinct category from "the server responded with an error status."
Reinforcement: Two separate failure categories exist here: transport failures (throw HttpRequestException) versus application-level failure status codes (don't throw — you check them).
3. A notification client receives a 400 Bad Request from an SMS gateway on one call, and a 503 Service Unavailable on another. What's the most sensible way to treat these two differently?
Correct: B
Why B is correct: A 4xx status generally signals a problem with the request itself (as sent) — retrying the exact same malformed request just reproduces the same failure. A 5xx status generally signals a server-side problem, which may resolve itself, making a retry (often with backoff) worthwhile.
Why A is incorrect: Blindly retrying a 400 wastes calls and can even look like abuse to the remote API — the two categories warrant genuinely different handling.
Why C is incorrect: This reverses the standard HTTP convention: 4xx is client-side (my request), 5xx is server-side (their infrastructure).
Why D is incorrect: Ignoring failed calls entirely means silently losing data or notifications — exactly what checking the status code is meant to prevent.
Reinforcement: Branching on the status code (or its 4xx/5xx category) is how real integrations decide whether a retry is worth attempting.
4. Why is it generally better to define a dedicated DTO for an external API's response, rather than deserializing directly into your application's internal domain model class?
Correct: B
Why B is correct: An external API's JSON shape is outside your control and can change over time. A dedicated DTO gives you one clear place to absorb that change (or map it into your own domain type), instead of a third-party API's naming or structure quietly leaking into — and potentially breaking — your core business logic.
Why A is incorrect: Deserialization speed depends on the shape and size of the data, not on whether the target class happens to be labeled a "DTO."
Why C is incorrect: System.Text.Json can deserialize into classes with methods just fine — this isn't a technical restriction, it's a design/maintainability recommendation.
Why D is incorrect: There's a real, practical benefit — decoupling your domain model from an external contract you don't control.
Reinforcement: Keep API-shaped DTOs separate from your domain model, and map between them explicitly where the two diverge.
You now know how to call a JSON REST API from .NET, deserialize responses into DTOs, and correctly handle non-success status codes.
dotnetmadeeasy.com — Learn C# and .NET, the right way.