CXone OAuth client_credentials 401 despite valid creds

Stuck on the auth handshake for CXone. Need to pull historical call data via the REST API from a Kotlin backend service. The goal is a simple client_credentials flow since there’s no user context here. Following the standard OAuth2 spec, I’m hitting https://api.ccxone.com/oauth/token with application/x-www-form-urlencoded.

Here’s the request setup in kotlinx.serialization:

val body = mapOf(
 "grant_type" to "client_credentials",
 "client_id" to "my_app_id",
 "client_secret" to "my_secret_key"
).let { it.entries.joinToString("&") { "${it.key}=${URLEncoder.encode(it.value, "UTF-8")}" } }

val response = httpClient.post("https://api.ccxone.com/oauth/token") {
 contentType(ContentType.Application.FormUrlEncoded)
 setBody(body)
}

Getting a 401 Unauthorized back. The response JSON is just "error": "invalid_client". I’ve double-checked the ID and secret against the CXone developer console. They look correct. Copied them directly to avoid typos. Tried sending them as JSON in the body too, but that returns 400 Bad Request with a hint about content type. The docs are sparse on the exact payload format for CXone specifically, unlike the Genesys Cloud docs which are explicit. Is there a specific header requirement I’m missing? Or maybe the region endpoint is different? The base URL api.ccxone.com seems right based on the portal. Running out of ideas here.