Running A/B Tests for NICE CXone Outbound Campaigns with Java
What You Will Build
- A Java application that clones an existing CXone outbound campaign, creates two variants with different dial strategies and script versions, and distributes contacts between them using a deterministic hash-based split.
- The code polls real-time disposition metrics, calculates conversion rates, applies a two-proportion z-test for statistical significance, pauses underperforming variants via the Campaign API, and exports a structured performance report.
- This tutorial uses Java 17+,
java.net.http.HttpClient, and the CXone REST API v2.
Prerequisites
- OAuth 2.0 Client Credentials grant configured in CXone Admin Console
- Required scopes:
campaign:read,campaign:write,contact:read,contact:write,reporting:read - Java 17 or higher
- Dependencies:
com.fasterxml.jackson.core:jackson-databind:2.15.2,org.slf4j:slf4j-simple:2.0.9 - CXone API v2 base URL:
https://api.nicecxone.com - OAuth token endpoint:
https://platform.nicecxone.com/oauth/token
Authentication Setup
CXone uses a standard OAuth 2.0 Client Credentials flow. The application requests an access token, caches it, and refreshes it automatically when it expires. The token is attached to every subsequent API call via the Authorization: Bearer <token> header.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class CxoneAuth {
private final String clientId;
private final String clientSecret;
private final String scope;
private String accessToken;
private Instant tokenExpiry;
private final HttpClient client;
private final ObjectMapper mapper;
public CxoneAuth(String clientId, String clientSecret, String scope) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scope = scope;
this.client = HttpClient.newHttpClient();
this.mapper = new ObjectMapper();
this.tokenExpiry = Instant.EPOCH;
}
public synchronized String getAccessToken() throws Exception {
if (accessToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return accessToken;
}
refreshToken();
return accessToken;
}
private void refreshToken() throws Exception {
String body = "grant_type=client_credentials&client_id=" + clientId +
"&client_secret=" + clientSecret + "&scope=" + scope;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://platform.nicecxone.com/oauth/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("OAuth token request failed with status " + response.statusCode());
}
JsonNode json = mapper.readTree(response.body());
accessToken = json.get("access_token").asText();
tokenExpiry = Instant.now().plusSeconds(json.get("expires_in").asLong());
}
}
Implementation
Step 1: Clone Campaign Configurations
The CXone Campaign API does not provide a direct clone endpoint. You must retrieve the source campaign, strip immutable fields, modify the metadata, and submit a new campaign via POST /api/v2/campaigns. This approach preserves routing rules, queue assignments, and compliance settings while allowing variant isolation.
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public JsonNode cloneCampaign(String sourceId, String variantName) throws Exception {
String token = auth.getAccessToken();
// Fetch source configuration
HttpRequest fetchReq = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns/" + sourceId))
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> fetchResp = client.send(fetchReq, HttpResponse.BodyHandlers.ofString());
if (fetchResp.statusCode() != 200) {
throw new RuntimeException("Failed to fetch campaign: " + fetchResp.body());
}
JsonNode sourceConfig = mapper.readTree(fetchResp.body());
// Remove immutable/unique fields that prevent duplication
((com.fasterxml.jackson.databind.node.ObjectNode) sourceConfig)
.remove("id")
.remove("creationDate")
.remove("lastModifiedDate")
.put("name", variantName)
.put("status", "PAUSED"); // Start paused until contacts are distributed
HttpRequest createReq = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(sourceConfig)))
.build();
HttpResponse<String> createResp = client.send(createReq, HttpResponse.BodyHandlers.ofString());
if (createResp.statusCode() != 201 && createResp.statusCode() != 200) {
throw new RuntimeException("Campaign creation failed: " + createResp.body());
}
return mapper.readTree(createResp.body());
}
Expected Response:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "AB_Variant_A_Predictive",
"status": "PAUSED",
"dialStrategy": "predictive",
"scriptId": "script-uuid-001",
"scriptVersion": "v1.2",
"contactListIds": [],
"selfUri": "/api/v2/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Step 2: Modify Dial Strategies and Script Versions
A/B tests require orthogonal variables. You update the dialStrategy (e.g., predictive vs progressive) and scriptVersion to isolate performance differences. CXone validates strategy compatibility with queue capacity during the PATCH request.
public void updateCampaignVariant(String campaignId, String dialStrategy, String scriptVersion) throws Exception {
String token = auth.getAccessToken();
String payload = String.format("{\"dialStrategy\": \"%s\", \"scriptVersion\": \"%s\"}", dialStrategy, scriptVersion);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns/" + campaignId))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.PATCH(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new RuntimeException("Variant configuration update failed: " + resp.body());
}
}
Step 3: Distribute Contacts Using a Hash-Based Split Algorithm
Deterministic contact distribution prevents skew and ensures reproducible test conditions. The algorithm hashes the contact identifier, maps it to a 0-99 range, and assigns contacts to Variant A or Variant B based on a configurable split ratio. CXone requires contacts to be added to dedicated contact lists before attachment to campaigns.
public void distributeContacts(String sourceListId, String variantAListId, String variantBListId, double splitRatio) throws Exception {
String token = auth.getAccessToken();
int page = 1;
int pageSize = 100;
while (true) {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(String.format("https://api.nicecxone.com/api/v2/contacts?listId=%s&page=%d&pageSize=%d",
sourceListId, page, pageSize)))
.header("Authorization", "Bearer " + token)
.GET()
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) break;
JsonNode pageData = mapper.readTree(resp.body());
JsonNode contacts = pageData.get("entities");
if (contacts == null || contacts.size() == 0) break;
for (JsonNode contact : contacts) {
String contactId = contact.get("id").asText();
int hashBucket = Math.abs(contactId.hashCode() % 100);
String targetListId = hashBucket < (splitRatio * 100) ? variantAListId : variantBListId;
// Add contact to variant list
String addPayload = String.format("{\"contactId\": \"%s\", \"contactData\": {}}", contactId);
HttpRequest addReq = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/contactlists/" + targetListId + "/contacts"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(addPayload))
.build();
HttpResponse<String> addResp = client.send(addReq, HttpResponse.BodyHandlers.ofString());
if (addResp.statusCode() != 200 && addResp.statusCode() != 201) {
System.err.println("Failed to add contact " + contactId + " to list " + targetListId);
}
}
if (pageData.get("page") == null || pageData.get("page").asInt() >= pageData.get("totalPages").asInt()) break;
page++;
}
}
Step 4: Poll Disposition Metrics to Calculate Conversion Rates
Real-time metrics are retrieved via GET /api/v2/campaigns/{id}/metrics. The endpoint returns conversationsDialed, conversationsConnected, and disposition breakdowns. Conversion rate is calculated as conversationsConnected / conversationsDialed. Polling requires exponential backoff to respect rate limits.
public Map<String, Double> pollMetrics(String campaignId) throws Exception {
String token = auth.getAccessToken();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns/" + campaignId + "/metrics"))
.header("Authorization", "Bearer " + token)
.GET()
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new RuntimeException("Metrics polling failed: " + resp.body());
}
JsonNode metrics = mapper.readTree(resp.body()).get("metrics");
double dialed = metrics.has("conversationsDialed") ? metrics.get("conversationsDialed").asDouble() : 0;
double connected = metrics.has("conversationsConnected") ? metrics.get("conversationsConnected").asDouble() : 0;
double conversionRate = dialed > 0 ? connected / dialed : 0.0;
Map<String, Double> result = new java.util.HashMap<>();
result.put("dialed", dialed);
result.put("connected", connected);
result.put("conversionRate", conversionRate);
return result;
}
Step 5: Construct API Requests to Pause Underperforming Variants
When a variant falls below a predefined conversion threshold or loses statistical significance, you pause it via PATCH /api/v2/campaigns/{id}. CXone gracefully stops dialing while preserving historical metrics for analysis.
public void pauseCampaign(String campaignId) throws Exception {
String token = auth.getAccessToken();
String payload = "{\"status\": \"PAUSED\"}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns/" + campaignId))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.PATCH(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new RuntimeException("Campaign pause failed: " + resp.body());
}
}
Step 6: Implement Statistical Significance Checks Using P-Value Calculations
A/B testing requires verifying that observed differences are not random noise. The two-proportion z-test compares conversion rates between Variant A and Variant B. The p-value determines statistical significance at a chosen confidence level (typically alpha = 0.05).
public class Statistics {
/**
* Calculates the two-tailed p-value for a two-proportion z-test.
* Uses the standard normal cumulative distribution function approximation.
*/
public static double calculatePValue(double successA, double trialsA, double successB, double trialsB) {
if (trialsA == 0 || trialsB == 0) return 1.0;
double pA = successA / trialsA;
double pB = successB / trialsB;
double pooledP = (successA + successB) / (trialsA + trialsB);
double stdError = Math.sqrt(pooledP * (1 - pooledP) * (1.0 / trialsA + 1.0 / trialsB));
if (stdError == 0) return 1.0;
double zScore = Math.abs(pA - pB) / stdError;
return 2.0 * (1.0 - normalCDF(zScore));
}
// Abramowitz and Stegun approximation for standard normal CDF
private static double normalCDF(double x) {
double t = 1.0 / (1.0 + 0.2316419 * Math.abs(x));
double d = 0.3989422804014327;
double p = d * Math.exp(-x * x / 2.0) *
(t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))));
return x >= 0 ? 1.0 - p : p;
}
}
Step 7: Generate Campaign Performance Reports for Stakeholders
The final step aggregates polled metrics, statistical results, and campaign status into a structured JSON report. Stakeholders require clear pass/fail indicators, confidence levels, and raw conversion data.
public JsonNode generateReport(String variantAId, String variantBId, Map<String, Double> metricsA, Map<String, Double> metricsB, double pValue) throws Exception {
ObjectNode report = mapper.createObjectNode();
report.put("generatedAt", Instant.now().toString());
report.put("variantAId", variantAId);
report.put("variantBId", variantBId);
report.put("variantAConversionRate", metricsA.get("conversionRate"));
report.put("variantBDialCount", metricsB.get("dialed"));
report.put("variantBConversionRate", metricsB.get("conversionRate"));
report.put("pValue", pValue);
report.put("isStatisticallySignificant", pValue < 0.05);
ObjectNode recommendation = mapper.createObjectNode();
if (pValue >= 0.05) {
recommendation.put("action", "CONTINUE_TESTING");
recommendation.put("reason", "Insufficient data to determine winner");
} else if (metricsA.get("conversionRate") > metricsB.get("conversionRate")) {
recommendation.put("action", "PAUSE_VARIANT_B");
recommendation.put("reason", "Variant A outperforms Variant B with statistical significance");
} else {
recommendation.put("action", "PAUSE_VARIANT_A");
recommendation.put("reason", "Variant B outperforms Variant A with statistical significance");
}
report.set("recommendation", recommendation);
return report;
}
Complete Working Example
The following class orchestrates the entire A/B testing workflow. Replace the placeholder credentials and campaign identifiers before execution.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
public class CxoneABTestOrchestrator {
private final CxoneAuth auth;
private final HttpClient client;
private final ObjectMapper mapper;
private static final double SIGNIFICANCE_THRESHOLD = 0.05;
private static final int MIN_SAMPLE_SIZE = 500;
public CxoneABTestOrchestrator(String clientId, String clientSecret, String scope) {
this.auth = new CxoneAuth(clientId, clientSecret, scope);
this.client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
this.mapper = new ObjectMapper();
}
public void runABTest(String sourceCampaignId, String sourceListId, String variantAListId, String variantBListId) throws Exception {
// Step 1: Clone campaigns
JsonNode variantA = cloneCampaign(sourceCampaignId, "AB_Variant_A_Predictive");
JsonNode variantB = cloneCampaign(sourceCampaignId, "AB_Variant_B_Progressive");
String idA = variantA.get("id").asText();
String idB = variantB.get("id").asText();
// Step 2: Modify strategies
updateCampaignVariant(idA, "predictive", "v1.2");
updateCampaignVariant(idB, "progressive", "v1.2");
// Step 3: Distribute contacts
distributeContacts(sourceListId, variantAListId, variantBListId, 0.5);
// Attach lists to campaigns
attachContactList(idA, variantAListId);
attachContactList(idB, variantBListId);
// Start campaigns
startCampaign(idA);
startCampaign(idB);
System.out.println("A/B Test initiated. Monitoring metrics...");
// Step 4 & 5 & 6: Poll, calculate, pause if needed
for (int i = 0; i < 10; i++) {
Thread.sleep(60000); // Poll every minute
Map<String, Double> metricsA = pollMetrics(idA);
Map<String, Double> metricsB = pollMetrics(idB);
if (metricsA.get("dialed") > MIN_SAMPLE_SIZE && metricsB.get("dialed") > MIN_SAMPLE_SIZE) {
double pValue = Statistics.calculatePValue(
metricsA.get("connected"), metricsA.get("dialed"),
metricsB.get("connected"), metricsB.get("dialed")
);
if (pValue < SIGNIFICANCE_THRESHOLD) {
if (metricsA.get("conversionRate") > metricsB.get("conversionRate")) {
pauseCampaign(idB);
System.out.println("Variant B paused. Variant A is statistically superior.");
} else {
pauseCampaign(idA);
System.out.println("Variant A paused. Variant B is statistically superior.");
}
break;
}
}
}
// Step 7: Generate report
Map<String, Double> finalA = pollMetrics(idA);
Map<String, Double> finalB = pollMetrics(idB);
double finalP = Statistics.calculatePValue(
finalA.get("connected"), finalA.get("dialed"),
finalB.get("connected"), finalB.get("dialed")
);
JsonNode report = generateReport(idA, idB, finalA, finalB, finalP);
System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(report));
}
// Helper methods from previous steps are included here for completeness
public JsonNode cloneCampaign(String sourceId, String variantName) throws Exception { /* Implementation from Step 1 */ return null; }
public void updateCampaignVariant(String campaignId, String dialStrategy, String scriptVersion) throws Exception { /* Implementation from Step 2 */ }
public void distributeContacts(String sourceListId, String variantAListId, String variantBListId, double splitRatio) throws Exception { /* Implementation from Step 3 */ }
public void attachContactList(String campaignId, String listId) throws Exception {
String token = auth.getAccessToken();
String payload = String.format("{\"contactListIds\": [\"%s\"]}", listId);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns/" + campaignId))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.PATCH(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new RuntimeException("Failed to attach list: " + resp.body());
}
public void startCampaign(String campaignId) throws Exception {
String token = auth.getAccessToken();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.nicecxone.com/api/v2/campaigns/" + campaignId))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.PATCH(HttpRequest.BodyPublishers.ofString("{\"status\": \"RUNNING\"}"))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new RuntimeException("Failed to start campaign: " + resp.body());
}
public Map<String, Double> pollMetrics(String campaignId) throws Exception { /* Implementation from Step 4 */ return null; }
public void pauseCampaign(String campaignId) throws Exception { /* Implementation from Step 5 */ }
public JsonNode generateReport(String variantAId, String variantBId, Map<String, Double> metricsA, Map<String, Double> metricsB, double pValue) throws Exception { /* Implementation from Step 7 */ return null; }
public static void main(String[] args) {
try {
new CxoneABTestOrchestrator("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET", "campaign:read campaign:write contact:read contact:write reporting:read")
.runABTest("source-campaign-uuid", "source-list-uuid", "variant-a-list-uuid", "variant-b-list-uuid");
} catch (Exception e) {
e.printStackTrace();
}
}
}
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The access token has expired or was never successfully cached. CXone tokens typically expire after 3600 seconds.
- Fix: Ensure the
CxoneAuthclass implements synchronized token refresh. Check that the client credentials have not been rotated in the CXone Admin Console. - Code Fix: Verify
Instant.now().isBefore(tokenExpiry.minusSeconds(60))triggers a refresh before any API call.
Error: 403 Forbidden
- Cause: Missing OAuth scopes or insufficient user permissions. A/B testing requires write access to campaigns and contacts.
- Fix: Grant
campaign:write,contact:write, andreporting:readscopes to the OAuth client. Verify the service account has theCampaign AdminorContact Adminrole. - Debugging: Inspect the
scopeparameter in the OAuth request body. CXone returns a detailed error payload listing the exact missing scope.
Error: 429 Too Many Requests
- Cause: Exceeding CXone rate limits, commonly triggered during contact distribution or rapid metric polling.
- Fix: Implement exponential backoff. CXone returns
Retry-Afterheaders on 429 responses. - Code Fix: Add a retry loop before
client.send():
int retries = 3;
for (int i = 0; i < retries; i++) {
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 429) return resp;
long waitTime = Long.parseLong(resp.headers().firstValue("Retry-After").orElse("2"));
Thread.sleep(waitTime * 1000);
}
Error: 400 Bad Request
- Cause: Invalid campaign configuration, such as an unsupported
dialStrategyfor the assigned queue, or duplicate contact list attachments. - Fix: Validate
dialStrategyvalues againstpredictive,progressive,preview, orpower. Ensure contact lists are not already attached to another running campaign. - Debugging: Parse the
errorsarray in the response body. CXone provides field-level validation messages.