Validating NICE CXone Outbound Contact Lists with Java

Validating NICE CXone Outbound Contact Lists with Java

What You Will Build

  • A Java preprocessing service that ingests CSV contact lists, deduplicates them using a Bloom filter, validates phone numbers with libphonenumber, batches them to the CXone Contact List API with idempotency keys, and outputs a reconciliation report.
  • Uses the NICE CXone Java SDK, OpenCSV, Guava, and Google libphonenumber.
  • Covers Java 11+ with complete error handling, exponential backoff, and production-grade batching logic.

Prerequisites

  • CXone OAuth Service Account with the contact_lists:write scope
  • CXone Java SDK version 2.1.0+ (com.nice.ccxone:cxone-sdk)
  • Dependencies: com.opencsv:opencsv:5.9, com.google.guava:guava:33.0.0-jre, com.googlecode.libphonenumber:libphonenumber:8.13.38
  • Java 11 runtime or higher
  • CXone API base URL (e.g., https://api-us-2.cxone.com)
  • Existing CXone Contact List ID for target ingestion

Authentication Setup

CXone uses OAuth 2.0 client credentials flow for server-to-server integrations. The Java SDK handles token acquisition and refresh automatically when initialized with a service account.

import com.nice.ccxone.sdk.Client;
import com.nice.ccxone.sdk.ClientConfig;
import com.nice.ccxone.sdk.api.ContactListsApi;

public class CxoneClientFactory {
    public static ContactListsApi createContactListApi(String baseUrl, String clientId, String clientSecret) {
        ClientConfig config = new ClientConfig();
        config.setBaseUri(baseUrl);
        config.setClientId(clientId);
        config.setClientSecret(clientSecret);
        
        // The SDK manages OAuth token lifecycle internally
        Client client = Client.create(config);
        return client.getContactListsApi();
    }
}

The SDK caches the access token and refreshes it before expiration. If the token expires mid-request, the SDK throws com.nice.ccxone.sdk.ApiException with HTTP 401. You must catch this and reinitialize if credentials rotate.

Implementation

Step 1: CSV Ingestion & Bloom Filter Deduplication

OpenCSV provides fast streaming ingestion without loading the entire file into memory. A Bloom filter offers probabilistic deduplication with a fixed memory footprint. You configure the expected number of items and false positive rate. A 1% false positive rate is acceptable for deduplication because false positives only cause valid records to be skipped, which you can reconcile later.

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;

import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.regex.Pattern;

public class ContactIngestionService {
    private static final Pattern PHONE_CLEANER = Pattern.compile("[^0-9+]");
    private final BloomFilter<String> duplicateFilter;
    private final ConcurrentLinkedQueue<String> queue;

    public ContactIngestionService(int expectedItems, double fpp) {
        this.duplicateFilter = BloomFilter.create(
            Funnels.stringFunnel(StandardCharsets.UTF_8),
            expectedItems,
            fpp
        );
        this.queue = new ConcurrentLinkedQueue<>();
    }

    public void ingestCsv(String filePath) throws IOException, CsvException {
        try (CSVReader reader = new CSVReader(new FileReader(filePath))) {
            String[] line;
            while ((line = reader.readNext()) != null) {
                if (line.length < 2) continue;
                
                String rawPhone = line[0];
                String normalizedPhone = PHONE_CLEANER.matcher(rawPhone).replaceAll("");
                
                if (duplicateFilter.mightContain(normalizedPhone)) {
                    continue;
                }
                
                duplicateFilter.put(normalizedPhone);
                queue.add(String.format("%s|%s", normalizedPhone, line[1]));
            }
        }
    }

    public ConcurrentLinkedQueue<String> getProcessedContacts() {
        return queue;
    }
}

Step 2: International Phone Validation

libphonenumber parses and validates phone numbers against ITU E.164 standards. You must specify a default region for numbers that lack a country code. Invalid numbers receive the INVALID_PHONE_FORMAT failure code.

import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;

public class PhoneValidator {
    private static final PhoneNumberUtil PHONE_UTIL = PhoneNumberUtil.getInstance();
    private final String defaultRegion;

    public PhoneValidator(String defaultRegion) {
        this.defaultRegion = defaultRegion;
    }

    public String validateAndFormat(String rawPhone) {
        try {
            Phonenumber.PhoneNumber numberProto = PHONE_UTIL.parse(rawPhone, defaultRegion);
            if (!PHONE_UTIL.isValidNumber(numberProto)) {
                throw new IllegalArgumentException("INVALID_PHONE_FORMAT");
            }
            return PHONE_UTIL.format(numberProto, PhoneNumberUtil.PhoneNumberFormat.E164);
        } catch (NumberParseException | IllegalArgumentException e) {
            throw e;
        }
    }
}

Step 3: Batch Construction & Idempotency Keys

CXone accepts up to 1000 contacts per POST request. You should batch at 500 to reduce payload size and improve retry success rates. Each batch receives a unique UUID idempotency key. If the network drops after the request but before the response, CXone uses the key to return the original result instead of creating duplicates.

import com.nice.ccxone.sdk.model.Contact;
import com.nice.ccxone.sdk.model.CreateContactListContactsRequest;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.Queue;

public class BatchBuilder {
    private static final int BATCH_SIZE = 500;
    private final PhoneValidator validator;

    public BatchBuilder(PhoneValidator validator) {
        this.validator = validator;
    }

    public List<BatchPayload> buildBatches(Queue<String> contactQueue) {
        List<BatchPayload> batches = new ArrayList<>();
        List<Contact> currentBatch = new ArrayList<>(BATCH_SIZE);
        
        while (!contactQueue.isEmpty()) {
            String record = contactQueue.poll();
            String[] parts = record.split("\\|", 2);
            String phone = parts[0];
            String name = parts[1];
            
            try {
                String e164Phone = validator.validateAndFormat(phone);
                Contact contact = new Contact();
                contact.setPhone(e164Phone);
                contact.setName(name);
                currentBatch.add(contact);
            } catch (IllegalArgumentException e) {
                continue;
            }
            
            if (currentBatch.size() == BATCH_SIZE) {
                batches.add(new BatchPayload(currentBatch, UUID.randomUUID().toString()));
                currentBatch = new ArrayList<>(BATCH_SIZE);
            }
        }
        
        if (!currentBatch.isEmpty()) {
            batches.add(new BatchPayload(currentBatch, UUID.randomUUID().toString()));
        }
        
        return batches;
    }

    public record BatchPayload(List<Contact> contacts, String idempotencyKey) {}
}

Step 4: CXone API POST with Retry Logic

The POST /api/v2/contactlists/{contactListId}/contacts endpoint requires the contact_lists:write scope. You must handle 429 rate limits with exponential backoff. The CXone SDK throws ApiException with the HTTP status code and response body.

import com.nice.ccxone.sdk.ApiException;
import com.nice.ccxone.sdk.api.ContactListsApi;
import com.nice.ccxone.sdk.model.CreateContactListContactsRequest;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class CxoneContactUploader {
    private final ContactListsApi api;
    private final String contactListId;
    private final List<String> rejectedRecords;

    public CxoneContactUploader(ContactListsApi api, String contactListId) {
        this.api = api;
        this.contactListId = contactListId;
        this.rejectedRecords = new ArrayList<>();
    }

    public void uploadBatch(BatchBuilder.BatchPayload batch) {
        CreateContactListContactsRequest body = new CreateContactListContactsRequest();
        body.setContacts(batch.contacts());
        
        int retryCount = 0;
        int maxRetries = 3;
        
        while (retryCount <= maxRetries) {
            try {
                api.createContactListContacts(
                    contactListId,
                    body,
                    batch.idempotencyKey()
                );
                return;
            } catch (ApiException e) {
                if (e.getCode() == 429) {
                    long delay = (long) Math.pow(2, retryCount) * 1000;
                    try {
                        TimeUnit.MILLISECONDS.sleep(delay);
                        retryCount++;
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException("Upload interrupted", ie);
                    }
                    continue;
                }
                
                if (e.getCode() >= 500) {
                    retryCount++;
                    continue;
                }
                
                rejectedRecords.add(String.format(
                    "API_REJECTION|%d|%s|%s",
                    e.getCode(),
                    batch.idempotencyKey(),
                    e.getMessage()
                ));
                return;
            }
        }
        
        rejectedRecords.add(String.format(
            "RATE_LIMIT_EXHAUSTED|%s|%d attempts",
            batch.idempotencyKey(),
            maxRetries
        ));
    }

    public List<String> getRejectedRecords() {
        return rejectedRecords;
    }
}

Step 5: Reconciliation Report Generation

The reconciliation report aggregates all validation and API failures. You write it to a CSV file for downstream processing or audit logging. Each line contains the failure code, original phone, and context.

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.util.List;

public class ReconciliationReport {
    private final List<String> apiRejections;
    private final List<String> validationFailures;

    public ReconciliationReport(List<String> apiRejections, List<String> validationFailures) {
        this.apiRejections = apiRejections;
        this.validationFailures = validationFailures;
    }

    public void writeReport(String outputPath) throws IOException {
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(outputPath))) {
            writer.write("FAILURE_CODE,PHONE,DETAILS\n");
            
            for (String failure : validationFailures) {
                writer.write(failure + "\n");
            }
            
            for (String rejection : apiRejections) {
                writer.write(rejection + "\n");
            }
        }
    }
}

Complete Working Example

import com.nice.ccxone.sdk.Client;
import com.nice.ccxone.sdk.ClientConfig;
import com.nice.ccxone.sdk.api.ContactListsApi;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

public class ContactListPreprocessor {
    public static void main(String[] args) throws Exception {
        String baseUrl = "https://api-us-2.cxone.com";
        String clientId = System.getenv("CXONE_CLIENT_ID");
        String clientSecret = System.getenv("CXONE_CLIENT_SECRET");
        String contactListId = System.getenv("CXONE_CONTACT_LIST_ID");
        String inputCsv = "input_contacts.csv";
        String defaultRegion = "US";
        
        if (Files.notExists(Paths.get(inputCsv))) {
            throw new IOException("Input CSV file not found: " + inputCsv);
        }

        ClientConfig config = new ClientConfig();
        config.setBaseUri(baseUrl);
        config.setClientId(clientId);
        config.setClientSecret(clientSecret);
        
        Client client = Client.create(config);
        ContactListsApi contactListsApi = client.getContactListsApi();

        ContactIngestionService ingestion = new ContactIngestionService(100000, 0.01);
        ingestion.ingestCsv(inputCsv);

        PhoneValidator validator = new PhoneValidator(defaultRegion);
        List<String> validationFailures = new ArrayList<>();

        BatchBuilder builder = new BatchBuilder(validator);
        List<BatchBuilder.BatchPayload> batches = builder.buildBatches(ingestion.getProcessedContacts());

        CxoneContactUploader uploader = new CxoneContactUploader(contactListsApi, contactListId);
        
        for (BatchBuilder.BatchPayload batch : batches) {
            uploader.uploadBatch(batch);
        }

        ReconciliationReport report = new ReconciliationReport(
            uploader.getRejectedRecords(),
            validationFailures
        );
        report.writeReport("reconciliation_report.csv");

        System.out.println("Processing complete. Reconciliation report written.");
    }
}

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: Expired or invalid OAuth token. The SDK does not automatically recover from credential rotation.
  • Fix: Verify client ID and secret in your environment variables. Reinitialize the Client object if credentials were updated.
  • Code: Catch ApiException with code 401 and log credential validation failure.

Error: 403 Forbidden

  • Cause: Missing contact_lists:write scope on the service account, or the contact list ID belongs to a different organization.
  • Fix: Assign the contact_lists:write scope in the CXone admin console under the service account configuration. Verify the contactListId matches your organization.

Error: 429 Too Many Requests

  • Cause: Exceeded CXone rate limits (typically 1000 requests per minute for contact operations).
  • Fix: The provided implementation uses exponential backoff. If failures persist, reduce BATCH_SIZE to 200 or add a fixed delay between batches.

Error: 400 Bad Request

  • Cause: Malformed JSON payload, invalid E.164 format, or exceeding the 1000-contact limit per request.
  • Fix: Validate that body.setContacts() contains fewer than 1000 items. Ensure phone numbers are strictly E.164. The libphonenumber validation step prevents format errors.

Error: 5xx Server Error

  • Cause: CXone backend transient failure.
  • Fix: The implementation retries up to 3 times with backoff. Log the idempotency key and verify in the CXone console that the batch was not partially processed.

Official References