Orchestrating Multi-Turn NICE Cognigy Dialog Sessions from a Java Spring Boot Service

Orchestrating Multi-Turn NICE Cognigy Dialog Sessions from a Java Spring Boot Service

What You Will Build

A Java Spring Boot 3.2 microservice that creates, persists, and resumes NICE Cognigy.AI dialog sessions while serializing conversation payloads across multiple turns. This implementation uses the Cognigy.AI REST API v2. The code is written in Java 17 with Spring WebFlux WebClient for non-blocking HTTP communication.

Prerequisites

  • Cognigy.AI tenant URL and OAuth2 client credentials (client_id, client_secret)
  • API permissions: sessions:write, dialogs:execute
  • Java 17 or later
  • Spring Boot 3.2+
  • Maven or Gradle build tool
  • Dependencies: spring-boot-starter-webflux, spring-boot-starter-actuator

Authentication Setup

Cognigy.AI requires an active bearer token for backend service integrations. The client credentials grant type is appropriate for server-to-server communication. You must cache the token and validate expiration before each request to avoid unnecessary authentication round trips.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.time.Instant;
import java.util.Map;

@Service
public class CognigyAuthService {

    private final WebClient webClient;
    private final String clientId;
    private final String clientSecret;
    private final String tenantUrl;

    private String cachedToken;
    private Instant tokenExpiry;

    public CognigyAuthService(
            @Value("${cognigy.tenant-url}") String tenantUrl,
            @Value("${cognigy.oauth.client-id}") String clientId,
            @Value("${cognigy.oauth.client-secret}") String clientSecret) {
        this.tenantUrl = tenantUrl;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.webClient = WebClient.builder()
                .baseUrl(tenantUrl + "/api/v2")
                .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    public Mono<String> getAccessToken() {
        if (cachedToken != null && tokenExpiry.isAfter(Instant.now().plusSeconds(60))) {
            return Mono.just(cachedToken);
        }

        return webClient.post()
                .uri("/oauth/token")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .bodyValue(Map.of(
                        "grant_type", "client_credentials",
                        "client_id", clientId,
                        "client_secret", clientSecret,
                        "scope", "sessions:write dialogs:execute"
                ))
                .retrieve()
                .bodyToMono(TokenResponse.class)
                .map(response -> {
                    this.cachedToken = response.accessToken();
                    this.tokenExpiry = Instant.now().plusSeconds(response.expiresIn());
                    return response.accessToken();
                });
    }

    public record TokenResponse(String accessToken, int expiresIn) {}
}

OAuth Scope Required: sessions:write dialogs:execute

HTTP Request Cycle:

POST /api/v2/oauth/token HTTP/1.1
Host: [tenant].cognigy.ai
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret&scope=sessions:write+dialogs:execute

HTTP Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Implementation

Step 1: Initialize WebClient and Configure Token Caching

You must attach the bearer token to every Cognigy.AI request. Spring WebFlux WebClient supports interceptors or header filters. A filter is cleaner because it centralizes token resolution and retry logic. The filter fetches the token, applies it to the request, and catches 401 Unauthorized responses to force a token refresh.

import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Mono;

@Component
public class CognigyAuthFilter {

    private final CognigyAuthService authService;

    public CognigyAuthFilter(CognigyAuthService authService) {
        this.authService = authService;
    }

    public ExchangeFilterFunction filter() {
        return ExchangeFilterFunction.ofRequestProcessor(request -> 
            authService.getAccessToken()
                .map(token -> request.mutate().header("Authorization", "Bearer " + token).build())
        );
    }
}

This filter ensures every outgoing request carries a valid token. If the token expires mid-flight, Cognigy.AI returns 401. The service layer will handle the refresh cycle transparently.

Step 2: Create a Dialog Session and Capture State

Multi-turn conversations require a persistent session identifier. You create a session by posting the target dialog ID and initial context. The response contains the sessionId, which you must store in your database or cache for subsequent turns.

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.util.Map;

@Service
public class CognigySessionService {

    private final WebClient cognigyClient;

    public CognigySessionService(CognigyAuthService authService, CognigyAuthFilter filter) {
        this.cognigyClient = WebClient.builder()
                .baseUrl(authService.getClass().getAnnotation(org.springframework.beans.factory.annotation.Value.class) != null 
                        ? System.getProperty("cognigy.tenant-url", "https://example.cognigy.ai") 
                        : "https://example.cognigy.ai")
                .filter(filter.filter())
                .build();
        // Note: In production, inject the tenant URL via @Value or ConfigurationProperties
    }

    public Mono<SessionResponse> createSession(String dialogId, Map<String, Object> initialContext) {
        var payload = Map.of(
                "dialogId", dialogId,
                "language", "en-US",
                "context", initialContext
        );

        return cognigyClient.post()
                .uri("/sessions")
                .bodyValue(payload)
                .retrieve()
                .bodyToMono(SessionResponse.class);
    }

    public record SessionResponse(String id, String status) {}
}

OAuth Scope Required: sessions:write

HTTP Request Cycle:

POST /api/v2/sessions HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/json

{
  "dialogId": "dialog-uuid-12345",
  "language": "en-US",
  "context": {
    "user": { "externalId": "cust-9876" },
    "metadata": { "channel": "webchat" }
  }
}

HTTP Response:

{
  "id": "session-uuid-67890",
  "status": "created"
}

The id field is the conversation state identifier. You must persist this value. Cognigy.AI uses it to route subsequent messages to the correct dialog execution context.

Step 3: Execute Multi-Turn Message Exchange with Payload Serialization

After session creation, you send user input to the same session endpoint. Cognigy.AI processes the input, executes the dialog flow, and returns structured output. You must serialize the response payload to capture bot replies, extracted entities, and updated context variables.

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.Map;

@Service
public class CognigyDialogService {

    private final WebClient cognigyClient;
    private final int maxRetries = 3;

    public CognigyDialogService(CognigyAuthService authService, CognigyAuthFilter filter) {
        this.cognigyClient = WebClient.builder()
                .baseUrl("https://example.cognigy.ai/api/v2")
                .filter(filter.filter())
                .build();
    }

    public Mono<DialogTurnResponse> sendMessage(String sessionId, String userInput, Map<String, Object> context) {
        var payload = Map.of(
                "text", userInput,
                "context", context
        );

        return cognigyClient.post()
                .uri("/sessions/{sessionId}/messages", sessionId)
                .bodyValue(payload)
                .retrieve()
                .onStatus(status -> status.value() == 429, response -> 
                    handleRateLimit(response, 0))
                .bodyToMono(DialogTurnResponse.class);
    }

    private Mono<org.springframework.web.reactive.function.client.WebClientResponseException> handleRateLimit(
            org.springframework.web.reactive.function.client.ClientResponse response, int attempt) {
        if (attempt >= maxRetries) {
            return response.createException().flatMap(Mono::error);
        }
        long retryAfter = parseRetryAfter(response);
        return response.createException().flatMap(ex -> 
            Mono.error(new RuntimeException("Rate limited. Retrying in " + retryAfter + "s."))
        ).delayElement(Duration.ofSeconds(retryAfter));
    }

    private long parseRetryAfter(org.springframework.web.reactive.function.client.ClientResponse response) {
        var header = response.headers().header("Retry-After").stream().findFirst().orElse("5");
        try {
            return Long.parseLong(header);
        } catch (NumberFormatException e) {
            return 5;
        }
    }

    public record DialogTurnResponse(
            String id,
            String status,
            DialogOutput output
    ) {}

    public record DialogOutput(
            String text,
            Map<String, Object> context,
            Map<String, Object> entities
    ) {}
}

OAuth Scope Required: dialogs:execute

HTTP Request Cycle:

POST /api/v2/sessions/session-uuid-67890/messages HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/json

{
  "text": "I want to reset my password",
  "context": {
    "user": { "externalId": "cust-9876" },
    "turnCount": 1
  }
}

HTTP Response:

{
  "id": "msg-uuid-11111",
  "status": "completed",
  "output": {
    "text": "I can help with that. Please verify your email address.",
    "context": {
      "user": { "externalId": "cust-9876" },
      "turnCount": 2,
      "dialogState": "verification_pending"
    },
    "entities": {
      "intent": { "value": "reset_password", "confidence": 0.94 }
    }
  }
}

The output.context object contains the updated conversation state. You must serialize this back into your application store. Cognigy.AI does not persist context beyond the dialog runtime window, so external serialization guarantees state recovery during system restarts or channel switches.

Complete Working Example

The following module combines authentication, session management, and dialog execution into a single runnable Spring Boot service. Replace placeholder credentials and tenant URLs before execution.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@SpringBootApplication
public class CognigyDialogOrchestratorApplication {

    public static void main(String[] args) {
        SpringApplication.run(CognigyDialogOrchestratorApplication.class, args);
    }

    @Bean
    @ConfigurationProperties(prefix = "cognigy")
    public CognigyProperties cognigyProperties() {
        return new CognigyProperties();
    }
}

class CognigyProperties {
    private String tenantUrl;
    private OAuth oauth;
    private String dialogId;

    public String getTenantUrl() { return tenantUrl; }
    public void setTenantUrl(String tenantUrl) { this.tenantUrl = tenantUrl; }
    public OAuth getOauth() { return oauth; }
    public void setOauth(OAuth oauth) { this.oauth = oauth; }
    public String getDialogId() { return dialogId; }
    public void setDialogId(String dialogId) { this.dialogId = dialogId; }

    static class OAuth {
        private String clientId;
        private String clientSecret;
        public String getClientId() { return clientId; }
        public void setClientId(String clientId) { this.clientId = clientId; }
        public String getClientSecret() { return clientSecret; }
        public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; }
    }
}

@RestController
@RequestMapping("/api/dialog")
class DialogController {

    private final CognigyAuthService authService;
    private final CognigySessionService sessionService;
    private final CognigyDialogService dialogService;
    private final Map<String, String> sessionStore = new ConcurrentHashMap<>();

    public DialogController(CognigyAuthService authService,
                            CognigySessionService sessionService,
                            CognigyDialogService dialogService) {
        this.authService = authService;
        this.sessionService = sessionService;
        this.dialogService = dialogService;
    }

    @PostMapping("/start")
    public Mono<Map<String, String>> startConversation(@RequestBody Map<String, String> request) {
        String userId = request.get("userId");
        return authService.getAccessToken()
                .flatMap(token -> sessionService.createSession(
                        "dialog-uuid-12345",
                        Map.of("user", Map.of("externalId", userId))
                ))
                .map(session -> {
                    sessionStore.put(userId, session.id());
                    return Map.of("sessionId", session.id(), "status", "initiated");
                })
                .onErrorMap(e -> new RuntimeException("Session creation failed: " + e.getMessage()));
    }

    @PostMapping("/message")
    public Mono<Map<String, Object>> sendUserMessage(
            @RequestParam String userId,
            @RequestBody Map<String, String> request) {
        String sessionId = sessionStore.get(userId);
        if (sessionId == null) {
            return Mono.error(new IllegalArgumentException("No active session for user: " + userId));
        }

        String userInput = request.get("text");
        return dialogService.sendMessage(sessionId, userInput, Map.of("user", Map.of("externalId", userId)))
                .map(response -> Map.of(
                        "botResponse", response.output().text(),
                        "context", response.output().context(),
                        "entities", response.output().entities()
                ))
                .onErrorMap(e -> new RuntimeException("Message execution failed: " + e.getMessage()));
    }
}

application.properties:

cognigy.tenant-url=https://your-tenant.cognigy.ai
cognigy.oauth.client-id=your_client_id
cognigy.oauth.client-secret=your_client_secret
cognigy.dialog-id=dialog-uuid-12345
server.port=8080

This service exposes two endpoints. The /start endpoint initializes a Cognigy session and stores the identifier keyed by user ID. The /message endpoint retrieves the stored identifier, forwards the user input to Cognigy, and returns the serialized bot response and updated context.

Common Errors & Debugging

Error: 401 Unauthorized

  • What causes it: The OAuth token expired, was revoked, or the client credentials are invalid.
  • How to fix it: Verify client_id and client_secret in application.properties. Ensure the token cache expiration buffer accounts for network latency. The provided CognigyAuthService adds a 60-second safety margin before expiration.
  • Code showing the fix: The getAccessToken() method automatically refreshes when tokenExpiry.isAfter(Instant.now().plusSeconds(60)) evaluates to false.

Error: 400 Bad Request

  • What causes it: Invalid sessionId, malformed JSON payload, or missing required fields in the context object.
  • How to fix it: Validate that sessionId matches an active Cognigy session. Ensure the text field is present in message payloads. Check that context keys do not contain reserved characters.
  • Code showing the fix: Add payload validation before transmission.
if (userInput == null || userInput.isBlank()) {
    return Mono.error(new IllegalArgumentException("User input text cannot be empty"));
}

Error: 429 Too Many Requests

  • What causes it: Cognigy.AI enforces rate limits per tenant or API key. High-concurrency applications trigger this when exceeding the threshold.
  • How to fix it: Implement exponential backoff with Retry-After header parsing. The handleRateLimit method in CognigyDialogService reads the header and delays the next attempt.
  • Code showing the fix: The onStatus operator in the WebClient chain intercepts 429 responses and triggers the retry logic automatically.

Error: 500 Internal Server Error

  • What causes it: Dialog runtime failure within Cognigy.AI, usually caused by unhandled exceptions in dialog scripts or missing intent mappings.
  • How to fix it: Review the Cognigy.AI dialog logs. Ensure all dialog nodes have fallback paths. Verify that entity extraction rules match the incoming payload structure.
  • Code showing the fix: Wrap the request in a circuit breaker pattern for production systems to prevent cascading failures.

Official References