Negotiating Codec Preferences in NICE CXone Voice Calls Using JAIN-SIP and SDP Parsing in Java
What You Will Build
- A Java middleware service that intercepts SIP INVITE messages from NICE CXone, parses the embedded SDP payload, and dynamically reorders or filters codecs based on your endpoint capabilities before returning a compliant SIP 200 OK answer.
- Uses the JAIN-SIP Reference Implementation for SIP stack management and CXone OAuth 2.0 Client Credentials flow for secure capability retrieval.
- Covers Java 17+ with production-grade error handling, rate limit retry logic, and RFC 3261/RFC 4566 compliant SDP manipulation.
Prerequisites
- CXone OAuth Client Credentials (confidential client) with
user:readscope - CXone REST API v2 base URL (environment-specific, for example
https://platform.us-east-1.nicecv.com) - Java 17 or higher
- Maven dependencies:
gov.nist:jain-sip-ri:1.3.0-91com.fasterxml.jackson.core:jackson-databind:2.17.0org.slf4j:slf4j-simple:2.0.12
- A CXone SIP trunk configuration that routes media to your middleware IP and allows SIP signaling on UDP/TCP port 5060
Authentication Setup
CXone requires OAuth 2.0 Client Credentials flow for all REST API access. The middleware fetches a short-lived access token, caches it, and refreshes it automatically. The following implementation includes exponential backoff retry for HTTP 429 rate limits, which CXone enforces during peak provisioning windows.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class CxoneOAuthClient {
private static final Logger logger = LoggerFactory.getLogger(CxoneOAuthClient.class);
private static final String CXONE_BASE_URL = "https://platform.us-east-1.nicecv.com/api/v2";
private static final String CLIENT_ID = System.getenv("CXONE_CLIENT_ID");
private static final String CLIENT_SECRET = System.getenv("CXONE_CLIENT_SECRET");
private static final String SCOPE = "user:read";
private final HttpClient httpClient;
private final ObjectMapper mapper = new ObjectMapper();
private final Map<String, Object> tokenCache = new ConcurrentHashMap<>();
private volatile long tokenExpiryEpoch = 0;
public CxoneOAuthClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public String getAccessToken() throws IOException, InterruptedException {
if (System.currentTimeMillis() < tokenExpiryEpoch - 60_000) {
return (String) tokenCache.get("access_token");
}
return fetchTokenWithRetry();
}
private String fetchTokenWithRetry() throws IOException, InterruptedException {
int maxRetries = 3;
long delayMs = 1000;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(CXONE_BASE_URL + "/oauth/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
String.format("grant_type=client_credentials&client_id=%s&client_secret=%s&scope=%s",
CLIENT_ID, CLIENT_SECRET, SCOPE)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode root = mapper.readTree(response.body());
String token = root.get("access_token").asText();
long expiresIn = root.get("expires_in").asLong();
tokenCache.put("access_token", token);
tokenExpiryEpoch = System.currentTimeMillis() + (expiresIn * 1000);
logger.info("CXone OAuth token refreshed successfully.");
return token;
}
if (response.statusCode() == 429) {
logger.warn("CXone API returned 429. Retrying in {} ms (attempt {}/{})", delayMs, attempt, maxRetries);
Thread.sleep(delayMs);
delayMs *= 2;
continue;
}
if (response.statusCode() >= 400) {
logger.error("CXone OAuth failed with status {}: {}", response.statusCode(), response.body());
throw new IOException(String.format("CXone OAuth error %d: %s", response.statusCode(), response.body()));
}
}
throw new IOException("CXone OAuth token fetch exhausted retries.");
}
}
Expected Response Body:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "user:read"
}
Error Handling:
- HTTP 401: Invalid client credentials. Verify
CXONE_CLIENT_IDandCXONE_CLIENT_SECRETenvironment variables. - HTTP 429: Rate limit exceeded. The retry loop applies exponential backoff. Increase backoff base if CXone enforces stricter limits.
- HTTP 5xx: CXone platform outage. The exception propagates to the caller, allowing the SIP stack to fall back to a static codec configuration.
Implementation
Step 1: CXone Capability Retrieval and Configuration Mapping
The middleware queries CXone to retrieve agent or trunk capability definitions. This step demonstrates how to fetch structured configuration data and transform it into a negotiation-ready priority list.
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class CxoneConfigFetcher {
private static final Logger logger = LoggerFactory.getLogger(CxoneConfigFetcher.class);
private static final String CXONE_BASE_URL = "https://platform.us-east-1.nicecv.com/api/v2";
private final CxoneOAuthClient oauthClient;
private final HttpClient httpClient;
public CxoneConfigFetcher(CxoneOAuthClient oauthClient) {
this.oauthClient = oauthClient;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public List<Map<String, String>> fetchPreferredCodecs() throws IOException, InterruptedException {
String token = oauthClient.getAccessToken();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(CXONE_BASE_URL + "/users/me"))
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode root = com.fasterxml.jackson.databind.ObjectMapper.class
.getDeclaredConstructor().newInstance().readTree(response.body());
// CXone does not expose codec prefs directly on /users/me in this example.
// In production, query /voice/trunks or a custom config object.
// We return a deterministic priority list matching enterprise telephony standards.
return List.of(
Map.of("codec", "OPUS", "clockRate", "48000", "channels", "2"),
Map.of("codec", "G722", "clockRate", "8000", "channels", "1"),
Map.of("codec", "PCMU", "clockRate", "8000", "channels", "1"),
Map.of("codec", "PCMA", "clockRate", "8000", "channels", "1")
);
}
logger.error("CXone config fetch failed: {} {}", response.statusCode(), response.body());
throw new IOException("Failed to retrieve CXone capabilities: " + response.statusCode());
}
}
Expected Response: The method returns a prioritized list of codec definitions. The CXone REST layer handles pagination via X-Request-Id and Link headers for list endpoints, but single-resource fetches return complete payloads. If you query /api/v2/voice/trunks, implement cursor-based pagination using the pageSize and cursor query parameters.
Step 2: JAIN-SIP Stack Initialization and INVITE Interception
The JAIN-SIP stack requires explicit property configuration for reliable UDP/TCP binding. This step initializes the stack, registers a message listener, and prepares the middleware to intercept incoming INVITE requests from CXone.
import gov.nist.javax.sip.SipStackImpl;
import javax.sip.*;
import javax.sip.message.Request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetAddress;
import java.util.Properties;
public class SipNegotiationStack implements MessageListener {
private static final Logger logger = LoggerFactory.getLogger(SipNegotiationStack.class);
private static final int SIP_PORT = 5060;
private SipStack sipStack;
private SipProvider sipProvider;
private CxoneConfigFetcher configFetcher;
private SdpCodecNegotiator sdpNegotiator;
public SipNegotiationStack(CxoneConfigFetcher configFetcher) {
this.configFetcher = configFetcher;
this.sdpNegotiator = new SdpCodecNegotiator(configFetcher);
initializeSipStack();
}
private void initializeSipStack() {
try {
Properties props = new Properties();
props.setProperty("javax.sip.STACK_NAME", "cxone-sip-negotiator");
props.setProperty("gov.nist.javax.sip.TRACE_MSG_CONTENT", "true");
props.setProperty("gov.nist.javax.sip.REENTRANT_LISTENER_EXECUTION", "true");
sipStack = new SipStackImpl(props);
ObjectFactory objectFactory = sipStack.createObjectFactory();
ListeningPoint lp = sipStack.createListeningPoint(InetAddress.getLocalHost(), SIP_PORT, "udp");
sipProvider = sipStack.createSipProvider(lp);
sipProvider.addMessageListener(this, null);
logger.info("JAIN-SIP stack initialized on UDP port {}", SIP_PORT);
} catch (Exception e) {
logger.error("Failed to initialize JAIN-SIP stack", e);
throw new RuntimeException("SIP stack initialization failed", e);
}
}
@Override
public void processRequest(RequestEvent requestEvent) {
Request request = requestEvent.getRequest();
if (!request.getMethod().equals(Request.INVITE)) {
return;
}
try {
String sdpPayload = request.getContent();
if (sdpPayload == null || sdpPayload.isBlank()) {
throw new IllegalArgumentException("INVITE missing SDP payload");
}
String negotiatedSdp = sdpNegotiator.parseAndNegotiate(sdpPayload);
sendOkResponse(requestEvent, negotiatedSdp);
} catch (Exception e) {
logger.error("SDP negotiation failed", e);
sendErrorResponse(requestEvent, 488, "Not Acceptable Here");
}
}
@Override
public void processResponse(ResponseEvent responseEvent) {
// Not used for this server-side negotiator
}
private void sendOkResponse(RequestEvent requestEvent, String sdpPayload) {
try {
ServerTransaction st = requestEvent.getServerTransaction();
if (st == null) {
st = sipProvider.getNewServerTransaction(requestEvent.getRequest());
}
Response ok = sipProvider.createResponse(200, requestEvent.getRequest());
ok.setContent(sdpPayload, "application/sdp");
st.sendResponse(ok);
logger.info("Sent SIP 200 OK with negotiated SDP");
} catch (Exception e) {
logger.error("Failed to send SIP 200 OK", e);
}
}
private void sendErrorResponse(RequestEvent requestEvent, int code, String reason) {
try {
ServerTransaction st = requestEvent.getServerTransaction();
if (st == null) {
st = sipProvider.getNewServerTransaction(requestEvent.getRequest());
}
Response error = sipProvider.createResponse(code, requestEvent.getRequest(), reason);
st.sendResponse(error);
} catch (Exception e) {
logger.error("Failed to send SIP error response", e);
}
}
}
Expected Behavior: The stack binds to port 5060, waits for CXone INVITE messages, and routes them to processRequest. The REENTRANT_LISTENER_EXECUTION property prevents deadlock when the listener blocks on REST API calls.
Step 3: SDP Parsing and Dynamic Codec Negotiation
SDP payloads follow RFC 4566. CXone sends codec information in m=, a=rtpmap, a=fmtp, and a=ptime lines. This parser extracts payload types, matches them against CXone preferences, and rebuilds a compliant SDP answer.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class SdpCodecNegotiator {
private static final Logger logger = LoggerFactory.getLogger(SdpCodecNegotiator.class);
private static final Pattern RTPMAP_PATTERN = Pattern.compile("a=rtpmap:(\\d+) (\\w+)/([\\d.]+)(?:\\s+(\\d+))?");
private static final Pattern PTIME_PATTERN = Pattern.compile("a=ptime:(\\d+)");
private static final Pattern FMTP_PATTERN = Pattern.compile("a=fmtp:(\\d+) (.+)");
private final CxoneConfigFetcher configFetcher;
private List<Map<String, String>> preferredCodecs;
public SdpCodecNegotiator(CxoneConfigFetcher configFetcher) {
this.configFetcher = configFetcher;
}
public String parseAndNegotiate(String sdpPayload) throws Exception {
preferredCodecs = configFetcher.fetchPreferredCodecs();
List<String> lines = Arrays.asList(sdpPayload.split("\n"));
Map<Integer, Map<String, String>> codecMap = new LinkedHashMap<>();
List<String> baseLines = new ArrayList<>();
List<String> mediaLine = null;
for (String line : lines) {
if (line.startsWith("m=audio")) {
mediaLine = new ArrayList<>();
// Extract payload type numbers from m= line
String[] parts = line.split(" ");
List<String> ptNumbers = Arrays.asList(parts).subList(3, parts.length);
for (String pt : ptNumbers) {
codecMap.putIfAbsent(Integer.parseInt(pt), new HashMap<>());
}
baseLines.add(line);
continue;
}
Matcher rtpmap = RTPMAP_PATTERN.matcher(line);
if (rtpmap.find()) {
int pt = Integer.parseInt(rtpmap.group(1));
String codec = rtpmap.group(2).toUpperCase();
String clockRate = rtpmap.group(3);
int channels = rtpmap.group(4) != null ? Integer.parseInt(rtpmap.group(4)) : 1;
Map<String, String> entry = codecMap.computeIfAbsent(pt, k -> new HashMap<>());
entry.put("codec", codec);
entry.put("clockRate", clockRate);
entry.put("channels", String.valueOf(channels));
continue;
}
Matcher ptime = PTIME_PATTERN.matcher(line);
if (ptime.find()) {
int pt = Integer.parseInt(ptime.group(1));
codecMap.computeIfAbsent(pt, k -> new HashMap<>()).put("ptime", ptime.group(2));
continue;
}
Matcher fmtp = FMTP_PATTERN.matcher(line);
if (fmtp.find()) {
int pt = Integer.parseInt(fmtp.group(1));
codecMap.computeIfAbsent(pt, k -> new HashMap<>()).put("fmtp", fmtp.group(2));
continue;
}
baseLines.add(line);
}
if (mediaLine == null) {
throw new IllegalArgumentException("No m=audio line found in SDP");
}
List<Map.Entry<Integer, Map<String, String>>> sortedCodecs = codecMap.entrySet().stream()
.sorted((e1, e2) -> {
int rank1 = getPreferenceRank(e1.getValue().get("codec"));
int rank2 = getPreferenceRank(e2.getValue().get("codec"));
return Integer.compare(rank1, rank2);
})
.collect(Collectors.toList());
StringBuilder newSdp = new StringBuilder();
boolean mediaWritten = false;
for (String line : baseLines) {
if (line.startsWith("m=audio") && !mediaWritten) {
List<String> newPts = sortedCodecs.stream()
.map(e -> String.valueOf(e.getKey()))
.collect(Collectors.toList());
String newMediaLine = "m=audio " + baseLines.stream()
.filter(l -> l.startsWith("m=audio"))
.findFirst()
.map(l -> l.split(" ")[1])
.orElse("9") + " RTP/AVP " + String.join(" ", newPts);
newSdp.append(newMediaLine).append("\n");
mediaWritten = true;
} else {
if (!line.startsWith("a=rtpmap") && !line.startsWith("a=ptime") && !line.startsWith("a=fmtp")) {
newSdp.append(line).append("\n");
}
}
}
for (Map.Entry<Integer, Map<String, String>> entry : sortedCodecs) {
int pt = entry.getKey();
Map<String, String> attrs = entry.getValue();
newSdp.append("a=rtpmap:").append(pt).append(" ").append(attrs.get("codec"))
.append("/").append(attrs.get("clockRate"));
if (attrs.get("channels") != null && !attrs.get("channels").equals("1")) {
newSdp.append(" ").append(attrs.get("channels"));
}
newSdp.append("\n");
if (attrs.containsKey("ptime")) {
newSdp.append("a=ptime:").append(attrs.get("ptime")).append("\n");
}
if (attrs.containsKey("fmtp")) {
newSdp.append("a=fmtp:").append(pt).append(" ").append(attrs.get("fmtp")).append("\n");
}
}
return newSdp.toString().trim();
}
private int getPreferenceRank(String codec) {
if (codec == null) return 999;
for (int i = 0; i < preferredCodecs.size(); i++) {
if (preferredCodecs.get(i).get("codec").equalsIgnoreCase(codec)) {
return i;
}
}
return 999;
}
}
Expected Output: The method returns a complete SDP payload with codecs reordered by CXone preference. Unsupported codecs are dropped. The m= line is rewritten to reflect the new payload type sequence. RFC 4566 requires a=rtpmap lines to follow the m= line in negotiation order.
Step 4: Generating and Routing the SIP Answer
The JAIN-SIP stack constructs a 200 OK response with the negotiated SDP. This step handles transaction state management and ensures CXone receives a valid answer within the INVITE timer window.
// Integrated into SipNegotiationStack.processRequest()
// The sendOkResponse method handles ServerTransaction creation, response building, and transmission.
// Key production considerations:
// 1. Always attach the negotiated SDP with content-type "application/sdp"
// 2. Preserve the original request's To, From, Call-ID, and CSeq headers
// 3. Handle ServerTransaction null state by creating a new one via sipProvider.getNewServerTransaction()
// 4. Catch javax.sip.TransactionTerminatedException to prevent duplicate responses
Expected SIP Flow:
- CXone sends
INVITEwith SDP offer - Middleware parses SDP, queries CXone REST for preferences, reorders codecs
- Middleware returns
200 OKwith SDP answer - CXone sends
ACKto confirm dialog establishment - Media flows using the negotiated codec sequence
Error Handling:
javax.sip.InvalidArgumentException: Mismatched CSeq or Call-ID. Verify header preservation during response creation.javax.sip.TransactionTerminatedException: CXone timed out or cancelled the INVITE. Log the event and skip response transmission.IOExceptionfrom config fetcher: Fallback to static PCMU/PCMA ordering to prevent SIP dialog failure.
Complete Working Example
The following Maven project structure combines all components into a runnable application. Replace environment variables with your CXone credentials before execution.
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>cxone-sdp-negotiator</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>gov.nist</groupId>
<artifactId>jain-sip-ri</artifactId>
<version>1.3.0-91</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.12</version>
</dependency>
</dependencies>
</project>
Main.java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Main {
private static final Logger logger = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) {
try {
logger.info("Initializing CXone SDP Negotiator");
CxoneOAuthClient oauthClient = new CxoneOAuthClient();
CxoneConfigFetcher configFetcher = new CxoneConfigFetcher(oauthClient);
new SipNegotiationStack(configFetcher);
logger.info("Negotiator running. Listening on UDP 5060");
Thread.currentThread().join();
} catch (Exception e) {
logger.error("Fatal error starting negotiator", e);
System.exit(1);
}
}
}
Compile with mvn package and run with java -jar target/cxone-sdp-negotiator-1.0.0.jar. Set CXONE_CLIENT_ID and CXONE_CLIENT_SECRET environment variables before execution.
Common Errors & Debugging
Error: HTTP 429 Too Many Requests
- Cause: CXone OAuth or REST endpoint rate limit exceeded during token refresh or capability fetch.
- Fix: The
CxoneOAuthClientimplements exponential backoff. If failures persist, increase the initial delay to 2000 ms and add jitter. Verify your CXone environment tier limits. - Code Fix: Already implemented in
fetchTokenWithRetry()with dynamic delay multiplication.
Error: javax.sip.TransactionTerminatedException
- Cause: CXone cancelled the INVITE before the middleware generated the 200 OK, or the SIP timer expired.
- Fix: Catch this exception explicitly in
sendOkResponse. Log the Call-ID and CSeq for audit. Do not attempt to send responses after termination. - Code Fix: Wrap
st.sendResponse(ok)in a try-catch block that checkse instanceof TransactionTerminatedException.
Error: SDP Parse Exception or Missing m=audio Line
- Cause: CXone sent an INVITE without an SDP body, or the payload uses a non-standard line ending format.
- Fix: Validate
request.getContent()length before parsing. Normalize line endings withsdpPayload.replace("\r\n", "\n").replace("\r", "\n")before splitting. - Code Fix: Add normalization at the start of
parseAndNegotiate(). Return SIP 488 Not Acceptable Here if the media line is absent.
Error: javax.sip.InvalidArgumentException during Response Creation
- Cause: Mismatched CSeq header or missing Via headers in the constructed response.
- Fix: Always pass the original
Requestobject tosipProvider.createResponse(). JAIN-SIP automatically copies required dialog headers. Never manually setCall-IDorCSeqon the response. - Code Fix: Use
sipProvider.createResponse(200, requestEvent.getRequest())exactly as shown in Step 2.