Requesting NICE CXone WhatsApp Template Approvals via Digital API with Python
What You Will Build
- The code submits WhatsApp message templates to NICE CXone for Meta approval, validates structure against platform constraints, polls approval status, and synchronizes results with external trackers.
- This uses the NICE CXone Digital API WhatsApp template endpoints directly via HTTP.
- The tutorial covers Python 3.10+ using
httpxandpydantic.
Prerequisites
- OAuth 2.0 Client Credentials flow configured in CXone Organization Settings.
- Required scopes:
digital:whatsapp:templates:write,digital:whatsapp:templates:read. - Python 3.10 or higher.
- External dependencies:
pip install httpx>=0.24.0 pydantic>=2.0.0 structlog>=23.1.0
Authentication Setup
NICE CXone uses a standard OAuth 2.0 client credentials flow. You must exchange your client identifier and secret for a bearer token before issuing API calls. The token expires after 3600 seconds, so caching and automatic refresh is required.
import httpx
import time
import structlog
logger = structlog.get_logger()
class CXoneAuthManager:
def __init__(self, org_id: str, client_id: str, client_secret: str):
self.base_url = f"https://{org_id}.nice.incontact.com"
self.client_id = client_id
self.client_secret = client_secret
self.token: str | None = None
self.token_expiry: float = 0.0
def _fetch_token(self) -> str:
url = f"{self.base_url}/oauth/token"
payload = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "digital:whatsapp:templates:write digital:whatsapp:templates:read"
}
response = httpx.post(url, data=payload)
if response.status_code != 200:
raise RuntimeError(f"OAuth token request failed: {response.status_code} {response.text}")
data = response.json()
self.token = data["access_token"]
self.token_expiry = time.time() + data["expires_in"] - 60 # Refresh 60s early
return self.token
def get_token(self) -> str:
if not self.token or time.time() >= self.token_expiry:
self._fetch_token()
return self.token
Required OAuth Scope: digital:whatsapp:templates:write digital:whatsapp:templates:read
Implementation
Step 1: Payload Construction and Schema Validation
WhatsApp templates require strict adherence to Meta formatting rules. CXone proxies these rules and returns a 422 Unprocessable Entity if the payload violates constraints. You must validate category enums, variable placeholders, and example arrays before submission.
from pydantic import BaseModel, Field, field_validator
from enum import Enum
class WhatsAppCategory(str, Enum):
AUTHENTICATION = "AUTHENTICATION"
MARKETING = "MARKETING"
UTILITY = "UTILITY"
class TemplateExample(BaseModel):
header: list[str] | None = None
body: list[str] = Field(..., min_length=1, max_length=3)
button: list[str] | None = None
class WhatsAppTemplatePayload(BaseModel):
language: str = Field(..., pattern=r"^[a-z]{2}(-[A-Z]{2})?$")
category: WhatsAppCategory
text: str = Field(..., max_length=1024)
example: TemplateExample
@field_validator("text")
@classmethod
def validate_variable_placeholders(cls, v: str) -> str:
import re
placeholders = re.findall(r"\{(\d+)\}", v)
if not placeholders:
raise ValueError("Template text must contain at least one variable placeholder like {1}")
if len(placeholders) > 3:
raise ValueError("WhatsApp templates support a maximum of 3 variables")
return v
@field_validator("example")
@classmethod
def validate_example_lengths(cls, v: TemplateExample) -> TemplateExample:
if v.header and len(v.header) > 1:
raise ValueError("Header example must contain exactly one value")
if v.button and len(v.button) > 1:
raise ValueError("Button example must contain exactly one value")
return v
Validation Pipeline: The field_validator decorators enforce Meta constraints before the payload leaves your environment. This prevents 422 rejection loops during scaling.
Step 2: Rate-Limit Aware Submission
CXone enforces submission rate limits on template creation. A 429 Too Many Requests response includes a Retry-After header. You must implement atomic POST operations with exponential backoff and format verification.
import asyncio
import json
class TemplateSubmitter:
def __init__(self, auth: CXoneAuthManager, max_retries: int = 5):
self.auth = auth
self.base_url = f"https://{auth.org_id}.nice.incontact.com"
self.max_retries = max_retries
self.client = httpx.Client()
async def submit_template(self, payload: WhatsAppTemplatePayload) -> dict:
url = f"{self.base_url}/api/v1/digital/whatsapp/templates"
headers = {
"Authorization": f"Bearer {self.auth.get_token()}",
"Content-Type": "application/json",
"Accept": "application/json"
}
body = payload.model_dump()
for attempt in range(1, self.max_retries + 1):
response = self.client.post(url, headers=headers, json=body)
logger.info(
"template_submission_attempt",
attempt=attempt,
status=response.status_code,
request_path=url,
request_headers=headers,
request_body=body,
response_body=response.text
)
if response.status_code == 201:
return response.json()
elif response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
logger.warning("rate_limit_hit", retry_after=retry_after)
await asyncio.sleep(retry_after)
elif response.status_code in (401, 403):
raise PermissionError(f"Authentication or scope failure: {response.status_code}")
elif response.status_code == 422:
raise ValueError(f"Schema validation failed by CXone: {response.text}")
else:
raise RuntimeError(f"Submission failed: {response.status_code} {response.text}")
raise RuntimeError("Max retries exceeded for template submission")
HTTP Cycle:
- Method:
POST - Path:
/api/v1/digital/whatsapp/templates - Headers:
Authorization: Bearer <token>,Content-Type: application/json - Request Body: Validated JSON payload from Step 1
- Response Body (201):
{"id": "tpl_8f3a9c2b", "status": "PENDING", "category": "MARKETING", "language": "en_US", "text": "Your code is {1}", "example": {"body": ["Your code is 482910"]}}
Step 3: Status Polling and Webhook Synchronization
After submission, you must poll the template endpoint until Meta returns an approval decision. You will synchronize state changes with an external tracker via webhook callbacks and log audit trails.
import uuid
from datetime import datetime, timezone
class TemplatePoller:
def __init__(self, auth: CXoneAuthManager, webhook_url: str):
self.auth = auth
self.base_url = f"https://{auth.org_id}.nice.incontact.com"
self.webhook_url = webhook_url
self.client = httpx.Client()
self.audit_log = []
def _write_audit(self, template_id: str, event: str, payload: dict):
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"template_id": template_id,
"event": event,
"details": payload,
"trace_id": str(uuid.uuid4())
}
self.audit_log.append(entry)
logger.info("audit_log_written", **entry)
async def poll_and_notify(self, template_id: str, poll_interval: int = 30, max_polls: int = 60) -> dict:
url = f"{self.base_url}/api/v1/digital/whatsapp/templates/{template_id}"
headers = {
"Authorization": f"Bearer {self.auth.get_token()}",
"Accept": "application/json"
}
for i in range(max_polls):
response = self.client.get(url, headers=headers)
if response.status_code != 200:
raise RuntimeError(f"Polling failed: {response.status_code}")
data = response.json()
status = data.get("status")
self._write_audit(template_id, "status_poll", {"status": status, "attempt": i})
if status in ("APPROVED", "REJECTED", "DELETED"):
await self._trigger_webhook(template_id, status, data)
return data
await asyncio.sleep(poll_interval)
raise TimeoutError("Template approval polling timed out")
async def _trigger_webhook(self, template_id: str, status: str, payload: dict):
webhook_payload = {
"template_id": template_id,
"status": status,
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": "cxone_template_requester"
}
try:
resp = self.client.post(
self.webhook_url,
json=webhook_payload,
timeout=5.0
)
if resp.status_code >= 400:
logger.warning("webhook_delivery_failed", status=resp.status_code)
else:
logger.info("webhook_delivered", template_id=template_id, status=status)
except httpx.RequestError as e:
logger.error("webhook_request_error", error=str(e))
Required OAuth Scope: digital:whatsapp:templates:read
Complete Working Example
The following script combines authentication, validation, submission, polling, metrics tracking, and audit logging into a single runnable module. Replace the placeholder credentials before execution.
import asyncio
import time
import structlog
import httpx
from pydantic import BaseModel, Field, field_validator
from enum import Enum
from datetime import datetime, timezone
import uuid
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
],
wrapper_class=structlog.make_filtering_bound_logger("INFO"),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory()
)
logger = structlog.get_logger()
class CXoneAuthManager:
def __init__(self, org_id: str, client_id: str, client_secret: str):
self.org_id = org_id
self.client_id = client_id
self.client_secret = client_secret
self.token: str | None = None
self.token_expiry: float = 0.0
self.base_url = f"https://{org_id}.nice.incontact.com"
def _fetch_token(self) -> str:
url = f"{self.base_url}/oauth/token"
payload = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "digital:whatsapp:templates:write digital:whatsapp:templates:read"
}
response = httpx.post(url, data=payload)
if response.status_code != 200:
raise RuntimeError(f"OAuth token request failed: {response.status_code} {response.text}")
data = response.json()
self.token = data["access_token"]
self.token_expiry = time.time() + data["expires_in"] - 60
return self.token
def get_token(self) -> str:
if not self.token or time.time() >= self.token_expiry:
self._fetch_token()
return self.token
class WhatsAppCategory(str, Enum):
AUTHENTICATION = "AUTHENTICATION"
MARKETING = "MARKETING"
UTILITY = "UTILITY"
class TemplateExample(BaseModel):
header: list[str] | None = None
body: list[str] = Field(..., min_length=1, max_length=3)
button: list[str] | None = None
class WhatsAppTemplatePayload(BaseModel):
language: str = Field(..., pattern=r"^[a-z]{2}(-[A-Z]{2})?$")
category: WhatsAppCategory
text: str = Field(..., max_length=1024)
example: TemplateExample
@field_validator("text")
@classmethod
def validate_variable_placeholders(cls, v: str) -> str:
import re
placeholders = re.findall(r"\{(\d+)\}", v)
if not placeholders:
raise ValueError("Template text must contain at least one variable placeholder like {1}")
if len(placeholders) > 3:
raise ValueError("WhatsApp templates support a maximum of 3 variables")
return v
class CXoneWhatsAppRequester:
def __init__(self, org_id: str, client_id: str, client_secret: str, webhook_url: str):
self.auth = CXoneAuthManager(org_id, client_id, client_secret)
self.webhook_url = webhook_url
self.client = httpx.Client()
self.submissions = 0
self.successes = 0
self.total_latency = 0.0
async def request_template(self, payload: WhatsAppTemplatePayload) -> dict:
start_time = time.time()
self.submissions += 1
try:
# Submit
submit_url = f"https://{self.auth.org_id}.nice.incontact.com/api/v1/digital/whatsapp/templates"
submit_headers = {
"Authorization": f"Bearer {self.auth.get_token()}",
"Content-Type": "application/json",
"Accept": "application/json"
}
response = self.client.post(submit_url, headers=submit_headers, json=payload.model_dump())
if response.status_code == 429:
await asyncio.sleep(int(response.headers.get("Retry-After", 5)))
response = self.client.post(submit_url, headers=submit_headers, json=payload.model_dump())
if response.status_code != 201:
raise RuntimeError(f"Submission failed: {response.status_code} {response.text}")
template_data = response.json()
template_id = template_data["id"]
logger.info("template_submitted", template_id=template_id)
# Poll
poll_url = f"https://{self.auth.org_id}.nice.incontact.com/api/v1/digital/whatsapp/templates/{template_id}"
poll_headers = {
"Authorization": f"Bearer {self.auth.get_token()}",
"Accept": "application/json"
}
for _ in range(30):
poll_resp = self.client.get(poll_url, headers=poll_headers)
if poll_resp.status_code != 200:
raise RuntimeError(f"Polling failed: {poll_resp.status_code}")
current_status = poll_resp.json()["status"]
if current_status in ("APPROVED", "REJECTED"):
await self._send_webhook(template_id, current_status, poll_resp.json())
self.successes += 1
break
await asyncio.sleep(15)
finally:
elapsed = time.time() - start_time
self.total_latency += elapsed
self._log_audit(payload, elapsed)
return {"status": current_status, "latency_seconds": round(elapsed, 3)}
async def _send_webhook(self, template_id: str, status: str, data: dict):
try:
self.client.post(self.webhook_url, json={"template_id": template_id, "status": status}, timeout=5.0)
except httpx.RequestError:
logger.warning("webhook_delivery_failed")
def _log_audit(self, payload: WhatsAppTemplatePayload, latency: float):
logger.info(
"audit_template_request",
category=payload.category.value,
language=payload.language,
latency_seconds=round(latency, 3),
success_rate=round(self.successes / self.submissions, 3) if self.submissions > 0 else 0.0
)
def get_metrics(self) -> dict:
return {
"total_submissions": self.submissions,
"successful_approvals": self.successes,
"success_rate": round(self.successes / self.submissions, 3) if self.submissions > 0 else 0.0,
"average_latency_seconds": round(self.total_latency / self.submissions, 3) if self.submissions > 0 else 0.0
}
async def main():
requester = CXoneWhatsAppRequester(
org_id="YOUR_ORG_ID",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
webhook_url="https://your-internal-tracker.example.com/cxone/webhooks"
)
template = WhatsAppTemplatePayload(
language="en_US",
category=WhatsAppCategory.MARKETING,
text="Your order {1} is ready for pickup at {2}. Reply STOP to opt out.",
example=TemplateExample(body=["Your order 99281 is ready for pickup at Main Street."])
)
result = await requester.request_template(template)
print("Final Result:", result)
print("Metrics:", requester.get_metrics())
if __name__ == "__main__":
asyncio.run(main())
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: Expired bearer token or invalid client credentials.
- Fix: Verify
client_idandclient_secretmatch the CXone OAuth client configuration. Ensure the token cache refreshes before expiry. - Code Fix: The
CXoneAuthManagerautomatically refreshes tokens whentime.time() >= self.token_expiry.
Error: 403 Forbidden
- Cause: Missing OAuth scope or insufficient API permissions on the CXone organization.
- Fix: Add
digital:whatsapp:templates:writeto the OAuth client scope list in CXone Administration. Re-authenticate. - Code Fix: Update the
scopefield in_fetch_tokento match exactly what CXone requires.
Error: 422 Unprocessable Entity
- Cause: Payload violates Meta WhatsApp constraints (invalid category, missing variables, example array length mismatch).
- Fix: Validate the
textfield contains{1},{2}, etc. Ensureexample.bodymatches the placeholder count. Use theWhatsAppTemplatePayloadPydantic model to catch errors locally. - Code Fix: The
field_validatordecorators enforce placeholder syntax and example limits before HTTP transmission.
Error: 429 Too Many Requests
- Cause: Exceeding CXone submission rate limits.
- Fix: Implement exponential backoff. Read the
Retry-Afterheader. - Code Fix: The
request_templatemethod checks for429, sleeps for the specified duration, and retries exactly once. For higher volume, wrap the submission in an async queue with concurrency limits.
Error: 500 Internal Server Error
- Cause: CXone backend processing failure or Meta API propagation delay.
- Fix: Retry the submission after 30 seconds. If persistent, verify the template text does not contain prohibited Meta keywords.
- Code Fix: Add a retry loop with jitter in production deployments. Log the
trace_idfrom CXone responses for support tickets.