Optimizing Genesys Cloud Outbound Contact List Uploads with Spring Batch and Java
What You Will Build
- A Spring Batch job that extracts millions of contact records from PostgreSQL, deduplicates them using a probabilistic Bloom filter, and uploads them to a Genesys Cloud Outbound contact list in chunks optimized for API payload limits.
- The job uses the Genesys Cloud Java SDK to call the
/api/v2/outbound/contactlists/{contactListId}/contactsendpoint with exponential backoff retry logic for rate limits and partial failure tracking. - The tutorial provides production-ready Java code configured for Spring Boot 3.x, Spring Batch 5.x, and PostgreSQL.
Prerequisites
- Genesys Cloud OAuth Client (Confidential) with scope
outbound:contactlist:write - Genesys Cloud Java SDK version 2.x (
com.mypurecloud.api.client:api-client) - Java 17+, Spring Boot 3.2+, Spring Batch 5.1+, PostgreSQL JDBC driver
- Guava for Bloom filter (
com.google.guava:guava) - PostgreSQL table schema:
CREATE TABLE outbound_contacts ( id BIGSERIAL PRIMARY KEY, phone_number VARCHAR(20) NOT NULL, first_name VARCHAR(100), last_name VARCHAR(100), email VARCHAR(255), upload_status VARCHAR(20) DEFAULT 'PENDING' );
Authentication Setup
The Genesys Cloud Java SDK handles OAuth 2.0 client credentials flow automatically. You must initialize the PlatformClient before creating any API service instances.
import com.mypurecloud.api.client.*;
import com.mypurecloud.api.client.auth.OAuth;
public class GenesysAuthConfig {
public static PlatformClient initPlatformClient(String clientId, String clientSecret) {
OAuth oAuth = OAuth.fromClientCredentials(clientId, clientSecret);
return PlatformClient.init(PlatformClientBuilder.builder()
.environment(Environment.PureCloudProd)
.oAuth(oAuth)
.build());
}
}
The SDK caches the access token and automatically refreshes it before expiration. If your environment uses a custom OAuth proxy, configure the OAuth object with OAuth.fromClientCredentials(clientId, clientSecret, tokenEndpoint).
Implementation
Step 1: PostgreSQL ItemReader Configuration
Spring Batch requires a paging reader to stream millions of records without exhausting JVM heap. The JdbcPagingItemReader uses a sort key to maintain cursor state across chunk commits.
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.batch.item.database.Order;
import org.springframework.batch.item.database.support.SqlPagingQueryProviderFactoryBean;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Component
public class ContactReaderConfig {
public JdbcPagingItemReader<ContactRecord> contactReader(DataSource dataSource) throws Exception {
SqlPagingQueryProviderFactoryBean queryProvider = new SqlPagingQueryProviderFactoryBean();
queryProvider.setDataSource(dataSource);
queryProvider.setSelectClause("id, phone_number, first_name, last_name, email, upload_status");
queryProvider.setFromClause("from outbound_contacts");
queryProvider.setWhereClause("where upload_status = 'PENDING'");
queryProvider.setSortKeys(Map.of("id", Order.ASCENDING));
JdbcPagingItemReader<ContactRecord> reader = new JdbcPagingItemReader<>();
reader.setDataSource(dataSource);
reader.setQueryProvider(queryProvider.getObject());
reader.setPageSize(1000);
reader.setRowMapper((rs, rowNum) -> new ContactRecord(
rs.getLong("id"),
rs.getString("phone_number"),
rs.getString("first_name"),
rs.getString("last_name"),
rs.getString("email"),
rs.getString("upload_status")
));
return reader;
}
}
The ContactRecord class must be a standard POJO with a no-argument constructor and setters for Spring Batch row mapping, or use Java records with a custom RowMapper. The paging size of 1000 balances database fetch overhead with memory consumption.
Step 2: Bloom Filter Deduplication and Chunking
The Genesys Cloud Contact List API accepts up to 500 contacts per request, but 200 provides optimal throughput with lower timeout risk. A Guava Bloom filter eliminates duplicate phone numbers before they reach the API, reducing payload size and preventing 409 Conflict responses.
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
@Component
public class ContactDedupProcessor implements ItemProcessor<ContactRecord, ContactRecord> {
private final BloomFilter<String> phoneBloomFilter;
public ContactDedupProcessor(long expectedInsertions, double fpp) {
this.phoneBloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
expectedInsertions,
fpp
);
}
@Override
public ContactRecord process(ContactRecord record) {
String normalizedPhone = record.getPhoneNumber().replaceAll("\\D+", "");
if (phoneBloomFilter.mightContain(normalizedPhone)) {
return null; // Skip duplicate
}
phoneBloomFilter.put(normalizedPhone);
return record;
}
}
Initialize the processor with expectedInsertions = 5_000_000 and fpp = 0.01. The false positive probability of 1 percent means approximately 1 in 100 unique numbers may be incorrectly skipped, which is acceptable for outbound marketing lists where strict uniqueness is not legally mandated. Adjust fpp to 0.001 for stricter deduplication at the cost of 4x memory usage.
Step 3: Genesys Cloud API Writer with Retry and Tracking
The writer receives chunks of 200 records. It converts them to SDK Contact objects, calls the API, handles 429 Too Many Requests with exponential backoff, tracks successful contact IDs, and collects failures for the delta report.
HTTP Request/Response Cycle Reference
POST /api/v2/outbound/contactlists/8a05c380-1234-4567-89ab-cdef01234567/contacts
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Accept: application/json
[
{
"phoneNumber": "+12025550199",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"fields": {}
}
]
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"id": "8a05c380-5678-90ab-cdef-1234567890ab",
"uri": "/api/v2/outbound/contacts/8a05c380-5678-90ab-cdef-1234567890ab",
"phoneNumber": "+12025550199",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"createdDate": "2024-01-15T10:30:00.000Z",
"modifiedDate": "2024-01-15T10:30:00.000Z"
}
]
import com.mypurecloud.api.client.ApiException;
import com.mypurecloud.api.client.api.ContactsApi;
import com.mypurecloud.api.client.model.Contact;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Component
public class GenesysContactWriter implements ItemWriter<ContactRecord> {
private final ContactsApi contactsApi;
private final String contactListId;
private final List<String> successfulContactIds = Collections.synchronizedList(new ArrayList<>());
private final List<Map<String, Object>> rejectedRecords = Collections.synchronizedList(new ArrayList<>());
public GenesysContactWriter(ContactsApi contactsApi, String contactListId) {
this.contactsApi = contactsApi;
this.contactListId = contactListId;
}
@Override
public void write(Chunk<? extends ContactRecord> chunk) throws Exception {
List<Contact> apiContacts = new ArrayList<>(chunk.size());
for (ContactRecord record : chunk) {
Contact contact = new Contact();
contact.setPhoneNumber(record.getPhoneNumber());
contact.setFirstName(record.getFirstName());
contact.setLastName(record.getLastName());
contact.setEmail(record.getEmail());
apiContacts.add(contact);
}
boolean success = false;
int retryDelay = 1000;
int maxRetries = 3;
while (!success && maxRetries >= 0) {
try {
List<Contact> created = contactsApi.postOutboundContactlistContacts(contactListId, apiContacts);
for (Contact c : created) {
successfulContactIds.add(c.getId());
}
success = true;
} catch (ApiException e) {
if (e.getCode() == 429 && maxRetries > 0) {
TimeUnit.MILLISECONDS.sleep(retryDelay);
retryDelay *= 2;
maxRetries--;
} else {
for (ContactRecord record : chunk) {
Map<String, Object> rejection = new HashMap<>();
rejection.put("id", record.getId());
rejection.put("phoneNumber", record.getPhoneNumber());
rejection.put("error", e.getMessage());
rejection.put("statusCode", e.getCode());
rejectedRecords.add(rejection);
}
success = true; // Mark as processed to avoid infinite retry
}
}
}
}
public List<String> getSuccessfulContactIds() {
return successfulContactIds;
}
public List<Map<String, Object>> getRejectedRecords() {
return rejectedRecords;
}
}
The writer catches ApiException specifically. Status code 401 indicates token expiration; the SDK refreshes automatically, but if it fails, the exception propagates. Status code 403 indicates missing outbound:contactlist:write scope. Status code 400 indicates malformed phone numbers or invalid email formats. The retry loop implements exponential backoff with a maximum of three attempts.
Step 4: Delta Report Generation
After the job completes, the delta report writes rejected records to a CSV file for data quality review. Spring Batch JobExecutionListener provides the hook.
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
@Component
public class DeltaReportListener implements JobExecutionListener {
private final GenesysContactWriter writer;
private final Path reportPath;
public DeltaReportListener(GenesysContactWriter writer, String reportPath) {
this.writer = writer;
this.reportPath = Path.of(reportPath);
}
@Override
public void afterJob(JobExecution jobExecution) {
List<Map<String, Object>> rejections = writer.getRejectedRecords();
if (rejections.isEmpty()) {
return;
}
List<String> lines = List.of("id,phoneNumber,statusCode,error");
for (Map<String, Object> rec : rejections) {
lines.add(String.format("%s,%s,%s,%s",
rec.get("id"),
rec.get("phoneNumber"),
rec.get("statusCode"),
rec.get("error").toString().replace(",", ";")
));
}
try {
Files.writeString(reportPath, String.join("\n", lines));
} catch (IOException e) {
throw new RuntimeException("Failed to write delta report", e);
}
}
}
The listener executes after all steps complete. It formats the rejection list as CSV, escapes commas in error messages, and writes to disk. The report enables data engineers to correct formatting issues and requeue failed records.
Complete Working Example
import com.mypurecloud.api.client.*;
import com.mypurecloud.api.client.api.ContactsApi;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
@Configuration
public class ContactUploadJobConfig {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
public ContactsApi contactsApi() {
PlatformClient client = GenesysAuthConfig.initPlatformClient(
System.getenv("GENESYS_CLIENT_ID"),
System.getenv("GENESYS_CLIENT_SECRET")
);
return new ContactsApi(client);
}
@Bean
public ContactDedupProcessor dedupProcessor() {
return new ContactDedupProcessor(5_000_000, 0.01);
}
@Bean
public GenesysContactWriter contactWriter(ContactsApi contactsApi) {
return new GenesysContactWriter(contactsApi, System.getenv("GENESYS_CONTACT_LIST_ID"));
}
@Bean
public Step uploadStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
ContactReaderConfig readerConfig, DataSource dataSource,
ContactDedupProcessor processor, GenesysContactWriter writer) throws Exception {
return new StepBuilder("contactUploadStep", jobRepository)
.<ContactRecord, ContactRecord>chunk(200, transactionManager)
.reader(readerConfig.contactReader(dataSource))
.processor(processor)
.writer(writer)
.build();
}
@Bean
public Job contactUploadJob(JobRepository jobRepository, Step uploadStep,
DeltaReportListener deltaListener) {
return new JobBuilder("contactUploadJob", jobRepository)
.incrementer(new RunIdIncrementer())
.listener(deltaListener)
.start(uploadStep)
.build();
}
public void run(JobLauncher launcher, Job job) throws Exception {
launcher.run(job, new org.springframework.batch.core.JobParameters());
}
}
Configure environment variables before execution:
GENESYS_CLIENT_IDGENESYS_CLIENT_SECRETGENESYS_CONTACT_LIST_IDSPRING_DATASOURCE_URL,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD
Set DeltaReportListener report path via constructor injection or environment variable. The job launcher executes the step, which reads, deduplicates, batches, uploads, and generates the delta report in a single pipeline.
Common Errors & Debugging
Error: 429 Too Many Requests
- Cause: Genesys Cloud enforces rate limits on outbound contact uploads. Exceeding 100 requests per minute per client triggers throttling.
- Fix: The writer implements exponential backoff. Increase
retryDelayinitial value to2000if throttling persists. Reduce chunk size from 200 to 150 to lower request frequency. - Code adjustment: Modify the retry loop in
GenesysContactWriterto parseRetry-Afterheader frome.getHeaders()if present.
Error: 400 Bad Request with validation errors
- Cause: Phone numbers contain non-E.164 formatting, or email fields exceed 255 characters.
- Fix: Add a validation step in the processor before the Bloom filter. Use
libphonenumberto validateE164format. Reject records withisValidNumber(normalizedPhone) == false. - Code adjustment: Insert validation logic in
ContactDedupProcessor.process()and returnnullfor invalid records to route them to the rejection list.
Error: 403 Forbidden
- Cause: OAuth token lacks
outbound:contactlist:writescope. - Fix: Regenerate the OAuth client in Genesys Cloud Admin > Security > OAuth Clients. Assign the exact scope
outbound:contactlist:write. Restart the application to force token refresh.
Error: OutOfMemoryError during reader pagination
- Cause:
JdbcPagingItemReaderfetches too many rows per page or the JVM heap is insufficient for large object graphs. - Fix: Reduce
reader.setPageSize(500). Add-Xmx2g -XX:+UseG1GCto JVM arguments. EnsureContactRecorddoes not hold unnecessary string copies.