Client Credentials grant returning 401 in Studio REST Proxy

We are trying to automate a data sync between our external CRM and CXone without involving a user context. The plan is to use the client_credentials grant type to generate an access token directly within a script using the REST Proxy action.

The goal is simple: get a token, hit the API, update a custom attribute. But the token generation step is failing with a 401 Unauthorized error. I’ve verified the Client ID and Client Secret in the Developer Portal. They match what I’m passing.

Here is the JSON payload I’m sending to the /oauth/token endpoint:

{
 "grant_type": "client_credentials",
 "client_id": "my-prod-app-id",
 "client_secret": "my-secret-key-here",
 "scope": "api:read api:write"
}

I’m sending this as a POST request. The Content-Type header is set to application/x-www-form-urlencoded because the docs say that’s required for the token endpoint. Wait, actually I tried application/json first since the body is JSON. That failed. So I switched to form-encoded.

In the form-encoded version, I’m sending the body as:
grant_type=client_credentials&client_id=my-prod-app-id&client_secret=my-secret-key-here&scope=api%3Aread%20api%3Awrite

The response body is just:
{"error":"invalid_client","error_description":"Client authentication failed"}

This is weird. I can use these exact same credentials in Postman and get a 200 OK with a valid token. The environment is production.

I checked the IP allowlist in the Developer Portal. The IP address of the CXone instance making the call is whitelisted. Or at least, I think it is. We’re using the internal CXone IP ranges.

Is there something specific about how the REST Proxy action handles the secret? Maybe it’s URL encoding the secret incorrectly? I noticed the secret has a # character in it. In the form-encoded string, it’s not being encoded. Could that be breaking the client authentication?

Also, does the client_credentials grant require a specific scope permission on the app itself? I have the api:read and api:write scopes checked in the app configuration.

Any ideas why Postman works but the script fails? I’m stuck on this 401.