Updated on
Sending JSON with HttpClient comes down to one decision: who turns the object into bytes, and who sets the Content-Type header. PostAsJsonAsync() does both for us in a single line.
The long way is still worth knowing, because PostAsJsonAsync() does the same job on our behalf. We serialize the object ourselves, wrap the string in a StringContent, and say the body is application/json.
We will build both against the public Petstore API, then compare them with the two options in between: JsonContent, and the source-generated overloads that a trimmed or Native AOT application needs.
How Do We Model the JSON We Are Sending?
The DTO decides what goes over the wire. Every property on it becomes one JSON property, so the shape of the class is the shape of the body we send.
PetDto carries an Id, a Name, a Status and a nested PetCategory, which is the object the Petstore API expects on POST /pet.
The attributes decide the names. [JsonPropertyName("name")] pins that property to name in the JSON, whatever the C# property is called and whatever serializer options are in play.
Pinning every name is a deliberate choice in this sample, and it is why both of the paths below send identical JSON. Strip the attributes off and the two paths diverge: JsonSerializer.Serialize() writes "Name", and PostAsJsonAsync() writes "name".
The nullable annotations carry meaning too. string? Name says the server may leave the property out of its response. If it does, deserialization still succeeds and leaves Name as null.
Here are both classes for the https://petstore.swagger.io/v2 API:
using System.Text.Json.Serialization;
public class PetCategory
{
[JsonPropertyName("name")]
public string? Name { get; set; }
}
public class PetDto
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("category")]
public PetCategory? Category { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
}
Initially, we define a category for the pet with the PetCategory class that defines only a Name property and use that in our PetDto class. Additionally, along the Category, we have an Id, Name and Status property.
How Do We POST JSON With HttpClient and StringContent?
StringContent wraps a string we have already serialized. It knows nothing about JSON.
Three steps put a JSON body on the wire. Serialize the object with JsonSerializer.Serialize(). Wrap the resulting string in a StringContent. Tell the server the body is JSON.
The third step is easy to miss, because the code compiles and the request goes out without it. new StringContent(json, Encoding.UTF8) sets the Content-Type header to text/plain; charset=utf-8, because text/plain is that constructor’s default media type, and the Petstore API answers that request with 415 Unsupported Media Type.
The three-argument constructor settles it in one line. new StringContent(json, Encoding.UTF8, "application/json") sends application/json; charset=utf-8, and the separate assignment to request.Content.Headers.ContentType is no longer needed.
Accept is a different header on a different object. It travels on the request rather than on the content, and it says what we are willing to read back. That is why it is set on request.Headers while the content type is set on request.Content.Headers, and why the two cannot be swapped.
Our article on POST, PUT and DELETE with HttpClient covers the same request shape for the other HTTP verbs.
We can convert a C# object to a JSON string using JsonSerializer class from System.Text.Json, which ships with .NET. We can also use JsonConvert class from Newtonsoft.Json NuGet package, but we will focus on using JsonSerializer class in our examples.
Building the Request With StringContent
Let’s use StringContent, starting with an interface:
public interface IPetService
{
Task<PetDto?> PostAsStringContentAsync();
}
Firstly, we define an IPetService interface with the PostAsStringContentAsync() method.
Next, let’s implement this interface:
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public class PetService(HttpClient httpClient) : IPetService
{
public async Task<PetDto?> PostAsStringContentAsync()
{
var petData = CreatePet();
var pet = JsonSerializer.Serialize(petData);
var request = new HttpRequestMessage(HttpMethod.Post, "pet");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Content = new StringContent(pet, Encoding.UTF8, "application/json");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PetDto>();
}
private PetDto CreatePet()
{
return new PetDto
{
Name = "German Shepherd",
Status = "Available",
Category = new PetCategory
{
Name = "Canines"
}
};
}
}
Firstly, we create a PetService class that takes the HttpClient through its primary constructor, and implement our interface IPetService. Then, in our PostAsStringContentAsync() method we implement a POST request to create a new pet. Moreover, we create a new PetDto object and serialize it into a JSON string using the JsonSerializer.Serialize() method.
Then, we create an HttpRequestMessage object for the POST request to the /pet endpoint of our test API.
Furthermore, we pass the JSON string, pet, and the media type application/json to the constructor of StringContent class, then this is assigned to the request.Content property. The media type indicates that we are sending JSON content. Moreover, the Accept header we define, tells the server that our client accepts only application/json content. We send the request asynchronously using the SendAsync() method of the HttpClient instance.
Following that, we receive an asynchronous response. Furthermore, we make sure with the EnsureSuccessStatusCode() method that we continue with a successful response. Finally, the ReadFromJsonAsync() method reads the response body and deserializes it into a PetDto in one call, and we return that object as the result of the service method.
Let’s have a look at how we register a HttpClient instance:
builder.Services.AddHttpClient<IPetService, PetService>(client =>
{
client.BaseAddress = new Uri("https://petstore.swagger.io/v2/");
});
In our Program class we call the AddHttpClient() method to register PetService as a typed client. This method ensures that we efficiently use HttpClient instances for our class, because IHttpClientFactory builds each one on a pooled, reusable message handler. Additionally, we set the BaseAddress property for the instance to our https://petstore.swagger.io/v2 API.
We explain registering a typed client with IHttpClientFactory in more detail separately.
HttpClient in ASP.NET Core check out our tutorial HttpClient with ASP.NET Core Tutorial.Now, let’s register an API endpoint in our Program class:
app.MapPost("/postAsStringContent", async (IPetService petService)
=> await petService.PostAsStringContentAsync());
In the MapPost() method, we add the API route postAsStringContent in which we invoke the PostAsStringContentAsync() method of our IPetService.
Testing the postAsStringContent Endpoint
Moving forward, let’s run our application and make a POST request to our endpoint. The route takes no request body, so curl is enough (5229 is the HTTP port in the sample’s launchSettings.json):
curl -X POST http://localhost:5229/postAsStringContent
The endpoint returns the pet that the Petstore API created:
{
"id": 9223372036854775807,
"category": {
"name": "Canines"
},
"name": "German Shepherd",
"status": "Available"
}
We execute our endpoint /postAsStringContent and get a successful JSON response from the URL where we posted our pet object. The Petstore API assigns the id, and it returned long.MaxValue for every pet we created while testing.
How Does PostAsJsonAsync Send a JSON Object?
PostAsJsonAsync() is an extension method on HttpClient, and it lives in System.Net.Http.Json. It collapses the three steps above into one call.
It serializes the value, builds a JsonContent from it, sets Content-Type to application/json; charset=utf-8, and sends the POST. One line replaces the five that build and send the request in the StringContent version, though it sends no Accept header.
It also changes the serializer options underneath. Microsoft’s reference is explicit that this method uses the Web defaults while JsonSerializer does not, which means camelCase property names on the way out. ReadFromJsonAsync() uses the same defaults, so matching on the way back is case-insensitive.
That difference is invisible in this sample, because every property on PetDto carries a [JsonPropertyName] attribute and an explicit name beats any naming policy.
The Async suffix belongs to the name. There is no PostAsJson(), and the extension will not resolve at all without a using System.Net.Http.Json; directive or an implicit global one.
The reference documentation states it in one line: “This method uses JsonSerializerDefaults.Web options for serialization, whereas JsonSerializer serialization methods do not, by default.”
Those defaults are what give us camelCase property names without asking, and how System.Text.Json handles camelCase on the web covers the naming policy in full.
Rewriting the Request With PostAsJsonAsync
Let’s add this approach to our PetService code in a new method:
public class PetService(HttpClient httpClient) : IPetService
{
public async Task<PetDto?> PostWithPostAsJsonAsync()
{
var petData = CreatePet();
var response = await httpClient.PostAsJsonAsync("pet", petData);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PetDto>();
}
}
We add the PostWithPostAsJsonAsync() method to our IPetService interface and the PetService class, a name that keeps it apart from the extension method it calls. We re-use the CreatePet() method to send the petData object via the PostAsJsonAsync() extension method for HttpClient. The PostAsJsonAsync() extension method encapsulates the serialization and setting of headers and contents. This is way cleaner and easier to read. Furthermore, we have significantly streamlined our code down to just four lines.
Now, let’s register a new API endpoint in our Program class:
app.MapPost("/postAsJson", async (IPetService petService)
=> await petService.PostWithPostAsJsonAsync());
In the MapPost() method, we register the API route postAsJson where we invoke the PostWithPostAsJsonAsync() method.
Testing the postAsJson Endpoint
Once again, let’s run our application and make a POST request to our newly added endpoint:
{
"id": 9223372036854775807,
"category": {
"name": "Canines"
},
"name": "German Shepherd",
"status": "Available"
}
We execute our newly implemented endpoint /postAsJson and get a successful JSON response from the target URL.
Which Way of Sending a JSON Body Should We Use?
Four options ship in the box, and they differ in how much they do for us.
StringContent is the manual one: we serialize and set the media type ourselves. It earns its place when the body did not come from a serializer at all.
JsonContent.Create() serializes for us and sets application/json; charset=utf-8 by itself. Reach for it when we need the content object rather than the call: to hang it on an HttpRequestMessage, to send a vendor media type such as application/vnd.api+json, or to send the same body with PutAsync().
PostAsJsonAsync() is JsonContent plus the POST, and it is the right default for an ordinary call.
The overloads taking a JsonTypeInfo<T> are those same options wired to generated code instead of reflection.
When a server refuses a request, check the header first. The last three options send application/json; charset=utf-8 without being asked, and only the manual one can get it wrong.
Here is what each option puts in the Content-Type header:
| Approach | Who serializes | Content-Type actually sent | Reach for it when |
|---|---|---|---|
new StringContent(json, Encoding.UTF8) | we do | text/plain; charset=utf-8 | never for JSON: the header is wrong |
new StringContent(json, Encoding.UTF8, "application/json") | we do | application/json; charset=utf-8 | the body is already a string |
JsonContent.Create(value) | System.Net.Http.Json | application/json; charset=utf-8 | we need the content object itself, or a different media type |
client.PostAsJsonAsync(url, value) | System.Net.Http.Json | application/json; charset=utf-8 | an ordinary POST of an object |
client.PostAsJsonAsync(url, value, PetContext.Default.PetDto) | the source generator | application/json; charset=utf-8 | the app is trimmed or Native AOT |
Whichever row we pick, the request still runs under HttpClient‘s default timeout of 100 seconds unless we change it. And when the body is a file rather than an object, sending multipart form data instead is the better fit.
How Do We POST JSON in a Trimmed or Native AOT App?
Reflection-based serialization does not survive trimming, and the compiler says so before the app does.
The PostAsJsonAsync() overloads that take no JsonTypeInfo are annotated with RequiresUnreferencedCode and RequiresDynamicCode, and the message names its own fix: use the overload that takes a JsonTypeInfo or JsonSerializerContext. In an ordinary console or web app the annotation is dormant. Publish the same code trimmed or as Native AOT and it becomes a warning.
A source-generated context is a partial class with one attribute per root type it should handle, and the generator writes the serialization code at compile time, where a trimmer can see it.
Passing PetContext.Default.PetDto to PostAsJsonAsync() selects the overload that never reflects. The same context works on the response: ReadFromJsonAsync(PetContext.Default.PetDto) reads it back with the generated metadata.
The bytes on the wire are identical to what the reflection-based overload sends, so the server cannot tell the two apart.
The context itself is four lines, and it must be partial, because the source generator writes the rest of the class:
using System.Text.Json.Serialization;
[JsonSerializable(typeof(PetDto))]
public partial class PetContext : JsonSerializerContext
{
}
In the sample, the PostAsSourceGeneratedJsonAsync() method passes PetContext.Default.PetDto to both PostAsJsonAsync() and ReadFromJsonAsync().
Conclusion
In conclusion, C# makes it easy to send JSON objects using HttpClient methods. We demonstrated how to use JSON serialization to convert objects to JSON strings and send them over HTTP requests. Now we know how to send JSON objects without racking our brains.
Tested with .NET 10.0.10.
