Genesys OAuth PKCE flow failing at token exchange with 400 Bad Request

I’m building a single-page application that acts as a wrapper around the Genesys Cloud interface. We want to use the Authorization Code flow with PKCE so the user doesn’t have to log in twice. The login redirect works fine. I can get the code and state back in the URL parameters.

The problem happens when I try to exchange that code for an access token. I’m making a POST request to the token endpoint.

Here is my code snippet for the exchange:

const formData = new URLSearchParams();
formData.append('grant_type', 'authorization_code');
formData.append('code', authCode);
formData.append('redirect_uri', 'https://myapp.local/callback');
formData.append('client_id', 'my-client-id');
formData.append('code_verifier', codeVerifier);

fetch('https://api.mypurecloud.com/oauth/token', {
 method: 'POST',
 headers: {
 'Content-Type': 'application/x-www-form-urlencoded'
 },
 body: formData
})

I’m generating the code_verifier and code_challenge correctly using base64url encoding of the SHA-256 hash. The code_challenge_method is set to S256 during the initial auth request.

The response I get back is a 400 Bad Request with this JSON payload:

{
 "error": "invalid_grant",
 "error_description": "Invalid authorization code"
}

The code is only used once. I’m capturing it immediately after the redirect. The redirect URI matches exactly what I put in the client settings. I’ve checked the timezone and the code doesn’t seem to be expiring before I use it. The flow takes less than 5 seconds from redirect to exchange.

Is there something specific about the PKCE implementation in Genesys that I’m missing? I’ve read the docs but they don’t mention any quirks with the token exchange step.