Ktor Content Negotiation: Which Converter Wins When Accept Headers Disagree
Ktor picks a response format from the Accept header — and from the order you registered converters. Here's how to make that fallback deliberate.
17 Sept 2025, 16:23 UTC

An endpoint that returns JSON works fine until a client sends Accept: application/xml and gets back a body it can't parse — or a status code it didn't expect. In Ktor, the plugin that decides this is ContentNegotiation, and the decision is driven almost entirely by two things: the Accept header and the order in which you registered your converters.
The thesis here is simple: treat converter registration order as API surface. It determines your fallback format, and it is the part of the configuration most likely to change behavior silently.
What ContentNegotiation actually decides
Content negotiation is the HTTP mechanism where a client states which media types it can handle and the server picks one. Ktor implements the server side as a plugin. You register one or more converters — a converter is the piece that knows how to turn a Kotlin object into bytes of a specific media type (JSON, XML, CBOR, and so on).
When a route calls call.respond(someObject), the plugin reads the request's Accept header, finds a registered converter whose media type matches, and serializes with it. If the header is missing or is */*, Ktor falls back to the first registered converter. That fallback is the behavior worth pinning down deliberately.
Version assumptions
The configuration shape below is Ktor 2.x, using the io.ktor.server.plugins.contentnegotiation package. Ktor 1.x used different package names and a slightly different install call; the resolution semantics — match the Accept header, fall back to the first registered converter — carried over. Check the artifact coordinates against your own version before copying, and confirm the two-argument json() signature exists in the release you are on.
A worked example: JSON plus a vendor media type
Assume a small invoicing API. You want plain application/json for most clients and a versioned vendor type, application/vnd.example.v2+json, for clients that opt in. Both can be served by the same kotlinx.serialization converter, registered twice under different media types.
// build.gradle.kts (dependencies)
// implementation("io.ktor:ktor-server-content-negotiation:2.x.y")
// implementation("io.ktor:ktor-serialization-kotlinx-json:2.x.y")
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class Invoice(val id: String, val totalCents: Long)
fun Application.configureApi() {
install(ContentNegotiation) {
// Registered first: becomes the fallback for Accept: */*
json(ContentType.Application.Json, Json { encodeDefaults = true })
json(ContentType.parse("application/vnd.example.v2+json"))
}
routing {
get("/invoices/{id}") {
val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest)
call.respond(Invoice(id, 4200))
}
}
}
Two things to notice. First, application/json is registered before the vendor type, so a client sending Accept: */* gets JSON rather than the vendor type. Second, the vendor type is a separate registration, not a separate route — the route body does not branch on format at all.
Checking the behavior
Run the server locally and send three requests. curl is enough; no special permissions are required beyond being able to reach the port.
curl -i -H "Accept: application/json" http://localhost:8080/invoices/42
curl -i -H "Accept: application/vnd.example.v2+json" http://localhost:8080/invoices/42
curl -i -H "Accept: application/xml" http://localhost:8080/invoices/42
What to inspect: the Content-Type response header and the status line. The first two should return 200 with the corresponding media type. The third is the interesting one — no XML converter is registered, so the request cannot be satisfied. Depending on your Ktor version this surfaces as a 406 Not Acceptable or as an exception thrown by the plugin. Confirm the actual status in your environment rather than assuming, because it can change between minor releases and changes again once you add an error-handling plugin.
If you would rather not have that ambiguity, register a catch-all converter or add an explicit StatusPages mapping for the negotiation failure so the client receives a documented response.
Where this approach breaks down
- Format is not semantics. A vendor media type tells you the client wants a particular representation. It does not tell you which fields to include. If v1 and v2 differ in shape, you still need branching logic or separate serializable classes — the media type only routes you to the right one.
- Every converter must handle every response type. If you register XML and then
respond()a type that converter cannot serialize, the failure happens at request time, not at startup. Adding a format widens what you have to test. - Order is invisible in code review. Reordering two
json()calls looks cosmetic in a diff but changes the default response format for every client that sends*/*. Worth a comment, as above. - Some responses skip negotiation entirely.
call.respond(HttpStatusCode.NotFound)with no body, orrespondText, bypasses the converters. That is usually fine, but it means "the API always negotiates" is not a safe assumption.
What to do next
Register the smallest set of converters that satisfies real clients, put the one you want as the default first, and comment why. Then add a test that asserts the Content-Type header for each Accept value you claim to support — that is the check that catches an accidental reordering. If you are adding a vendor media type for versioning, decide up front what happens when a client asks for a version you do not serve, and make that response explicit rather than leaving it to the plugin's default.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.