SPA Auth Code Flow with PKCE: 401 on token exchange after successful redirect

Hey folks,

Spent the last day wrestling with the Genesys Cloud OAuth implementation for a new internal tool. We’re building a vanilla JS SPA (no heavy framework overhead) that needs to pull some real-time queue stats. Since it’s a browser-based app, I’m trying to stick to the Authorization Code flow with PKCE to keep things secure.

The initial authorization request seems to work fine. I generate the code_verifier and code_challenge, hit the authorize endpoint, and the user gets redirected back to my callback URL with the code param intact. Here’s how I’m constructing the initial URL:

const codeVerifier = generateRandomString(64);
const codeChallenge = await generateCodeChallenge(codeVerifier);

const authUrl = new URL('https://api.mypurecloud.com/oauth/authorize');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', 'my-client-id');
authUrl.searchParams.append('redirect_uri', 'http://localhost:3000/callback');
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('scope', 'analytics:reports:read');

window.location.href = authUrl.toString();

The redirect lands on my callback page, and I can see the code in the query string. The problem happens immediately when I try to exchange that code for an access token. I’m sending a POST to /oauth/token with the following body:

const tokenResponse = await fetch('https://api.mypurecloud.com/oauth/token', {
 method: 'POST',
 headers: {
 'Content-Type': 'application/x-www-form-urlencoded',
 },
 body: new URLSearchParams({
 grant_type: 'authorization_code',
 client_id: 'my-client-id',
 code: queryParams.get('code'),
 code_verifier: codeVerifier, // Saved in session storage during auth step
 redirect_uri: 'http://localhost:3000/callback'
 })
});

I keep getting a 401 Unauthorized response. The error payload looks like this:

{
 "error": "invalid_grant",
 "error_description": "Bad request"
}

I’ve double-checked that the code_verifier I’m sending matches the one used to generate the challenge. I even logged both out to the console to be sure. The client_id is definitely correct since it works for the auth step. I’m also making sure the redirect_uri matches exactly what was registered in the Developer Console.

Is there something specific about how Genesys handles the PKCE verification step that I’m missing? Or maybe a timing issue with the code expiration? The code doesn’t seem to expire instantly, but I’m swapping it for a token within seconds of the redirect.

Any ideas why the exchange is failing?