Python requests OAuth2 Client Credentials returns 401 despite valid secrets

Running into a wall with a simple auth script. Trying to grab an access token using the Client Credentials flow via Python requests. The goal is to feed this token into a webhook processor that validates incoming Genesys Cloud events before pushing them to our internal queue.

Here is the snippet I’m using:

import requests

url = "https://api.mypurecloud.com/oauth/token"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
payload = {
 "grant_type": "client_credentials",
 "client_id": "my-client-id",
 "client_secret": "my-client-secret"
}

resp = requests.post(url, headers=headers, data=payload)
print(resp.status_code)
print(resp.json())

Getting a 401 Unauthorized back. The response body is just {"error": "invalid_client", "error_description": "Bad client credentials"}.

I’ve double-checked the Client ID and Secret in the Developer Console. They are correct. I can use these same credentials in Postman without any issues. The endpoint URL is definitely https://api.mypurecloud.com/oauth/token.

Thought it might be a clock skew issue since I’ve seen that cause weird auth failures before, but my machine time is synced via NTP and matches the server time within milliseconds. Also, I’m not using a refresh token here, just straight client credentials.

Tried adding Authorization: Basic base64(client_id:client_secret) to the headers instead of putting them in the body, but that also failed with the same 401. The docs say the body params should be fine.

Is there something specific about how the requests library handles the form encoding that might be mangling the payload? Or is there a specific scope I need to request even for client credentials? The error message is pretty vague.

Any ideas on what I’m missing?

The docs for OAuth client credentials are pretty clear on the content type. You need application/x-www-form-urlencoded, not JSON.

payload = {
 'grant_type': 'client_credentials',
 'client_id': YOUR_CLIENT_ID,
 'client_secret': YOUR_SECRET
}
requests.post(url, data=payload) # use data, not json

Switch to data in your post call.