PKCE code_verifier hash mismatch on Genesys Cloud OAuth for SPA

Why is the authorization server rejecting my code exchange with a 400 Bad Request when using PKCE?

I’m building a single-page app that needs to call the Data Action API, so I’m implementing the Authorization Code flow with PKCE. The initial auth request works fine, and I get the code back. But when I try to swap that code for a token, the server complains about the verifier.

Here’s the exchange call:

const response = 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',
 code: authCode,
 redirect_uri: 'http://localhost:3000/callback',
 client_id: myClientId,
 code_verifier: codeVerifier // Generated with crypto.getRandomValues
 })
});

The error payload is straightforward:

{
 "error": "invalid_grant",
 "error_description": "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
}

I’ve double-checked the redirect URI. It matches exactly. I’m generating the code_challenge using SHA-256 on the code_verifier and base64url encoding it. The verifier string is 43 characters long. Is Genesys Cloud strict about the encoding format? I’ve seen some SDKs use standard base64 with padding while others strip the equals signs.

My hashing logic looks like this:

const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)))
 .replace(/\+/g, '-')
 .replace(/\//g, '_')
 .replace(/=/g, '');

Does the OAuth endpoint require a specific variation of base64url? Or is there something else breaking the PKCE validation? I’ve tried regenerating the verifier multiple times but the result is always the same 400 error. The token endpoint doesn’t give much detail beyond the generic invalid_grant message.