Skip to content
← Back to Objects
Code

Commio Messaging, Voice, and Number Helper

Use PHP to send SMS and MMS, receive delivery reports, manage numbers, handle 10DLC or toll-free messaging, read webhooks, and connect supported voice tools.

Object signature

new PhpogCommioApiClient($config)

Classification

TypeCommunications Helper ObjectUsage levelProduction

Categories

  • APIs and Webhooks
  • Email and Messaging
  • Developer Utilities

Compatibility

Works with Commio accounts that provide portal-created tokens and enabled messaging or voice products. Use the simple methods for common workflows, and use the endpoint map when your active Commio docs list a different path.

Constructor parameters

Account IDCommio account_id value from the Commio.io user profile, used by messaging and account-scoped endpoints.User IDCommio user_id value from the Commio.io user profile, used by messaging workflows.API tokenAPI token created in the Commio.io API Tokens tab. Store outside the public web root.Webhook secretOptional webhook secret used by the configurable HMAC validation helper.API base URLBase URL for active Commio API requests. Override this if the current Commio documentation or account onboarding page specifies a different host.Voice API base URLOptional separate base URL for voice and SIP account workflows where the active API documentation uses a different host.Token header modeHow the API token is attached: bearer, x-api-token, token, query_token, or none.Endpoint mapEndpoint map overrides for customer-specific or newer Commio endpoint paths.Verify TLS certificateWhether cURL verifies TLS certificates. Keep true in production.Retry limitSmall retry count for transient 408, 429, or 5xx or cURL transport failures.

When to use it

Send SMS and MMSUse this for private PHP tools that need Commio messaging after sender setup, opt-in, and account rules are handled.Read message resultsGood fits include delivery reports, inbound-message parsing, status checks, and webhook-driven updates.Manage messaging numbersUse it for supported number search, orders, feature updates, 10DLC work, toll-free messaging, and provisioning checks.Adapt to account-specific docsUse the endpoint map or raw request methods when your active Commio account documentation uses a specific path or payload.

When not to use it

No consent or registration planDo not send SMS or MMS until opt-in, sender registration, toll-free or 10DLC rules, and unsubscribe handling are defined.Anonymous send formsDo not expose send, number, or provisioning helpers directly to public forms.Guessing endpoint behaviorIf your Commio portal or current documentation lists a different endpoint, configure that endpoint instead of hardcoding assumptions.

How it works

Add Commio settingsPass account ID, user ID, API token, base URL, and optional endpoint overrides from private config.Call a workflow helperUse simple methods for SMS, MMS, delivery reports, inbound messages, numbers, 10DLC, toll-free messaging, lookup, and voice tools.The object sends HTTPS requestsIt handles authentication headers, timeout controls, retries, response decoding, and safe debug snapshots.You get a clean resultResponses are normalized into a consistent success, message, data, HTTP status, and debug shape.Advanced calls stay possiblerawRequest() and rawVoiceRequest() let experienced users call account-enabled endpoints that do not need a named helper yet.

Integration notes

Keep credentials privateStore account ID, user ID, API token, and webhook secret outside public web files.Match your active docsCommio endpoint paths may vary by account and product generation. Override endpoint paths from your current portal documentation when needed.Validate webhooks firstCheck signatures or account-defined verification before updating local message, number, or provisioning records.Protect send actionsPut sending, number orders, provisioning, and campaign work behind login, authorization, CSRF checks, throttling, and audit logs.

Security notes

  • Never hardcode Commio tokens in public PHP files or source-library examples.
  • Validate opt-in and sender registration before sending SMS and MMS.
  • Verify inbound webhooks with the signature mode configured for the account before mutating local records.
  • Use account-level permissions, CSRF checks, throttling, and audit logs around all admin actions.
  • Keep raw request/response logging disabled unless redaction, consent, and retention are defined.

Code

<?php

/*
 * Copyright (c) 2026 Jeffery L. Paris <jparis@phpog.com>.
 * Free for personal and internal use. Paid project use requires visible credit
 * to Jeffery L. Paris. Corporate use requires a paid license fee unless a
 * separate written license states otherwise.
 */

/**
 * Commio Messaging, Voice, and Number Helper for PHP communication workflows.
 *
 * This object helps PHP projects send SMS or MMS, receive delivery reports, manage numbers, handle 10DLC or toll-free messaging, parse webhooks, and connect supported voice tools.
 *
 * Authentication:
 * - Commio API calls require tokens created in the Commio customer portal.
 * - Account ID and User ID values are also required for messaging workflows.
 * - The token is normally sent as a Bearer token unless token_header_mode is
 *   changed to match the active Commio documentation for the account.
 *
 * Safety notes:
 * - Store tokens outside public web roots.
 * - Keep TLS verification enabled in production.
 * - Configure SMS IP whitelists and inbound/outbound messaging URLs before
 *   sending production traffic.
 * - Respect opt-in, consent, HELP/STOP handling, TCPA/CTIA rules, 10DLC
 *   registration, toll-free verification, sender eligibility, carrier policy,
 *   emergency-call restrictions, and customer-specific product limits.
 * - Do not log API tokens, full phone numbers, message bodies, MMS media,
 *   webhook secrets, raw request bodies, or account configuration details unless
 *   policy and retention rules allow it.
 * - Use rawRequest() for endpoints not represented by a named helper.
 *
 * @package PHPOG\Objects
 */
class PhpogCommioApiClient {
	/**
	 * Commio account identifier from the Commio.io user profile.
	 *
	 * @var string
	 */
	protected $account_id = '';

	/**
	 * Commio user identifier from the Commio.io user profile.
	 *
	 * @var string
	 */
	protected $user_id = '';

	/**
	 * API token created in the Commio.io API Tokens tab.
	 *
	 * @var string
	 */
	protected $api_token = '';

	/**
	 * Optional webhook secret used by local validation helpers.
	 *
	 * @var string
	 */
	protected $webhook_secret = '';

	/**
	 * Base URL for active Commio API requests.
	 *
	 * @var string
	 */
	protected $api_base_url = 'https://api.thinq.com';

	/**
	 * Base URL for active Commio voice/SIP API requests when separate.
	 *
	 * @var string
	 */
	protected $voice_base_url = '';

	/**
	 * Token header mode: bearer, x-api-token, token, query_token, or none.
	 *
	 * @var string
	 */
	protected $token_header_mode = 'bearer';

	/**
	 * Query parameter name used by query_token mode.
	 *
	 * @var string
	 */
	protected $query_token_name = 'token';

	/**
	 * Configurable endpoint map for named helpers.
	 *
	 * @var array
	 */
	protected $endpoints = array();

	/**
	 * Request timeout in seconds.
	 *
	 * @var int
	 */
	protected $timeout_seconds = 30;

	/**
	 * Connection timeout in seconds.
	 *
	 * @var int
	 */
	protected $connect_timeout_seconds = 10;

	/**
	 * Whether cURL verifies TLS certificates.
	 *
	 * @var bool
	 */
	protected $verify_peer = true;

	/**
	 * HTTP user agent sent to Commio.
	 *
	 * @var string
	 */
	protected $user_agent = 'PHPOG Commio API Client/1.0';

	/**
	 * Whether diagnostic methods include redacted request/response snapshots.
	 *
	 * @var bool
	 */
	protected $debug = false;

	/**
	 * Automatic retry count for retryable transport/HTTP failures.
	 *
	 * @var int
	 */
	protected $max_retries = 1;

	/**
	 * Last redacted request snapshot.
	 *
	 * @var array
	 */
	protected $last_request = array();

	/**
	 * Last normalized response snapshot.
	 *
	 * @var array
	 */
	protected $last_response = array();

	/**
	 * Build a Commio API client.
	 *
	 * Recognized config keys: account_id, user_id, api_token, webhook_secret,
	 * api_base_url, voice_base_url, token_header_mode, query_token_name,
	 * endpoints, timeout_seconds, connect_timeout_seconds, verify_peer,
	 * user_agent, debug, and max_retries.
	 *
	 * @param array $config Client configuration values.
	 */
	public function __construct($config = array()) {
		$this->endpoints = $this->getDefaultEndpoints();

		if (isset($config['account_id']) && is_string($config['account_id'])) {
			$this->account_id = trim($config['account_id']);
		}

		if (isset($config['user_id']) && is_string($config['user_id'])) {
			$this->user_id = trim($config['user_id']);
		}

		if (isset($config['api_token']) && is_string($config['api_token'])) {
			$this->api_token = $config['api_token'];
		}

		if (isset($config['webhook_secret']) && is_string($config['webhook_secret'])) {
			$this->webhook_secret = $config['webhook_secret'];
		}

		if (isset($config['api_base_url']) && is_string($config['api_base_url'])) {
			$this->api_base_url = rtrim(trim($config['api_base_url']), '/');
		}

		if (isset($config['voice_base_url']) && is_string($config['voice_base_url'])) {
			$this->voice_base_url = rtrim(trim($config['voice_base_url']), '/');
		}

		if (isset($config['token_header_mode']) && is_string($config['token_header_mode'])) {
			$this->token_header_mode = strtolower(trim($config['token_header_mode']));
		}

		if (isset($config['query_token_name']) && is_string($config['query_token_name'])) {
			$this->query_token_name = trim($config['query_token_name']);
		}

		if (isset($config['endpoints']) && is_array($config['endpoints'])) {
			foreach ($config['endpoints'] as $key => $path) {
				if (is_string($key) && is_string($path) && $key !== '') {
					$this->endpoints[$key] = $path;
				}
			}
		}

		if (isset($config['timeout_seconds']) && is_numeric($config['timeout_seconds'])) {
			$this->timeout_seconds = (int) $config['timeout_seconds'];
		}

		if (isset($config['connect_timeout_seconds']) && is_numeric($config['connect_timeout_seconds'])) {
			$this->connect_timeout_seconds = (int) $config['connect_timeout_seconds'];
		}

		if (isset($config['verify_peer'])) {
			$this->verify_peer = (bool) $config['verify_peer'];
		}

		if (isset($config['user_agent']) && is_string($config['user_agent'])) {
			$this->user_agent = trim($config['user_agent']);
		}

		if (isset($config['debug'])) {
			$this->debug = (bool) $config['debug'];
		}

		if (isset($config['max_retries']) && is_numeric($config['max_retries'])) {
			$this->max_retries = (int) $config['max_retries'];
			if ($this->max_retries < 0) {
				$this->max_retries = 0;
			}
		}
	}

	/**
	 * Return the default endpoint map.
	 *
	 * These paths intentionally remain isolated in a single map so a production
	 * installation can update them from configuration if Commio's active API docs,
	 * customer portal generation, or account enablement uses different paths.
	 *
	 * @return array Default endpoint key/path map.
	 */
	protected function getDefaultEndpoints() {
		return array(
			'send_sms' => '/sms/send',
			'send_mms_url' => '/sms/mms/url',
			'send_mms_file' => '/sms/mms/file',
			'sms_status' => '/sms/status/{message_guid}',
			'inbound_sms_config' => '/accounts/{account_id}/users/{user_id}/sms/inbound-config',
			'outbound_sms_config' => '/accounts/{account_id}/users/{user_id}/sms/outbound-config',
			'sms_ip_whitelists' => '/accounts/{account_id}/users/{user_id}/sms/ip-whitelists',
			'webhook_subscriptions' => '/accounts/{account_id}/webhooks',
			'numbers' => '/accounts/{account_id}/numbers',
			'number_detail' => '/accounts/{account_id}/numbers/{number}',
			'number_search' => '/accounts/{account_id}/numbers/search',
			'number_order' => '/accounts/{account_id}/numbers/orders',
			'number_disconnect' => '/accounts/{account_id}/numbers/{number}/disconnect',
			'number_features' => '/accounts/{account_id}/numbers/{number}/features',
			'brands' => '/accounts/{account_id}/messaging/10dlc/brands',
			'brand_detail' => '/accounts/{account_id}/messaging/10dlc/brands/{brand_id}',
			'campaigns' => '/accounts/{account_id}/messaging/10dlc/campaigns',
			'campaign_detail' => '/accounts/{account_id}/messaging/10dlc/campaigns/{campaign_id}',
			'campaign_numbers' => '/accounts/{account_id}/messaging/10dlc/campaigns/{campaign_id}/numbers',
			'tollfree_verifications' => '/accounts/{account_id}/messaging/tollfree/verifications',
			'tollfree_verification_detail' => '/accounts/{account_id}/messaging/tollfree/verifications/{verification_id}',
			'sms_provisioning' => '/accounts/{account_id}/messaging/provisioning',
			'lrn_lookup' => '/lookup/lrn',
			'cnam_lookup' => '/lookup/cnam',
			'outbound_profiles' => '/accounts/{account_id}/outbound/profiles',
			'outbound_profile_detail' => '/accounts/{account_id}/outbound/profiles/{profile_id}',
			'trunks' => '/accounts/{account_id}/voice/trunks',
			'trunk_detail' => '/accounts/{account_id}/voice/trunks/{trunk_id}',
			'cdrs' => '/accounts/{account_id}/voice/cdrs'
		);
	}

	/**
	 * Return the last redacted request snapshot.
	 *
	 * @return array Last request debug data.
	 */
	public function getLastRequest() {
		return $this->last_request;
	}

	/**
	 * Return the last normalized response snapshot.
	 *
	 * @return array Last response data.
	 */
	public function getLastResponse() {
		return $this->last_response;
	}

	/**
	 * Send an SMS message through the configured Commio messaging endpoint.
	 *
	 * @param string $from Sender DID/number.
	 * @param string $to Recipient number.
	 * @param string $message Text message body.
	 * @param array $options Optional extra Commio fields such as callback URL,
	 *                       reference ID, campaign ID, tag, or customer metadata.
	 * @return array Normalized response.
	 */
	public function sendSms($from, $to, $message, $options = array()) {
		$payload = $this->buildMessagePayload($from, $to, $message, $options);

		if ($payload['success'] === false) {
			return $payload;
		}

		return $this->requestEndpoint('send_sms', 'POST', $payload['data'], array(), true);
	}

	/**
	 * Send an MMS message with media URLs.
	 *
	 * @param string $from Sender DID/number.
	 * @param string $to Recipient number.
	 * @param string $message Optional text body.
	 * @param array $media_urls One or more HTTPS media URLs.
	 * @param array $options Optional extra Commio fields.
	 * @return array Normalized response.
	 */
	public function sendMmsByUrl($from, $to, $message, $media_urls, $options = array()) {
		$payload = $this->buildMessagePayload($from, $to, $message, $options);

		if ($payload['success'] === false) {
			return $payload;
		}

		if (!is_array($media_urls) || count($media_urls) < 1) {
			return $this->errorResponse('At least one media URL is required.');
		}

		$payload['data']['media_urls'] = array_values($media_urls);

		return $this->requestEndpoint('send_mms_url', 'POST', $payload['data'], array(), true);
	}

	/**
	 * Send an MMS message by multipart file upload.
	 *
	 * @param string $from Sender DID/number.
	 * @param string $to Recipient number.
	 * @param string $message Optional text body.
	 * @param array $file_paths One or more local absolute file paths.
	 * @param array $options Optional extra Commio fields.
	 * @return array Normalized response.
	 */
	public function sendMmsByFile($from, $to, $message, $file_paths, $options = array()) {
		$payload = $this->buildMessagePayload($from, $to, $message, $options);

		if ($payload['success'] === false) {
			return $payload;
		}

		if (!is_array($file_paths) || count($file_paths) < 1) {
			return $this->errorResponse('At least one media file path is required.');
		}

		$index = 0;
		foreach ($file_paths as $path) {
			if (!is_string($path) || $path === '' || !is_readable($path)) {
				return $this->errorResponse('Every media file path must be readable.');
			}

			$payload['data']['media_file_'.$index] = new CURLFile($path);
			$index++;
		}

		return $this->requestEndpoint('send_mms_file', 'POST', $payload['data'], array(), false);
	}

	/**
	 * Request status for a previously submitted SMS/MMS message GUID.
	 *
	 * @param string $message_guid Message GUID returned by Commio.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function requestMessageStatus($message_guid, $parameters = array()) {
		if (!is_string($message_guid) || trim($message_guid) === '') {
			return $this->errorResponse('Message GUID is required.');
		}

		$replacements = array('message_guid' => trim($message_guid));

		return $this->requestEndpoint('sms_status', 'GET', $parameters, array(), false, $replacements);
	}

	/**
	 * Fetch inbound SMS configuration for the account/user.
	 *
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function getInboundSmsConfig($parameters = array()) {
		return $this->requestEndpoint('inbound_sms_config', 'GET', $parameters);
	}

	/**
	 * Update inbound SMS configuration such as webhook URL, format, attachment
	 * behavior, and inbound routing options supported by the account.
	 *
	 * @param array $parameters Commio configuration fields.
	 * @return array Normalized response.
	 */
	public function updateInboundSmsConfig($parameters) {
		return $this->requestEndpoint('inbound_sms_config', 'POST', $parameters, array(), true);
	}

	/**
	 * Fetch outbound SMS configuration for delivery-notification settings.
	 *
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function getOutboundSmsConfig($parameters = array()) {
		return $this->requestEndpoint('outbound_sms_config', 'GET', $parameters);
	}

	/**
	 * Update outbound SMS configuration such as DLR URL, DLR format, and
	 * intermediate DLR behavior where supported by Commio.
	 *
	 * @param array $parameters Commio configuration fields.
	 * @return array Normalized response.
	 */
	public function updateOutboundSmsConfig($parameters) {
		return $this->requestEndpoint('outbound_sms_config', 'POST', $parameters, array(), true);
	}

	/**
	 * List SMS IP whitelist records.
	 *
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function listSmsIpWhitelists($parameters = array()) {
		return $this->requestEndpoint('sms_ip_whitelists', 'GET', $parameters);
	}

	/**
	 * Add an SMS IP whitelist entry.
	 *
	 * @param string $ip_address IP address or CIDR where supported.
	 * @param array $options Optional description or account fields.
	 * @return array Normalized response.
	 */
	public function addSmsIpWhitelist($ip_address, $options = array()) {
		if (!is_string($ip_address) || trim($ip_address) === '') {
			return $this->errorResponse('IP address is required.');
		}

		$payload = $options;
		$payload['ip_address'] = trim($ip_address);

		return $this->requestEndpoint('sms_ip_whitelists', 'POST', $payload, array(), true);
	}

	/**
	 * Remove an SMS IP whitelist entry by ID or IP value.
	 *
	 * @param string $identifier Whitelist ID or IP address.
	 * @param array $options Optional request fields.
	 * @return array Normalized response.
	 */
	public function deleteSmsIpWhitelist($identifier, $options = array()) {
		if (!is_string($identifier) || trim($identifier) === '') {
			return $this->errorResponse('Whitelist identifier is required.');
		}

		$parameters = $options;
		$parameters['identifier'] = trim($identifier);

		return $this->requestEndpoint('sms_ip_whitelists', 'DELETE', $parameters, array(), true);
	}

	/**
	 * List webhook subscriptions configured through Commio.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function listWebhookSubscriptions($parameters = array()) {
		return $this->requestEndpoint('webhook_subscriptions', 'GET', $parameters);
	}

	/**
	 * Create a webhook subscription for DLR, toll-free verification, SMS
	 * provisioning, inbound messages, or another Commio-supported event type.
	 *
	 * @param string $event_type Webhook event type.
	 * @param string $webhook_url Public HTTPS callback URL.
	 * @param array $options Optional fields.
	 * @return array Normalized response.
	 */
	public function createWebhookSubscription($event_type, $webhook_url, $options = array()) {
		if (!is_string($event_type) || trim($event_type) === '') {
			return $this->errorResponse('Webhook event type is required.');
		}

		if (!is_string($webhook_url) || filter_var($webhook_url, FILTER_VALIDATE_URL) === false) {
			return $this->errorResponse('A valid webhook URL is required.');
		}

		$payload = $options;
		$payload['event_type'] = trim($event_type);
		$payload['webhook_url'] = trim($webhook_url);

		return $this->requestEndpoint('webhook_subscriptions', 'POST', $payload, array(), true);
	}

	/**
	 * Search for available DIDs or phone numbers.
	 *
	 * @param array $filters Search filters such as area code, rate center, state,
	 *                       number type, SMS enabled, or limit.
	 * @return array Normalized response.
	 */
	public function searchNumbers($filters = array()) {
		return $this->requestEndpoint('number_search', 'GET', $filters);
	}

	/**
	 * Order one or more phone numbers.
	 *
	 * @param array $numbers Phone numbers or order line data.
	 * @param array $options Optional order fields.
	 * @return array Normalized response.
	 */
	public function orderNumbers($numbers, $options = array()) {
		if (!is_array($numbers) || count($numbers) < 1) {
			return $this->errorResponse('At least one number/order item is required.');
		}

		$payload = $options;
		$payload['numbers'] = array_values($numbers);

		return $this->requestEndpoint('number_order', 'POST', $payload, array(), true);
	}

	/**
	 * List account numbers.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function listNumbers($parameters = array()) {
		return $this->requestEndpoint('numbers', 'GET', $parameters);
	}

	/**
	 * Fetch one number's details.
	 *
	 * @param string $number DID or telephone number.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function fetchNumber($number, $parameters = array()) {
		$replacements = $this->buildNumberReplacement($number);
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('number_detail', 'GET', $parameters, array(), false, $replacements['data']);
	}

	/**
	 * Update number features such as SMS enablement, CNAM, E911, routing, or other
	 * account-supported feature flags.
	 *
	 * @param string $number DID or telephone number.
	 * @param array $parameters Feature fields.
	 * @return array Normalized response.
	 */
	public function updateNumberFeatures($number, $parameters) {
		$replacements = $this->buildNumberReplacement($number);
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('number_features', 'POST', $parameters, array(), true, $replacements['data']);
	}

	/**
	 * Disconnect a number where the account/token has permission.
	 *
	 * @param string $number DID or telephone number.
	 * @param array $parameters Optional disconnect fields.
	 * @return array Normalized response.
	 */
	public function disconnectNumber($number, $parameters = array()) {
		$replacements = $this->buildNumberReplacement($number);
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('number_disconnect', 'POST', $parameters, array(), true, $replacements['data']);
	}

	/**
	 * List 10DLC brands.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function list10DlcBrands($parameters = array()) {
		return $this->requestEndpoint('brands', 'GET', $parameters);
	}

	/**
	 * Create or submit a 10DLC brand payload.
	 *
	 * @param array $brand_data Brand fields required by Commio/carriers.
	 * @return array Normalized response.
	 */
	public function create10DlcBrand($brand_data) {
		return $this->requestEndpoint('brands', 'POST', $brand_data, array(), true);
	}

	/**
	 * Fetch 10DLC brand details.
	 *
	 * @param string $brand_id Brand identifier.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function fetch10DlcBrand($brand_id, $parameters = array()) {
		$replacements = $this->buildIdentifierReplacement('brand_id', $brand_id, 'Brand ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('brand_detail', 'GET', $parameters, array(), false, $replacements['data']);
	}

	/**
	 * List 10DLC campaigns.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function list10DlcCampaigns($parameters = array()) {
		return $this->requestEndpoint('campaigns', 'GET', $parameters);
	}

	/**
	 * Create or submit a 10DLC campaign payload.
	 *
	 * @param array $campaign_data Campaign fields required by Commio/carriers.
	 * @return array Normalized response.
	 */
	public function create10DlcCampaign($campaign_data) {
		return $this->requestEndpoint('campaigns', 'POST', $campaign_data, array(), true);
	}

	/**
	 * Fetch 10DLC campaign details.
	 *
	 * @param string $campaign_id Campaign identifier.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function fetch10DlcCampaign($campaign_id, $parameters = array()) {
		$replacements = $this->buildIdentifierReplacement('campaign_id', $campaign_id, 'Campaign ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('campaign_detail', 'GET', $parameters, array(), false, $replacements['data']);
	}

	/**
	 * Assign phone numbers to a registered 10DLC campaign.
	 *
	 * @param string $campaign_id Campaign identifier.
	 * @param array $numbers Numbers to assign.
	 * @param array $options Optional request fields.
	 * @return array Normalized response.
	 */
	public function assignNumbersTo10DlcCampaign($campaign_id, $numbers, $options = array()) {
		$replacements = $this->buildIdentifierReplacement('campaign_id', $campaign_id, 'Campaign ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		if (!is_array($numbers) || count($numbers) < 1) {
			return $this->errorResponse('At least one number is required.');
		}

		$payload = $options;
		$payload['numbers'] = array_values($numbers);

		return $this->requestEndpoint('campaign_numbers', 'POST', $payload, array(), true, $replacements['data']);
	}

	/**
	 * Remove a phone number from a 10DLC campaign.
	 *
	 * @param string $campaign_id Campaign identifier.
	 * @param string $number Number to remove.
	 * @param array $options Optional request fields.
	 * @return array Normalized response.
	 */
	public function removeNumberFrom10DlcCampaign($campaign_id, $number, $options = array()) {
		$replacements = $this->buildIdentifierReplacement('campaign_id', $campaign_id, 'Campaign ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		if (!is_string($number) || trim($number) === '') {
			return $this->errorResponse('Number is required.');
		}

		$payload = $options;
		$payload['number'] = trim($number);

		return $this->requestEndpoint('campaign_numbers', 'DELETE', $payload, array(), true, $replacements['data']);
	}

	/**
	 * List toll-free verification submissions.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function listTollFreeVerifications($parameters = array()) {
		return $this->requestEndpoint('tollfree_verifications', 'GET', $parameters);
	}

	/**
	 * Create or submit a toll-free verification payload.
	 *
	 * @param array $verification_data Toll-free verification fields.
	 * @return array Normalized response.
	 */
	public function createTollFreeVerification($verification_data) {
		return $this->requestEndpoint('tollfree_verifications', 'POST', $verification_data, array(), true);
	}

	/**
	 * Fetch toll-free verification details.
	 *
	 * @param string $verification_id Verification identifier.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function fetchTollFreeVerification($verification_id, $parameters = array()) {
		$replacements = $this->buildIdentifierReplacement('verification_id', $verification_id, 'Verification ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('tollfree_verification_detail', 'GET', $parameters, array(), false, $replacements['data']);
	}

	/**
	 * Update toll-free verification details where Commio permits edits.
	 *
	 * @param string $verification_id Verification identifier.
	 * @param array $verification_data Verification fields.
	 * @return array Normalized response.
	 */
	public function updateTollFreeVerification($verification_id, $verification_data) {
		$replacements = $this->buildIdentifierReplacement('verification_id', $verification_id, 'Verification ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('tollfree_verification_detail', 'PATCH', $verification_data, array(), true, $replacements['data']);
	}

	/**
	 * List SMS provisioning status records.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function listSmsProvisioningStatus($parameters = array()) {
		return $this->requestEndpoint('sms_provisioning', 'GET', $parameters);
	}

	/**
	 * Request or update SMS provisioning status for one or more numbers.
	 *
	 * @param array $numbers Numbers to provision or inspect.
	 * @param array $options Optional provisioning fields.
	 * @return array Normalized response.
	 */
	public function requestSmsProvisioning($numbers, $options = array()) {
		if (!is_array($numbers) || count($numbers) < 1) {
			return $this->errorResponse('At least one number is required.');
		}

		$payload = $options;
		$payload['numbers'] = array_values($numbers);

		return $this->requestEndpoint('sms_provisioning', 'POST', $payload, array(), true);
	}

	/**
	 * Perform an LRN lookup where the account token has permission.
	 *
	 * @param string $number Number to dip.
	 * @param array $options Optional fields.
	 * @return array Normalized response.
	 */
	public function lookupLrn($number, $options = array()) {
		return $this->lookupByEndpoint('lrn_lookup', $number, $options);
	}

	/**
	 * Perform a CNAM lookup where the account token has permission.
	 *
	 * @param string $number Number to dip.
	 * @param array $options Optional fields.
	 * @return array Normalized response.
	 */
	public function lookupCnam($number, $options = array()) {
		return $this->lookupByEndpoint('cnam_lookup', $number, $options);
	}

	/**
	 * List outbound account profiles for voice/SIP products.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function listOutboundProfiles($parameters = array()) {
		return $this->requestEndpoint('outbound_profiles', 'GET', $parameters, array(), false, array(), true);
	}

	/**
	 * Create an outbound account profile for voice/SIP products.
	 *
	 * @param array $profile_data Profile fields.
	 * @return array Normalized response.
	 */
	public function createOutboundProfile($profile_data) {
		return $this->requestEndpoint('outbound_profiles', 'POST', $profile_data, array(), true, array(), true);
	}

	/**
	 * Fetch an outbound profile.
	 *
	 * @param string $profile_id Profile identifier.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function fetchOutboundProfile($profile_id, $parameters = array()) {
		$replacements = $this->buildIdentifierReplacement('profile_id', $profile_id, 'Profile ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('outbound_profile_detail', 'GET', $parameters, array(), false, $replacements['data'], true);
	}

	/**
	 * Update an outbound profile.
	 *
	 * @param string $profile_id Profile identifier.
	 * @param array $profile_data Profile fields.
	 * @return array Normalized response.
	 */
	public function updateOutboundProfile($profile_id, $profile_data) {
		$replacements = $this->buildIdentifierReplacement('profile_id', $profile_id, 'Profile ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('outbound_profile_detail', 'PATCH', $profile_data, array(), true, $replacements['data'], true);
	}

	/**
	 * Delete an outbound profile.
	 *
	 * @param string $profile_id Profile identifier.
	 * @param array $parameters Optional request fields.
	 * @return array Normalized response.
	 */
	public function deleteOutboundProfile($profile_id, $parameters = array()) {
		$replacements = $this->buildIdentifierReplacement('profile_id', $profile_id, 'Profile ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('outbound_profile_detail', 'DELETE', $parameters, array(), true, $replacements['data'], true);
	}

	/**
	 * List voice/SIP trunks where the account/token exposes trunk APIs.
	 *
	 * @param array $parameters Optional filters.
	 * @return array Normalized response.
	 */
	public function listTrunks($parameters = array()) {
		return $this->requestEndpoint('trunks', 'GET', $parameters, array(), false, array(), true);
	}

	/**
	 * Fetch one voice/SIP trunk.
	 *
	 * @param string $trunk_id Trunk identifier.
	 * @param array $parameters Optional query parameters.
	 * @return array Normalized response.
	 */
	public function fetchTrunk($trunk_id, $parameters = array()) {
		$replacements = $this->buildIdentifierReplacement('trunk_id', $trunk_id, 'Trunk ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('trunk_detail', 'GET', $parameters, array(), false, $replacements['data'], true);
	}

	/**
	 * Update one voice/SIP trunk.
	 *
	 * @param string $trunk_id Trunk identifier.
	 * @param array $trunk_data Trunk fields.
	 * @return array Normalized response.
	 */
	public function updateTrunk($trunk_id, $trunk_data) {
		$replacements = $this->buildIdentifierReplacement('trunk_id', $trunk_id, 'Trunk ID is required.');
		if ($replacements['success'] === false) {
			return $replacements;
		}

		return $this->requestEndpoint('trunk_detail', 'PATCH', $trunk_data, array(), true, $replacements['data'], true);
	}

	/**
	 * List call-detail records where account reporting API is enabled.
	 *
	 * @param array $parameters Optional date, direction, number, and pagination filters.
	 * @return array Normalized response.
	 */
	public function listCallDetailRecords($parameters = array()) {
		return $this->requestEndpoint('cdrs', 'GET', $parameters, array(), false, array(), true);
	}

	/**
	 * Parse an inbound SMS/MMS webhook payload from Commio.
	 *
	 * @param array|string $payload Request array or raw body.
	 * @param string $content_type HTTP content type.
	 * @return array Normalized parsed payload.
	 */
	public function parseInboundMessageWebhook($payload, $content_type = '') {
		$parsed = $this->parseWebhookPayload($payload, $content_type);
		if ($parsed['success'] === false) {
			return $parsed;
		}

		$data = $parsed['data'];

		return $this->successResponse('Inbound message webhook parsed.', array(
			'event_type' => $this->firstValue($data, array('event_type', 'type', 'webhook_type'), 'inbound_message'),
			'message_guid' => $this->firstValue($data, array('message_guid', 'sms_guid', 'guid', 'message_id'), ''),
			'from' => $this->firstValue($data, array('from', 'From', 'ani', 'source'), ''),
			'to' => $this->firstValue($data, array('to', 'To', 'dnis', 'destination'), ''),
			'body' => $this->firstValue($data, array('body', 'Body', 'message', 'text'), ''),
			'media' => $this->extractMediaFields($data),
			'raw' => $data
		));
	}

	/**
	 * Parse a delivery receipt / delivery notification webhook payload.
	 *
	 * @param array|string $payload Request array or raw body.
	 * @param string $content_type HTTP content type.
	 * @return array Normalized parsed payload.
	 */
	public function parseDeliveryReceiptWebhook($payload, $content_type = '') {
		$parsed = $this->parseWebhookPayload($payload, $content_type);
		if ($parsed['success'] === false) {
			return $parsed;
		}

		$data = $parsed['data'];

		return $this->successResponse('Delivery receipt webhook parsed.', array(
			'event_type' => $this->firstValue($data, array('event_type', 'type', 'webhook_type'), 'delivery_receipt'),
			'message_guid' => $this->firstValue($data, array('message_guid', 'sms_guid', 'guid', 'message_id'), ''),
			'status' => $this->firstValue($data, array('status', 'Status', 'delivery_status', 'dlr_status'), ''),
			'status_code' => $this->firstValue($data, array('status_code', 'code', 'dlr_code'), ''),
			'timestamp' => $this->firstValue($data, array('timestamp', 'date', 'created_at', 'updated_at'), ''),
			'raw' => $data
		));
	}

	/**
	 * Parse toll-free verification status webhooks.
	 *
	 * @param array|string $payload Request array or raw body.
	 * @param string $content_type HTTP content type.
	 * @return array Normalized parsed payload.
	 */
	public function parseTollFreeVerificationWebhook($payload, $content_type = '') {
		$parsed = $this->parseWebhookPayload($payload, $content_type);
		if ($parsed['success'] === false) {
			return $parsed;
		}

		$data = $parsed['data'];

		return $this->successResponse('Toll-free verification webhook parsed.', array(
			'event_type' => $this->firstValue($data, array('event_type', 'type', 'webhook_type'), 'tollfree_verification'),
			'verification_id' => $this->firstValue($data, array('verification_id', 'submission_id', 'id'), ''),
			'number' => $this->firstValue($data, array('number', 'phone_number', 'tn'), ''),
			'status' => $this->firstValue($data, array('status', 'verification_status'), ''),
			'raw' => $data
		));
	}

	/**
	 * Parse SMS provisioning status webhooks.
	 *
	 * @param array|string $payload Request array or raw body.
	 * @param string $content_type HTTP content type.
	 * @return array Normalized parsed payload.
	 */
	public function parseSmsProvisioningWebhook($payload, $content_type = '') {
		$parsed = $this->parseWebhookPayload($payload, $content_type);
		if ($parsed['success'] === false) {
			return $parsed;
		}

		$data = $parsed['data'];

		return $this->successResponse('SMS provisioning webhook parsed.', array(
			'event_type' => $this->firstValue($data, array('event_type', 'type', 'webhook_type'), 'sms_provisioning'),
			'number' => $this->firstValue($data, array('number', 'phone_number', 'tn'), ''),
			'status' => $this->firstValue($data, array('status', 'provisioning_status'), ''),
			'reason' => $this->firstValue($data, array('reason', 'message', 'description'), ''),
			'raw' => $data
		));
	}

	/**
	 * Validate an HMAC-style webhook signature.
	 *
	 * Because Commio webhook signing details can depend on product settings, this
	 * method accepts a mode and header name instead of assuming one global shape.
	 * Supported modes: hmac_sha256_body, hmac_sha1_body, hmac_sha256_url_body,
	 * hmac_sha256_url_params.
	 *
	 * @param array $headers Request headers.
	 * @param string $raw_body Raw request body.
	 * @param string $public_url Exact public URL used for validation, if needed.
	 * @param array $parameters Parsed request parameters, if needed.
	 * @param array $options header_name, mode, secret, prefix, and tolerance options.
	 * @return array Validation response.
	 */
	public function validateWebhookSignature($headers, $raw_body, $public_url = '', $parameters = array(), $options = array()) {
		if (!is_array($headers)) {
			return $this->errorResponse('Headers must be an array.');
		}

		if (!is_string($raw_body)) {
			return $this->errorResponse('Raw body must be a string.');
		}

		$header_name = 'X-Commio-Signature';
		if (isset($options['header_name']) && is_string($options['header_name']) && trim($options['header_name']) !== '') {
			$header_name = trim($options['header_name']);
		}

		$mode = 'hmac_sha256_body';
		if (isset($options['mode']) && is_string($options['mode']) && trim($options['mode']) !== '') {
			$mode = strtolower(trim($options['mode']));
		}

		$secret = $this->webhook_secret;
		if (isset($options['secret']) && is_string($options['secret'])) {
			$secret = $options['secret'];
		}

		if ($secret === '') {
			return $this->errorResponse('Webhook secret is required.');
		}

		$provided_signature = $this->getHeaderValue($headers, $header_name);
		if ($provided_signature === '') {
			return $this->errorResponse('Webhook signature header is missing.');
		}

		$signed_value = $raw_body;
		if ($mode === 'hmac_sha256_url_body' || $mode === 'hmac_sha1_url_body') {
			$signed_value = $public_url.$raw_body;
		} elseif ($mode === 'hmac_sha256_url_params' || $mode === 'hmac_sha1_url_params') {
			$signed_value = $public_url.$this->buildSortedParameterString($parameters);
		}

		$algo = 'sha256';
		if ($mode === 'hmac_sha1_body' || $mode === 'hmac_sha1_url_body' || $mode === 'hmac_sha1_url_params') {
			$algo = 'sha1';
		}

		$expected_signature = base64_encode(hash_hmac($algo, $signed_value, $secret, true));
		$hex_signature = hash_hmac($algo, $signed_value, $secret, false);

		$provided_clean = trim($provided_signature);
		$prefix = '';
		if (isset($options['prefix']) && is_string($options['prefix'])) {
			$prefix = trim($options['prefix']);
		}

		if ($prefix !== '' && stripos($provided_clean, $prefix.' ') === 0) {
			$provided_clean = trim(substr($provided_clean, strlen($prefix) + 1));
		}

		$valid = false;
		if (function_exists('hash_equals')) {
			if (hash_equals($expected_signature, $provided_clean) || hash_equals($hex_signature, $provided_clean)) {
				$valid = true;
			}
		} else {
			if ($expected_signature === $provided_clean || $hex_signature === $provided_clean) {
				$valid = true;
			}
		}

		return $this->successResponse('Webhook signature validation completed.', array(
			'valid' => $valid,
			'mode' => $mode,
			'header_name' => $header_name
		));
	}

	/**
	 * Make a raw request to any documented Commio endpoint.
	 *
	 * @param string $method HTTP method.
	 * @param string $path Relative API path or absolute URL.
	 * @param array $parameters Query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to JSON-encode body data.
	 * @param bool $use_voice_base Whether to use voice_base_url when path is relative.
	 * @return array Normalized response.
	 */
	public function rawRequest($method, $path, $parameters = array(), $headers = array(), $send_json = false, $use_voice_base = false) {
		return $this->performRequest($method, $path, $parameters, $headers, $send_json, $use_voice_base);
	}

	/**
	 * Make a raw request specifically against the configured voice/SIP base URL.
	 *
	 * @param string $method HTTP method.
	 * @param string $path Relative voice path or absolute URL.
	 * @param array $parameters Query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to JSON-encode body data.
	 * @return array Normalized response.
	 */
	public function rawVoiceRequest($method, $path, $parameters = array(), $headers = array(), $send_json = false) {
		return $this->performRequest($method, $path, $parameters, $headers, $send_json, true);
	}

	/**
	 * Dispatch a request through a named endpoint key.
	 *
	 * @param string $endpoint_key Endpoint map key.
	 * @param string $method HTTP method.
	 * @param array $parameters Query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to JSON-encode body data.
	 * @param array $replacements Additional path replacements.
	 * @param bool $use_voice_base Whether to use voice_base_url for relative paths.
	 * @return array Normalized response.
	 */
	protected function requestEndpoint($endpoint_key, $method, $parameters = array(), $headers = array(), $send_json = false, $replacements = array(), $use_voice_base = false) {
		if (!isset($this->endpoints[$endpoint_key]) || !is_string($this->endpoints[$endpoint_key]) || trim($this->endpoints[$endpoint_key]) === '') {
			return $this->errorResponse('Endpoint key is not configured: '.$endpoint_key);
		}

		$path = $this->buildEndpointPath($this->endpoints[$endpoint_key], $replacements);

		return $this->performRequest($method, $path, $parameters, $headers, $send_json, $use_voice_base);
	}

	/**
	 * Execute an HTTP request with retries and response normalization.
	 *
	 * @param string $method HTTP method.
	 * @param string $path Relative path or absolute URL.
	 * @param array $parameters Query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to JSON-encode body data.
	 * @param bool $use_voice_base Whether to use voice base URL.
	 * @return array Normalized response.
	 */
	protected function performRequest($method, $path, $parameters = array(), $headers = array(), $send_json = false, $use_voice_base = false) {
		$credential_check = $this->validateCredentials();
		if ($credential_check['success'] === false) {
			return $credential_check;
		}

		if (!is_string($method) || trim($method) === '') {
			return $this->errorResponse('HTTP method is required.');
		}

		if (!is_string($path) || trim($path) === '') {
			return $this->errorResponse('API path is required.');
		}

		if (!is_array($parameters)) {
			return $this->errorResponse('Request parameters must be an array.');
		}

		if (!is_array($headers)) {
			return $this->errorResponse('Request headers must be an array.');
		}

		$method = strtoupper(trim($method));
		$url = $this->buildUrl($path, $use_voice_base);
		$header_lines = $this->buildHeaders($headers, $send_json);
		$query_parameters = $parameters;
		$body_parameters = $parameters;

		if ($method === 'GET' || $method === 'DELETE') {
			$url = $this->appendQueryString($url, $query_parameters);
			$body_parameters = array();
		}

		$this->last_request = array(
			'method' => $method,
			'url' => $this->redactUrl($url),
			'headers' => $this->redactHeaders($header_lines),
			'parameters' => $this->redactArray($parameters),
			'send_json' => $send_json
		);

		$attempt = 0;
		$max_attempts = $this->max_retries + 1;
		$last_result = array();

		while ($attempt < $max_attempts) {
			$attempt++;
			$result = $this->executeCurlRequest($method, $url, $body_parameters, $header_lines, $send_json);
			$result['data']['attempts'] = $attempt;
			$last_result = $result;

			if ($this->shouldRetry($result) === false || $attempt >= $max_attempts) {
				break;
			}

			usleep(250000 * $attempt);
		}

		$this->last_response = $last_result;

		return $last_result;
	}

	/**
	 * Execute a single cURL request.
	 *
	 * @param string $method HTTP method.
	 * @param string $url Request URL.
	 * @param array $body_parameters Body parameters.
	 * @param array $header_lines Header lines.
	 * @param bool $send_json Whether to send JSON.
	 * @return array Normalized response.
	 */
	protected function executeCurlRequest($method, $url, $body_parameters, $header_lines, $send_json) {
		$ch = curl_init();
		if ($ch === false) {
			return $this->errorResponse('Unable to initialize cURL.');
		}

		curl_setopt($ch, CURLOPT_URL, $url);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
		curl_setopt($ch, CURLOPT_HEADER, false);
		curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
		curl_setopt($ch, CURLOPT_HTTPHEADER, $header_lines);
		curl_setopt($ch, CURLOPT_USERAGENT, $this->user_agent);
		curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout_seconds);
		curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->connect_timeout_seconds);
		curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->verify_peer);
		curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

		if ($method !== 'GET' && $method !== 'DELETE') {
			if ($send_json === true) {
				$json_body = json_encode($body_parameters);
				if ($json_body === false) {
					curl_close($ch);
					return $this->errorResponse('Unable to encode request JSON: '.json_last_error_msg());
				}
				curl_setopt($ch, CURLOPT_POSTFIELDS, $json_body);
			} else {
				curl_setopt($ch, CURLOPT_POSTFIELDS, $body_parameters);
			}
		}

		$raw_body = curl_exec($ch);
		$curl_errno = curl_errno($ch);
		$curl_error = curl_error($ch);
		$http_code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
		$content_type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
		curl_close($ch);

		if ($raw_body === false) {
			$raw_body = '';
		}

		$decoded = null;
		$json_error = '';
		if ($raw_body !== '') {
			$decoded = json_decode($raw_body, true);
			if (json_last_error() !== JSON_ERROR_NONE) {
				$json_error = json_last_error_msg();
			}
		}

		$message = $this->extractResponseMessage($http_code, $decoded, $raw_body, $curl_errno, $curl_error, $json_error);
		$success = false;
		if ($curl_errno === 0 && $http_code >= 200 && $http_code < 300) {
			$success = true;
		}

		$response_data = array(
			'http_code' => $http_code,
			'content_type' => $content_type,
			'response' => $decoded,
			'raw_body' => $raw_body,
			'curl_errno' => $curl_errno,
			'curl_error' => $curl_error,
			'json_error' => $json_error
		);

		if ($this->debug === true) {
			$response_data['request'] = $this->last_request;
		}

		return array(
			'success' => $success,
			'message' => $message,
			'data' => $response_data
		);
	}

	/**
	 * Build a message payload with shared validation.
	 *
	 * @param string $from Sender number.
	 * @param string $to Recipient number.
	 * @param string $message Message body.
	 * @param array $options Optional extra fields.
	 * @return array Normalized payload response.
	 */
	protected function buildMessagePayload($from, $to, $message, $options) {
		if (!is_string($from) || trim($from) === '') {
			return $this->errorResponse('Sender number is required.');
		}

		if (!is_string($to) || trim($to) === '') {
			return $this->errorResponse('Recipient number is required.');
		}

		if (!is_string($message)) {
			return $this->errorResponse('Message body must be a string.');
		}

		if (!is_array($options)) {
			return $this->errorResponse('Message options must be an array.');
		}

		$payload = $options;
		$payload['account_id'] = $this->account_id;
		$payload['user_id'] = $this->user_id;
		$payload['from'] = trim($from);
		$payload['to'] = trim($to);
		$payload['message'] = $message;

		return $this->successResponse('Message payload built.', $payload);
	}

	/**
	 * Perform a lookup request against a configured lookup endpoint.
	 *
	 * @param string $endpoint_key Endpoint key.
	 * @param string $number Number to look up.
	 * @param array $options Optional request fields.
	 * @return array Normalized response.
	 */
	protected function lookupByEndpoint($endpoint_key, $number, $options = array()) {
		if (!is_string($number) || trim($number) === '') {
			return $this->errorResponse('Number is required.');
		}

		$parameters = $options;
		$parameters['number'] = trim($number);
		$parameters['account_id'] = $this->account_id;
		$parameters['user_id'] = $this->user_id;

		return $this->requestEndpoint($endpoint_key, 'GET', $parameters);
	}

	/**
	 * Validate minimum credentials needed for API calls.
	 *
	 * @return array Validation response.
	 */
	protected function validateCredentials() {
		if ($this->api_base_url === '') {
			return $this->errorResponse('API base URL is required.');
		}

		if ($this->api_token === '' && $this->token_header_mode !== 'none') {
			return $this->errorResponse('API token is required.');
		}

		return $this->successResponse('Credentials available.', array());
	}

	/**
	 * Build final endpoint path by replacing account/user and supplied tokens.
	 *
	 * @param string $path Endpoint path template.
	 * @param array $replacements Path replacement values.
	 * @return string Replaced path.
	 */
	protected function buildEndpointPath($path, $replacements = array()) {
		$base_replacements = array(
			'account_id' => $this->account_id,
			'user_id' => $this->user_id
		);

		foreach ($replacements as $key => $value) {
			if (is_string($key) && (is_string($value) || is_numeric($value))) {
				$base_replacements[$key] = (string) $value;
			}
		}

		foreach ($base_replacements as $key => $value) {
			$path = str_replace('{'.$key.'}', rawurlencode($value), $path);
		}

		return $path;
	}

	/**
	 * Build URL from path or accept an absolute URL.
	 *
	 * @param string $path Relative path or absolute URL.
	 * @param bool $use_voice_base Whether to use voice base URL.
	 * @return string Request URL.
	 */
	protected function buildUrl($path, $use_voice_base = false) {
		$path = trim($path);
		if (preg_match('/^https?:\/\//i', $path) === 1) {
			return $path;
		}

		$base_url = $this->api_base_url;
		if ($use_voice_base === true && $this->voice_base_url !== '') {
			$base_url = $this->voice_base_url;
		}

		if (substr($path, 0, 1) !== '/') {
			$path = '/'.$path;
		}

		return rtrim($base_url, '/').$path;
	}

	/**
	 * Build request headers including authentication.
	 *
	 * @param array $headers Extra header lines or key/value headers.
	 * @param bool $send_json Whether JSON content type is needed.
	 * @return array Header lines.
	 */
	protected function buildHeaders($headers, $send_json = false) {
		$header_lines = array(
			'Accept: application/json'
		);

		if ($send_json === true) {
			$header_lines[] = 'Content-Type: application/json';
		}

		if ($this->token_header_mode === 'bearer' && $this->api_token !== '') {
			$header_lines[] = 'Authorization: Bearer '.$this->api_token;
		} elseif ($this->token_header_mode === 'x-api-token' && $this->api_token !== '') {
			$header_lines[] = 'X-API-Token: '.$this->api_token;
		} elseif ($this->token_header_mode === 'token' && $this->api_token !== '') {
			$header_lines[] = 'Token: '.$this->api_token;
		}

		foreach ($headers as $key => $value) {
			if (is_int($key) && is_string($value)) {
				$header_lines[] = $value;
			} elseif (is_string($key) && (is_string($value) || is_numeric($value))) {
				$header_lines[] = $key.': '.$value;
			}
		}

		return $header_lines;
	}

	/**
	 * Append query string parameters to URL.
	 *
	 * @param string $url Base URL.
	 * @param array $parameters Query parameters.
	 * @return string URL with query string.
	 */
	protected function appendQueryString($url, $parameters) {
		if ($this->token_header_mode === 'query_token' && $this->api_token !== '' && $this->query_token_name !== '') {
			$parameters[$this->query_token_name] = $this->api_token;
		}

		if (count($parameters) < 1) {
			return $url;
		}

		$query = http_build_query($parameters, '', '&');
		if ($query === '') {
			return $url;
		}

		if (strpos($url, '?') === false) {
			return $url.'?'.$query;
		}

		return $url.'&'.$query;
	}

	/**
	 * Decide whether a response should be retried.
	 *
	 * @param array $result Normalized response.
	 * @return bool Whether to retry.
	 */
	protected function shouldRetry($result) {
		if (!isset($result['data']) || !is_array($result['data'])) {
			return false;
		}

		$curl_errno = 0;
		if (isset($result['data']['curl_errno'])) {
			$curl_errno = (int) $result['data']['curl_errno'];
		}

		$http_code = 0;
		if (isset($result['data']['http_code'])) {
			$http_code = (int) $result['data']['http_code'];
		}

		if ($curl_errno !== 0) {
			return true;
		}

		if ($http_code === 408 || $http_code === 429 || ($http_code >= 500 && $http_code <= 599)) {
			return true;
		}

		return false;
	}

	/**
	 * Extract a useful response message from HTTP/cURL/API data.
	 *
	 * @param int $http_code HTTP status code.
	 * @param mixed $decoded Decoded JSON response.
	 * @param string $raw_body Raw response body.
	 * @param int $curl_errno cURL error number.
	 * @param string $curl_error cURL error string.
	 * @param string $json_error JSON decode error.
	 * @return string Response message.
	 */
	protected function extractResponseMessage($http_code, $decoded, $raw_body, $curl_errno, $curl_error, $json_error) {
		if ($curl_errno !== 0) {
			return 'cURL error '.$curl_errno.': '.$curl_error;
		}

		if (is_array($decoded)) {
			$keys = array('message', 'error', 'error_message', 'description', 'status');
			foreach ($keys as $key) {
				if (isset($decoded[$key]) && (is_string($decoded[$key]) || is_numeric($decoded[$key]))) {
					return (string) $decoded[$key];
				}
			}
		}

		if ($raw_body !== '' && $json_error !== '' && $http_code >= 400) {
			return 'HTTP '.$http_code.' response was not valid JSON: '.$json_error;
		}

		if ($http_code >= 200 && $http_code < 300) {
			return 'Commio request completed.';
		}

		return 'Commio request returned HTTP '.$http_code.'.';
	}

	/**
	 * Parse JSON or form webhook payload.
	 *
	 * @param array|string $payload Raw or parsed payload.
	 * @param string $content_type HTTP content type.
	 * @return array Normalized parsed payload.
	 */
	protected function parseWebhookPayload($payload, $content_type = '') {
		if (is_array($payload)) {
			return $this->successResponse('Webhook payload parsed.', $payload);
		}

		if (!is_string($payload)) {
			return $this->errorResponse('Webhook payload must be an array or string.');
		}

		$content_type = strtolower($content_type);
		$data = array();

		if (strpos($content_type, 'json') !== false || substr(trim($payload), 0, 1) === '{') {
			$data = json_decode($payload, true);
			if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
				return $this->errorResponse('Unable to parse webhook JSON: '.json_last_error_msg());
			}
		} else {
			parse_str($payload, $data);
			if (!is_array($data)) {
				$data = array();
			}
		}

		return $this->successResponse('Webhook payload parsed.', $data);
	}

	/**
	 * Return the first populated value for known payload keys.
	 *
	 * @param array $data Payload data.
	 * @param array $keys Candidate keys.
	 * @param string $default Default value.
	 * @return mixed First available value.
	 */
	protected function firstValue($data, $keys, $default = '') {
		foreach ($keys as $key) {
			if (isset($data[$key]) && $data[$key] !== '') {
				return $data[$key];
			}
		}

		return $default;
	}

	/**
	 * Extract media-like fields from webhook payloads.
	 *
	 * @param array $data Payload data.
	 * @return array Media values.
	 */
	protected function extractMediaFields($data) {
		$media = array();
		foreach ($data as $key => $value) {
			if (stripos($key, 'media') !== false || stripos($key, 'attachment') !== false || stripos($key, 'mms') !== false) {
				$media[$key] = $value;
			}
		}

		return $media;
	}

	/**
	 * Build number path replacement safely.
	 *
	 * @param string $number Phone number.
	 * @return array Replacement response.
	 */
	protected function buildNumberReplacement($number) {
		if (!is_string($number) || trim($number) === '') {
			return $this->errorResponse('Number is required.');
		}

		return $this->successResponse('Number replacement built.', array('number' => trim($number)));
	}

	/**
	 * Build a generic identifier replacement safely.
	 *
	 * @param string $key Replacement key.
	 * @param string $value Replacement value.
	 * @param string $error_message Error message.
	 * @return array Replacement response.
	 */
	protected function buildIdentifierReplacement($key, $value, $error_message) {
		if (!is_string($value) || trim($value) === '') {
			return $this->errorResponse($error_message);
		}

		return $this->successResponse('Identifier replacement built.', array($key => trim($value)));
	}

	/**
	 * Get a request header from a case-insensitive header array.
	 *
	 * @param array $headers Headers.
	 * @param string $name Header name.
	 * @return string Header value or empty string.
	 */
	protected function getHeaderValue($headers, $name) {
		foreach ($headers as $key => $value) {
			if (is_string($key) && strtolower($key) === strtolower($name)) {
				return (string) $value;
			}
		}

		$name_prefix = strtolower($name).':';
		foreach ($headers as $value) {
			if (is_string($value) && stripos($value, $name_prefix) === 0) {
				return trim(substr($value, strlen($name_prefix)));
			}
		}

		return '';
	}

	/**
	 * Build a sorted parameter string for signature validation.
	 *
	 * @param array $parameters Request parameters.
	 * @return string Sorted parameter string.
	 */
	protected function buildSortedParameterString($parameters) {
		if (!is_array($parameters) || count($parameters) < 1) {
			return '';
		}

		ksort($parameters);
		$out = '';
		foreach ($parameters as $key => $value) {
			if (is_scalar($value)) {
				$out .= $key.$value;
			}
		}

		return $out;
	}

	/**
	 * Redact sensitive URL values.
	 *
	 * @param string $url URL.
	 * @return string Redacted URL.
	 */
	protected function redactUrl($url) {
		if ($this->api_token !== '') {
			$url = str_replace(rawurlencode($this->api_token), '[redacted]', $url);
			$url = str_replace($this->api_token, '[redacted]', $url);
		}

		return $url;
	}

	/**
	 * Redact sensitive headers.
	 *
	 * @param array $headers Header lines.
	 * @return array Redacted header lines.
	 */
	protected function redactHeaders($headers) {
		$out = array();
		foreach ($headers as $header) {
			if (!is_string($header)) {
				continue;
			}

			if (stripos($header, 'authorization:') === 0 || stripos($header, 'x-api-token:') === 0 || stripos($header, 'token:') === 0) {
				$out[] = preg_replace('/:.*/', ': [redacted]', $header);
			} else {
				$out[] = $header;
			}
		}

		return $out;
	}

	/**
	 * Redact sensitive array fields.
	 *
	 * @param array $data Array to redact.
	 * @return array Redacted array.
	 */
	protected function redactArray($data) {
		$out = array();
		foreach ($data as $key => $value) {
			$key_lower = strtolower((string) $key);
			if (strpos($key_lower, 'token') !== false || strpos($key_lower, 'secret') !== false || strpos($key_lower, 'password') !== false) {
				$out[$key] = '[redacted]';
			} elseif (is_array($value)) {
				$out[$key] = $this->redactArray($value);
			} else {
				$out[$key] = $value;
			}
		}

		return $out;
	}

	/**
	 * Build a standard success response.
	 *
	 * @param string $message Response message.
	 * @param array $data Response data.
	 * @return array Response array.
	 */
	protected function successResponse($message, $data = array()) {
		return array(
			'success' => true,
			'message' => $message,
			'data' => $data
		);
	}

	/**
	 * Build a standard error response.
	 *
	 * @param string $message Error message.
	 * @param array $data Optional error data.
	 * @return array Response array.
	 */
	protected function errorResponse($message, $data = array()) {
		return array(
			'success' => false,
			'message' => $message,
			'data' => $data
		);
	}
}