Implement PCI-Compliant Payment Webhooks and Async Status Polling for Cognigy.AI in Java
What You Will Build
This tutorial builds a Spring Boot service that receives raw card data from a Cognigy.AI webhook, tokenizes it through a PCI-compliant gateway, polls an asynchronous payment status endpoint with exponential backoff, and pushes a dynamic card response back to the active Cognigy session. The implementation uses the Cognigy.AI v1 REST API and standard payment tokenization patterns. The code is written in Java 17 with Spring Boot 3.2.
Prerequisites
- Cognigy.AI API key with
session:updatepermission - Spring Boot 3.2+ and Java 17+
- Maven or Gradle build tool
- Dependencies:
spring-boot-starter-web,spring-boot-starter-webflux,lombok(optional),jackson-databind - Access to a PCI-compliant tokenization service (Stripe, Braintree, or equivalent)
- Payment gateway API credentials for status polling
Authentication Setup
Cognigy.AI v1 uses API key authentication rather than OAuth2. You must construct a Basic Authorization header using your API key and secret. The payment gateway typically requires Bearer token authentication. Both authentication methods are configured in a dedicated properties class and resolved at runtime.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Base64;
@Component
public class AuthConfig {
@Value("${cognigy.api.key}")
private String cognigyApiKey;
@Value("${cognigy.api.secret}")
private String cognigyApiSecret;
@Value("${payment.gateway.token}")
private String paymentGatewayToken;
public String getCognigyAuthHeader() {
String credentials = cognigyApiKey + ":" + cognigyApiSecret;
return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes());
}
public String getPaymentAuthHeader() {
return "Bearer " + paymentGatewayToken;
}
}
The session:update permission scope is required for all Cognigy session modification calls. Without this scope, the API returns a 403 Forbidden response.
Implementation
Step 1: Webhook Ingestion and PCI Tokenization
The webhook endpoint receives a POST request from Cognigy.AI containing the session identifier and raw card details. The service must never log or persist the primary account number (PAN). Instead, it forwards the card data to a PCI-compliant tokenization endpoint, receives a secure token, and stores only the token alongside the transaction reference.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.util.retry.Retry;
import java.time.Duration;
import java.util.Map;
@Service
public class PaymentService {
private final WebClient webClient;
private final AuthConfig authConfig;
private final ObjectMapper objectMapper;
public PaymentService(WebClient.Builder webClientBuilder, AuthConfig authConfig) {
this.webClient = webClientBuilder.build();
this.authConfig = authConfig;
this.objectMapper = new ObjectMapper();
}
public String tokenizeCard(Map<String, String> cardData) {
String requestBody = """
{
"card_number": "%s",
"expiry_month": "%s",
"expiry_year": "%s",
"cvv": "%s"
}
""".formatted(
cardData.get("card_number"),
cardData.get("expiry_month"),
cardData.get("expiry_year"),
cardData.get("cvv")
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", authConfig.getPaymentAuthHeader());
HttpEntity<String> request = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<String> response = webClient.post()
.uri("https://api.paymentgateway.com/v1/tokens")
.headers(h -> h.addAll(headers))
.bodyValue(requestBody)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, resp -> resp.bodyToMono(String.class)
.flatMap(body -> {
System.err.println("Tokenization failed: " + body);
return null;
}))
.onStatus(HttpStatus::is5xxServerError, resp -> resp.bodyToMono(String.class)
.flatMap(body -> {
System.err.println("Gateway error: " + body);
return null;
}))
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
.filter(throwable -> throwable instanceof java.net.http.HttpClient.ResponseTimeoutException))
.block();
if (response.getStatusCode().is2xxSuccessful()) {
JsonNode json = objectMapper.readTree(response.getBody());
return json.get("token").asText();
}
throw new RuntimeException("Card tokenization failed with status: " + response.getStatusCode());
} catch (Exception e) {
throw new RuntimeException("Failed to tokenize card data", e);
}
}
}
The tokenization endpoint requires the payment:write scope on the gateway side. The retry logic handles transient 429 rate-limit responses and network timeouts. The CVV is transmitted only during tokenization and is never stored in the application database.
Step 2: Asynchronous Payment Status Polling
Payment gateways process transactions asynchronously. The service must poll the status endpoint until the transaction reaches a terminal state (completed, failed, or declined). Exponential backoff prevents overwhelming the gateway with rapid requests.
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.util.retry.Retry;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
@Service
public class PollingService {
private final WebClient webClient;
private final AuthConfig authConfig;
public PollingService(WebClient.Builder webClientBuilder, AuthConfig authConfig) {
this.webClient = webClientBuilder.build();
this.authConfig = authConfig;
}
public CompletableFuture<String> pollPaymentStatus(String transactionId) {
return webClient.get()
.uri("https://api.paymentgateway.com/v1/transactions/{id}/status", transactionId)
.header("Authorization", authConfig.getPaymentAuthHeader())
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response ->
response.createException().flatMap(Mono::error))
.onStatus(HttpStatus::is5xxServerError, response ->
response.createException().flatMap(Mono::error))
.retryWhen(Retry.backoff(10, Duration.ofSeconds(2))
.maxBackoff(Duration.ofSeconds(30))
.filter(throwable -> {
if (throwable instanceof org.springframework.web.reactive.function.client.WebClientResponseExceptionTooManyRequests) {
return true;
}
return false;
}))
.bodyToMono(String.class)
.repeatWhenRepeatable(repeatContext ->
repeatContext.retryWhen(Retry.backoff(10, Duration.ofSeconds(2))
.maxBackoff(Duration.ofSeconds(30))))
.map(statusResponse -> {
if (statusResponse.contains("\"status\":\"completed\"")) {
return "success";
} else if (statusResponse.contains("\"status\":\"failed\"") ||
statusResponse.contains("\"status\":\"declined\"")) {
return "failure";
}
return "pending";
})
.doOnNext(status -> System.out.println("Payment status: " + status))
.toFuture();
}
}
The polling loop respects the payment:read scope. The retry configuration specifically catches 429 Too Many Requests responses and applies backoff. The method returns a CompletableFuture to allow non-blocking execution while the webhook responds immediately to Cognigy.AI.
Step 3: Updating the Cognigy Session with Dynamic Cards
Once the payment status resolves, the service calls the Cognigy.AI session update endpoint. The payload contains a structured card object that renders in the Cognigy.AI webchat or channel interface. The endpoint requires the session:update permission scope.
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Service
public class CognigySessionService {
private final RestClient restClient;
private final AuthConfig authConfig;
private final ObjectMapper objectMapper;
public CognigySessionService(RestClient.Builder restClientBuilder, AuthConfig authConfig) {
this.restClient = restClientBuilder
.baseUrl("https://{region}.cognigy.com/api/v1")
.defaultHeader("Authorization", authConfig.getCognigyAuthHeader())
.build();
this.authConfig = authConfig;
this.objectMapper = new ObjectMapper();
}
public void updateSessionWithCard(String sessionId, String userId, boolean isSuccess) {
String message = isSuccess ? "Your payment has been processed successfully."
: "Your payment could not be completed. Please try again.";
String cardJson = isSuccess
? """
{
"title": "Payment Confirmed",
"subtitle": "Transaction ID: %s",
"image": "https://example.com/assets/check.png",
"buttons": [
{ "label": "View Receipt", "payload": "action:view_receipt" }
]
}
"""
: """
{
"title": "Payment Failed",
"subtitle": "Please verify your card details.",
"image": "https://example.com/assets/error.png",
"buttons": [
{ "label": "Retry Payment", "payload": "action:retry_payment" },
{ "label": "Contact Support", "payload": "action:contact_support" }
]
}
""";
String requestBody = """
{
"sessionId": "%s",
"userId": "%s",
"message": "%s",
"card": %s
}
""".formatted(sessionId, userId, message, cardJson);
try {
ResponseEntity<String> response = restClient.post()
.uri("/session/update")
.header("Authorization", authConfig.getCognigyAuthHeader())
.contentType(MediaType.APPLICATION_JSON)
.body(requestBody)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, (req, res) -> {
System.err.println("Cognigy 4xx error: " + res.getStatusCode() + " " + res.getBody());
})
.onStatus(HttpStatus::is5xxServerError, (req, res) -> {
System.err.println("Cognigy 5xx error: " + res.getStatusCode());
})
.toEntity(String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("Failed to update Cognigy session. Status: " + response.getStatusCode());
}
} catch (Exception e) {
throw new RuntimeException("Session update failed", e);
}
}
}
The Cognigy session update endpoint does not support pagination. It returns a 200 OK on success. The card payload follows the Cognigy.AI card specification, which requires title, subtitle, and optional buttons. The session:update permission scope is mandatory for this call.
Complete Working Example
The following Spring Boot application combines the webhook controller, tokenization, polling, and session update logic into a single runnable module. Replace the placeholder region and credentials before deployment.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.reactive.function.client.WebClient;
@SpringBootApplication
public class CognigyPaymentWebhookApplication {
public static void main(String[] args) {
SpringApplication.run(CognigyPaymentWebhookApplication.class, args);
}
@Bean
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
}
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@RestController
@RequestMapping("/webhook/cognigy/payment")
public class PaymentWebhookController {
private final PaymentService paymentService;
private final PollingService pollingService;
private final CognigySessionService cognigySessionService;
public PaymentWebhookController(PaymentService paymentService,
PollingService pollingService,
CognigySessionService cognigySessionService) {
this.paymentService = paymentService;
this.pollingService = pollingService;
this.cognigySessionService = cognigySessionService;
}
@PostMapping
public ResponseEntity<String> handlePaymentWebhook(@RequestBody Map<String, Object> payload) {
String sessionId = (String) payload.get("sessionId");
String userId = (String) payload.get("userId");
Map<String, String> cardData = (Map<String, String>) payload.get("cardData");
if (sessionId == null || userId == null || cardData == null) {
return ResponseEntity.badRequest().body("{\"error\": \"Missing required fields: sessionId, userId, cardData\"}");
}
try {
String token = paymentService.tokenizeCard(cardData);
String transactionId = "txn_" + System.currentTimeMillis(); // Replace with actual gateway transaction ID
CompletableFuture<String> statusFuture = pollingService.pollPaymentStatus(transactionId);
statusFuture.thenAccept(status -> {
boolean isSuccess = "success".equals(status);
cognigySessionService.updateSessionWithCard(sessionId, userId, isSuccess);
}).exceptionally(ex -> {
System.err.println("Payment polling failed: " + ex.getMessage());
cognigySessionService.updateSessionWithCard(sessionId, userId, false);
return null;
});
return ResponseEntity.accepted().body("{\"status\": \"processing\", \"transactionId\": \"" + transactionId + "\"}");
} catch (Exception e) {
return ResponseEntity.internalServerError().body("{\"error\": \"Webhook processing failed: " + e.getMessage() + "\"}");
}
}
}
# application.yml
cognigy:
api:
key: YOUR_COGNIGY_API_KEY
secret: YOUR_COGNIGY_API_SECRET
payment:
gateway:
token: YOUR_GATEWAY_BEARER_TOKEN
spring:
webflux:
base-url: https://api.paymentgateway.com
The controller returns 202 Accepted immediately to Cognigy.AI, preventing webhook timeout errors. The async polling and session update occur in the background. The YAML configuration externalizes credentials for environment-specific deployment.
Common Errors & Debugging
Error: 401 Unauthorized on Cognigy Session Update
- Cause: The API key is invalid, expired, or missing the
session:updatepermission scope. - Fix: Verify the API key in the Cognigy.AI console. Navigate to Settings > API Keys and ensure the
session:updatepermission is checked. Reconstruct the Basic Auth header using the exact key and secret pair. - Code Fix: Log the authorization header before transmission to verify Base64 encoding matches
apiKey:secret.
Error: 429 Too Many Requests on Payment Gateway
- Cause: The polling frequency exceeds the gateway rate limit, or multiple concurrent transactions trigger threshold limits.
- Fix: Increase the exponential backoff delay in the
Retry.backoffconfiguration. Implement request deduplication if multiple webhooks trigger identical transactions. - Code Fix: Adjust
Retry.backoff(10, Duration.ofSeconds(5))and add a circuit breaker pattern for sustained 429 responses.
Error: 400 Bad Request on Cognigy Session Update
- Cause: The JSON payload contains malformed card structure, invalid sessionId format, or missing required fields.
- Fix: Validate the card JSON against Cognigy.AI card schema requirements. Ensure
sessionIdanduserIdmatch the active session returned by the webhook. Remove trailing commas or escape quotes in string interpolation. - Code Fix: Use
objectMapper.readTree()to validate JSON before transmission. Log the exact request body for inspection.
Error: Payment Status Stuck on Pending
- Cause: The gateway requires additional verification steps (3D Secure, manual review) that extend beyond the polling window.
- Fix: Implement a maximum retry count with a fallback to a manual review state. Send a Cognigy card prompting the user to check email for verification links.
- Code Fix: Add a retry counter to
pollPaymentStatus. After 15 attempts, return"pending_review"and trigger a fallback card response.