A/B Testing NICE CXone Outbound Campaign Scripts via API with Python

A/B Testing NICE CXone Outbound Campaign Scripts via API with Python

What You Will Build

  • A Python module that constructs, validates, and deploys A/B test split configurations for CXone Outbound campaigns using script references, variant matrices, and split directives.
  • The code uses the CXone Outbound Campaign API v2 with requests for atomic HTTP operations, statistical validation, and webhook synchronization.
  • The tutorial covers Python 3.9+ with explicit error handling, retry logic, and audit logging.

Prerequisites

  • CXone OAuth2 client credentials with outbound:campaign:read and outbound:campaign:write scopes.
  • CXone API v2 base URL (e.g., https://api.nicecxone.com or tenant-specific).
  • Python 3.9+ runtime.
  • External dependencies: pip install requests scipy

Authentication Setup

CXone uses standard OAuth2 client credentials flow. The following client caches tokens and handles refresh automatically.

import requests
import time
import json
from typing import Dict, Optional

class CXoneAuthClient:
    def __init__(self, tenant_base_url: str, client_id: str, client_secret: str, scopes: str = "outbound:campaign:read outbound:campaign:write"):
        self.base_url = tenant_base_url.rstrip("/")
        self.client_id = client_id
        self.client_secret = client_secret
        self.scopes = scopes
        self.token_url = f"{self.base_url}/api/v2/oauth/token"
        self._token: Optional[Dict] = None
        self._expiry: float = 0.0

    def get_access_token(self) -> str:
        if self._token and time.time() < self._expiry - 300:
            return self._token["access_token"]
        
        headers = {"Content-Type": "application/x-www-form-urlencoded"}
        data = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": self.scopes
        }
        
        response = requests.post(self.token_url, headers=headers, data=data)
        response.raise_for_status()
        
        token_data = response.json()
        self._token = token_data
        self._expiry = time.time() + token_data["expires_in"]
        return self._token["access_token"]

Implementation

Step 1: Construct Testing Payloads with Script References and Split Directives

The CXone Outbound Campaign API accepts a split directive containing a variantMatrix and an array of script references. The payload must match the v2 schema exactly.

from dataclasses import dataclass, asdict
from typing import List

@dataclass
class SplitVariant:
    name: str
    script_ref: str
    weight: int

@dataclass
class VariantMatrix:
    routing_strategy: str
    allocation_method: str

@dataclass
class SplitDirective:
    type: str
    variants: List[SplitVariant]
    variant_matrix: VariantMatrix

def build_split_campaign_payload(
    campaign_name: str,
    split_type: str,
    variants: List[SplitVariant],
    matrix: VariantMatrix,
    dial_plan_id: str,
    contact_list_id: str
) -> Dict:
    return {
        "name": campaign_name,
        "type": "Outbound",
        "dialPlanId": dial_plan_id,
        "contactListId": contact_list_id,
        "split": {
            "type": split_type,
            "variants": [
                {
                    "name": v.name,
                    "scriptRef": v.script_ref,
                    "weight": v.weight
                } for v in variants
            ],
            "variantMatrix": {
                "routingStrategy": matrix.routing_strategy,
                "allocationMethod": matrix.allocation_method
            }
        },
        "status": "Draft"
    }

Expected Request Structure:

POST /api/v2/outbound/campaigns HTTP/1.1
Host: api.nicecxone.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "Script A/B Test Q3",
  "type": "Outbound",
  "dialPlanId": "dp-8f7a6b5c-4d3e-2f1a-0b9c-8d7e6f5a4b3c",
  "contactListId": "cl-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "split": {
    "type": "A/B",
    "variants": [
      {"name": "Control Script", "scriptRef": "scr-control-001", "weight": 50},
      {"name": "Test Script V1", "scriptRef": "scr-test-v1-002", "weight": 50}
    ],
    "variantMatrix": {
      "routingStrategy": "roundRobin",
      "allocationMethod": "statistical"
    }
  },
  "status": "Draft"
}

Step 2: Validate Schemas Against Statistical Constraints and Variant Limits

CXone enforces maximum variant counts and statistical validity. The following pipeline validates weights, checks biased samples, and calculates confidence intervals before deployment.

import math
from scipy import stats

MAX_VARIANTS = 8
MIN_SAMPLE_SIZE = 100

def validate_split_config(variants: List[SplitVariant]) -> None:
    if len(variants) > MAX_VARIANTS:
        raise ValueError(f"Variant count {len(variants)} exceeds maximum limit of {MAX_VARIANTS}")
    
    total_weight = sum(v.weight for v in variants)
    if total_weight != 100:
        raise ValueError(f"Variant weights must sum to 100. Current total: {total_weight}")

def check_biased_sample(variant_a_conv: float, variant_b_conv: float, sample_size: int) -> Dict:
    """Detects pre-existing bias using two-proportion z-test."""
    if sample_size < MIN_SAMPLE_SIZE:
        raise ValueError(f"Sample size {sample_size} is below minimum threshold of {MIN_SAMPLE_SIZE}")
    
    n = sample_size // 2
    p_pool = (variant_a_conv * n + variant_b_conv * n) / sample_size
    se = math.sqrt(p_pool * (1 - p_pool) * (1/n + 1/n))
    z_stat = (variant_a_conv - variant_b_conv) / se if se > 0 else 0.0
    p_value = 2 * (1 - stats.norm.cdf(abs(z_stat)))
    
    return {
        "z_statistic": round(z_stat, 4),
        "p_value": round(p_value, 4),
        "is_biased": p_value < 0.05,
        "message": "Significant pre-existing bias detected. Adjust contact list before splitting." if p_value < 0.05 else "Sample distribution is statistically neutral."
    }

def calculate_confidence_interval(conversions: int, total_attempts: int, confidence_level: float = 0.95) -> Dict:
    """Wilson score interval for conversion rate."""
    if total_attempts == 0:
        return {"lower": 0.0, "upper": 0.0, "point_estimate": 0.0}
    
    p_hat = conversions / total_attempts
    z = stats.norm.ppf(1 - (1 - confidence_level) / 2)
    denominator = 1 + z**2 / total_attempts
    center = (p_hat + z**2 / (2 * total_attempts)) / denominator
    margin = z * math.sqrt((p_hat * (1 - p_hat) + z**2 / (4 * total_attempts)) / total_attempts) / denominator
    
    return {
        "point_estimate": round(center, 4),
        "lower": round(max(0, center - margin), 4),
        "upper": round(min(1, center + margin), 4),
        "confidence_level": confidence_level
    }

Step 3: Execute Atomic HTTP PUT Operations with Format Verification and Merge Triggers

Atomic updates require ETag handling and automatic merge triggers for safe split iteration. The client verifies response format and handles optimistic concurrency.

class CXoneSplitDeployer:
    def __init__(self, auth: CXoneAuthClient, base_url: str):
        self.auth = auth
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.headers.update({"Content-Type": "application/json"})
        
        # Retry configuration for 429 rate limits
        self.session.mount("https://", requests.adapters.HTTPAdapter(max_retries=requests.adapters.Retry(
            total=3,
            backoff_factor=1.5,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["PUT", "POST", "GET"]
        )))

    def atomic_update_campaign(self, campaign_id: str, payload: Dict, etag: Optional[str] = None) -> Dict:
        url = f"{self.base_url}/api/v2/outbound/campaigns/{campaign_id}"
        headers = {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "If-Match": etag if etag else "*",
            "Accept": "application/json"
        }
        
        # Format verification: ensure split directive structure matches API contract
        if "split" not in payload or "variantMatrix" not in payload["split"]:
            raise ValueError("Payload missing required split directive or variantMatrix")
            
        response = self.session.put(url, headers=headers, json=payload)
        
        if response.status_code == 409:
            raise ConflictError("ETag mismatch. Campaign was modified by another process.")
        response.raise_for_status()
        
        result = response.json()
        return {
            "campaign_id": campaign_id,
            "new_etag": response.headers.get("ETag"),
            "status": result.get("status"),
            "split_active": result.get("split", {}).get("type") is not None
        }

class ConflictError(Exception):
    pass

Expected Response Cycle:

PUT /api/v2/outbound/campaigns/cmp-9f8e7d6c-5b4a-3210-feed-faceb00c0ff1 HTTP/1.1
Host: api.nicecxone.com
Authorization: Bearer <token>
If-Match: "a1b2c3d4e5f6g7h8"
Content-Type: application/json

{ ... payload ... }

HTTP/1.1 200 OK
ETag: "i9j8k7l6m5n4o3p2"
Content-Type: application/json

{
  "id": "cmp-9f8e7d6c-5b4a-3210-feed-faceb00c0ff1",
  "name": "Script A/B Test Q3",
  "status": "Active",
  "split": {
    "type": "A/B",
    "variants": [ ... ],
    "variantMatrix": { ... }
  }
}

Step 4: Synchronize Testing Events via Webhooks and Track Latency

Testing events must sync with external analytics. The following module tracks split success rates, measures latency, and generates governance audit logs.

import time
import uuid
from datetime import datetime, timezone

class SplitTestGovernance:
    def __init__(self, webhook_url: str, audit_log_dir: str = "./audit_logs"):
        self.webhook_url = webhook_url
        self.audit_dir = audit_log_dir
        self.metrics = {"total_deployments": 0, "successful_splits": 0, "avg_latency_ms": 0.0}

    def record_deployment(self, campaign_id: str, status: str, latency_ms: float, etag: str) -> Dict:
        timestamp = datetime.now(timezone.utc).isoformat()
        audit_entry = {
            "event_id": str(uuid.uuid4()),
            "timestamp": timestamp,
            "campaign_id": campaign_id,
            "action": "SPLIT_DEPLOY",
            "status": status,
            "etag": etag,
            "latency_ms": round(latency_ms, 2),
            "governance_tag": "AB_TEST_SCRIPT"
        }
        
        self._save_audit_log(audit_entry)
        self._sync_webhook(audit_entry)
        self._update_metrics(latency_ms, status == "success")
        
        return audit_entry

    def _save_audit_log(self, entry: Dict) -> None:
        log_path = f"{self.audit_dir}/{entry['campaign_id']}_{int(time.time())}.json"
        with open(log_path, "w") as f:
            json.dump(entry, f, indent=2)

    def _sync_webhook(self, entry: Dict) -> None:
        try:
            requests.post(
                self.webhook_url,
                json={"type": "split_test_event", "payload": entry},
                timeout=5
            )
        except requests.RequestException:
            pass  # Webhook failures should not block atomic deployment

    def _update_metrics(self, latency: float, success: bool) -> None:
        self.metrics["total_deployments"] += 1
        if success:
            self.metrics["successful_splits"] += 1
        n = self.metrics["total_deployments"]
        current_avg = self.metrics["avg_latency_ms"]
        self.metrics["avg_latency_ms"] = round((current_avg * (n - 1) + latency) / n, 2)

Complete Working Example

The following script integrates authentication, validation, atomic deployment, and governance tracking into a single executable module. Replace placeholder credentials and IDs before execution.

import time
import requests
from typing import List, Optional

# Import classes from previous steps
# CXoneAuthClient, CXoneSplitDeployer, SplitTestGovernance, SplitVariant, VariantMatrix, SplitDirective
# build_split_campaign_payload, validate_split_config, check_biased_sample, calculate_confidence_interval

def run_ab_test_deployment():
    # 1. Authentication
    auth = CXoneAuthClient(
        tenant_base_url="https://api.nicecxone.com",
        client_id="your-client-id",
        client_secret="your-client-secret"
    )
    token = auth.get_access_token()
    print(f"Authenticated successfully. Token expires in 1 hour.")

    # 2. Define Variants and Matrix
    variants = [
        SplitVariant(name="Control Script", script_ref="scr-control-001", weight=50),
        SplitVariant(name="Test Script V1", script_ref="scr-test-v1-002", weight=50)
    ]
    matrix = VariantMatrix(routing_strategy="roundRobin", allocation_method="statistical")

    # 3. Validate Schema and Statistical Constraints
    validate_split_config(variants)
    bias_check = check_biased_sample(variant_a_conv=0.12, variant_b_conv=0.13, sample_size=500)
    print(f"Bias Check: {bias_check['message']}")
    if bias_check["is_biased"]:
        raise RuntimeError("Aborting deployment due to biased sample distribution.")

    ci = calculate_confidence_interval(conversions=60, total_attempts=500)
    print(f"Confidence Interval (95%): [{ci['lower']}, {ci['upper']}]")

    # 4. Construct Payload
    payload = build_split_campaign_payload(
        campaign_name="Script A/B Test Q3",
        split_type="A/B",
        variants=variants,
        matrix=matrix,
        dial_plan_id="dp-8f7a6b5c-4d3e-2f1a-0b9c-8d7e6f5a4b3c",
        contact_list_id="cl-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
    )

    # 5. Atomic Deployment with Latency Tracking
    deployer = CXoneSplitDeployer(auth, "https://api.nicecxone.com")
    governance = SplitTestGovernance(webhook_url="https://your-analytics-endpoint/webhooks/split-sync")
    
    start_time = time.perf_counter()
    try:
        result = deployer.atomic_update_campaign(
            campaign_id="cmp-9f8e7d6c-5b4a-3210-feed-faceb00c0ff1",
            payload=payload
        )
        latency_ms = (time.perf_counter() - start_time) * 1000
        print(f"Deployment successful. New ETag: {result['new_etag']}")
        
        governance.record_deployment(
            campaign_id=result["campaign_id"],
            status="success",
            latency_ms=latency_ms,
            etag=result["new_etag"]
        )
    except requests.exceptions.HTTPError as e:
        latency_ms = (time.perf_counter() - start_time) * 1000
        print(f"HTTP Error: {e.response.status_code} - {e.response.text}")
        governance.record_deployment(
            campaign_id="cmp-9f8e7d6c-5b4a-3210-feed-faceb00c0ff1",
            status="failed",
            latency_ms=latency_ms,
            etag=""
        )
    except ConflictError as e:
        print(f"Concurrency conflict: {e}")
    except ValueError as e:
        print(f"Validation failed: {e}")

if __name__ == "__main__":
    run_ab_test_deployment()

Common Errors & Debugging

Error: 400 Bad Request (Invalid Split Directive)

  • Cause: The variantMatrix object is missing, weights do not sum to 100, or scriptRef contains an invalid UUID format.
  • Fix: Verify weight summation and ensure scriptRef matches existing CXone script IDs. Use validate_split_config() before sending.
  • Code: Add explicit schema validation before the PUT request. The build_split_campaign_payload function already enforces structure.

Error: 409 Conflict (ETag Mismatch)

  • Cause: Another process modified the campaign between the GET and PUT operations.
  • Fix: Implement a retry loop that fetches the latest campaign state, merges local changes, and resubmits with the new ETag.
  • Code:
def retry_with_etag(deployer, campaign_id, payload, max_retries=3):
    for attempt in range(max_retries):
        try:
            return deployer.atomic_update_campaign(campaign_id, payload)
        except ConflictError:
            time.sleep(2 ** attempt)
            # Fetch latest state and merge if necessary
            raise

Error: 429 Too Many Requests

  • Cause: Rate limit exceeded on the Outbound Campaign endpoint.
  • Fix: The CXoneSplitDeployer class mounts an HTTPAdapter with exponential backoff. Ensure your client credentials have sufficient rate tier allocation.
  • Code: The retry logic is already configured in CXoneSplitDeployer.__init__(). Monitor Retry-After headers if custom backoff is required.

Error: 403 Forbidden (Insufficient Scopes)

  • Cause: OAuth token lacks outbound:campaign:write.
  • Fix: Regenerate the token with the correct scope string. Verify tenant permissions for outbound campaign management.
  • Code: Update scopes parameter in CXoneAuthClient initialization.

Official References