POST /oauth/token returns 401 for CI/CD long-lived token

Does anyone know why my POST to /oauth/token returns a 401 Unauthorized when I try to generate a long-lived API token for a CI/CD pipeline? The documentation says “Provide client credentials to obtain an access token.” I am sending this JSON payload: {“grant_type”: “client_credentials”, “client_id”: “myapp”, “client_secret”: “secret123”, “duration”: “long”}. The flow fails immediately. I need the token to run automated Data Action calls without manual login. Why is the duration parameter ignored?

TL;DR: Remove the duration field and ensure Content-Type is application/x-www-form-urlencoded.

This looks like a classic client credential flow mismatch. The Genesys Cloud OAuth endpoint does not accept JSON payloads by default for the standard token grant, nor does it recognize a duration parameter in the request body. You are likely hitting a 401 because the server cannot parse the credentials or rejects the unknown field.

Here is the corrected curl command using form-encoded data:

curl -X POST "https://api.mypurecloud.com/oauth/token" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret"

If you are using Python with requests, use the data parameter instead of json:

import requests

resp = requests.post(
 "https://api.mypurecloud.com/oauth/token",
 data={
 "grant_type": "client_credentials",
 "client_id": env.CLIENT_ID,
 "client_secret": env.CLIENT_SECRET
 }
)

The token returned is valid for 900 seconds. If you need a “long-lived” token for CI/CD, you should generate an API Token via the Admin UI or the /api/v2/oauth/apitokens endpoint, which allows you to set expiration dates explicitly. The client credentials flow is designed for short-lived service-to-service auth.

TL;DR: The 401 is a symptom of malformed content, but the real risk is using client credentials for CI/CD long-lived tokens without strict scope isolation.

Make sure you are sending the payload as application/x-www-form-urlencoded, not JSON. The Genesys Cloud OAuth 2.0 implementation for the client_credentials grant type strictly expects form-encoded data. Sending JSON triggers a 400 Bad Request or 401 Unauthorized because the server cannot extract the client_id and client_secret from the body. The duration parameter is also invalid in this context and causes immediate rejection.

Here is the correct curl command for testing:

curl -X POST "https://api.mypurecloud.com/oauth/token" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET"

In your CI/CD pipeline, use the SDK’s OAuthClient to handle this securely. Do not hardcode secrets.

OAuthClient oAuthClient = platformClient.getOAuthClient();
OAuthClientCredentialsCredentials credentials = new OAuthClientCredentialsCredentials();
credentials.setClientId("YOUR_CLIENT_ID");
credentials.setClientSecret("YOUR_CLIENT_SECRET");

// Use a specific scope, not 'default'
List<String> scopes = Arrays.asList("analytics:reports:read");
credentials.setScopes(scopes);

OAuthClientCredentialsTokenResponse tokenResponse = oAuthClient.postOAuthToken(credentials);
String accessToken = tokenResponse.getAccessToken();

Warning: Client credentials tokens do not expire quickly by default unless configured in the app settings. If your CI/CD job fails, the token remains valid until revoked. This creates a security gap where automated scripts might run with stale permissions. Always rotate secrets and restrict scopes to the minimum required for the Data Action calls. I see this mistake often in mobile backend integrations where devs copy-paste token logic without checking the endpoint requirements.

This is a standard form-encoding mismatch. The endpoint rejects JSON payloads for client credentials.

curl -X POST https://api.mypurecloud.com/oauth/token \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=client_credentials&client_id=ID&client_secret=SECRET"

Switch to URL-encoded data. The duration parameter is ignored anyway.