How to generate a long-lived API token for a CI/CD pipeline
What You Will Build
- A script that authenticates with Genesys Cloud using the OAuth2 Client Credentials Grant to obtain an access token.
- This tutorial uses the Genesys Cloud REST API via the
requestslibrary in Python. - The primary language is Python, with concepts applicable to any HTTP-capable language.
Prerequisites
- OAuth Client Type: You must create an OAuth Client in the Genesys Cloud Admin Portal. The client type must be Confidential (or “Public” if using PKCE, but Client Credentials is standard for CI/CD).
- Required Scopes: The specific scopes depend on your API usage (e.g.,
analytics:conversation:view,user:read). For this tutorial, we will request a broad set or specific scopes relevant to your pipeline. - SDK Version: This example uses raw HTTP requests, which are version-agnostic for the authentication endpoint. If using the Python SDK, ensure
genesyscloudversion 130.0.0 or higher. - Language/Runtime: Python 3.8+.
- External Dependencies:
pip install requests
Authentication Setup
The standard OAuth2 Authorization Code flow is unsuitable for CI/CD pipelines because it requires interactive user consent. Instead, you must use the Client Credentials Grant. This flow allows a machine (your CI/CD agent) to authenticate directly with the authorization server using a Client ID and Client Secret.
The resulting access token is short-lived (typically 1 hour). To maintain a “long-lived” session in a pipeline, your script must handle token expiration and refresh logic automatically.
Step 1: Configure the OAuth Client
Before writing code, you must configure the identity provider in Genesys Cloud.
- Log in to the Genesys Cloud Admin Portal.
- Navigate to Admin > Security > OAuth Clients.
- Click Add Client.
- Enter a name (e.g.,
CI-CD-Pipeline-Bot). - Set the Client Type to Confidential.
- Copy the Client ID and Client Secret. Store these securely in your CI/CD environment variables (e.g.,
GENESYS_CLIENT_ID,GENESYS_CLIENT_SECRET). - Define the Scopes. For a read-only analytics pipeline, you might add
analytics:conversation:viewanduser:read.
Step 2: Implement the Token Request
The authentication endpoint for Genesys Cloud is https://api.mypurecloud.com/oauth/token. You must send a POST request with application/x-www-form-urlencoded content.
Here is the working Python code to fetch the initial token.
import requests
import os
import time
from datetime import datetime, timedelta
from typing import Optional, Tuple
# Configuration from Environment Variables
CLIENT_ID = os.getenv("GENESYS_CLIENT_ID")
CLIENT_SECRET = os.getenv("GENESYS_CLIENT_SECRET")
BASE_URL = "https://api.mypurecloud.com"
AUTH_URL = f"{BASE_URL}/oauth/token"
class GenesysAuthenticator:
def __init__(self, client_id: str, client_secret: str):
self.client_id = client_id
self.client_secret = client_secret
self.access_token: Optional[str] = None
self.expires_at: Optional[datetime] = None
self.token_type: str = "Bearer"
def get_access_token(self) -> str:
"""
Returns a valid access token.
If the current token is expired or missing, it fetches a new one.
"""
# Check if we have a valid token
if self.is_token_valid():
return self.access_token
# Token is missing or expired, fetch a new one
self._fetch_new_token()
return self.access_token
def is_token_valid(self) -> bool:
"""
Checks if the current token exists and has not expired.
We subtract 5 minutes as a buffer to prevent race conditions during API calls.
"""
if not self.access_token or not self.expires_at:
return False
# Buffer time: 5 minutes
buffer = timedelta(minutes=5)
return datetime.utcnow() < (self.expires_at - buffer)
def _fetch_new_token(self) -> None:
"""
Performs the OAuth2 Client Credentials Grant flow.
"""
if not self.client_id or not self.client_secret:
raise ValueError("Client ID and Client Secret must be provided via environment variables.")
# The payload for Client Credentials Grant
payload = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret
}
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
try:
# Send the request
response = requests.post(AUTH_URL, data=payload, headers=headers, timeout=10)
# Raise exception for HTTP errors (4xx, 5xx)
response.raise_for_status()
# Parse the JSON response
data = response.json()
# Extract token details
self.access_token = data["access_token"]
self.token_type = data.get("token_type", "Bearer")
# Calculate expiration time
# expires_in is returned in seconds
expires_in = int(data["expires_in"])
self.expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
print(f"Token acquired. Expires in {expires_in} seconds.")
except requests.exceptions.HTTPError as http_err:
if response.status_code == 401:
raise Exception("Authentication failed. Check Client ID and Client Secret.") from http_err
elif response.status_code == 403:
raise Exception("Authentication forbidden. Check OAuth Client configuration.") from http_err
else:
raise Exception(f"HTTP error occurred: {http_err}") from http_err
except requests.exceptions.RequestException as req_err:
raise Exception(f"Network error occurred: {req_err}") from req_err
except KeyError as key_err:
raise Exception(f"Unexpected response format. Missing key: {key_err}") from key_err
except ValueError as val_err:
raise Exception(f"Invalid JSON response: {val_err}") from val_err
# Usage Example
if __name__ == "__main__":
auth = GenesysAuthenticator(CLIENT_ID, CLIENT_SECRET)
token = auth.get_access_token()
print(f"Current Token: {token[:10]}...")
Step 3: Implementing Retry Logic for Rate Limits
CI/CD pipelines often fail due to transient 429 (Too Many Requests) errors. Genesys Cloud uses a sliding window rate limiter. You must implement exponential backoff in your API client wrapper.
Below is a helper function that wraps API calls with retry logic.
import requests
import time
import random
def make_api_call_with_retry(url: str, method: str = "GET", headers: dict = None, data: dict = None, max_retries: int = 3) -> requests.Response:
"""
Makes an HTTP request with exponential backoff retry logic for 429 and 5xx errors.
Args:
url: The full URL of the API endpoint.
method: HTTP method (GET, POST, etc.).
headers: Dictionary of headers.
data: Dictionary of body data.
max_retries: Maximum number of retry attempts.
Returns:
requests.Response object.
"""
if headers is None:
headers = {}
for attempt in range(max_retries + 1):
try:
# Add standard headers if not present
if "Content-Type" not in headers and method in ["POST", "PUT", "PATCH"]:
headers["Content-Type"] = "application/json"
response = requests.request(method, url, headers=headers, json=data, timeout=30)
# Success or client error (4xx) - do not retry
if response.status_code < 400 or response.status_code < 500:
return response
# Retryable server error (5xx) or Rate Limit (429)
if response.status_code in [429, 500, 502, 503, 504]:
if attempt < max_retries:
# Calculate backoff time: 2^attempt + random jitter
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"Received status {response.status_code}. Retrying in {wait_time:.2f} seconds...")
time.sleep(wait_time)
continue
else:
raise Exception(f"Max retries exceeded for {url}. Status: {response.status_code}")
# Non-retryable error
response.raise_for_status()
except requests.exceptions.RequestException as e:
if attempt < max_retries:
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"Request failed: {e}. Retrying in {wait_time:.2f} seconds...")
time.sleep(wait_time)
else:
raise Exception(f"Max retries exceeded due to network error: {e}")
return None
Step 4: Consuming the API
Now, combine the authentication and retry logic to call a real Genesys Cloud API. We will query the Users API to list users, which requires the user:read scope.
def get_users(auth: GenesysAuthenticator, page: int = 1, page_size: int = 25) -> list:
"""
Fetches a list of users from Genesys Cloud.
"""
# 1. Get a valid token
token = auth.get_access_token()
# 2. Set up headers
headers = {
"Authorization": f"{auth.token_type} {token}",
"Content-Type": "application/json"
}
# 3. Define the endpoint
url = f"{BASE_URL}/api/v2/users"
# 4. Make the request with retry logic
# Note: requests.request supports params for query strings
params = {
"pageSize": page_size,
"pageNumber": page
}
response = make_api_call_with_retry(url, method="GET", headers=headers, params=params)
# 5. Handle response
if response.status_code == 200:
data = response.json()
entities = data.get("entities", [])
print(f"Retrieved {len(entities)} users.")
return entities
elif response.status_code == 401:
# Token might have expired during the retry window or scope issue
print("401 Unauthorized. Refreshing token and retrying once.")
auth._fetch_new_token()
# Retry one more time with new token
new_token = auth.access_token
headers["Authorization"] = f"{auth.token_type} {new_token}"
response = make_api_call_with_retry(url, method="GET", headers=headers, params=params)
if response.status_code == 200:
return response.json().get("entities", [])
else:
raise Exception(f"Retry failed with status {response.status_code}")
else:
raise Exception(f"Failed to fetch users. Status: {response.status_code}, Body: {response.text}")
# Run the example
if __name__ == "__main__":
auth = GenesysAuthenticator(CLIENT_ID, CLIENT_SECRET)
users = get_users(auth)
for user in users[:3]:
print(f"User: {user['name']} (ID: {user['id']})")
Complete Working Example
Below is the complete, self-contained Python script. Save this as genesys_ci_cd_auth.py. Ensure you have set the GENESYS_CLIENT_ID and GENESYS_CLIENT_SECRET environment variables before running.
import requests
import os
import time
import random
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
# ==============================================================================
# Configuration
# ==============================================================================
CLIENT_ID = os.getenv("GENESYS_CLIENT_ID")
CLIENT_SECRET = os.getenv("GENESYS_CLIENT_SECRET")
BASE_URL = "https://api.mypurecloud.com"
AUTH_URL = f"{BASE_URL}/oauth/token"
# ==============================================================================
# Authentication Module
# ==============================================================================
class GenesysAuthenticator:
def __init__(self, client_id: str, client_secret: str):
self.client_id = client_id
self.client_secret = client_secret
self.access_token: Optional[str] = None
self.expires_at: Optional[datetime] = None
self.token_type: str = "Bearer"
def get_access_token(self) -> str:
"""
Returns a valid access token.
If the current token is expired or missing, it fetches a new one.
"""
if self.is_token_valid():
return self.access_token
self._fetch_new_token()
return self.access_token
def is_token_valid(self) -> bool:
"""
Checks if the current token exists and has not expired.
Includes a 5-minute buffer to prevent race conditions.
"""
if not self.access_token or not self.expires_at:
return False
buffer = timedelta(minutes=5)
return datetime.utcnow() < (self.expires_at - buffer)
def _fetch_new_token(self) -> None:
"""
Performs the OAuth2 Client Credentials Grant flow.
"""
if not self.client_id or not self.client_secret:
raise ValueError("Client ID and Client Secret must be provided via environment variables.")
payload = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret
}
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
try:
response = requests.post(AUTH_URL, data=payload, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
self.token_type = data.get("token_type", "Bearer")
expires_in = int(data["expires_in"])
self.expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
except requests.exceptions.HTTPError as http_err:
if response.status_code == 401:
raise Exception("Authentication failed. Check Client ID and Client Secret.") from http_err
elif response.status_code == 403:
raise Exception("Authentication forbidden. Check OAuth Client configuration.") from http_err
else:
raise Exception(f"HTTP error occurred: {http_err}") from http_err
except requests.exceptions.RequestException as req_err:
raise Exception(f"Network error occurred: {req_err}") from req_err
except KeyError as key_err:
raise Exception(f"Unexpected response format. Missing key: {key_err}") from key_err
# ==============================================================================
# HTTP Client with Retry Logic
# ==============================================================================
def make_api_call_with_retry(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
max_retries: int = 3
) -> requests.Response:
"""
Makes an HTTP request with exponential backoff retry logic for 429 and 5xx errors.
"""
if headers is None:
headers = {}
for attempt in range(max_retries + 1):
try:
if "Content-Type" not in headers and method in ["POST", "PUT", "PATCH"]:
headers["Content-Type"] = "application/json"
response = requests.request(method, url, headers=headers, params=params, json=data, timeout=30)
if response.status_code < 500 and response.status_code != 429:
return response
if response.status_code in [429, 500, 502, 503, 504]:
if attempt < max_retries:
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"Rate limited or server error ({response.status_code}). Retrying in {wait_time:.2f}s...")
time.sleep(wait_time)
continue
else:
raise Exception(f"Max retries exceeded for {url}. Status: {response.status_code}")
response.raise_for_status()
except requests.exceptions.RequestException as e:
if attempt < max_retries:
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"Request failed: {e}. Retrying in {wait_time:.2f}s...")
time.sleep(wait_time)
else:
raise Exception(f"Max retries exceeded due to network error: {e}")
return None
# ==============================================================================
# Business Logic: Fetch Users
# ==============================================================================
def get_users(auth: GenesysAuthenticator, page: int = 1, page_size: int = 25) -> List[Dict[str, Any]]:
"""
Fetches a list of users from Genesys Cloud.
Scope required: user:read
"""
token = auth.get_access_token()
headers = {
"Authorization": f"{auth.token_type} {token}",
"Content-Type": "application/json"
}
url = f"{BASE_URL}/api/v2/users"
params = {
"pageSize": page_size,
"pageNumber": page
}
response = make_api_call_with_retry(url, method="GET", headers=headers, params=params)
if response.status_code == 200:
data = response.json()
entities = data.get("entities", [])
print(f"Retrieved {len(entities)} users.")
return entities
elif response.status_code == 401:
print("401 Unauthorized. Refreshing token and retrying once.")
auth._fetch_new_token()
new_token = auth.access_token
headers["Authorization"] = f"{auth.token_type} {new_token}"
response = make_api_call_with_retry(url, method="GET", headers=headers, params=params)
if response.status_code == 200:
return response.json().get("entities", [])
else:
raise Exception(f"Retry failed with status {response.status_code}")
else:
raise Exception(f"Failed to fetch users. Status: {response.status_code}, Body: {response.text}")
# ==============================================================================
# Main Execution
# ==============================================================================
if __name__ == "__main__":
if not CLIENT_ID or not CLIENT_SECRET:
print("Error: GENESYS_CLIENT_ID and GENESYS_CLIENT_SECRET environment variables are required.")
exit(1)
try:
auth = GenesysAuthenticator(CLIENT_ID, CLIENT_SECRET)
users = get_users(auth, page=1, page_size=10)
print("\n--- User List ---")
for user in users:
print(f"ID: {user['id']} | Name: {user['name']} | Email: {user.get('email', 'N/A')}")
except Exception as e:
print(f"Fatal Error: {e}")
exit(1)
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The Client ID or Client Secret is incorrect, or the OAuth Client is disabled in the Genesys Cloud Admin Portal.
- Fix: Verify the credentials in your CI/CD environment variables. Ensure the OAuth Client is active.
- Code Check: Ensure you are sending the
client_secretin the body, not in the header. Genesys Cloud requiresapplication/x-www-form-urlencodedwithclient_idandclient_secretin the body for the token endpoint.
Error: 403 Forbidden
- Cause: The OAuth Client does not have the required scopes for the API endpoint you are calling.
- Fix: Go to the OAuth Client configuration in the Admin Portal. Add the required scope (e.g.,
user:readfor the Users API). Save the changes. - Note: Scope changes can take up to 15 minutes to propagate. If you recently added a scope, wait and retry.
Error: 429 Too Many Requests
- Cause: You have exceeded the API rate limit for your organization or specific endpoint.
- Fix: Implement exponential backoff with jitter, as shown in the
make_api_call_with_retryfunction. Do not retry immediately. Read theRetry-Afterheader if present in the response. - Code Check: Ensure your retry logic respects the 429 status code and waits before the next attempt.
Error: Invalid Grant
- Cause: The
grant_typeis notclient_credentials, or the credentials are malformed. - Fix: Ensure the payload sent to
/oauth/tokenincludesgrant_type: client_credentials.