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_IDandCXONE_CLIENT_SECRET. Ensure the token is refreshed before the first API call. TheGetCXoneTokenfunction 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:readto 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
startTimethat falls in the past. - Fix: Validate timezones against the IANA database using
time.LoadLocation. EnsurestartTimeis strictly greater thantime.Now().UTC(). TheCalculateSchedulesfunction enforces RFC3339 formatting and timezone resolution.
Error: Analytics Poll Returns Empty Data
- Cause: The
dateFromanddateTowindow 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.inarray contains the exact campaign identifier. The polling loop uses a 24-hour sliding window to capture recent execution data.