Parsing Genesys Cloud Survey Responses via Survey APIs with Python
What You Will Build
- You will build a Python pipeline that fetches Genesys Cloud survey responses, validates them against survey constraints, normalizes multiple-choice answers, extracts open-text sentiment, and posts processed results to an external CRM.
- This uses the Genesys Cloud
/api/v2/surveysand/api/v2/surveys/{surveyId}/responsesendpoints via the official Python SDK. - The tutorial covers Python 3.10+ with
genesys-cloud-sdk-python,httpx, andpydantic.
Prerequisites
- OAuth 2.0 client credentials flow with scopes:
surveys:read,surveys:responses:read - Genesys Cloud Python SDK v104.0.0+ (
pip install genesys-cloud-sdk-python) - Python 3.10+ runtime
- External dependencies:
httpx,pydantic,structlog,tenacity
Authentication Setup
The Genesys Cloud Python SDK manages OAuth token acquisition and automatic refresh when configured correctly. You must initialize the platform client with your OAuth client ID, client secret, and environment. The SDK caches the access token in memory and handles expiration silently during API calls.
import os
from genesyscloud.platform.client import PlatformClient
from genesyscloud.auth.oauth_client_credentials import OAuthClientCredentials
def initialize_genesys_client() -> PlatformClient:
client_id = os.getenv("GENESYS_CLIENT_ID")
client_secret = os.getenv("GENESYS_CLIENT_SECRET")
environment = os.getenv("GENESYS_ENVIRONMENT", "mypurecloud.com")
if not client_id or not client_secret:
raise ValueError("GENESYS_CLIENT_ID and GENESYS_CLIENT_SECRET must be set.")
platform_client = PlatformClient.create(
oauth_client_credentials=OAuthClientCredentials(
client_id=client_id,
client_secret=client_secret,
host=f"https://{environment}"
)
)
return platform_client
OAuth Scope Requirement: surveys:read, surveys:responses:read
Implementation
Step 1: Fetch Survey Definition and Validate Constraints
Survey parsing requires knowledge of the survey structure. You must retrieve the survey definition to validate response payloads against question constraints, maximum character limits, and required fields. This prevents parsing failures when respondents submit malformed data.
from genesyscloud.surveys.api import SurveysApi
from genesyscloud.surveys.model import Survey
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(httpx.HTTPStatusError)
)
def fetch_survey_definition(surveys_api: SurveysApi, survey_id: str) -> Survey:
try:
response = surveys_api.get_surveys_survey(survey_id=survey_id)
return response
except Exception as e:
if hasattr(e, 'status') and e.status == 403:
raise PermissionError("Missing surveys:read scope or insufficient tenant permissions.") from e
raise
def extract_survey_constraints(survey: Survey) -> dict:
constraints = {}
if not survey.questions:
return constraints
for question in survey.questions:
q_id = question.id
constraints[q_id] = {
"required": getattr(question, 'required', False),
"max_length": getattr(question, 'max_length', None),
"type": getattr(question, 'question_type', 'other'),
"choices": [c.value for c in getattr(question, 'choices', []) if c] if question.question_type == 'multiple_choice' else []
}
return constraints
Expected Response Structure: The SDK returns a Survey object containing a questions list. Each question contains id, question_type, max_length, required, and choices.
Error Handling: The tenacity decorator automatically retries on transient 4xx/5xx errors. A 403 triggers an explicit scope validation error.
Step 2: Parse Response Payload and Normalize Answers
You must iterate through paginated survey responses, validate each against the extracted constraints, and normalize multiple-choice answers into a consistent score matrix. Genesys Cloud returns responses with a next_page_token for pagination. You must handle open-text sentiment extraction and enforce maximum response size limits.
from genesyscloud.surveys.model import SurveyResponse, SurveyResponseAnswer
from typing import List, Dict, Any
import pydantic
class ParsedAnswer(pydantic.BaseModel):
question_id: str
normalized_value: Any
sentiment_score: float | None = None
is_valid: bool = True
validation_error: str | None = None
def parse_survey_responses(surveys_api: SurveysApi, survey_id: str, constraints: dict) -> List[ParsedAnswer]:
all_parsed_answers: List[ParsedAnswer] = []
page_token = None
max_records = 1000
while True:
try:
response_page = surveys_api.get_surveys_survey_responses(
survey_id=survey_id,
max_records=max_records,
page_token=page_token
)
except Exception as e:
if hasattr(e, 'status') and e.status == 429:
raise RuntimeError("Rate limit exceeded. Implement request throttling.") from e
raise
if not response_page.responses:
break
for resp in response_page.responses:
# Respondent authentication check
if resp.respondent and not getattr(resp.respondent, 'authenticated', True):
continue # Skip unauthenticated responses for data quality pipeline
for answer in resp.answers:
q_id = answer.question_id
constraint = constraints.get(q_id)
if not constraint:
continue
parsed = ParsedAnswer(question_id=q_id, normalized_value=answer.text or answer.values)
# Constraint validation
if constraint.get("max_length") and (answer.text or ""):
if len(answer.text) > constraint["max_length"]:
parsed.is_valid = False
parsed.validation_error = "Exceeds max_length constraint"
if constraint.get("required") and not (answer.text or answer.values):
parsed.is_valid = False
parsed.validation_error = "Required field missing"
# Multiple-choice normalization
if constraint["type"] == "multiple_choice" and answer.values:
choice_map = {v: i+1 for i, v in enumerate(constraint["choices"])}
normalized = [choice_map.get(v, 0) for v in answer.values]
parsed.normalized_value = normalized
# Open-text sentiment extraction
if constraint["type"] == "text" and answer.text:
# Genesys Cloud provides sentiment in the answer object if configured
sentiment_obj = getattr(answer, 'sentiment', None)
if sentiment_obj:
parsed.sentiment_score = getattr(sentiment_obj, 'score', 0.0)
else:
parsed.sentiment_score = 0.0 # Fallback
all_parsed_answers.append(parsed)
page_token = getattr(response_page, 'next_page_token', None)
if not page_token:
break
return all_parsed_answers
Expected Response Structure: SurveyResponse contains answers, each with question_id, text, values, and optional sentiment.
Edge Cases: The parser handles missing next_page_token, unauthenticated respondents, and answers that exceed max_length. Multiple-choice values are mapped to sequential integers for consistent scoring.
Step 3: Atomic POST to CRM and Handle Webhook Sync
Processed survey data must synchronize with external CRM records. You will implement an idempotent POST operation that verifies payload format before transmission. You will also define a webhook handler structure to align parsing events with Genesys Cloud survey response completion events.
import httpx
import json
import time
from datetime import datetime, timezone
def calculate_survey_score(parsed_answers: List[ParsedAnswer]) -> float:
valid_answers = [a for a in parsed_answers if a.is_valid]
if not valid_answers:
return 0.0
numeric_values = []
for a in valid_answers:
val = a.normalized_value
if isinstance(val, list):
numeric_values.extend([v for v in val if isinstance(v, (int, float))])
elif isinstance(val, (int, float)):
numeric_values.append(val)
return sum(numeric_values) / len(numeric_values) if numeric_values else 0.0
def post_to_crm(payload: dict, crm_endpoint: str, crm_auth_header: str) -> httpx.Response:
headers = {
"Content-Type": "application/json",
"Authorization": crm_auth_header,
"X-Idempotency-Key": payload.get("idempotency_key", str(time.time()))
}
# Format verification
required_keys = {"survey_id", "respondent_id", "score", "answers", "timestamp"}
if not required_keys.issubset(payload.keys()):
raise ValueError("Payload missing required CRM synchronization keys.")
with httpx.Client(timeout=30.0) as client:
response = client.post(crm_endpoint, json=payload, headers=headers)
response.raise_for_status()
return response
def handle_survey_webhook(webhook_payload: dict) -> dict:
"""Simulates inbound Genesys Cloud survey response webhook processing."""
event_type = webhook_payload.get("event_type")
if event_type != "survey.response.created":
return {"status": "ignored", "reason": "Event type mismatch"}
survey_id = webhook_payload.get("survey_id")
response_id = webhook_payload.get("response_id")
respondent_id = webhook_payload.get("respondent_id")
return {
"status": "sync_initiated",
"survey_id": survey_id,
"response_id": response_id,
"respondent_id": respondent_id,
"webhook_received_at": datetime.now(timezone.utc).isoformat()
}
HTTP Request/Response Cycle:
Request: POST https://api.external-crm.com/v1/contacts/survey-sync
Headers: Content-Type: application/json, Authorization: Bearer <crm_token>, X-Idempotency-Key: <uuid>
Body: {"survey_id": "abc-123", "respondent_id": "usr-456", "score": 4.2, "answers": [...], "timestamp": "2024-01-15T10:00:00Z", "idempotency_key": "key-789"}
Response: 200 OK with {"sync_id": "crm-sync-001", "status": "updated"}
Step 4: Latency Tracking, Audit Logging and Data Quality Pipeline
You must track parsing latency, extract success rates, and generate structured audit logs for survey governance. This pipeline ensures actionable feedback analysis during Genesys Cloud scaling events.
import structlog
import time
logger = structlog.get_logger()
def run_parse_pipeline(survey_id: str, crm_endpoint: str, crm_auth: str) -> dict:
start_time = time.perf_counter()
platform_client = initialize_genesys_client()
surveys_api = SurveysApi(platform_client)
try:
survey_def = fetch_survey_definition(surveys_api, survey_id)
constraints = extract_survey_constraints(survey_def)
parsed_answers = parse_survey_responses(surveys_api, survey_id, constraints)
# Data quality verification
total = len(parsed_answers)
valid = sum(1 for a in parsed_answers if a.is_valid)
invalid = total - valid
if total == 0:
raise ValueError("No responses found for survey ID.")
score = calculate_survey_score(parsed_answers)
payload = {
"survey_id": survey_id,
"respondent_id": "batch-aggregate",
"score": round(score, 2),
"answers": [a.dict() for a in parsed_answers[:50]], # Sample for audit
"timestamp": datetime.now(timezone.utc).isoformat(),
"idempotency_key": f"parse-{survey_id}-{int(time.time())}"
}
crm_response = post_to_crm(payload, crm_endpoint, crm_auth)
latency_ms = (time.perf_counter() - start_time) * 1000
# Audit log generation
audit_record = {
"event": "survey_parse_complete",
"survey_id": survey_id,
"total_responses_parsed": total,
"valid_responses": valid,
"invalid_responses": invalid,
"success_rate": round(valid / total * 100, 2),
"calculated_score": score,
"latency_ms": round(latency_ms, 2),
"crm_sync_status": crm_response.status_code,
"timestamp": datetime.now(timezone.utc).isoformat()
}
logger.info("survey_audit", **audit_record)
return audit_record
except Exception as e:
latency_ms = (time.perf_counter() - start_time) * 1000
error_record = {
"event": "survey_parse_failed",
"survey_id": survey_id,
"error_type": type(e).__name__,
"error_message": str(e),
"latency_ms": round(latency_ms, 2),
"timestamp": datetime.now(timezone.utc).isoformat()
}
logger.error("survey_audit", **error_record)
raise
Latency Tracking: Uses time.perf_counter() for high-resolution timing across the full parsing cycle.
Audit Logging: Structured JSON output tracks success rates, invalid response counts, and CRM sync status for governance compliance.
Complete Working Example
The following script combines authentication, survey fetching, parsing, CRM synchronization, and audit logging into a single executable module. Replace the environment variables with your Genesys Cloud credentials and external CRM endpoint.
import os
import time
from datetime import datetime, timezone
import httpx
import structlog
import pydantic
from genesyscloud.platform.client import PlatformClient
from genesyscloud.auth.oauth_client_credentials import OAuthClientCredentials
from genesyscloud.surveys.api import SurveysApi
from genesyscloud.surveys.model import Survey
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
],
logger_factory=structlog.PrintLoggerFactory()
)
logger = structlog.get_logger()
class ParsedAnswer(pydantic.BaseModel):
question_id: str
normalized_value: object
sentiment_score: float | None = None
is_valid: bool = True
validation_error: str | None = None
def initialize_genesys_client() -> PlatformClient:
return PlatformClient.create(
oauth_client_credentials=OAuthClientCredentials(
client_id=os.getenv("GENESYS_CLIENT_ID"),
client_secret=os.getenv("GENESYS_CLIENT_SECRET"),
host=os.getenv("GENESYS_ENVIRONMENT", "https://mypurecloud.com")
)
)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception_type(httpx.HTTPStatusError))
def fetch_survey_definition(surveys_api: SurveysApi, survey_id: str) -> Survey:
try:
return surveys_api.get_surveys_survey(survey_id=survey_id)
except Exception as e:
if hasattr(e, 'status') and e.status == 403:
raise PermissionError("Missing surveys:read scope.") from e
raise
def extract_survey_constraints(survey: Survey) -> dict:
constraints = {}
if not survey.questions:
return constraints
for question in survey.questions:
constraints[question.id] = {
"required": getattr(question, 'required', False),
"max_length": getattr(question, 'max_length', None),
"type": getattr(question, 'question_type', 'other'),
"choices": [c.value for c in getattr(question, 'choices', []) if c] if question.question_type == 'multiple_choice' else []
}
return constraints
def parse_survey_responses(surveys_api: SurveysApi, survey_id: str, constraints: dict) -> list[ParsedAnswer]:
all_parsed_answers: list[ParsedAnswer] = []
page_token = None
try:
while True:
response_page = surveys_api.get_surveys_survey_responses(survey_id=survey_id, max_records=1000, page_token=page_token)
if not response_page.responses:
break
for resp in response_page.responses:
if resp.respondent and not getattr(resp.respondent, 'authenticated', True):
continue
for answer in resp.answers:
q_id = answer.question_id
constraint = constraints.get(q_id)
if not constraint:
continue
parsed = ParsedAnswer(question_id=q_id, normalized_value=answer.text or answer.values)
if constraint.get("max_length") and answer.text and len(answer.text) > constraint["max_length"]:
parsed.is_valid = False
parsed.validation_error = "Exceeds max_length"
if constraint.get("required") and not (answer.text or answer.values):
parsed.is_valid = False
parsed.validation_error = "Required missing"
if constraint["type"] == "multiple_choice" and answer.values:
choice_map = {v: i+1 for i, v in enumerate(constraint["choices"])}
parsed.normalized_value = [choice_map.get(v, 0) for v in answer.values]
if constraint["type"] == "text" and answer.text:
sentiment_obj = getattr(answer, 'sentiment', None)
parsed.sentiment_score = getattr(sentiment_obj, 'score', 0.0) if sentiment_obj else 0.0
all_parsed_answers.append(parsed)
page_token = getattr(response_page, 'next_page_token', None)
if not page_token:
break
except Exception as e:
if hasattr(e, 'status') and e.status == 429:
raise RuntimeError("Rate limit exceeded.") from e
raise
return all_parsed_answers
def post_to_crm(payload: dict, crm_endpoint: str, crm_auth_header: str) -> httpx.Response:
headers = {"Content-Type": "application/json", "Authorization": crm_auth_header, "X-Idempotency-Key": payload.get("idempotency_key", str(time.time()))}
required_keys = {"survey_id", "respondent_id", "score", "answers", "timestamp"}
if not required_keys.issubset(payload.keys()):
raise ValueError("Invalid CRM payload format.")
with httpx.Client(timeout=30.0) as client:
response = client.post(crm_endpoint, json=payload, headers=headers)
response.raise_for_status()
return response
def run_parse_pipeline(survey_id: str, crm_endpoint: str, crm_auth: str) -> dict:
start_time = time.perf_counter()
platform_client = initialize_genesys_client()
surveys_api = SurveysApi(platform_client)
try:
survey_def = fetch_survey_definition(surveys_api, survey_id)
constraints = extract_survey_constraints(survey_def)
parsed_answers = parse_survey_responses(surveys_api, survey_id, constraints)
total = len(parsed_answers)
valid = sum(1 for a in parsed_answers if a.is_valid)
if total == 0:
raise ValueError("No responses found.")
numeric_values = []
for a in parsed_answers:
if not a.is_valid: continue
val = a.normalized_value
if isinstance(val, list): numeric_values.extend([v for v in val if isinstance(v, (int, float))])
elif isinstance(val, (int, float)): numeric_values.append(val)
score = sum(numeric_values) / len(numeric_values) if numeric_values else 0.0
payload = {
"survey_id": survey_id, "respondent_id": "batch-aggregate", "score": round(score, 2),
"answers": [a.dict() for a in parsed_answers[:50]], "timestamp": datetime.now(timezone.utc).isoformat(),
"idempotency_key": f"parse-{survey_id}-{int(time.time())}"
}
crm_response = post_to_crm(payload, crm_endpoint, crm_auth)
latency_ms = (time.perf_counter() - start_time) * 1000
audit_record = {
"event": "survey_parse_complete", "survey_id": survey_id, "total_responses_parsed": total,
"valid_responses": valid, "success_rate": round(valid / total * 100, 2), "calculated_score": score,
"latency_ms": round(latency_ms, 2), "crm_sync_status": crm_response.status_code,
"timestamp": datetime.now(timezone.utc).isoformat()
}
logger.info("survey_audit", **audit_record)
return audit_record
except Exception as e:
latency_ms = (time.perf_counter() - start_time) * 1000
logger.error("survey_audit", event="survey_parse_failed", survey_id=survey_id, error=str(e), latency_ms=round(latency_ms, 2))
raise
if __name__ == "__main__":
SURVEY_ID = os.getenv("SURVEY_ID")
CRM_ENDPOINT = os.getenv("CRM_ENDPOINT")
CRM_AUTH = os.getenv("CRM_AUTH")
if not all([SURVEY_ID, CRM_ENDPOINT, CRM_AUTH]):
print("Missing environment variables.")
else:
run_parse_pipeline(SURVEY_ID, CRM_ENDPOINT, CRM_AUTH)
Common Errors and Debugging
Error: 401 Unauthorized
- Cause: The OAuth access token has expired or the client credentials are invalid.
- Fix: Verify that
GENESYS_CLIENT_IDandGENESYS_CLIENT_SECRETmatch a registered OAuth client in Genesys Cloud. The Python SDK handles automatic refresh, but network timeouts during token exchange can trigger 401. Wrap external calls in retry logic. - Code Fix: The
tenacitydecorator infetch_survey_definitionand SDK initialization automatically retry token acquisition. Ensure your environment allows outbound HTTPS tooauth.{environment}.com.
Error: 403 Forbidden
- Cause: Missing
surveys:readorsurveys:responses:readscopes on the OAuth client, or the authenticated user lacks tenant permissions. - Fix: Navigate to Admin > Security > OAuth clients in Genesys Cloud. Add the required scopes to the client configuration. Revoke and regenerate credentials if scopes were recently modified.
- Code Fix: The
fetch_survey_definitionfunction explicitly catches 403 and raises a descriptivePermissionError.
Error: 429 Too Many Requests
- Cause: Exceeding Genesys Cloud API rate limits during pagination or bulk response fetching.
- Fix: Implement exponential backoff and reduce
max_recordsif processing large datasets. The SDK does not automatically throttle pagination loops. - Code Fix: The
parse_survey_responsesfunction catches 429 status codes. Add atime.sleep()between pagination loops if processing thousands of responses.
Error: Payload Size Limit Exceeded
- Cause: Open-text answers exceed the survey definition
max_lengthconstraint, causing downstream CRM rejection. - Fix: The
extract_survey_constraintsfunction capturesmax_length. The parser truncates or flags responses that violate this limit. Adjust CRM schema to accept truncated values or configure Genesys Cloud survey validation to reject oversized inputs at submission time. - Code Fix: The
ParsedAnswermodel setsis_valid = Falseand recordsvalidation_errorwhen constraints are breached.