Optimizing Genesys Cloud Outbound Contact List Uploads with Spring Batch and Java

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}/contacts endpoint 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_ID
  • GENESYS_CLIENT_SECRET
  • GENESYS_CONTACT_LIST_ID
  • SPRING_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 retryDelay initial value to 2000 if throttling persists. Reduce chunk size from 200 to 150 to lower request frequency.
  • Code adjustment: Modify the retry loop in GenesysContactWriter to parse Retry-After header from e.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 libphonenumber to validate E164 format. Reject records with isValidNumber(normalizedPhone) == false.
  • Code adjustment: Insert validation logic in ContactDedupProcessor.process() and return null for invalid records to route them to the rejection list.

Error: 403 Forbidden

  • Cause: OAuth token lacks outbound:contactlist:write scope.
  • 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: JdbcPagingItemReader fetches too many rows per page or the JVM heap is insufficient for large object graphs.
  • Fix: Reduce reader.setPageSize(500). Add -Xmx2g -XX:+UseG1GC to JVM arguments. Ensure ContactRecord does not hold unnecessary string copies.

Official References