Mert Özen Further With Every Line
Backend .NET Core Web API — #43

Localization in Web APIs: Accept-Language, RequestLocalization, Localized Validation and ProblemDetails Messages

Mert Özen Nov 15, 2026 9 min 15 views
Localization in Web APIs: Accept-Language, RequestLocalization, Localized Validation and ProblemDetails Messages

Localization in an ASP.NET Core Web API means determining the request culture from Accept-Language, the query string or a cookie and producing the human-readable texts (validation and ProblemDetails messages) per language, while machine fields such as type, code and error keys never change. On .NET 10 we make Bookstore's institutional order endpoint TR/EN with resx, IStringLocalizer and ErrorMessageResourceType.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404).

Guess First

In the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, a user has chosen Turkish as the UI language and, out of Turkish habit, typed 300,5 into the price filter. The client sends the request like this:

GET /api/books?maxPrice=300,5
Accept-Language: tr-TR

Request localization is on in the API and Turkish is supported. What value does the decimal? MaxPrice parameter in BookQuery get: 300.5, 400, or 3005? Which books are in the returned list?

In the previous post we turned all of the API's error responses into ProblemDetails, but they're all in English. Bookstore's users speak Turkish and English, and the school staff using the institutional portal mostly speak Turkish. In this post we set up localization in the API: determining the request culture, reading messages from resource files, producing validation and ProblemDetails messages per language and, most importantly, what must not be localized. Localization on the view side, IViewLocalizer and Razor, is covered in the localization post of the ASP.NET Core MVC and Razor Pages series.

RequestLocalization: Determining the Culture

builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddRequestLocalization(options =>
{
    string[] cultures = ["en-US", "tr-TR"];
    options.SetDefaultCulture(cultures[0])
        .AddSupportedCultures(cultures)
        .AddSupportedUICultures(cultures);
    options.ApplyCurrentCultureToResponseHeaders = true;
});

// ...

app.UseForwardedHeaders();
app.UseRequestLocalization();
app.UseExceptionHandler();

On every request the middleware tries the culture providers in order: first the query string (?culture=tr-TR&ui-culture=tr-TR), then the .AspNetCore.Culture cookie, and finally the Accept-Language header. The first match wins, and CultureInfo.CurrentCulture and CultureInfo.CurrentUICulture are set for that request. If nothing matches, or the requested language isn't supported, it falls back to the default. ApplyCurrentCultureToResponseHeaders also tells the client the chosen language through the Content-Language header:

$ curl -i -X POST localhost:5238/api/institution/orders -H "X-Tenant-Id: 101" -H "Accept-Language: de-DE" -H "Content-Type: application/json" -d '{"items":[{"isbn":"978-0441013593","quantity":5},{"isbn":"978-0141439587","quantity":0}]}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Content-Language: en-US

German isn't supported, so it fell back to en-US. A browser sending tr-TR,tr;q=0.9 got tr-TR. The difference between the two cultures matters: CurrentCulture controls number, date and currency formatting, CurrentUICulture which resource file text is read from. In APIs you usually keep them the same, but if you need something like "English UI, Turkish number format" they can be chosen separately. On the Angular side, putting the language choice into every request's Accept-Language header with an interceptor is cleaner than cluttering the query string.

What Culture Changes and What It Doesn't

The answer to the guess:

$ curl -s "localhost:5238/api/books?maxPrice=300.5" -H "Accept-Language: tr-TR" | jq -r '.[].title'
Emma

$ curl -s "localhost:5238/api/books?maxPrice=300,5" -H "Accept-Language: tr-TR" | jq -r '.[].title'
Dune
Emma
Middlemarch
The Hobbit

300,5 was read as 3005 and all four books came back; the request didn't even fail. As we said in the binding post, number 39, a minimal API passes the invariant culture to TryParse, not the request culture. In the invariant culture the comma is the thousands separator; 300,5 is a valid number and its value is 3005. When I repeated the experiment with Accept-Language: en-US the result didn't change, so request localization doesn't touch query binding at all. When I tried it in a controller with [FromQuery] decimal max, I got the same result; MVC also converts query and route values with the invariant culture.

This is actually the right design: a URL is a machine contract, and the same request must mean the same thing in every language. The mistake lies with the client that puts the number into the URL as the user typed it. In Angular the form value needs to be converted to a number before it's sent. What you can do on the API side is reject values containing a comma for parameters such as a price filter, or use a type with its own TryParse, as in the earlier posts. In a JSON body things are clearer: JSON numbers always use a dot, and System.Text.Json never looks at the culture.

Where culture really has an effect is the output side: decimal.ToString(), date formats, messages produced with string.Format. In the .NET 11 post we saw 0,01 in the Range message; in this post's messages numbers are formatted by culture too. Numbers in JSON responses, on the other hand, stay independent of culture.

IStringLocalizer and Resource Files

The texts live in .resx files. Each language has its own file; the culture suffix in the file name (.tr) says which language it belongs to:

Resources/
  SharedMessages.resx
  SharedMessages.tr.resx
  ValidationMessages.resx
  ValidationMessages.tr.resx
<data name="MinimumInstitutionalQuantity" xml:space="preserve">
  <value>Kurumsal siparişlerde toplam en az {0} adet olmalı; bu siparişte {1} adet var.</value>
</data>
<data name="IsbnNotFound.Title" xml:space="preserve">
  <value>Kitap bulunamadı.</value>
</data>
<data name="Status404" xml:space="preserve">
  <value>İstenen kaynak bulunamadı.</value>
</data>

IStringLocalizer<T> derives the resource name from the full name of T. In an API it's practical to gather messages in a single marker class instead of scattering them across classes:

namespace Bookstore.Api;

// Marker class for IStringLocalizer<SharedMessages>; the texts live in Resources/SharedMessages*.resx
public sealed class SharedMessages;

Because ResourcesPath = "Resources" and the root namespace is Bookstore.Api, IStringLocalizer<SharedMessages> looks for the resource named Bookstore.Api.Resources.SharedMessages. The build compiled the Turkish file into a satellite assembly called bin/Debug/net10.0/tr/Bookstore.Api.resources.dll. When a key isn't found, the localizer doesn't throw; it returns the key itself and sets ResourceNotFound to true. That can silently carry a missing translation onto the screen; shortly we'll use it on purpose in ProblemDetails.

Localizing Validation Messages

In .NET 10 there's no IStringLocalizer integration for the messages of AddValidation(); that's a new feature we saw in the .NET 11 post. In .NET 10 the old Data Annotations route works: you tell the attribute which resource property to read the message from. Those properties must be public static:

// Data Annotations' ErrorMessageResourceType needs public static string properties
public static class ValidationMessages
{
    private static readonly ResourceManager Resources =
        new("Bookstore.Api.Resources.ValidationMessages", typeof(ValidationMessages).Assembly);

    public static string Range => Resources.GetString(nameof(Range), CultureInfo.CurrentUICulture)!;

    public static string Required => Resources.GetString(nameof(Required), CultureInfo.CurrentUICulture)!;
}

public record InstitutionOrderLine(
    [Required(ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.Required))]
    string Isbn,
    [Range(1, 500, ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.Range))]
    int Quantity);

Visual Studio's resx designer generates this class for you but makes it internal; for the attribute to reach it, you have to switch the access modifier to public in the designer. Writing it by hand is a one-off job and makes plain what it does. The institutional order rule "at least 20 copies in total" looks at more than one line, so it lives in IValidatableObject; we produce the message there with the localizer:

public record InstitutionOrderRequest(List<InstitutionOrderLine> Items) : IValidatableObject
{
    public const int MinimumQuantity = 20;

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        int total = Items.Sum(i => i.Quantity);
        if (total < MinimumQuantity)
        {
            IStringLocalizer<SharedMessages> localizer =
                validationContext.GetRequiredService<IStringLocalizer<SharedMessages>>();
            yield return new ValidationResult(
                localizer["MinimumInstitutionalQuantity", MinimumQuantity, total],
                [nameof(Items)]);
        }
    }
}
$ curl -X POST localhost:5238/api/institution/orders -H "X-Tenant-Id: 101" -H "Accept-Language: tr-TR" -H "Content-Type: application/json" -d '{"items":[{"isbn":"978-0441013593","quantity":5},{"isbn":"978-0141439587","quantity":0}]}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bir veya daha fazla doğrulama hatası oluştu.","status":400,"errors":{"Items[1].Quantity":["Quantity alanı 1 ile 500 arasında olmalı."]},"traceId":"00-b68d6f2c0947b2b3a89bfdfdde91c348-348f5a15b8476ca9-00"}

$ curl -X POST ... -H "Accept-Language: en-US" ...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Items[1].Quantity":["Quantity must be between 1 and 500."]},"traceId":"00-690ef4d66f635055648686ef3fef9649-29c306607bfd332e-00"}

$ curl -X POST ... -H "Accept-Language: tr-TR" -d '{"items":[{"isbn":"978-0441013593","quantity":5}]}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bir veya daha fazla doğrulama hatası oluştu.","status":400,"errors":{"Items":["Kurumsal siparişlerde toplam en az 20 adet olmalı; bu siparişte 5 adet var."]},"traceId":"00-e2558ddfdc479ca2563aac05c99584ff-12fcf769b77df50b-00"}

In the first request the "at least 20" message didn't appear although the total was 5. That's the known behavior of Data Annotations: if one of the property-level attributes fails, the object-level Validate doesn't run. The client sees the line errors first and the total rule once they're fixed. Quantity in the message is still English, because {0} is the property name. If you want to translate it as well, use [Display(Name = ..., ResourceType = ...)]; but the key of the errors dictionary (Items[1].Quantity) must stay the same in every language.

Localizing ProblemDetails

For errors other than validation there are two separate cases. For the ProblemDetails the framework produces itself (404, 405, validation 400), we change the titles in CustomizeProblemDetails:

builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
{
    // Problems with their own code arrive already localized
    if (context.ProblemDetails.Extensions.ContainsKey("code") || context.ProblemDetails.Status is not { } status)
    {
        return;
    }

    IStringLocalizer<SharedMessages> localizer =
        context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<SharedMessages>>();
    LocalizedString title = localizer[$"Status{status}"];
    if (!title.ResourceNotFound)
    {
        context.ProblemDetails.Title = title;
    }
});
$ curl -i localhost:5238/api/nope -H "Accept-Language: tr-TR"
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: tr-TR

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"İstenen kaynak bulunamadı.","status":404,"traceId":"00-ce4c00348c59ff46b69ab284b9192798-467bad1dfe342b73-00"}

Thanks to the ResourceNotFound check, a status code without a translation keeps the framework's English title, and the key name (such as Status418) doesn't leak to the client. Requests sent with the ?culture=tr-TR query string and with the .AspNetCore.Culture=c=tr-TR|uic=tr-TR cookie got the same Turkish title.

The second case is our own business rules. When an institutional order arrives with an ISBN that isn't in the catalog, the endpoint produces its own problem and localizes the text right there:

if (order.Items.FirstOrDefault(i => !Isbn.TryParse(i.Isbn, null, out Isbn isbn) || catalog.Find(isbn) is null) is { } missing)
{
    // type and code are for machines: they never change with the language
    return TypedResults.Problem(
        type: "https://bookstore.example.com/problems/isbn-not-found",
        title: localizer["IsbnNotFound.Title"],
        detail: localizer["IsbnNotFound.Detail", missing.Isbn],
        statusCode: StatusCodes.Status422UnprocessableEntity,
        extensions: new Dictionary<string, object?> { ["code"] = "catalog.isbn_not_found", ["isbn"] = missing.Isbn });
}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Content-Language: tr-TR

{"type":"https://bookstore.example.com/problems/isbn-not-found","title":"Kitap bulunamadı.","status":422,"detail":"Katalogda 978-0000000000 ISBN'li bir kitap yok.","code":"catalog.isbn_not_found","isbn":"978-0000000000","traceId":"00-fbdb9a7ab3ceb27d9b855ec5c81cd3cc-4833809c8cfc2dfa-00"}

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Content-Language: en-US

{"type":"https://bookstore.example.com/problems/isbn-not-found","title":"The book could not be found.","status":422,"detail":"No book with ISBN 978-0000000000 is in the catalog.","code":"catalog.isbn_not_found","isbn":"978-0000000000","traceId":"00-7e875bb4ae78a9df1ee99015c79110ae-038ad5822ec4e190-00"}

What Not to Localize

Put the two 422s side by side and the rule appears: the fields humans read (title, detail, validation messages) changed with the language; the fields machines read (type, status, code, isbn, the keys of errors) didn't. The Angular client should pick its behavior by code, for example marking the cart line when catalog.isbn_not_found arrives, and only show the message to the user. A client that writes an if on title breaks the day the user switches languages. The type URI is, by RFC 9457's definition, the identity of the error type; an identity that changes with the language isn't one. By the same logic, enum values, JSON property names, order statuses ("placed") and log messages aren't localized either. Team members of every language must be able to read the logs, and the log of the same error must not look different depending on the language. The design side of the error contract, that is, how to choose the code catalog and type URIs, is covered in the error contract post of the upcoming REST API Design series.

The Controller Equivalent

For Data Annotations messages in controllers there's AddControllers().AddDataAnnotationsLocalization(); you write a key as the attribute's ErrorMessage, and MVC resolves it through IStringLocalizer. So in .NET 10 the controller side is more comfortable than minimal APIs; .NET 11 closed that gap for AddValidation(). Model binding's own messages ("The value 'abc' is not valid." and the like) are changed through MvcOptions.ModelBindingMessageProvider; in a minimal API those messages don't reach the client in Production anyway. UseRequestLocalization, IStringLocalizer, resource files and ProblemDetails customization are the same on both sides.

Common Pitfalls

First, putting UseRequestLocalization late in the pipeline. I tried moving the middleware after UseRouting:

$ curl -i localhost:5238/api/nope -H "Accept-Language: tr-TR"
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: tr-TR

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"The resource could not be found.","status":404,"traceId":"00-0c37c6de2f0361591ab8e0af6b7af6dc-4abc35fa2692a5e7-00"}

The header says Turkish, the title is English. The validation 400 stayed Turkish because it's written inside the endpoint. The 404, however, is written by the status code pages middleware after the inner middleware has returned. The localization middleware sets the culture inside the async flow, and once the inner flow finishes, the outer middleware sees the old culture. The rule: before every middleware that can write a response, that is, before the exception handler and status code pages. If you use RouteDataRequestCultureProvider, it has to come after routing; then you read the culture feature yourself for the exception handler's messages.

Second, trying to parse a decimal number in a JSON body or query string by culture. We saw it in the guess: 300,5 became 3005 without an error. The contract between machines must stay invariant; the client should do the conversion.

Third, localizing fields read by machines: code, type, enum values and errors keys. Once you publish a localized code, taking it back is a breaking change.

Fourth, forgetting Turkish casing. In a request whose culture is tr-TR, "title".ToUpper() gives "TİTLE". Use ToUpperInvariant or StringComparison.OrdinalIgnoreCase for comparisons and key generation; this pitfall is covered in detail in the localization post of the MVC series and in the globalization post of the upcoming Everyday .NET APIs with C# series.

A Small Experiment

★☆☆ Add [Display(Name = "Quantity", ResourceType = typeof(ValidationMessages))] to InstitutionOrderLine.Quantity, add a Quantity property to ValidationMessages and the "Quantity"/"Adet" translation to the resx files. Confirm that the Turkish message becomes "Adet alanı 1 ile 500 arasında olmalı." while the errors key stays the same.

★★☆ Replace BookQuery.MaxPrice with a Price type that rejects values containing a comma (NumberStyles.AllowDecimalPoint and CultureInfo.InvariantCulture inside TryParse). See ?maxPrice=300,5 return 400 now.

★★★ Move the project to net11.0 and, with AddLocalization() and AddValidation() together, translate the messages without ErrorMessageResourceType, using only keys such as RangeAttribute_Error. Compare how the two approaches behave together on the same DTO.

With this post the everyday API surface block that started in 38 is complete too: from HttpContext to binding, from filters to JSON, from middleware to localization, we've looked at the pieces you touch every day on .NET 10. The series will continue with a new version post when .NET 12 ships.