Scraping NICE CXone Outbound Contact Lists via API with C#
What You Will Build
This tutorial builds a C# service that extracts contacts from NICE CXone outbound lists using asynchronous export jobs and paginated result retrieval. It uses the CXone Outbound Campaign REST API endpoints for list exports, status polling, and atomic contact retrieval. The implementation uses C# 12 with HttpClient, System.Text.Json, and async/await patterns for production-grade data extraction.
Prerequisites
- OAuth client type: Confidential Client (Client Credentials Grant)
- Required scopes:
outbound:campaign:view outbound:list:view outbound:contact:view - API version: CXone REST API v2
- Runtime: .NET 8
- External dependencies:
System.Text.Json,System.Net.Http,System.Diagnostics
Authentication Setup
CXone requires OAuth 2.0 client credentials authentication. You must exchange your client ID and secret for a bearer token before issuing any outbound API requests. The token expires after one hour and must be refreshed or regenerated. The following method handles token acquisition, caches the expiration timestamp, and throws a descriptive exception on failure.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
public class CxonAuthClient
{
private readonly HttpClient _http;
private readonly string _orgId;
private readonly string _clientId;
private readonly string _clientSecret;
private string _accessToken = string.Empty;
private DateTime _tokenExpiry = DateTime.MinValue;
public CxonAuthClient(string orgId, string clientId, string clientSecret)
{
_orgId = orgId;
_clientId = clientId;
_clientSecret = clientSecret;
_http = new HttpClient();
_http.Timeout = TimeSpan.FromSeconds(15);
}
public async Task<string> GetAccessTokenAsync()
{
if (DateTime.UtcNow < _tokenExpiry.AddMinutes(-5))
return _accessToken;
var tokenUrl = $"https://{_orgId}.cxone.com/oauth/token";
var content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret)
});
var response = await _http.PostAsync(tokenUrl, content);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Authentication failed with status {response.StatusCode}. Verify client credentials and scope assignments.");
var payload = await response.Content.ReadFromJsonAsync<AuthTokenResponse>();
_accessToken = payload.AccessToken;
_tokenExpiry = DateTime.UtcNow.AddSeconds(payload.ExpiresIn);
return _accessToken;
}
public async Task<HttpResponseMessage> SendWithAuthAsync(HttpRequestMessage request)
{
var token = await GetAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await _http.SendAsync(request);
}
public void Dispose() => _http.Dispose();
}
public class AuthTokenResponse
{
[JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")] public string TokenType { get; set; } = string.Empty;
}
Implementation
Step 1: Construct Export Payload and Validate Campaign Constraints
The CXone outbound engine enforces strict limits on export jobs. You must specify a list ID, a filter matrix to narrow the contact set, and an export directive that defines format and row limits. The maximum export row limit per job is 100,000. Exceeding this limit causes immediate 400 Bad Request responses. The validation method checks schema integrity, enforces the row cap, and verifies that consent status values match CXone enum constraints.
public record ExportPayload(
string ListId,
FilterMatrix Filter,
ExportDirective Directive
);
public record FilterMatrix(
string? Status,
string? ConsentStatus,
bool? Deduplicate
);
public record ExportDirective(
string Format,
int MaxRows,
bool IncludeHeaders
);
public static class ExportValidator
{
public static void Validate(ExportPayload payload)
{
if (string.IsNullOrWhiteSpace(payload.ListId))
throw new ArgumentException("ListId is required and cannot be empty.");
if (payload.Directive.MaxRows > 100000)
throw new InvalidOperationException("CXone outbound engine limits exports to 100,000 rows per job.");
var validConsent = new[] { "OPT_IN", "OPT_OUT", "UNKNOWN", "NONE" };
if (!string.IsNullOrEmpty(payload.Filter.ConsentStatus) && !validConsent.Contains(payload.Filter.ConsentStatus))
throw new ArgumentException($"Invalid ConsentStatus. Must be one of: {string.Join(", ", validConsent)}");
if (!new[] { "JSON", "CSV" }.Contains(payload.Directive.Format))
throw new ArgumentException("Export format must be JSON or CSV.");
}
}
Step 2: Trigger Export Job and Poll Completion Status
You initiate the scrape by POSTing the validated payload to /api/v2/outbound/lists/{listId}/contacts/export. CXone returns an exportId immediately. The export runs asynchronously on the campaign engine. You must poll /api/v2/outbound/lists/{listId}/contacts/export/{exportId} until the status transitions to COMPLETE. The polling loop implements exponential backoff for 429 rate limits and retries transient 5xx errors.
public async Task<string> TriggerExportAsync(CxonAuthClient auth, ExportPayload payload)
{
var url = $"https://{auth.OrgId}.cxone.com/api/v2/outbound/lists/{payload.ListId}/contacts/export";
var json = JsonSerializer.Serialize(payload);
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
};
var response = await auth.SendWithAuthAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
throw new HttpRequestException("Rate limited (429). Implement exponential backoff before retrying.");
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Export trigger failed with status {response.StatusCode}");
var result = await response.Content.ReadFromJsonAsync<ExportJobResponse>();
return result.ExportId;
}
public async Task PollExportStatusAsync(CxonAuthClient auth, string listId, string exportId)
{
var url = $"https://{auth.OrgId}.cxone.com/api/v2/outbound/lists/{listId}/contacts/export/{exportId}";
int delayMs = 1000;
const int maxDelay = 10000;
while (true)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await auth.SendWithAuthAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
await Task.Delay(delayMs);
delayMs = Math.Min(delayMs * 2, maxDelay);
continue;
}
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Status poll failed with {response.StatusCode}");
var status = await response.Content.ReadFromJsonAsync<ExportStatusResponse>();
if (status.Status == "COMPLETE") break;
if (status.Status == "FAILED")
throw new InvalidOperationException($"Export job failed: {status.ErrorMessage}");
await Task.Delay(delayMs);
delayMs = Math.Min(delayMs * 2, maxDelay);
}
}
public class ExportJobResponse
{
[JsonPropertyName("exportId")] public string ExportId { get; set; } = string.Empty;
}
public class ExportStatusResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = string.Empty;
[JsonPropertyName("errorMessage")] public string ErrorMessage { get; set; } = string.Empty;
}
Step 3: Atomic GET Operations with Pagination and Format Verification
Once the export completes, you retrieve contacts via /api/v2/outbound/lists/{listId}/contacts/export/{exportId}/results. The endpoint returns paginated results with a nextPageToken. You must verify the JSON structure matches the expected contact schema before processing. The pagination loop continues until nextPageToken is null or empty. Each page fetch is atomic and includes format validation to prevent malformed data from entering downstream pipelines.
public class ContactResult
{
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("phoneNumber")] public string PhoneNumber { get; set; } = string.Empty;
[JsonPropertyName("email")] public string Email { get; set; } = string.Empty;
[JsonPropertyName("consentStatus")] public string ConsentStatus { get; set; } = string.Empty;
[JsonPropertyName("firstName")] public string FirstName { get; set; } = string.Empty;
[JsonPropertyName("lastName")] public string LastName { get; set; } = string.Empty;
}
public async Task<List<ContactResult>> FetchPaginatedResultsAsync(CxonAuthClient auth, string listId, string exportId, int pageSize = 1000)
{
var contacts = new List<ContactResult>();
string? pageToken = null;
var url = $"https://{auth.OrgId}.cxone.com/api/v2/outbound/lists/{listId}/contacts/export/{exportId}/results";
while (true)
{
var query = $"pageSize={pageSize}";
if (!string.IsNullOrEmpty(pageToken)) query += $"&pageToken={pageToken}";
var request = new HttpRequestMessage(HttpMethod.Get, $"{url}?{query}");
var response = await auth.SendWithAuthAsync(request);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Result fetch failed with {response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var items = doc.RootElement.GetProperty("items");
pageToken = doc.RootElement.TryGetProperty("nextPageToken", out var tokenProp)
? tokenProp.GetString()
: null;
foreach (var item in items.EnumerateArray())
{
var contact = JsonSerializer.Deserialize<ContactResult>(item.GetRawText());
if (contact == null) continue;
contacts.Add(contact);
}
if (string.IsNullOrEmpty(pageToken)) break;
}
return contacts;
}
Step 4: Validation Pipeline, CRM Sync, Latency Tracking, and Audit Logging
Raw exports require cleansing before CRM synchronization. The validation pipeline filters contacts by consent status, removes duplicates based on phone number and email combinations, and tracks processing latency. Each action generates an audit log entry for campaign governance. Upon completion, the service emits a webhook payload to your external CRM system.
public record AuditLog(string Timestamp, string Action, int ContactsProcessed, double LatencyMs, string Status);
public class ContactScraper
{
private readonly List<AuditLog> _auditLogs = new();
public async Task<CrmSyncPayload> RunScrapeAsync(CxonAuthClient auth, ExportPayload payload)
{
var stopwatch = Stopwatch.StartNew();
_auditLogs.Add(new AuditLog(DateTime.UtcNow, "ExportTrigger", 0, 0, "INITIATED"));
ExportValidator.Validate(payload);
var exportId = await TriggerExportAsync(auth, payload);
await PollExportStatusAsync(auth, payload.ListId, exportId);
var rawContacts = await FetchPaginatedResultsAsync(auth, payload.ListId, exportId);
stopwatch.Stop();
_auditLogs.Add(new AuditLog(DateTime.UtcNow, "DataExtraction", rawContacts.Count, stopwatch.ElapsedMilliseconds, "COMPLETE"));
var cleanContacts = ApplyValidationPipeline(rawContacts);
var syncPayload = new CrmSyncPayload(cleanContacts, payload.ListId);
await SyncToCrmAsync(syncPayload);
return syncPayload;
}
private List<ContactResult> ApplyValidationPipeline(List<ContactResult> raw)
{
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var cleaned = new List<ContactResult>();
foreach (var contact in raw)
{
if (contact.ConsentStatus != "OPT_IN") continue;
var dedupKey = $"{contact.PhoneNumber}|{contact.Email}";
if (seenKeys.Contains(dedupKey)) continue;
seenKeys.Add(dedupKey);
cleaned.Add(contact);
}
return cleaned;
}
private async Task SyncToCrmAsync(CrmSyncPayload payload)
{
// Replace with actual CRM endpoint
var webhookUrl = "https://your-crm-endpoint.com/api/v1/contacts/sync";
var json = JsonSerializer.Serialize(payload);
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
{
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
};
// Simulated sync for tutorial safety
Console.WriteLine($"CRM Sync Triggered: {payload.Contacts.Count} contacts queued for {payload.ListId}");
}
public List<AuditLog> GetAuditLogs() => _auditLogs;
}
public record CrmSyncPayload(List<ContactResult> Contacts, string ListId);
Complete Working Example
The following script combines all components into a single executable program. Replace the placeholder credentials and list ID before running. The script handles authentication, export triggering, status polling, paginated extraction, validation, CRM synchronization, and audit logging.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Diagnostics;
public class CxonAuthClient
{
private readonly HttpClient _http;
public readonly string OrgId;
private readonly string _clientId;
private readonly string _clientSecret;
private string _accessToken = string.Empty;
private DateTime _tokenExpiry = DateTime.MinValue;
public CxonAuthClient(string orgId, string clientId, string clientSecret)
{
OrgId = orgId;
_clientId = clientId;
_clientSecret = clientSecret;
_http = new HttpClient();
_http.Timeout = TimeSpan.FromSeconds(15);
}
public async Task<string> GetAccessTokenAsync()
{
if (DateTime.UtcNow < _tokenExpiry.AddMinutes(-5))
return _accessToken;
var tokenUrl = $"https://{OrgId}.cxone.com/oauth/token";
var content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret)
});
var response = await _http.PostAsync(tokenUrl, content);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Authentication failed with status {response.StatusCode}.");
var payload = await response.Content.ReadFromJsonAsync<AuthTokenResponse>();
_accessToken = payload.AccessToken;
_tokenExpiry = DateTime.UtcNow.AddSeconds(payload.ExpiresIn);
return _accessToken;
}
public async Task<HttpResponseMessage> SendWithAuthAsync(HttpRequestMessage request)
{
var token = await GetAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await _http.SendAsync(request);
}
public void Dispose() => _http.Dispose();
}
public class AuthTokenResponse
{
[JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
}
public record ExportPayload(string ListId, FilterMatrix Filter, ExportDirective Directive);
public record FilterMatrix(string? Status, string? ConsentStatus, bool? Deduplicate);
public record ExportDirective(string Format, int MaxRows, bool IncludeHeaders);
public record ContactResult(string Id, string PhoneNumber, string Email, string ConsentStatus, string FirstName, string LastName);
public record CrmSyncPayload(List<ContactResult> Contacts, string ListId);
public record AuditLog(string Timestamp, string Action, int ContactsProcessed, double LatencyMs, string Status);
public class ExportJobResponse { [JsonPropertyName("exportId")] public string ExportId { get; set; } = string.Empty; }
public class ExportStatusResponse { [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("errorMessage")] public string ErrorMessage { get; set; } = string.Empty; }
public static class ExportValidator
{
public static void Validate(ExportPayload payload)
{
if (string.IsNullOrWhiteSpace(payload.ListId)) throw new ArgumentException("ListId is required.");
if (payload.Directive.MaxRows > 100000) throw new InvalidOperationException("CXone limits exports to 100,000 rows.");
if (!new[] { "OPT_IN", "OPT_OUT", "UNKNOWN", "NONE" }.Contains(payload.Filter.ConsentStatus ?? string.Empty))
throw new ArgumentException("Invalid ConsentStatus.");
if (!new[] { "JSON", "CSV" }.Contains(payload.Directive.Format)) throw new ArgumentException("Format must be JSON or CSV.");
}
}
public class ContactScraper
{
private readonly List<AuditLog> _auditLogs = new();
public async Task<CrmSyncPayload> RunScrapeAsync(CxonAuthClient auth, ExportPayload payload)
{
var sw = Stopwatch.StartNew();
_auditLogs.Add(new AuditLog(DateTime.UtcNow.ToString("o"), "ExportTrigger", 0, 0, "INITIATED"));
ExportValidator.Validate(payload);
var exportId = await TriggerExportAsync(auth, payload);
await PollExportStatusAsync(auth, payload.ListId, exportId);
var raw = await FetchPaginatedResultsAsync(auth, payload.ListId, exportId);
sw.Stop();
_auditLogs.Add(new AuditLog(DateTime.UtcNow.ToString("o"), "DataExtraction", raw.Count, sw.ElapsedMilliseconds, "COMPLETE"));
var clean = ApplyValidationPipeline(raw);
var sync = new CrmSyncPayload(clean, payload.ListId);
await SyncToCrmAsync(sync);
return sync;
}
private async Task<string> TriggerExportAsync(CxonAuthClient auth, ExportPayload payload)
{
var url = $"https://{auth.OrgId}.cxone.com/api/v2/outbound/lists/{payload.ListId}/contacts/export";
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json")
};
var response = await auth.SendWithAuthAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
throw new HttpRequestException("Rate limited (429). Backoff and retry.");
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Export trigger failed with {response.StatusCode}");
return (await response.Content.ReadFromJsonAsync<ExportJobResponse>()).ExportId;
}
private async Task PollExportStatusAsync(CxonAuthClient auth, string listId, string exportId)
{
var url = $"https://{auth.OrgId}.cxone.com/api/v2/outbound/lists/{listId}/contacts/export/{exportId}";
var delay = 1000;
while (true)
{
var response = await auth.SendWithAuthAsync(new HttpRequestMessage(HttpMethod.Get, url));
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { await Task.Delay(delay); delay = Math.Min(delay * 2, 10000); continue; }
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"Poll failed with {response.StatusCode}");
var status = await response.Content.ReadFromJsonAsync<ExportStatusResponse>();
if (status.Status == "COMPLETE") break;
if (status.Status == "FAILED") throw new InvalidOperationException($"Export failed: {status.ErrorMessage}");
await Task.Delay(delay);
delay = Math.Min(delay * 2, 10000);
}
}
private async Task<List<ContactResult>> FetchPaginatedResultsAsync(CxonAuthClient auth, string listId, string exportId, int pageSize = 1000)
{
var contacts = new List<ContactResult>();
string? token = null;
var url = $"https://{auth.OrgId}.cxone.com/api/v2/outbound/lists/{listId}/contacts/export/{exportId}/results";
while (true)
{
var query = $"pageSize={pageSize}" + (string.IsNullOrEmpty(token) ? "" : $"&pageToken={token}");
var response = await auth.SendWithAuthAsync(new HttpRequestMessage(HttpMethod.Get, $"{url}?{query}"));
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"Fetch failed with {response.StatusCode}");
var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
foreach (var item in doc.RootElement.GetProperty("items").EnumerateArray())
{
var c = JsonSerializer.Deserialize<ContactResult>(item.GetRawText());
if (c != null) contacts.Add(c);
}
token = doc.RootElement.TryGetProperty("nextPageToken", out var t) ? t.GetString() : null;
if (string.IsNullOrEmpty(token)) break;
}
return contacts;
}
private List<ContactResult> ApplyValidationPipeline(List<ContactResult> raw)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var cleaned = new List<ContactResult>();
foreach (var c in raw)
{
if (c.ConsentStatus != "OPT_IN") continue;
var key = $"{c.PhoneNumber}|{c.Email}";
if (seen.Contains(key)) continue;
seen.Add(key);
cleaned.Add(c);
}
return cleaned;
}
private async Task SyncToCrmAsync(CrmSyncPayload payload)
{
Console.WriteLine($"CRM Sync: {payload.Contacts.Count} clean contacts queued for list {payload.ListId}");
}
public List<AuditLog> GetAuditLogs() => _auditLogs;
}
public class Program
{
public static async Task Main()
{
var auth = new CxonAuthClient("your-org-id", "your-client-id", "your-client-secret");
var payload = new ExportPayload(
"12345678-1234-1234-1234-123456789012",
new FilterMatrix("ACTIVE", "OPT_IN", true),
new ExportDirective("JSON", 50000, true)
);
var scraper = new ContactScraper();
try
{
var result = await scraper.RunScrapeAsync(auth, payload);
Console.WriteLine($"Scrape complete. Extracted {result.Contacts.Count} valid contacts.");
foreach (var log in scraper.GetAuditLogs())
Console.WriteLine($"[{log.Timestamp}] {log.Action}: {log.ContactsProcessed} items | {log.LatencyMs}ms | {log.Status}");
}
catch (Exception ex)
{
Console.WriteLine($"Scrape failed: {ex.Message}");
}
finally
{
auth.Dispose();
}
}
}
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: The OAuth token expired, the client credentials are incorrect, or the token was not attached to the request headers.
- How to fix it: Verify the
client_idandclient_secretmatch your CXone OAuth application. Ensure theAuthorization: Bearer <token>header is present on every outbound request. The providedCxonAuthClientautomatically refreshes tokens before expiry.
Error: 403 Forbidden
- What causes it: The OAuth application lacks the required scopes for outbound operations.
- How to fix it: Assign
outbound:campaign:view,outbound:list:view, andoutbound:contact:viewto your OAuth client in the CXone admin console. Reauthenticate after scope changes.
Error: 429 Too Many Requests
- What causes it: You exceeded the CXone API rate limit for your tenant tier or triggered rapid polling.
- How to fix it: Implement exponential backoff. The polling and fetch loops in this tutorial detect 429 responses and delay subsequent requests, doubling the wait time up to 10 seconds. Avoid parallelizing export triggers.
Error: 400 Bad Request
- What causes it: The export payload violates campaign engine constraints. Common triggers include
MaxRowsexceeding 100,000, invalidConsentStatusenum values, or malformed JSON. - How to fix it: Run the payload through
ExportValidator.Validate()before submission. EnsureMaxRowsstays at or below 100,000. Verify consent status matchesOPT_IN,OPT_OUT,UNKNOWN, orNONE.