Monitoring NICE CXone SCIM Provisioning Status via API with Go
What You Will Build
- This tutorial builds a Go service that monitors NICE CXone SCIM provisioning operations by querying operation status, tracking sync deltas, and triggering alerts on failure thresholds.
- It uses the NICE CXone SCIM Operations API (
GET /api/v2/scim/operations) and standard HTTP clients. - The implementation is written in Go 1.21+ using the
net/http,encoding/json, andnet/urlstandard libraries.
Prerequisites
- OAuth client type: Confidential client (Client Credentials Grant) with
scim:readscope - API version: CXone API v2
- Language/runtime: Go 1.21 or later
- External dependencies: None (standard library only)
- Environment variables:
CXONE_TENANT,CXONE_CLIENT_ID,CXONE_CLIENT_SECRET,ALERT_WEBHOOK_URL
Authentication Setup
NICE CXone uses OAuth 2.0 Client Credentials Grant for server-to-server API access. You must cache the access token and refresh it before expiration to avoid 401 errors during polling loops.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type TokenCache struct {
Token string
ExpiresAt time.Time
}
var tokenCache TokenCache
func fetchOAuthToken(tenant, clientID, clientSecret string) (*OAuthTokenResponse, error) {
url := fmt.Sprintf("https://%s.my.cxone.com/oauth/token", tenant)
payload := fmt.Sprintf("client_id=%s&client_secret=%s&grant_type=client_credentials&scope=scim:read", clientID, clientSecret)
req, err := http.NewRequest("POST", url, bytes.NewBufferString(payload))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
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 nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oauth token error: status %d", resp.StatusCode)
}
var tokenResp OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
tokenCache.Token = tokenResp.AccessToken
tokenCache.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
return &tokenResp, nil
}
func getValidToken(tenant, clientID, clientSecret string) (string, error) {
if time.Now().Before(tokenCache.ExpiresAt.Add(-60 * time.Second)) {
return tokenCache.Token, nil
}
_, err := fetchOAuthToken(tenant, clientID, clientSecret)
return tokenCache.Token, err
}
Required OAuth scope: scim:read
Error handling: The token fetch validates HTTP 200, caches the token with a 60-second early refresh buffer, and wraps all errors for context.
Implementation
Step 1: Construct Status Query Payloads with Operation IDs, Sync Timestamps, and Error Filters
The CXone SCIM Operations API accepts query parameters for filtering by status, creation time window, and pagination. You must validate the retention window (CXone retains SCIM operation data for 30 days) and enforce query limits (maximum 1000 records per request) to prevent 400 errors.
import (
"net/url"
"time"
)
type SCIMQueryParams struct {
Status string
CreatedAfter time.Time
CreatedBefore time.Time
Limit int
Offset int
}
func buildSCIMQuery(params SCIMQueryParams) (string, error) {
// Validate retention window (30 days maximum)
window := params.CreatedBefore.Sub(params.CreatedAfter)
if window.Hours() > 720 {
return "", fmt.Errorf("query window exceeds 30-day retention limit")
}
if params.Limit > 1000 || params.Limit <= 0 {
return "", fmt.Errorf("limit must be between 1 and 1000")
}
q := url.Values{}
if params.Status != "" {
q.Set("status", params.Status)
}
if !params.CreatedAfter.IsZero() {
q.Set("createdAfter", params.CreatedAfter.Format(time.RFC3339))
}
if !params.CreatedBefore.IsZero() {
q.Set("createdBefore", params.CreatedBefore.Format(time.RFC3339))
}
q.Set("limit", fmt.Sprintf("%d", params.Limit))
q.Set("offset", fmt.Sprintf("%d", params.Offset))
return q.Encode(), nil
}
Required OAuth scope: scim:read
Expected response structure:
{
"operations": [
{
"id": "op_8a7b9c2d-1e3f-4a5b-6c7d-8e9f0a1b2c3d",
"status": "completed",
"createdTime": "2024-05-15T08:30:00Z",
"updatedTime": "2024-05-15T08:32:15Z",
"totalRecords": 45,
"completedRecords": 45,
"failedRecords": 0,
"errors": []
}
],
"nextPage": null
}
Step 2: Handle Asynchronous Status Retrieval via Polling with Delta Updates
SCIM provisioning jobs run asynchronously. You must implement a polling loop that fetches operations created after the last sync timestamp, tracks deltas between polls, and handles 429 rate limits with exponential backoff.
type SCIMOperation struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedTime string `json:"createdTime"`
UpdatedTime string `json:"updatedTime"`
TotalRecords int `json:"totalRecords"`
CompletedRecords int `json:"completedRecords"`
FailedRecords int `json:"failedRecords"`
Errors []string `json:"errors"`
}
type SCIMResponse struct {
Operations []SCIMOperation `json:"operations"`
NextPage *string `json:"nextPage"`
}
func pollSCIMOperations(tenant, token, query string, lastSync time.Time) ([]SCIMOperation, error) {
baseURL := fmt.Sprintf("https://%s.my.cxone.com/api/v2/scim/operations?%s", tenant, query)
var allOps []SCIMOperation
currentQuery := query
for {
req, err := http.NewRequest("GET", baseURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// Handle rate limiting with exponential backoff
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := 5
if header := resp.Header.Get("Retry-After"); header != "" {
fmt.Sscanf(header, "%d", &retryAfter)
}
time.Sleep(time.Duration(retryAfter) * time.Second)
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
}
defer resp.Body.Close()
var scimResp SCIMResponse
if err := json.NewDecoder(resp.Body).Decode(&scimResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Filter for delta updates since last sync
for _, op := range scimResp.Operations {
created, err := time.Parse(time.RFC3339, op.CreatedTime)
if err != nil {
continue
}
if created.After(lastSync) {
allOps = append(allOps, op)
}
}
if scimResp.NextPage == nil || *scimResp.NextPage == "" {
break
}
currentQuery = *scimResp.NextPage
baseURL = fmt.Sprintf("https://%s.my.cxone.com/api/v2/scim/operations?%s", tenant, currentQuery)
}
return allOps, nil
}
Required OAuth scope: scim:read
Pagination handling: The loop follows nextPage cursors until exhausted.
Retry logic: 429 responses trigger a sleep using the Retry-After header or a 5-second fallback.
Step 3: Implement Status Alerting Logic Using Threshold-Based Triggers and Notification Routing
You must calculate sync completion rates and error frequencies, then route alerts to an external webhook when thresholds are breached. This section implements threshold evaluation and HTTP notification dispatch.
type AlertPayload struct {
Timestamp string `json:"timestamp"`
TotalOps int `json:"total_operations"`
CompletedOps int `json:"completed_operations"`
FailedOps int `json:"failed_operations"`
ErrorRate float64 `json:"error_rate_percent"`
Trigger string `json:"trigger"`
Operations []SCIMOperation `json:"failed_operations"`
}
func evaluateAndAlert(operations []SCIMOperation, threshold float64, webhookURL string) error {
if len(operations) == 0 {
return nil
}
var failedOps []SCIMOperation
totalFailed := 0
for _, op := range operations {
if op.Status == "failed" || op.FailedRecords > 0 {
totalFailed += op.FailedRecords
failedOps = append(failedOps, op)
}
}
errorRate := float64(totalFailed) / float64(len(operations)) * 100.0
if errorRate > threshold {
payload := AlertPayload{
Timestamp: time.Now().UTC().Format(time.RFC3339),
TotalOps: len(operations),
CompletedOps: len(operations) - len(failedOps),
FailedOps: len(failedOps),
ErrorRate: errorRate,
Trigger: fmt.Sprintf("error_rate_%0.1f_percent", threshold),
Operations: failedOps,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal alert payload: %w", err)
}
req, err := http.NewRequest("POST", webhookURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create alert request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("alert dispatch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("alert webhook returned status %d", resp.StatusCode)
}
fmt.Printf("Alert triggered: %.2f%% error rate exceeds %.2f%% threshold\n", errorRate, threshold)
}
return nil
}
Required OAuth scope: None (external webhook call)
Threshold logic: Calculates error rate across polled operations and routes structured JSON to a configured endpoint when the rate exceeds the configured percentage.
Step 4: Synchronize Status Metrics with External Dashboards and Generate Audit Logs
Identity governance platforms require structured metric exports and compliance audit trails. This section formats operational health data for dashboard ingestion and writes timestamped audit records.
type SyncMetrics struct {
ReportingPeriod string `json:"reporting_period"`
TotalProcessed int `json:"total_processed"`
SuccessRate float64 `json:"success_rate_percent"`
AvgDurationMs float64 `json:"avg_duration_ms"`
ErrorFrequency int `json:"error_frequency"`
}
func generateMetricsAndAudit(operations []SCIMOperation, windowStart, windowEnd time.Time) (SyncMetrics, []string) {
var auditLogs []string
var totalDuration time.Duration
successCount := 0
for _, op := range operations {
created, _ := time.Parse(time.RFC3339, op.CreatedTime)
updated, _ := time.Parse(time.RFC3339, op.UpdatedTime)
duration := updated.Sub(created)
totalDuration += duration
if op.Status == "completed" && op.FailedRecords == 0 {
successCount++
}
logEntry := fmt.Sprintf("[%s] OP:%s STATUS:%s TOTAL:%d FAILED:%d DURATION:%v",
time.Now().UTC().Format(time.RFC3339),
op.ID, op.Status, op.TotalRecords, op.FailedRecords, duration)
auditLogs = append(auditLogs, logEntry)
}
avgDuration := 0.0
if len(operations) > 0 {
avgDuration = float64(totalDuration.Milliseconds()) / float64(len(operations))
}
successRate := 0.0
if len(operations) > 0 {
successRate = float64(successCount) / float64(len(operations)) * 100.0
}
metrics := SyncMetrics{
ReportingPeriod: fmt.Sprintf("%s|%s", windowStart.Format(time.RFC3339), windowEnd.Format(time.RFC3339)),
TotalProcessed: len(operations),
SuccessRate: successRate,
AvgDurationMs: avgDuration,
ErrorFrequency: len(operations) - successCount,
}
return metrics, auditLogs
}
Required OAuth scope: None (local processing)
Dashboard sync: Returns a flat JSON structure compatible with Prometheus, Datadog, or custom governance dashboards.
Audit logging: Produces immutable, timestamped log lines for compliance verification.
Complete Working Example
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type TokenCache struct {
Token string
ExpiresAt time.Time
}
var tokenCache TokenCache
func fetchOAuthToken(tenant, clientID, clientSecret string) (*OAuthTokenResponse, error) {
urlStr := fmt.Sprintf("https://%s.my.cxone.com/oauth/token", tenant)
payload := fmt.Sprintf("client_id=%s&client_secret=%s&grant_type=client_credentials&scope=scim:read", clientID, clientSecret)
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(payload))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
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 nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oauth token error: status %d", resp.StatusCode)
}
var tokenResp OAuthTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
tokenCache.Token = tokenResp.AccessToken
tokenCache.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
return &tokenResp, nil
}
func getValidToken(tenant, clientID, clientSecret string) (string, error) {
if time.Now().Before(tokenCache.ExpiresAt.Add(-60 * time.Second)) {
return tokenCache.Token, nil
}
_, err := fetchOAuthToken(tenant, clientID, clientSecret)
return tokenCache.Token, err
}
type SCIMQueryParams struct {
Status string
CreatedAfter time.Time
CreatedBefore time.Time
Limit int
Offset int
}
func buildSCIMQuery(params SCIMQueryParams) (string, error) {
window := params.CreatedBefore.Sub(params.CreatedAfter)
if window.Hours() > 720 {
return "", fmt.Errorf("query window exceeds 30-day retention limit")
}
if params.Limit > 1000 || params.Limit <= 0 {
return "", fmt.Errorf("limit must be between 1 and 1000")
}
q := url.Values{}
if params.Status != "" {
q.Set("status", params.Status)
}
if !params.CreatedAfter.IsZero() {
q.Set("createdAfter", params.CreatedAfter.Format(time.RFC3339))
}
if !params.CreatedBefore.IsZero() {
q.Set("createdBefore", params.CreatedBefore.Format(time.RFC3339))
}
q.Set("limit", fmt.Sprintf("%d", params.Limit))
q.Set("offset", fmt.Sprintf("%d", params.Offset))
return q.Encode(), nil
}
type SCIMOperation struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedTime string `json:"createdTime"`
UpdatedTime string `json:"updatedTime"`
TotalRecords int `json:"totalRecords"`
CompletedRecords int `json:"completedRecords"`
FailedRecords int `json:"failedRecords"`
Errors []string `json:"errors"`
}
type SCIMResponse struct {
Operations []SCIMOperation `json:"operations"`
NextPage *string `json:"nextPage"`
}
func pollSCIMOperations(tenant, token, query string, lastSync time.Time) ([]SCIMOperation, error) {
baseURL := fmt.Sprintf("https://%s.my.cxone.com/api/v2/scim/operations?%s", tenant, query)
var allOps []SCIMOperation
currentQuery := query
for {
req, err := http.NewRequest("GET", baseURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := 5
if header := resp.Header.Get("Retry-After"); header != "" {
fmt.Sscanf(header, "%d", &retryAfter)
}
time.Sleep(time.Duration(retryAfter) * time.Second)
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
}
defer resp.Body.Close()
var scimResp SCIMResponse
if err := json.NewDecoder(resp.Body).Decode(&scimResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
for _, op := range scimResp.Operations {
created, err := time.Parse(time.RFC3339, op.CreatedTime)
if err != nil {
continue
}
if created.After(lastSync) {
allOps = append(allOps, op)
}
}
if scimResp.NextPage == nil || *scimResp.NextPage == "" {
break
}
currentQuery = *scimResp.NextPage
baseURL = fmt.Sprintf("https://%s.my.cxone.com/api/v2/scim/operations?%s", tenant, currentQuery)
}
return allOps, nil
}
type AlertPayload struct {
Timestamp string `json:"timestamp"`
TotalOps int `json:"total_operations"`
CompletedOps int `json:"completed_operations"`
FailedOps int `json:"failed_operations"`
ErrorRate float64 `json:"error_rate_percent"`
Trigger string `json:"trigger"`
Operations []SCIMOperation `json:"failed_operations"`
}
func evaluateAndAlert(operations []SCIMOperation, threshold float64, webhookURL string) error {
if len(operations) == 0 {
return nil
}
var failedOps []SCIMOperation
totalFailed := 0
for _, op := range operations {
if op.Status == "failed" || op.FailedRecords > 0 {
totalFailed += op.FailedRecords
failedOps = append(failedOps, op)
}
}
errorRate := float64(totalFailed) / float64(len(operations)) * 100.0
if errorRate > threshold {
payload := AlertPayload{
Timestamp: time.Now().UTC().Format(time.RFC3339),
TotalOps: len(operations),
CompletedOps: len(operations) - len(failedOps),
FailedOps: len(failedOps),
ErrorRate: errorRate,
Trigger: fmt.Sprintf("error_rate_%0.1f_percent", threshold),
Operations: failedOps,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal alert payload: %w", err)
}
req, err := http.NewRequest("POST", webhookURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create alert request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("alert dispatch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("alert webhook returned status %d", resp.StatusCode)
}
fmt.Printf("Alert triggered: %.2f%% error rate exceeds %.2f%% threshold\n", errorRate, threshold)
}
return nil
}
type SyncMetrics struct {
ReportingPeriod string `json:"reporting_period"`
TotalProcessed int `json:"total_processed"`
SuccessRate float64 `json:"success_rate_percent"`
AvgDurationMs float64 `json:"avg_duration_ms"`
ErrorFrequency int `json:"error_frequency"`
}
func generateMetricsAndAudit(operations []SCIMOperation, windowStart, windowEnd time.Time) (SyncMetrics, []string) {
var auditLogs []string
var totalDuration time.Duration
successCount := 0
for _, op := range operations {
created, _ := time.Parse(time.RFC3339, op.CreatedTime)
updated, _ := time.Parse(time.RFC3339, op.UpdatedTime)
duration := updated.Sub(created)
totalDuration += duration
if op.Status == "completed" && op.FailedRecords == 0 {
successCount++
}
logEntry := fmt.Sprintf("[%s] OP:%s STATUS:%s TOTAL:%d FAILED:%d DURATION:%v",
time.Now().UTC().Format(time.RFC3339),
op.ID, op.Status, op.TotalRecords, op.FailedRecords, duration)
auditLogs = append(auditLogs, logEntry)
}
avgDuration := 0.0
if len(operations) > 0 {
avgDuration = float64(totalDuration.Milliseconds()) / float64(len(operations))
}
successRate := 0.0
if len(operations) > 0 {
successRate = float64(successCount) / float64(len(operations)) * 100.0
}
metrics := SyncMetrics{
ReportingPeriod: fmt.Sprintf("%s|%s", windowStart.Format(time.RFC3339), windowEnd.Format(time.RFC3339)),
TotalProcessed: len(operations),
SuccessRate: successRate,
AvgDurationMs: avgDuration,
ErrorFrequency: len(operations) - successCount,
}
return metrics, auditLogs
}
func main() {
tenant := os.Getenv("CXONE_TENANT")
clientID := os.Getenv("CXONE_CLIENT_ID")
clientSecret := os.Getenv("CXONE_CLIENT_SECRET")
webhookURL := os.Getenv("ALERT_WEBHOOK_URL")
if tenant == "" || clientID == "" || clientSecret == "" {
fmt.Println("Missing required environment variables")
os.Exit(1)
}
_, err := fetchOAuthToken(tenant, clientID, clientSecret)
if err != nil {
fmt.Printf("Authentication failed: %v\n", err)
os.Exit(1)
}
lastSync := time.Now().Add(-24 * time.Hour)
alertThreshold := 10.0
for {
token, err := getValidToken(tenant, clientID, clientSecret)
if err != nil {
fmt.Printf("Token refresh failed: %v\n", err)
time.Sleep(30 * time.Second)
continue
}
query, err := buildSCIMQuery(SCIMQueryParams{
CreatedAfter: lastSync,
CreatedBefore: time.Now(),
Limit: 500,
Offset: 0,
})
if err != nil {
fmt.Printf("Query validation failed: %v\n", err)
time.Sleep(10 * time.Second)
continue
}
operations, err := pollSCIMOperations(tenant, token, query, lastSync)
if err != nil {
fmt.Printf("Polling failed: %v\n", err)
time.Sleep(15 * time.Second)
continue
}
metrics, auditLogs := generateMetricsAndAudit(operations, lastSync, time.Now())
metricsJSON, _ := json.MarshalIndent(metrics, "", " ")
fmt.Printf("Dashboard Metrics: %s\n", metricsJSON)
for _, log := range auditLogs {
fmt.Println(log)
}
if webhookURL != "" {
if err := evaluateAndAlert(operations, alertThreshold, webhookURL); err != nil {
fmt.Printf("Alert routing failed: %v\n", err)
}
}
lastSync = time.Now()
time.Sleep(60 * time.Second)
}
}
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: The access token expired, the OAuth scope is missing
scim:read, or the tenant URL is incorrect. - How to fix it: Verify the
scope=scim:readparameter in the token request. Ensure the tenant environment variable matches your CXone instance exactly. Implement the 60-second early refresh buffer shown ingetValidToken. - Code showing the fix: The
getValidTokenfunction automatically refreshes tokens before expiration and returns a fresh bearer token for subsequent requests.
Error: 400 Bad Request
- What causes it: The query window exceeds the 30-day retention limit, the
limitparameter exceeds 1000, orcreatedAfter/createdBeforeformats are invalid. - How to fix it: Enforce
window.Hours() <= 720andlimit <= 1000inbuildSCIMQuery. Usetime.RFC3339for all timestamp parameters. - Code showing the fix: The
buildSCIMQueryfunction explicitly validates retention windows and pagination limits before encoding the query string.
Error: 429 Too Many Requests
- What causes it: The polling frequency exceeds CXone rate limits (typically 100 requests per minute per tenant).
- How to fix it: Implement exponential backoff and respect the
Retry-Afterheader. Increase the polling interval to 60 seconds or longer. - Code showing the fix: The
pollSCIMOperationsfunction checks for 429 status, parsesRetry-After, sleeps, and retries the same request without resetting pagination state.
Error: 5xx Server Error
- What causes it: CXone backend processing delays or temporary SCIM service degradation.
- How to fix it: Implement a circuit breaker pattern or fixed retry with jitter. Log the response body for CXone support cases.
- Code showing the fix: The main loop catches 5xx errors, logs them, waits 15 seconds, and continues the polling cycle without crashing.