Building a Token Bucket Rate Adapter for NICE CXone Webhooks in Go
What You Will Build
- A Go-based HTTP service that receives NICE CXone webhooks, applies a token bucket rate limiter with burst allowance, and forwards processed payloads while safely handling 429 throttling responses.
- The implementation uses the NICE CXone REST API surface (
/api/v2/integrations/webhooks) and standard OAuth 2.0 client credentials authentication. - The tutorial covers Go 1.21+ with the standard library, demonstrating atomic rate limiting, retry-after parsing, latency tracking, and structured audit logging.
Prerequisites
- OAuth 2.0 Client Credentials grant configured in the NICE CXone admin console
- Required OAuth scopes:
integration:webhook:read,integration:webhook:write - Go 1.21 or later installed locally
- No external dependencies required (standard library only)
- Access to a CXone organization with webhook management permissions
Authentication Setup
NICE CXone uses standard OAuth 2.0 client credentials flow. You must fetch an access token before calling any CXone API endpoint. The token expires after thirty minutes and requires caching to prevent unnecessary authentication requests.
package main
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"sync"
"time"
)
const (
cxoneOAuthURL = "https://api.mycxone.com/oauth/token"
cxoneAPIBase = "https://api.mycxone.com/api/v2"
)
type OAuthToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type TokenCache struct {
mu sync.RWMutex
token OAuthToken
expires time.Time
}
func (c *TokenCache) Get() (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if time.Now().Before(c.expires) {
return c.token.AccessToken, nil
}
return "", fmt.Errorf("token expired")
}
func (c *TokenCache) Refresh(clientID, clientSecret string) error {
c.mu.Lock()
defer c.mu.Unlock()
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}},
Timeout: 10 * time.Second,
}
req, err := http.NewRequest(http.MethodPost, cxoneOAuthURL, bytes.NewBufferString(form.Encode()))
if err != nil {
return fmt.Errorf("failed to create oauth request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("oauth request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("oauth authentication failed with status %d: %s", resp.StatusCode, string(body))
}
var token OAuthToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return fmt.Errorf("failed to decode oauth response: %w", err)
}
c.token = token
c.expires = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
return nil
}
Implementation
Step 1: Token Bucket Rate Limiter with Burst Allowance
The rate limiter implements a token bucket algorithm. It tracks the last refill time, maintains a maximum capacity, and allows a configurable burst allowance. All operations use mutex synchronization to prevent race conditions during concurrent webhook processing.
type RateBucket struct {
mu sync.Mutex
capacity float64
tokens float64
refillRate float64 // tokens per second
lastRefill time.Time
burstAllow float64
}
func NewRateBucket(capacity, refillRate, burstAllow float64) *RateBucket {
return &RateBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
lastRefill: time.Now(),
burstAllow: burstAllow,
}
}
func (b *RateBucket) TryAcquire() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
elapsed := now.Sub(b.lastRefill).Seconds()
b.tokens += elapsed * b.refillRate
if b.tokens > b.capacity {
b.tokens = b.capacity
}
b.lastRefill = now
if b.tokens >= 1.0 {
b.tokens -= 1.0
return true
}
return false
}
func (b *RateBucket) AllowBurst() bool {
b.mu.Lock()
defer b.mu.Unlock()
// Burst allowance permits temporary overflow up to capacity + burstAllow
effectiveCapacity := b.capacity + b.burstAllow
return b.tokens >= 1.0 || effectiveCapacity > b.capacity
}
Step 2: 429 Status Checking and Retry-After Verification Pipeline
When CXone returns a 429 Too Many Requests response, the adapter extracts the Retry-After header. If the header is missing, it falls back to exponential backoff. The pipeline retries the request atomically while updating throttle success metrics.
type RetryPipeline struct {
maxRetries int
baseDelay time.Duration
logger *slog.Logger
}
func NewRetryPipeline(maxRetries int, logger *slog.Logger) *RetryPipeline {
return &RetryPipeline{
maxRetries: maxRetries,
baseDelay: 500 * time.Millisecond,
logger: logger,
}
}
func (p *RetryPipeline) ExecuteWithRetry(reqFunc func() (*http.Response, error)) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= p.maxRetries; attempt++ {
resp, err := reqFunc()
if err != nil {
lastErr = err
p.logger.Warn("request failed", "attempt", attempt, "error", err)
time.Sleep(p.baseDelay * time.Duration(1<<attempt))
continue
}
if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
// Parse Retry-After header
retryAfterStr := resp.Header.Get("Retry-After")
waitDuration := p.baseDelay * time.Duration(1<<attempt)
if retryAfterStr != "" {
if seconds, parseErr := time.ParseDuration(retryAfterStr + "s"); parseErr == nil {
waitDuration = seconds
} else {
p.logger.Warn("invalid Retry-After format, using exponential backoff", "header", retryAfterStr)
}
}
p.logger.Info("throttled by CXone, backing off", "status", resp.StatusCode, "retry_after", retryAfterStr, "wait_ms", waitDuration.Milliseconds())
time.Sleep(waitDuration)
lastErr = fmt.Errorf("rate limited: %s", retryAfterStr)
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
Step 3: Webhook Processing with Latency Tracking and Audit Logging
The webhook handler validates incoming payloads against CXone schema constraints, measures processing latency, and writes structured audit logs. It synchronizes with an external rate limiter via a callback endpoint.
type WebhookPayload struct {
EventType string `json:"eventType"`
EventID string `json:"eventID"`
Timestamp string `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
type AuditRecord struct {
Timestamp string `json:"timestamp"`
EventID string `json:"eventID"`
EventType string `json:"eventType"`
LatencyMs float64 `json:"latency_ms"`
Status string `json:"status"`
RateLimited bool `json:"rate_limited"`
ThrottleSuccess int `json:"throttle_success_count"`
}
type RateAdapter struct {
bucket *RateBucket
retryPipe *RetryPipeline
oauthCache *TokenCache
externalLimiterURL string
logger *slog.Logger
throttleSuccess int64
}
func NewRateAdapter(bucket *RateBucket, retryPipe *RetryPipeline, oauthCache *TokenCache, externalLimiterURL string, logger *slog.Logger) *RateAdapter {
return &RateAdapter{
bucket: bucket,
retryPipe: retryPipe,
oauthCache: oauthCache,
externalLimiterURL: externalLimiterURL,
logger: logger,
}
}
func (a *RateAdapter) HandleWebhook(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload schema", http.StatusBadRequest)
return
}
// Validate against CXone constraints
if payload.EventID == "" || payload.EventType == "" {
http.Error(w, "missing required fields: eventID, eventType", http.StatusBadRequest)
return
}
// Check rate limit
rateLimited := !a.bucket.TryAcquire()
if rateLimited {
a.logger.Warn("webhook rejected by local rate bucket", "event_id", payload.EventID)
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"error":"local_rate_limit_exceeded"}`))
return
}
// Sync with external rate limiter
go a.syncExternalLimiter(payload)
// Process via CXone API with retry pipeline
resp, err := a.retryPipe.ExecuteWithRetry(func() (*http.Response, error) {
token, err := a.oauthCache.Get()
if err != nil {
if refreshErr := a.oauthCache.Refresh(os.Getenv("CXONE_CLIENT_ID"), os.Getenv("CXONE_CLIENT_SECRET")); refreshErr != nil {
return nil, refreshErr
}
token, _ = a.oauthCache.Get()
}
endpoint := fmt.Sprintf("%s/integrations/webhooks", cxoneAPIBase)
req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(r.Body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
return client.Do(req)
})
latency := time.Since(startTime).Milliseconds()
status := "success"
if err != nil || resp == nil {
status = "failed"
a.logger.Error("webhook processing failed", "event_id", payload.EventID, "latency_ms", latency, "error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"processing_failed"}`))
} else {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
atomic.AddInt64(&a.throttleSuccess, 1)
}
a.logger.Info("webhook processed", "event_id", payload.EventID, "latency_ms", latency, "status", resp.StatusCode)
w.WriteHeader(resp.StatusCode)
w.Write([]byte(`{"status":"adapted"}`))
}
// Write audit log
audit := AuditRecord{
Timestamp: time.Now().UTC().Format(time.RFC3339),
EventID: payload.EventID,
EventType: payload.EventType,
LatencyMs: float64(latency),
Status: status,
RateLimited: rateLimited,
ThrottleSuccess: int(atomic.LoadInt64(&a.throttleSuccess)),
}
auditJSON, _ := json.Marshal(audit)
a.logger.Info("audit_log", "record", string(auditJSON))
}
func (a *RateAdapter) syncExternalLimiter(payload WebhookPayload) {
reqBody := map[string]interface{}{
"event_id": payload.EventID,
"rate_ref": "cxone_webhook_adapter",
"window_matrix": map[string]int{"window_seconds": 60, "requests": 1},
"throttle_directive": "adaptive_backoff",
}
jsonBody, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, a.externalLimiterURL, bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
a.logger.Warn("external limiter sync failed", "error", err)
return
}
defer resp.Body.Close()
}
Step 4: Service Initialization and HTTP Server Routing
The final step wires the components together, exposes the webhook endpoint, and configures structured logging. The server listens on a configurable port and gracefully shuts down on interrupt signals.
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
bucket := NewRateBucket(10.0, 2.0, 3.0) // 10 capacity, 2 tokens/sec, 3 burst
retryPipe := NewRetryPipeline(3, logger)
oauthCache := &TokenCache{}
adapter := NewRateAdapter(
bucket,
retryPipe,
oauthCache,
os.Getenv("EXTERNAL_LIMITER_URL"),
logger,
)
http.HandleFunc("/webhooks/cxone", adapter.HandleWebhook)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
logger.Info("rate adapter starting", "port", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
Complete Working Example
The following module combines all components into a single runnable application. Set the environment variables CXONE_CLIENT_ID, CXONE_CLIENT_SECRET, EXTERNAL_LIMITER_URL, and PORT before execution.
package main
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"sync"
"sync/atomic"
"time"
)
const (
cxoneOAuthURL = "https://api.mycxone.com/oauth/token"
cxoneAPIBase = "https://api.mycxone.com/api/v2"
)
// OAuthToken represents the CXone OAuth 2.0 response
type OAuthToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// TokenCache provides thread-safe token storage and refresh logic
type TokenCache struct {
mu sync.RWMutex
token OAuthToken
expires time.Time
}
func (c *TokenCache) Get() (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if time.Now().Before(c.expires) {
return c.token.AccessToken, nil
}
return "", fmt.Errorf("token expired")
}
func (c *TokenCache) Refresh(clientID, clientSecret string) error {
c.mu.Lock()
defer c.mu.Unlock()
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}},
Timeout: 10 * time.Second,
}
req, err := http.NewRequest(http.MethodPost, cxoneOAuthURL, bytes.NewBufferString(form.Encode()))
if err != nil {
return fmt.Errorf("failed to create oauth request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("oauth request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("oauth authentication failed with status %d: %s", resp.StatusCode, string(body))
}
var token OAuthToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return fmt.Errorf("failed to decode oauth response: %w", err)
}
c.token = token
c.expires = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
return nil
}
// RateBucket implements a token bucket algorithm with burst allowance
type RateBucket struct {
mu sync.Mutex
capacity float64
tokens float64
refillRate float64
lastRefill time.Time
burstAllow float64
}
func NewRateBucket(capacity, refillRate, burstAllow float64) *RateBucket {
return &RateBucket{
capacity: capacity,
tokens: capacity,
refillRate: refillRate,
lastRefill: time.Now(),
burstAllow: burstAllow,
}
}
func (b *RateBucket) TryAcquire() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
elapsed := now.Sub(b.lastRefill).Seconds()
b.tokens += elapsed * b.refillRate
if b.tokens > b.capacity {
b.tokens = b.capacity
}
b.lastRefill = now
if b.tokens >= 1.0 {
b.tokens -= 1.0
return true
}
return false
}
// RetryPipeline handles 429 responses and Retry-After headers
type RetryPipeline struct {
maxRetries int
baseDelay time.Duration
logger *slog.Logger
}
func NewRetryPipeline(maxRetries int, logger *slog.Logger) *RetryPipeline {
return &RetryPipeline{
maxRetries: maxRetries,
baseDelay: 500 * time.Millisecond,
logger: logger,
}
}
func (p *RetryPipeline) ExecuteWithRetry(reqFunc func() (*http.Response, error)) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= p.maxRetries; attempt++ {
resp, err := reqFunc()
if err != nil {
lastErr = err
p.logger.Warn("request failed", "attempt", attempt, "error", err)
time.Sleep(p.baseDelay * time.Duration(1<<attempt))
continue
}
if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
retryAfterStr := resp.Header.Get("Retry-After")
waitDuration := p.baseDelay * time.Duration(1<<attempt)
if retryAfterStr != "" {
if seconds, parseErr := time.ParseDuration(retryAfterStr + "s"); parseErr == nil {
waitDuration = seconds
} else {
p.logger.Warn("invalid Retry-After format, using exponential backoff", "header", retryAfterStr)
}
}
p.logger.Info("throttled by CXone, backing off", "status", resp.StatusCode, "retry_after", retryAfterStr, "wait_ms", waitDuration.Milliseconds())
time.Sleep(waitDuration)
lastErr = fmt.Errorf("rate limited: %s", retryAfterStr)
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
// WebhookPayload matches CXone inbound webhook schema
type WebhookPayload struct {
EventType string `json:"eventType"`
EventID string `json:"eventID"`
Timestamp string `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
// AuditRecord captures infrastructure governance data
type AuditRecord struct {
Timestamp string `json:"timestamp"`
EventID string `json:"eventID"`
EventType string `json:"eventType"`
LatencyMs float64 `json:"latency_ms"`
Status string `json:"status"`
RateLimited bool `json:"rate_limited"`
ThrottleSuccess int `json:"throttle_success_count"`
}
// RateAdapter orchestrates rate limiting, retry logic, and audit logging
type RateAdapter struct {
bucket *RateBucket
retryPipe *RetryPipeline
oauthCache *TokenCache
externalLimiterURL string
logger *slog.Logger
throttleSuccess int64
}
func NewRateAdapter(bucket *RateBucket, retryPipe *RetryPipeline, oauthCache *TokenCache, externalLimiterURL string, logger *slog.Logger) *RateAdapter {
return &RateAdapter{
bucket: bucket,
retryPipe: retryPipe,
oauthCache: oauthCache,
externalLimiterURL: externalLimiterURL,
logger: logger,
}
}
func (a *RateAdapter) HandleWebhook(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload schema", http.StatusBadRequest)
return
}
if payload.EventID == "" || payload.EventType == "" {
http.Error(w, "missing required fields: eventID, eventType", http.StatusBadRequest)
return
}
rateLimited := !a.bucket.TryAcquire()
if rateLimited {
a.logger.Warn("webhook rejected by local rate bucket", "event_id", payload.EventID)
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"error":"local_rate_limit_exceeded"}`))
return
}
go a.syncExternalLimiter(payload)
resp, err := a.retryPipe.ExecuteWithRetry(func() (*http.Response, error) {
token, err := a.oauthCache.Get()
if err != nil {
if refreshErr := a.oauthCache.Refresh(os.Getenv("CXONE_CLIENT_ID"), os.Getenv("CXONE_CLIENT_SECRET")); refreshErr != nil {
return nil, refreshErr
}
token, _ = a.oauthCache.Get()
}
endpoint := fmt.Sprintf("%s/integrations/webhooks", cxoneAPIBase)
req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(r.Body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
return client.Do(req)
})
latency := time.Since(startTime).Milliseconds()
status := "success"
if err != nil || resp == nil {
status = "failed"
a.logger.Error("webhook processing failed", "event_id", payload.EventID, "latency_ms", latency, "error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"processing_failed"}`))
} else {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
atomic.AddInt64(&a.throttleSuccess, 1)
}
a.logger.Info("webhook processed", "event_id", payload.EventID, "latency_ms", latency, "status", resp.StatusCode)
w.WriteHeader(resp.StatusCode)
w.Write([]byte(`{"status":"adapted"}`))
}
audit := AuditRecord{
Timestamp: time.Now().UTC().Format(time.RFC3339),
EventID: payload.EventID,
EventType: payload.EventType,
LatencyMs: float64(latency),
Status: status,
RateLimited: rateLimited,
ThrottleSuccess: int(atomic.LoadInt64(&a.throttleSuccess)),
}
auditJSON, _ := json.Marshal(audit)
a.logger.Info("audit_log", "record", string(auditJSON))
}
func (a *RateAdapter) syncExternalLimiter(payload WebhookPayload) {
reqBody := map[string]interface{}{
"event_id": payload.EventID,
"rate_ref": "cxone_webhook_adapter",
"window_matrix": map[string]int{"window_seconds": 60, "requests": 1},
"throttle_directive": "adaptive_backoff",
}
jsonBody, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, a.externalLimiterURL, bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
a.logger.Warn("external limiter sync failed", "error", err)
return
}
defer resp.Body.Close()
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
bucket := NewRateBucket(10.0, 2.0, 3.0)
retryPipe := NewRetryPipeline(3, logger)
oauthCache := &TokenCache{}
adapter := NewRateAdapter(
bucket,
retryPipe,
oauthCache,
os.Getenv("EXTERNAL_LIMITER_URL"),
logger,
)
http.HandleFunc("/webhooks/cxone", adapter.HandleWebhook)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
logger.Info("rate adapter starting", "port", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: Invalid or expired OAuth token, incorrect client credentials, or missing
integration:webhook:readscope. - Fix: Verify the
CXONE_CLIENT_IDandCXONE_CLIENT_SECRETenvironment variables. Confirm the OAuth client in CXone has the required scopes assigned. Check the token cache expiration logic. - Code Fix: The
TokenCache.Refreshmethod automatically retries authentication. Add explicit logging around the/oauth/tokenresponse to capture CXone error messages.
Error: 403 Forbidden
- Cause: The OAuth client lacks write permissions for webhooks, or the target organization restricts API access by IP.
- Fix: Assign
integration:webhook:writeto the OAuth client. Add your server IP to the CXone organization allowlist. - Code Fix: Inspect the response body on 403 status. CXone returns detailed scope violation messages that indicate the exact missing permission.
Error: 429 Too Many Requests
- Cause: Exceeded CXone global rate limits or organization-specific quotas. The token bucket capacity was set too high for the downstream allowance.
- Fix: Reduce the
capacityparameter inNewRateBucket. Increase therefillRateto match CXone documented limits (typically 100 requests per minute for webhook operations). Ensure theRetry-Afterheader parsing correctly converts seconds to duration. - Code Fix: The
RetryPipeline.ExecuteWithRetrymethod automatically sleeps for the duration specified inRetry-After. If the header is missing, it falls back to exponential backoff. Monitor thethrottle_success_countin audit logs to tune capacity.
Error: 5xx Server Errors
- Cause: CXone platform instability or malformed request payload.
- Fix: Validate the JSON schema against CXone documentation. Ensure
eventIDandeventTypeare present. Add request/response body logging during development. - Code Fix: The retry pipeline handles transient 5xx errors automatically. Persistent 5xx responses indicate a platform-side issue that requires CXone support escalation.