PKCE code_verifier mismatch on Genesys Cloud SPA auth

Getting a 400 Bad Request when I try to swap the authorization code for a token. The error payload is {"error": "invalid_grant", "error_description": "code_verifier does not match"}.

I’m building a simple React app to let agents log in. I generate the code_verifier and code_challenge on the client side using SHA-256. Here is how I create the challenge:

async function generatePKCE() {
 const verifier = crypto.getRandomValues(new Uint8Array(32)).toString();
 const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
 const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
 return { verifier, challenge };
}

I send the challenge in the GET request to /oauth/authorize. The redirect comes back with the code. Then I call POST /oauth/token with grant_type=authorization_code, the code, and the original verifier.

The redirect URI matches exactly. I’ve checked the URL encoding. Is Genesys Cloud expecting the challenge to be plain base64 instead of base64url? Or am I messing up the verifier storage? The docs are vague on the hashing implementation details.