403 on reporting endpoint with client_credentials grant

{“code”:“invalid_grant”,“message”:“Invalid authorization code.”} keeps popping up when i hit POST /api/v2/oauth/token. trying to set up a python script that runs hourly to pull queue stats and drop them into a local csv. don’t need any human login flow, just a backend service account on a linux vm.

read the oauth docs but the grant types are confusing. authorization_code needs a redirect uri and browser flow which feels wrong for a cron job. client_credentials seems right for machine-to-machine but the scope requirements are messy. do i need analytics:reporting alone or does it require user:read too? the token only lasts 30 minutes so managing refreshes in a simple script is already annoying.

here’s the curl i’m testing:
curl -X POST “https://api.mypurecloud.com/api/v2/oauth/token
-H “Authorization: Basic bXljbGllbnQ6bXlzZWNyZXQ=”
-d “grant_type=client_credentials&scope=analytics:reporting”

gets a token fine, but calling /api/v2/analytics/reporting/queues/summary throws a 403. am i missing a scope mapping or is the client credentials grant just blocked for reporting endpoints? the platform ui shows different scopes available depending on the grant type selected. stuck on this.

i’m a java dev so i don’t know python syntax, but the oauth logic is the same. the error invalid_grant usually means the client secret or id is wrong, or the scope is missing.

for machine-to-machine, client_credentials is definitely the right choice. you don’t need a redirect uri. make sure you are sending the credentials as Basic Auth in the header, not in the body.

in java we do this:

String credentials = Base64.encodeBase64String((clientId + ":" + clientSecret).getBytes());
headers.put("Authorization", "Basic " + credentials);
headers.put("Content-Type", "application/x-www-form-urlencoded");
// body: grant_type=client_credentials&scope=analytics:readonly

if you get 403 after getting the token, check the role permissions on that service account in admin. it needs analytics:readonly scope. also, cache the token. it’s valid for 1 hour. don’t call /oauth/token every time your cron runs. just check if the current token is expired. hope this helps.

the invalid_grant error is tricky. often it’s not the secret itself but how the scope is defined. for backend scripts pulling queue stats, you need the analytics:call-summary:read scope explicitly. if you just use admin:org:read, the token generates but the API call fails later with a 403, which is confusing.

also, check the client type in the admin portal. it must be set to Confidential. if it’s Public, the client_credentials grant won’t work at all.

here is a quick curl test to isolate the issue. run this from your linux vm. replace the placeholders.

curl -X POST "https://api.mypurecloud.com/api/v2/oauth/token" \
 -H "Authorization: Basic BASE64(CLIENT_ID:CLIENT_SECRET)" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=client_credentials&scope=analytics:call-summary:read"

if this returns a 200 with a token, your python script headers are likely wrong. the basic auth header needs the Basic prefix and the base64 encoded string. don’t forget the space.

basic auth header is likely malformed or the scope list is empty in your request body. here is a working python snippet using requests that handles the header encoding and includes the required analytics scope.

import requests
import base64

creds = f"{client_id}:{client_secret}"
b64creds = base64.b64encode(creds.encode()).decode()
headers = {
 "Authorization": f"Basic {b64creds}",
 "Content-Type": "application/x-www-form-urlencoded"
}
data = {
 "grant_type": "client_credentials",
 "scope": "analytics:call-summary:read"
}
resp = requests.post("https://api.mypurecloud.com/api/v2/oauth/token", headers=headers, data=data)
print(resp.json())

if the token comes back but the api call still 403s, the service account role in the admin portal is missing the analytics permissions. client_credentials grants don’t inherit user roles. you have to assign the specific analytics scope to the integration itself in the developer console settings, not just the service account user profile.

yeah, the scope issue is usually the silent killer here. client_credentials looks right on paper, but if you don’t pin down the exact scope during token generation, the token is useless for specific endpoints. the 403 comes later because the token lacks the authority, not because the grant failed.

also, make sure your client is definitely Confidential. if it’s Public, the server rejects the secret entirely.

for python, using requests makes this way cleaner than building the header manually. here’s a snippet that handles the base64 auth and scope in one go:

import requests
import base64

client_id = "your_client_id"
client_secret = "your_client_secret"
auth_string = f"{client_id}:{client_secret}"
auth_bytes = auth_string.encode('utf-8')
auth_header = base64.b64encode(auth_bytes).decode('utf-8')

headers = {
 "Authorization": f"Basic {auth_header}",
 "Content-Type": "application/x-www-form-urlencoded"
}

data = {
 "grant_type": "client_credentials",
 "scope": "analytics:call-summary:read" # CRITICAL: add this
}

response = requests.post(
 "https://api.mypurecloud.com/api/v2/oauth/token",
 headers=headers,
 data=data
)
print(response.json())

the scope parameter in the body is what matters. if you leave it out, the token is generic and won’t touch analytics endpoints. we see this a lot with bulk export jobs too-the token works for listing recordings but fails on the actual download because the recording:read scope was missing from the initial grant.

check your client settings in the admin portal under Developers > Integration > Clients. ensure the Allowed Grant Types includes client_credentials and the Allowed Scopes list matches what you’re requesting. if there’s a mismatch there, the server drops the request before it even checks the secret.