The incident
Here's a common way this bites a team: a new client integration β often a quick script or a frontend fetch() call β starts hitting an API endpoint and every request comes back 415 Unsupported Media Type. The natural response is to paste the request body into a JSON validator, confirm it's completely well-formed, and get stuck there, because the validator has no way to reveal that the actual problem is the Content-Type header the client is sending (or not sending at all) β which is checked before the body is ever read.
The body was never the problem
Every JSON validator agreed the request body was well-formed. That's actually consistent with a 415 response, not evidence against it β 415 Unsupported Media Type is decided entirely by the request's Content-Type header, before ASP.NET Core's model binding ever attempts to parse the body. If the declared media type doesn't match what any registered input formatter accepts, the framework rejects the request outright; the JSON inside is never even read.
Why the Content-Type header alone is enough to fail
An [ApiController] action with a [FromBody] parameter relies on ASP.NET Core's collection of IInputFormatter implementations to deserialize the request body. The default JSON formatter, SystemTextJsonInputFormatter, only accepts a specific set of media types:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost]
public IActionResult Create([FromBody] CreateOrderRequest request)
{
// This method body never runs if Content-Type doesn't match a
// registered input formatter's supported media types.
return Ok();
}
}
// SystemTextJsonInputFormatter's default supported media types:
// application/json
// text/json
// application/*+json (matches e.g. application/vnd.api+json)
//
// A request with any of the following headers is rejected with 415
// before Create() is ever invoked, regardless of what the body contains:
// Content-Type: text/plain
// Content-Type: application/x-www-form-urlencoded
// (Content-Type header omitted entirely)
Content-Type: text/plain is a specific, common trigger β it's the default content type some HTTP client libraries and quick fetch() calls send when the caller sets the body as a plain string without explicitly setting headers, even when that string is valid JSON text. The server has no way to know the string is JSON; it only has the header's declared media type to decide which formatter to try, and text/plain matches none of them.
A second, unrelated gotcha in the same neighborhood
Once the Content-Type header is fixed and the body is actually being parsed, there's a completely different pitfall that produces a different symptom, not a 415: System.Text.Json matches property names case-sensitively by default. A JSON body with "userId" will not bind to a C# property named UserId unless case-insensitive matching is explicitly enabled β but this doesn't throw an error or return any particular status code. The property simply binds to its default value (null, 0, or similar), silently, and the request otherwise succeeds:
public class CreateOrderRequest
{
public int UserId { get; set; }
public string Sku { get; set; } = string.Empty;
}
// Body sent by the client: { "user_id": 42, "sku": "ABC-123" }
// Neither key matches a property name at all (case or otherwise) β both bind to defaults:
// UserId == 0, Sku == "" β no exception, no 415, no indication anything went wrong.
// Fix β case-insensitive matching (still requires matching key names, just not matching case):
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.PropertyNameCaseInsensitive = true);
This is worth stating plainly because the two problems get conflated easily: a Content-Type mismatch produces an explicit 415 before the body is read at all; a property name mismatch produces a 200 (or whatever the action returns) with silently wrong data, because System.Text.Json treats an unmatched JSON key as simply absent rather than as an error. Fixing the header does nothing for the second problem, and enabling case-insensitive matching does nothing for the first β they're independent failure modes that happen to both involve "the JSON looks fine but something's still wrong."
The fastest way to tell which one you're looking at
Check the actual HTTP status code first. A 415 response means the problem is the Content-Type header β the body was never parsed, so nothing about the JSON's structure or casing is relevant yet. A 200-range response with unexpectedly null or default-valued fields on the server side means the header was fine and the body was parsed, but something about the property names didn't line up β that's when case sensitivity, and the exact key names in the payload, become the thing to check. Format the exact body being sent with the JSON Formatter to see every key exactly as the server would receive it, side by side with your DTO's property names.