PKCE code_verifier mismatch on /api/v2/oauth/token with Kotlin SPA

How do I correctly format the code_verifier when swapping the auth code for a token in a Genesys Cloud SPA? I’m building a Kotlin multi-platform web app and trying to implement the Authorization Code flow with PKCE. The login redirect to Genesys Cloud works fine, but the token exchange via POST /api/v2/oauth/token keeps failing with a 400 Bad Request.

The response body says {"error": "invalid_grant", "error_description": "Code challenge does not match the code verifier."}. I’ve double-checked the flow. I generate a 128-byte random string, base64url encode it to get the code_challenge, and send that in the initial login request. Then, after the redirect, I send the original raw string as the code_verifier in the token request body.

Here’s the Kotlin snippet handling the token exchange:

val codeVerifier = generateRandomString(128) // raw bytes
val codeChallenge = base64UrlEncode(SHA256(codeVerifier))

// ... redirect to login ... 

val tokenRequest = mapOf(
 "grant_type" to "authorization_code",
 "code" to authCodeFromRedirect,
 "code_verifier" to codeVerifier, 
 "client_id" to clientId,
 "redirect_uri" to "https://myapp.com/callback"
)

val response = http.post("/api/v2/oauth/token") {
 setBody(tokenRequest)
 contentType(ContentType.Application.FormUrlEncoded)
}.bodyAsText()

The codeVerifier variable holds the raw string. I’m not hashing it again for the token request, just sending it as-is. Is Genesys Cloud expecting the verifier to be hashed or encoded differently in the token payload? Or is there a whitespace issue in how I’m constructing the form data? I’ve tried logging both the challenge and verifier to console, and they look correct, but the API rejects it every time. The scopes I’m requesting are just openid and profile for now. Nothing fancy. Just trying to get a basic login working without the implicit flow.