Automating NICE CXone Outbound Campaign Scheduling with a Go Worker

Automating NICE CXone Outbound Campaign Scheduling with a Go Worker

What You Will Build

A background Go worker that parses cron expressions, calculates timezone-adjusted start times per contact, submits schedule objects to the CXone Outbound Campaign API, polls the outbound analytics endpoint for execution metrics, and dispatches a completion webhook when the campaign finishes. This tutorial uses the NICE CXone REST API surface. The implementation is written in Go 1.21+ using the standard library and robfig/cron.

Prerequisites

  • CXone OAuth 2.0 confidential client (Client Credentials flow)
  • Required scopes: outbound:campaign:write outbound:contact:read analytics:read
  • Go 1.21 or higher
  • External dependencies: github.com/robfig/cron/v3
  • Access to a CXone environment with Outbound enabled and a configured campaign ID

Authentication Setup

CXone uses OAuth 2.0 Client Credentials for server-to-server integrations. The worker must acquire a bearer token before making API calls. Tokens expire after one hour, so production systems should cache and refresh them. This example implements a token fetcher with basic error handling.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

type OAuthResponse struct {
	AccessToken string `json:"access_token"`
	ExpiresIn   int    `json:"expires_in"`
	TokenType   string `json:"token_type"`
}

func GetCXoneToken(clientID, clientSecret, env string) (string, error) {
	url := fmt.Sprintf("https://%s.niceincontact.com/api/v2/oauth/token", env)
	payload := fmt.Sprintf("grant_type=client_credentials&scope=outbound%%3Acampaign%%3Awrite+outbound%%3Acontact%%3Aread+analytics%%3Aread")
	
	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(payload))
	if err != nil {
		return "", fmt.Errorf("failed to create oauth request: %w", err)
	}
	req.SetBasicAuth(clientID, clientSecret)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	client := &http.Client{Timeout: 10 * time.Second}
	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 error %d: %s", resp.StatusCode, string(body))
	}

	var tokenResp OAuthResponse
	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
		return "", fmt.Errorf("failed to decode oauth response: %w", err)
	}
	return tokenResp.AccessToken, nil
}

Implementation

Step 1: Parse Cron Expressions and Calculate Timezone-Aware Start Times

Outbound campaigns must respect local calling hours. The worker receives a cron string and a list of contacts with timezone attributes. It calculates the next valid run time, converts it to each contact’s local timezone, and builds schedule payloads.

package main

import (
	"fmt"
	"time"

	"github.com/robfig/cron/v3"
)

type Contact struct {
	ID       string `json:"id"`
	Timezone string `json:"timezone"`
}

type ScheduleRequest struct {
	CampaignID  string `json:"campaignId"`
	StartTime   string `json:"startTime"`
	EndTime     string `json:"endTime"`
	Timezone    string `json:"timezone"`
	ScheduleType string `json:"scheduleType"`
}

func CalculateSchedules(cronExpr string, campaignID string, contacts []Contact) ([]ScheduleRequest, error) {
	sched, err := cron.ParseStandard(cronExpr)
	if err != nil {
		return nil, fmt.Errorf("invalid cron expression: %w", err)
	}

	var schedules []ScheduleRequest
	now := time.Now()

	for _, c := range contacts {
		loc, err := time.LoadLocation(c.Timezone)
		if err != nil {
			return nil, fmt.Errorf("invalid timezone %q for contact %s: %w", c.Timezone, c.ID, err)
		}

		nextRun := sched.Next(now)
		localStart := nextRun.In(loc)
		localEnd := localStart.Add(4 * time.Hour)

		schedules = append(schedules, ScheduleRequest{
			CampaignID:   campaignID,
			StartTime:    localStart.Format(time.RFC3339),
			EndTime:      localEnd.Format(time.RFC3339),
			Timezone:     c.Timezone,
			ScheduleType: "ONE_TIME",
		})
	}
	return schedules, nil
}

Step 2: Submit Schedule Objects via the Outbound Campaign API

CXone accepts schedule definitions through POST /api/v2/outbound/campaigns/{campaignId}/schedules. The API returns a 201 Created response with the schedule identifier. This step includes a retry wrapper to handle 429 rate limits gracefully.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net/http"
	"time"
)

func DoWithRetry(url string, method string, headers map[string]string, body []byte, maxRetries int) (*http.Response, error) {
	var resp *http.Response
	var err error
	for attempt := 0; attempt <= maxRetries; attempt++ {
		req, _ := http.NewRequest(method, url, bytes.NewBuffer(body))
		for k, v := range headers {
			req.Header.Set(k, v)
		}
		if body != nil {
			req.Header.Set("Content-Type", "application/json")
		}

		client := &http.Client{Timeout: 30 * time.Second}
		resp, err = client.Do(req)
		if err != nil {
			return nil, fmt.Errorf("http error: %w", err)
		}

		if resp.StatusCode == http.StatusTooManyRequests {
			retryAfter := resp.Header.Get("Retry-After")
			delay := 2.0
			if retryAfter != "" {
				if parsed, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
					delay = parsed.Seconds()
				}
			}
			backoff := math.Pow(2, float64(attempt)) * delay
			time.Sleep(time.Duration(backoff) * time.Second)
			continue
		}

		if resp.StatusCode >= 300 {
			defer resp.Body.Close()
			bodyBytes, _ := io.ReadAll(resp.Body)
			return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(bodyBytes))
		}
		return resp, nil
	}
	return nil, fmt.Errorf("max retries exceeded")
}

func SubmitSchedule(token, campaignID string, schedule ScheduleRequest) (string, error) {
	url := fmt.Sprintf("https://api.e.8x8.com/api/v2/outbound/campaigns/%s/schedules", campaignID)
	payload, err := json.Marshal(schedule)
	if err != nil {
		return "", fmt.Errorf("marshal error: %w", err)
	}

	headers := map[string]string{"Authorization": "Bearer " + token}
	resp, err := DoWithRetry(url, http.MethodPost, headers, payload, 3)
	if err != nil {
		return "", fmt.Errorf("submit schedule failed: %w", err)
	}
	defer resp.Body.Close()

	var result map[string]interface{}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("decode schedule response: %w", err)
	}
	id, ok := result["id"].(string)
	if !ok {
		return "", fmt.Errorf("missing schedule id in response")
	}
	return id, nil
}

Step 3: Poll the Analytics Endpoint for Execution Status

CXone tracks outbound execution metrics through POST /api/v2/analytics/outbound/details/query. The worker polls this endpoint at fixed intervals, aggregates the callsCompleted metric, and determines when the campaign reaches completion. Pagination is handled by checking the page and pageSize fields in the response.

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

type AnalyticsQuery struct {
	DateFrom string     `json:"dateFrom"`
	DateTo   string     `json:"dateTo"`
	GroupBy  []string   `json:"groupBy"`
	Metrics  []string   `json:"metrics"`
	Filter   FilterBody `json:"filter"`
	Page     int        `json:"page,omitempty"`
	PageSize int        `json:"pageSize,omitempty"`
}

type FilterBody struct {
	CampaignID CampaignFilter `json:"campaignId"`
}

type CampaignFilter struct {
	In []string `json:"in"`
}

type AnalyticsResponse struct {
	Data []map[string]interface{} `json:"data"`
	Page int                      `json:"page"`
	TotalPages int                `json:"totalPages"`
}

func PollCampaignAnalytics(token, campaignID string, expectedCompleted int) (bool, map[string]interface{}, error) {
	now := time.Now()
	query := AnalyticsQuery{
		DateFrom: now.Add(-24 * time.Hour).Format(time.RFC3339),
		DateTo:   now.Format(time.RFC3339),
		GroupBy:  []string{"campaignId"},
		Metrics:  []string{"callsCompleted", "callsAttempted", "connected"},
		Filter: FilterBody{
			CampaignID: CampaignFilter{In: []string{campaignID}},
		},
		Page:     1,
		PageSize: 100,
	}

	url := "https://api.e.8x8.com/api/v2/analytics/outbound/details/query"
	payload, _ := json.Marshal(query)
	headers := map[string]string{"Authorization": "Bearer " + token}

	var totalCompleted float64
	page := 1

	for {
		reqPayload, _ := json.Marshal(AnalyticsQuery{
			DateFrom: query.DateFrom,
			DateTo:   query.DateTo,
			GroupBy:  query.GroupBy,
			Metrics:  query.Metrics,
			Filter:   query.Filter,
			Page:     page,
			PageSize: 100,
		})

		resp, err := DoWithRetry(url, http.MethodPost, headers, reqPayload, 3)
		if err != nil {
			return false, nil, fmt.Errorf("analytics poll failed: %w", err)
		}

		var analyticsResp AnalyticsResponse
		if err := json.NewDecoder(resp.Body).Decode(&analyticsResp); err != nil {
			resp.Body.Close()
			return false, nil, fmt.Errorf("decode analytics: %w", err)
		}
		resp.Body.Close()

		for _, row := range analyticsResp.Data {
			if completed, ok := row["callsCompleted"].(float64); ok {
				totalCompleted += completed
			}
		}

		if page >= analyticsResp.TotalPages {
			break
		}
		page++
	}

	metrics := map[string]interface{}{
		"totalCompleted": totalCompleted,
		"expected":       expectedCompleted,
		"campaignId":     campaignID,
	}

	isComplete := int(totalCompleted) >= expectedCompleted
	return isComplete, metrics, nil
}

Step 4: Trigger Completion Webhook

When the analytics poll confirms completion, the worker dispatches a structured payload to a configured webhook endpoint. This enables downstream systems to trigger notifications, close CRM records, or trigger reconciliation jobs.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type WebhookPayload struct {
	Event     string                 `json:"event"`
	Timestamp string                 `json:"timestamp"`
	Data      map[string]interface{} `json:"data"`
}

func TriggerCompletionWebhook(webhookURL string, metrics map[string]interface{}) error {
	payload := WebhookPayload{
		Event:     "campaign.completed",
		Timestamp: time.Now().UTC().Format(time.RFC3339),
		Data:      metrics,
	}

	body, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("marshal webhook payload: %w", err)
	}

	req, _ := http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("User-Agent", "CXoneSchedulerWorker/1.0")

	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("webhook request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("webhook returned %d", resp.StatusCode)
	}
	return nil
}

Complete Working Example

The following script ties all components into a single executable worker. It reads configuration from environment variables, processes contacts, submits schedules, polls analytics, and dispatches the webhook.

package main

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"time"
)

func main() {
	clientID := os.Getenv("CXONE_CLIENT_ID")
	clientSecret := os.Getenv("CXONE_CLIENT_SECRET")
	env := os.Getenv("CXONE_ENV")
	campaignID := os.Getenv("CXONE_CAMPAIGN_ID")
	cronExpr := os.Getenv("SCHEDULE_CRON")
	webhookURL := os.Getenv("WEBHOOK_URL")
	expectedCompletedStr := os.Getenv("EXPECTED_COMPLETED")

	if expectedCompletedStr == "" {
		expectedCompletedStr = "50"
	}
	expectedCompleted, _ := strconv.Atoi(expectedCompletedStr)

	if clientID == "" || clientSecret == "" || campaignID == "" || cronExpr == "" || webhookURL == "" {
		log.Fatal("Missing required environment variables")
	}

	// Step 1: Authenticate
	token, err := GetCXoneToken(clientID, clientSecret, env)
	if err != nil {
		log.Fatalf("Authentication failed: %v", err)
	}

	// Step 2: Load contacts (simulated batch)
	contacts := []Contact{
		{ID: "c1", Timezone: "America/New_York"},
		{ID: "c2", Timezone: "America/Los_Angeles"},
		{ID: "c3", Timezone: "Europe/London"},
	}

	// Step 3: Calculate timezone-aware schedules
	schedules, err := CalculateSchedules(cronExpr, campaignID, contacts)
	if err != nil {
		log.Fatalf("Schedule calculation failed: %v", err)
	}

	// Step 4: Submit schedules to CXone
	for _, s := range schedules {
		scheduleID, err := SubmitSchedule(token, s.CampaignID, s)
		if err != nil {
			log.Printf("Failed to submit schedule for %s: %v", s.Timezone, err)
			continue
		}
		log.Printf("Submitted schedule %s for timezone %s", scheduleID, s.Timezone)
	}

	// Step 5: Poll analytics until completion or timeout
	timeout := time.After(1 * time.Hour)
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	log.Println("Monitoring campaign execution via analytics...")
	for {
		select {
		case <-timeout:
			log.Fatal("Campaign monitoring timed out")
		case <-ticker.C:
			complete, metrics, err := PollCampaignAnalytics(token, campaignID, expectedCompleted)
			if err != nil {
				log.Printf("Analytics poll error: %v", err)
				continue
			}

			log.Printf("Progress: %.0f/%d calls completed", metrics["totalCompleted"], expectedCompleted)

			if complete {
				log.Println("Campaign completed. Triggering webhook...")
				if err := TriggerCompletionWebhook(webhookURL, metrics); err != nil {
					log.Printf("Webhook delivery failed: %v", err)
				}
				log.Println("Worker finished successfully.")
				return
			}
		}
	}
}

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: The OAuth token has expired, the client credentials are incorrect, or the scope does not include outbound:campaign:write.
  • Fix: Verify CXONE_CLIENT_ID and CXONE_CLIENT_SECRET. Ensure the token is refreshed before the first API call. The GetCXoneToken function validates the response status code and returns a descriptive error.

Error: 403 Forbidden

  • Cause: The OAuth client lacks the required scope, or the campaign ID does not belong to the authenticated tenant.
  • Fix: Add outbound:campaign:write outbound:contact:read analytics:read to the client credential scope in the CXone admin console. Confirm the campaign ID is valid and active.

Error: 422 Unprocessable Entity

  • Cause: The schedule payload contains invalid timezone identifiers, malformed RFC3339 timestamps, or a startTime that falls in the past.
  • Fix: Validate timezones against the IANA database using time.LoadLocation. Ensure startTime is strictly greater than time.Now().UTC(). The CalculateSchedules function enforces RFC3339 formatting and timezone resolution.

Error: Analytics Poll Returns Empty Data

  • Cause: The dateFrom and dateTo window does not overlap with the campaign execution period, or the campaign filter syntax is incorrect.
  • Fix: Expand the query window to cover the full scheduled duration. Verify the filter.campaignId.in array contains the exact campaign identifier. The polling loop uses a 24-hour sliding window to capture recent execution data.

Official References