Skip to content
← Back to Objects
Code

Twilio Messaging and Voice Helper

Send texts, place calls, verify users, manage phone numbers, build simple TwiML replies, handle conversations, and check Twilio webhooks from PHP.

Object signature

new PhpogTwilioRestApiClient($config)

Classification

TypeCommunications Helper ObjectUsage levelProduction

Categories

  • APIs and Webhooks
  • Email and Messaging
  • Developer Utilities

Compatibility

Works with Twilio accounts that allow account credentials or API key credentials. Use the simple methods for common communication work, and use raw request methods for advanced Twilio products enabled on the account.

Constructor parameters

Account SIDTwilio Account SID used for classic account-scoped 2010 API resources.Auth tokenTwilio Auth Token. Required for classic REST authentication and webhook signature validation.API key SIDTwilio API Key SID. Preferred username for REST authentication when paired with api_key_secret.API key secretTwilio API Key Secret. Store outside the public web root and never echo it.Base URLBase URL for Twilio's classic REST API. Product wrappers use their own product hosts.API versionClassic Twilio REST API version used for /Accounts/{AccountSid}/ resources.RegionOptional Twilio region code for region-aware classic API hosts when configured.EdgeOptional Twilio edge code for edge/region-aware classic API hosts when configured.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 customer messagesUse this for SMS, MMS, or enabled channel messaging when your sender setup and opt-in rules are already handled.Add voice callsGood fits include placing outbound calls, updating active calls, reading recordings, and working with conferences.Verify usersUse the Verify helpers when your app needs a clean PHP wrapper around user verification flows.Keep webhook handling saferUse the validation helpers before trusting inbound Twilio webhook data.

When not to use it

No consent or sender setupDo not send messages until opt-in, sender registration, and channel rules are handled outside this object.Anonymous send formsDo not expose send or call methods directly to public forms. Add login, authorization, CSRF checks, throttling, and audit logs.Replacing Twilio account policyThis object moves requests. Your app must still enforce account rules, billing limits, consent, and data-retention policy.

How it works

Add Twilio credentialsPass account credentials or API key credentials from private config.Call a communication helperUse simple methods for messages, calls, Verify, Lookup, phone numbers, Conversations, TwiML, and webhooks.The object sends REST requestsIt uses HTTPS, basic authentication, timeout controls, retry handling, and JSON decoding.You get a clean resultResponses are normalized into a consistent success, message, data, HTTP status, and debug shape.Advanced calls stay possibleRaw request helpers let experienced users call Twilio endpoints not wrapped by a named method.

Integration notes

Keep credentials privateStore Account SID, Auth Token, API Key SID, and API Key Secret outside public web files.Respect consent rulesKeep opt-in, sender registration, throttling, and unsubscribe handling in your application workflow.Validate webhooks firstCheck Twilio signatures before updating local orders, users, messages, or call records.Log safelyAvoid storing raw responses that contain phone numbers, message bodies, recordings, or secrets unless your retention policy allows it.

Security notes

  • Keep Twilio credentials outside public web roots and never commit them to shared code repositories.
  • Prefer API Keys for REST calls and keep Auth Token access narrow because webhook validation depends on it.
  • Use TLS verification in production and validate exact webhook URLs before trusting inbound events.
  • Throttle public forms before invoking SMS, voice, Verify, or number-purchase methods.
  • Redact message bodies, phone numbers, one-time codes, and credential values from logs unless a lawful, documented retention policy requires otherwise.

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.
 */

/**
 * Twilio Messaging and Voice Helper for PHP communication workflows.
 *
 * This object helps PHP projects send messages, place calls, verify users, manage numbers, build TwiML replies, check webhooks, and reach advanced Twilio REST endpoints when needed.
 *
 * Authentication:
 * - Recommended: Twilio API Key SID and API Key Secret using HTTP Basic auth.
 * - Supported: Account SID and Auth Token using HTTP Basic auth.
 * - Webhook validation uses the Auth Token, because Twilio signs inbound
 *   webhooks with the account auth token.
 *
 * Safety notes:
 * - Store credentials outside public web roots.
 * - Keep TLS verification enabled in production.
 * - Validate phone numbers, consent, sender ownership, opt-out rules, and
 *   regional compliance before calling send/call helpers.
 * - Do not log request bodies that contain one-time codes, message bodies,
 *   customer phone numbers, or authentication tokens.
 * - Twilio product availability, channels, regions, and rate limits vary by
 *   account, country, product, and sender registration status.
 *
 * @package PHPOG\Objects
 */
class PhpogTwilioRestApiClient {
	/**
	 * Twilio Account SID used in classic 2010 account-scoped resources.
	 *
	 * @var string
	 */
	protected $account_sid = '';

	/**
	 * Twilio Auth Token. Required for classic auth and webhook validation.
	 *
	 * @var string
	 */
	protected $auth_token = '';

	/**
	 * Twilio API Key SID. Preferred username for REST API Basic auth.
	 *
	 * @var string
	 */
	protected $api_key_sid = '';

	/**
	 * Twilio API Key Secret. Preferred password for REST API Basic auth.
	 *
	 * @var string
	 */
	protected $api_key_secret = '';

	/**
	 * Base host for Twilio's classic REST API.
	 *
	 * @var string
	 */
	protected $base_url = 'https://api.twilio.com';

	/**
	 * Classic REST API version used for account-scoped calls.
	 *
	 * @var string
	 */
	protected $api_version = '2010-04-01';

	/**
	 * Optional Twilio region code, such as ie1, used for region-specific hosts.
	 *
	 * @var string
	 */
	protected $region = '';

	/**
	 * Optional Twilio edge code used with region/edge-aware hosts.
	 *
	 * @var string
	 */
	protected $edge = '';

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

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

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

	/**
	 * HTTP user agent sent to Twilio.
	 *
	 * @var string
	 */
	protected $user_agent = 'PHPOG Twilio REST 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 Twilio REST API client.
	 *
	 * Recognized config keys: account_sid, auth_token, api_key_sid,
	 * api_key_secret, base_url, api_version, region, edge, timeout_seconds,
	 * connect_timeout_seconds, verify_peer, user_agent, debug, and max_retries.
	 *
	 * @param array $config Client configuration values.
	 */
	public function __construct($config = array()) {
		if (!is_array($config)) {
			$config = array();
		}

		if (!empty($config['account_sid'])) {
			$this->account_sid = trim((string)$config['account_sid']);
		}

		if (!empty($config['auth_token'])) {
			$this->auth_token = trim((string)$config['auth_token']);
		}

		if (!empty($config['api_key_sid'])) {
			$this->api_key_sid = trim((string)$config['api_key_sid']);
		}

		if (!empty($config['api_key_secret'])) {
			$this->api_key_secret = trim((string)$config['api_key_secret']);
		}

		if (!empty($config['base_url'])) {
			$this->base_url = $this->normalizeBaseUrl($config['base_url']);
		}

		if (!empty($config['api_version'])) {
			$this->api_version = trim((string)$config['api_version']);
		}

		if (!empty($config['region'])) {
			$this->region = $this->cleanHostPart($config['region']);
		}

		if (!empty($config['edge'])) {
			$this->edge = $this->cleanHostPart($config['edge']);
		}

		if (!empty($config['timeout_seconds'])) {
			$this->timeout_seconds = (int)$config['timeout_seconds'];
		}

		if (!empty($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 (!empty($config['user_agent'])) {
			$this->user_agent = trim((string)$config['user_agent']);
		}

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

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

		if ($this->timeout_seconds < 1) {
			$this->timeout_seconds = 30;
		}

		if ($this->connect_timeout_seconds < 1) {
			$this->connect_timeout_seconds = 10;
		}
	}

	/**
	 * Set classic Account SID/Auth Token credentials.
	 *
	 * @param string $account_sid Twilio Account SID.
	 * @param string $auth_token Twilio Auth Token.
	 * @return $this
	 */
	public function setAccountCredentials($account_sid, $auth_token) {
		$this->account_sid = trim((string)$account_sid);
		$this->auth_token = trim((string)$auth_token);
		return $this;
	}

	/**
	 * Set preferred API Key credentials for REST calls.
	 *
	 * The Account SID is still required for classic account-scoped resource paths.
	 *
	 * @param string $account_sid Twilio Account SID.
	 * @param string $api_key_sid Twilio API Key SID.
	 * @param string $api_key_secret Twilio API Key Secret.
	 * @return $this
	 */
	public function setApiKeyCredentials($account_sid, $api_key_sid, $api_key_secret) {
		$this->account_sid = trim((string)$account_sid);
		$this->api_key_sid = trim((string)$api_key_sid);
		$this->api_key_secret = trim((string)$api_key_secret);
		return $this;
	}

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

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

	/**
	 * Call any Twilio REST endpoint by absolute or relative path.
	 *
	 * Use this method for endpoints that do not yet have named wrappers. Relative
	 * paths are resolved against the configured Twilio base URL. Absolute HTTPS
	 * URLs are used as supplied after validation.
	 *
	 * @param string $method HTTP method.
	 * @param string $path Absolute URL or relative API path.
	 * @param array $parameters Request query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to send parameters as JSON.
	 * @return array Normalized PHPOG result.
	 */
	public function rawRestRequest($method, $path, $parameters = array(), $headers = array(), $send_json = false) {
		return $this->request($method, $path, $parameters, $headers, $send_json);
	}

	/**
	 * Call a classic /2010-04-01/Accounts/{AccountSid}/ resource path.
	 *
	 * @param string $method HTTP method.
	 * @param string $resource_path Resource path below the account SID.
	 * @param array $parameters Request query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to send parameters as JSON.
	 * @return array Normalized PHPOG result.
	 */
	public function requestAccountResource($method, $resource_path, $parameters = array(), $headers = array(), $send_json = false) {
		if ($this->account_sid === '') {
			return $this->buildError('Account SID is required for account-scoped Twilio resources.', array());
		}

		$resource_path = '/' . trim((string)$resource_path, '/');
		$path = '/' . trim($this->api_version, '/') . '/Accounts/' . rawurlencode($this->account_sid) . $resource_path;
		return $this->request($method, $path, $parameters, $headers, $send_json);
	}

	/**
	 * Call a Twilio product host such as conversations.twilio.com or lookups.twilio.com.
	 *
	 * @param string $host Product host without scheme or with full HTTPS scheme.
	 * @param string $method HTTP method.
	 * @param string $path Product API path.
	 * @param array $parameters Request query/body parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether to send parameters as JSON.
	 * @return array Normalized PHPOG result.
	 */
	public function requestProductResource($host, $method, $path, $parameters = array(), $headers = array(), $send_json = false) {
		$host = trim((string)$host);
		if ($host === '') {
			return $this->buildError('Product host is required.', array());
		}

		if (stripos($host, 'https://') !== 0) {
			$host = 'https://' . $host;
		}

		$path = '/' . ltrim((string)$path, '/');
		return $this->request($method, rtrim($host, '/') . $path, $parameters, $headers, $send_json);
	}

	/**
	 * Send an SMS, MMS, or channel message through Programmable Messaging.
	 *
	 * Common options include MediaUrl, MessagingServiceSid, StatusCallback,
	 * ApplicationSid, MaxPrice, ProvideFeedback, ValidityPeriod, and SmartEncoded.
	 *
	 * @param string $from Twilio sender, Messaging Service SID, or channel sender.
	 * @param string $to Destination number or channel address.
	 * @param string $body Message body.
	 * @param array $options Additional Twilio message parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function sendMessage($from, $to, $body, $options = array()) {
		$parameters = $this->mergeParameters(array(
			'From' => (string)$from,
			'To' => (string)$to,
			'Body' => (string)$body
		), $options);

		return $this->requestAccountResource('POST', '/Messages.json', $parameters);
	}

	/**
	 * Send a WhatsApp message through Twilio Programmable Messaging.
	 *
	 * Twilio expects WhatsApp addresses to use the whatsapp: prefix.
	 *
	 * @param string $from WhatsApp-enabled Twilio sender.
	 * @param string $to Destination WhatsApp number.
	 * @param string $body Message body.
	 * @param array $options Additional Twilio message parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function sendWhatsappMessage($from, $to, $body, $options = array()) {
		if (stripos($from, 'whatsapp:') !== 0) {
			$from = 'whatsapp:' . $from;
		}

		if (stripos($to, 'whatsapp:') !== 0) {
			$to = 'whatsapp:' . $to;
		}

		return $this->sendMessage($from, $to, $body, $options);
	}

	/**
	 * List messages with optional filters such as To, From, DateSent, PageSize.
	 *
	 * @param array $filters Twilio list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listMessages($filters = array()) {
		return $this->requestAccountResource('GET', '/Messages.json', $filters);
	}

	/**
	 * Fetch a single message by Message SID.
	 *
	 * @param string $message_sid Message SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchMessage($message_sid) {
		return $this->requestAccountResource('GET', '/Messages/' . rawurlencode((string)$message_sid) . '.json');
	}

	/**
	 * Update a message resource, commonly for feedback/status callback changes.
	 *
	 * @param string $message_sid Message SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateMessage($message_sid, $parameters = array()) {
		return $this->requestAccountResource('POST', '/Messages/' . rawurlencode((string)$message_sid) . '.json', $parameters);
	}

	/**
	 * Delete a message record where the account/API permits it.
	 *
	 * @param string $message_sid Message SID.
	 * @return array Normalized PHPOG result.
	 */
	public function deleteMessage($message_sid) {
		return $this->requestAccountResource('DELETE', '/Messages/' . rawurlencode((string)$message_sid) . '.json');
	}

	/**
	 * List media records attached to a message.
	 *
	 * @param string $message_sid Message SID.
	 * @param array $filters Optional list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listMessageMedia($message_sid, $filters = array()) {
		return $this->requestAccountResource('GET', '/Messages/' . rawurlencode((string)$message_sid) . '/Media.json', $filters);
	}

	/**
	 * Fetch one media record attached to a message.
	 *
	 * @param string $message_sid Message SID.
	 * @param string $media_sid Media SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchMessageMedia($message_sid, $media_sid) {
		$path = '/Messages/' . rawurlencode((string)$message_sid) . '/Media/' . rawurlencode((string)$media_sid) . '.json';
		return $this->requestAccountResource('GET', $path);
	}

	/**
	 * Delete one media record attached to a message.
	 *
	 * @param string $message_sid Message SID.
	 * @param string $media_sid Media SID.
	 * @return array Normalized PHPOG result.
	 */
	public function deleteMessageMedia($message_sid, $media_sid) {
		$path = '/Messages/' . rawurlencode((string)$message_sid) . '/Media/' . rawurlencode((string)$media_sid) . '.json';
		return $this->requestAccountResource('DELETE', $path);
	}

	/**
	 * Create an outbound voice call.
	 *
	 * @param string $from Twilio caller ID.
	 * @param string $to Destination number or client address.
	 * @param string $url TwiML URL for call instructions.
	 * @param array $options Additional call parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function createCall($from, $to, $url, $options = array()) {
		$parameters = $this->mergeParameters(array(
			'From' => (string)$from,
			'To' => (string)$to,
			'Url' => (string)$url
		), $options);

		return $this->requestAccountResource('POST', '/Calls.json', $parameters);
	}

	/**
	 * List voice calls with optional filters.
	 *
	 * @param array $filters Twilio list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listCalls($filters = array()) {
		return $this->requestAccountResource('GET', '/Calls.json', $filters);
	}

	/**
	 * Fetch a single voice call by Call SID.
	 *
	 * @param string $call_sid Call SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchCall($call_sid) {
		return $this->requestAccountResource('GET', '/Calls/' . rawurlencode((string)$call_sid) . '.json');
	}

	/**
	 * Update an active call, commonly to redirect, complete, or cancel it.
	 *
	 * @param string $call_sid Call SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateCall($call_sid, $parameters = array()) {
		return $this->requestAccountResource('POST', '/Calls/' . rawurlencode((string)$call_sid) . '.json', $parameters);
	}

	/**
	 * List recordings for the account or a specific call.
	 *
	 * @param array $filters Optional filters. Use CallSid to narrow by call.
	 * @return array Normalized PHPOG result.
	 */
	public function listRecordings($filters = array()) {
		return $this->requestAccountResource('GET', '/Recordings.json', $filters);
	}

	/**
	 * Fetch one recording by Recording SID.
	 *
	 * @param string $recording_sid Recording SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchRecording($recording_sid) {
		return $this->requestAccountResource('GET', '/Recordings/' . rawurlencode((string)$recording_sid) . '.json');
	}

	/**
	 * Delete a recording by Recording SID.
	 *
	 * @param string $recording_sid Recording SID.
	 * @return array Normalized PHPOG result.
	 */
	public function deleteRecording($recording_sid) {
		return $this->requestAccountResource('DELETE', '/Recordings/' . rawurlencode((string)$recording_sid) . '.json');
	}

	/**
	 * List conferences.
	 *
	 * @param array $filters Twilio conference filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listConferences($filters = array()) {
		return $this->requestAccountResource('GET', '/Conferences.json', $filters);
	}

	/**
	 * Fetch a conference by Conference SID.
	 *
	 * @param string $conference_sid Conference SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchConference($conference_sid) {
		return $this->requestAccountResource('GET', '/Conferences/' . rawurlencode((string)$conference_sid) . '.json');
	}

	/**
	 * Update a conference by Conference SID.
	 *
	 * @param string $conference_sid Conference SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateConference($conference_sid, $parameters = array()) {
		return $this->requestAccountResource('POST', '/Conferences/' . rawurlencode((string)$conference_sid) . '.json', $parameters);
	}

	/**
	 * List participants in a conference.
	 *
	 * @param string $conference_sid Conference SID.
	 * @param array $filters Optional participant filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listConferenceParticipants($conference_sid, $filters = array()) {
		$path = '/Conferences/' . rawurlencode((string)$conference_sid) . '/Participants.json';
		return $this->requestAccountResource('GET', $path, $filters);
	}

	/**
	 * Fetch a conference participant.
	 *
	 * @param string $conference_sid Conference SID.
	 * @param string $call_sid Participant call SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchConferenceParticipant($conference_sid, $call_sid) {
		$path = '/Conferences/' . rawurlencode((string)$conference_sid) . '/Participants/' . rawurlencode((string)$call_sid) . '.json';
		return $this->requestAccountResource('GET', $path);
	}

	/**
	 * Update a conference participant.
	 *
	 * @param string $conference_sid Conference SID.
	 * @param string $call_sid Participant call SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateConferenceParticipant($conference_sid, $call_sid, $parameters = array()) {
		$path = '/Conferences/' . rawurlencode((string)$conference_sid) . '/Participants/' . rawurlencode((string)$call_sid) . '.json';
		return $this->requestAccountResource('POST', $path, $parameters);
	}

	/**
	 * Search available local phone numbers for a country.
	 *
	 * @param string $country Two-letter country code.
	 * @param array $filters Search filters.
	 * @return array Normalized PHPOG result.
	 */
	public function searchAvailableLocalNumbers($country, $filters = array()) {
		$path = '/AvailablePhoneNumbers/' . rawurlencode(strtoupper((string)$country)) . '/Local.json';
		return $this->requestAccountResource('GET', $path, $filters);
	}

	/**
	 * Search available toll-free phone numbers for a country.
	 *
	 * @param string $country Two-letter country code.
	 * @param array $filters Search filters.
	 * @return array Normalized PHPOG result.
	 */
	public function searchAvailableTollFreeNumbers($country, $filters = array()) {
		$path = '/AvailablePhoneNumbers/' . rawurlencode(strtoupper((string)$country)) . '/TollFree.json';
		return $this->requestAccountResource('GET', $path, $filters);
	}

	/**
	 * Buy/provision an incoming phone number.
	 *
	 * @param array $parameters Twilio purchase parameters such as PhoneNumber or AreaCode.
	 * @return array Normalized PHPOG result.
	 */
	public function buyIncomingPhoneNumber($parameters = array()) {
		return $this->requestAccountResource('POST', '/IncomingPhoneNumbers.json', $parameters);
	}

	/**
	 * List incoming phone numbers.
	 *
	 * @param array $filters List filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listIncomingPhoneNumbers($filters = array()) {
		return $this->requestAccountResource('GET', '/IncomingPhoneNumbers.json', $filters);
	}

	/**
	 * Fetch one incoming phone number.
	 *
	 * @param string $phone_number_sid IncomingPhoneNumber SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchIncomingPhoneNumber($phone_number_sid) {
		return $this->requestAccountResource('GET', '/IncomingPhoneNumbers/' . rawurlencode((string)$phone_number_sid) . '.json');
	}

	/**
	 * Update webhook/application settings for an incoming phone number.
	 *
	 * @param string $phone_number_sid IncomingPhoneNumber SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateIncomingPhoneNumber($phone_number_sid, $parameters = array()) {
		return $this->requestAccountResource('POST', '/IncomingPhoneNumbers/' . rawurlencode((string)$phone_number_sid) . '.json', $parameters);
	}

	/**
	 * Release an incoming phone number from the account.
	 *
	 * @param string $phone_number_sid IncomingPhoneNumber SID.
	 * @return array Normalized PHPOG result.
	 */
	public function releaseIncomingPhoneNumber($phone_number_sid) {
		return $this->requestAccountResource('DELETE', '/IncomingPhoneNumbers/' . rawurlencode((string)$phone_number_sid) . '.json');
	}

	/**
	 * List Messaging Services.
	 *
	 * @param array $filters Optional list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listMessagingServices($filters = array()) {
		return $this->request('GET', '/v1/Services', $filters, array(), false, 'https://messaging.twilio.com');
	}

	/**
	 * Create a Messaging Service.
	 *
	 * @param string $friendly_name Human-readable service name.
	 * @param array $options Additional service parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function createMessagingService($friendly_name, $options = array()) {
		$parameters = $this->mergeParameters(array('FriendlyName' => (string)$friendly_name), $options);
		return $this->request('POST', '/v1/Services', $parameters, array(), false, 'https://messaging.twilio.com');
	}

	/**
	 * Fetch a Messaging Service.
	 *
	 * @param string $service_sid Messaging Service SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchMessagingService($service_sid) {
		return $this->request('GET', '/v1/Services/' . rawurlencode((string)$service_sid), array(), array(), false, 'https://messaging.twilio.com');
	}

	/**
	 * Update a Messaging Service.
	 *
	 * @param string $service_sid Messaging Service SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateMessagingService($service_sid, $parameters = array()) {
		return $this->request('POST', '/v1/Services/' . rawurlencode((string)$service_sid), $parameters, array(), false, 'https://messaging.twilio.com');
	}

	/**
	 * Delete a Messaging Service.
	 *
	 * @param string $service_sid Messaging Service SID.
	 * @return array Normalized PHPOG result.
	 */
	public function deleteMessagingService($service_sid) {
		return $this->request('DELETE', '/v1/Services/' . rawurlencode((string)$service_sid), array(), array(), false, 'https://messaging.twilio.com');
	}

	/**
	 * Add a phone number to a Messaging Service.
	 *
	 * @param string $service_sid Messaging Service SID.
	 * @param string $phone_number_sid IncomingPhoneNumber SID.
	 * @return array Normalized PHPOG result.
	 */
	public function addPhoneNumberToMessagingService($service_sid, $phone_number_sid) {
		$path = '/v1/Services/' . rawurlencode((string)$service_sid) . '/PhoneNumbers';
		return $this->request('POST', $path, array('PhoneNumberSid' => (string)$phone_number_sid), array(), false, 'https://messaging.twilio.com');
	}

	/**
	 * Perform a Twilio Lookup request for a phone number.
	 *
	 * @param string $phone_number Phone number to inspect.
	 * @param array $parameters Lookup query parameters, such as Type.
	 * @return array Normalized PHPOG result.
	 */
	public function lookupPhoneNumber($phone_number, $parameters = array()) {
		$path = '/v2/PhoneNumbers/' . rawurlencode((string)$phone_number);
		return $this->request('GET', $path, $parameters, array(), false, 'https://lookups.twilio.com');
	}

	/**
	 * List Verify Services.
	 *
	 * @param array $filters Optional list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listVerificationServices($filters = array()) {
		return $this->request('GET', '/v2/Services', $filters, array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Create a Verify Service.
	 *
	 * @param string $friendly_name Human-readable Verify Service name.
	 * @param array $options Additional service options.
	 * @return array Normalized PHPOG result.
	 */
	public function createVerificationService($friendly_name, $options = array()) {
		$parameters = $this->mergeParameters(array('FriendlyName' => (string)$friendly_name), $options);
		return $this->request('POST', '/v2/Services', $parameters, array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Fetch a Verify Service.
	 *
	 * @param string $service_sid Verify Service SID.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchVerificationService($service_sid) {
		return $this->request('GET', '/v2/Services/' . rawurlencode((string)$service_sid), array(), array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Update a Verify Service.
	 *
	 * @param string $service_sid Verify Service SID.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateVerificationService($service_sid, $parameters = array()) {
		return $this->request('POST', '/v2/Services/' . rawurlencode((string)$service_sid), $parameters, array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Delete a Verify Service.
	 *
	 * @param string $service_sid Verify Service SID.
	 * @return array Normalized PHPOG result.
	 */
	public function deleteVerificationService($service_sid) {
		return $this->request('DELETE', '/v2/Services/' . rawurlencode((string)$service_sid), array(), array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Start a Verify verification by SMS, voice, WhatsApp, email, or other channel.
	 *
	 * @param string $service_sid Verify Service SID.
	 * @param string $to Destination address.
	 * @param string $channel Verification channel.
	 * @param array $options Additional verification parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function startVerification($service_sid, $to, $channel = 'sms', $options = array()) {
		$parameters = $this->mergeParameters(array(
			'To' => (string)$to,
			'Channel' => (string)$channel
		), $options);
		$path = '/v2/Services/' . rawurlencode((string)$service_sid) . '/Verifications';
		return $this->request('POST', $path, $parameters, array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Check a Verify verification code.
	 *
	 * @param string $service_sid Verify Service SID.
	 * @param string $to Destination address.
	 * @param string $code User-submitted verification code.
	 * @param array $options Additional verification-check parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function checkVerification($service_sid, $to, $code, $options = array()) {
		$parameters = $this->mergeParameters(array(
			'To' => (string)$to,
			'Code' => (string)$code
		), $options);
		$path = '/v2/Services/' . rawurlencode((string)$service_sid) . '/VerificationCheck';
		return $this->request('POST', $path, $parameters, array(), false, 'https://verify.twilio.com');
	}

	/**
	 * Create a Conversations conversation.
	 *
	 * @param string $friendly_name Human-readable conversation name.
	 * @param array $options Additional conversation parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function createConversation($friendly_name = '', $options = array()) {
		$parameters = $options;
		if ($friendly_name !== '') {
			$parameters['FriendlyName'] = (string)$friendly_name;
		}
		return $this->request('POST', '/v1/Conversations', $parameters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * List Conversations conversations.
	 *
	 * @param array $filters Optional list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listConversations($filters = array()) {
		return $this->request('GET', '/v1/Conversations', $filters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * Fetch a conversation by SID or unique name.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @return array Normalized PHPOG result.
	 */
	public function fetchConversation($conversation_sid) {
		return $this->request('GET', '/v1/Conversations/' . rawurlencode((string)$conversation_sid), array(), array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * Update a conversation.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @param array $parameters Twilio update parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function updateConversation($conversation_sid, $parameters = array()) {
		return $this->request('POST', '/v1/Conversations/' . rawurlencode((string)$conversation_sid), $parameters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * Delete a conversation.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @return array Normalized PHPOG result.
	 */
	public function deleteConversation($conversation_sid) {
		return $this->request('DELETE', '/v1/Conversations/' . rawurlencode((string)$conversation_sid), array(), array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * Add a participant to a conversation.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @param array $parameters Participant parameters such as Identity, MessagingBinding.Address, or MessagingBinding.ProxyAddress.
	 * @return array Normalized PHPOG result.
	 */
	public function addConversationParticipant($conversation_sid, $parameters = array()) {
		$path = '/v1/Conversations/' . rawurlencode((string)$conversation_sid) . '/Participants';
		return $this->request('POST', $path, $parameters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * List conversation participants.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @param array $filters Optional list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listConversationParticipants($conversation_sid, $filters = array()) {
		$path = '/v1/Conversations/' . rawurlencode((string)$conversation_sid) . '/Participants';
		return $this->request('GET', $path, $filters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * Send a message into a conversation.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @param string $author Message author/identity.
	 * @param string $body Message body.
	 * @param array $options Additional message parameters.
	 * @return array Normalized PHPOG result.
	 */
	public function sendConversationMessage($conversation_sid, $author, $body, $options = array()) {
		$parameters = $this->mergeParameters(array(
			'Author' => (string)$author,
			'Body' => (string)$body
		), $options);
		$path = '/v1/Conversations/' . rawurlencode((string)$conversation_sid) . '/Messages';
		return $this->request('POST', $path, $parameters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * List messages in a conversation.
	 *
	 * @param string $conversation_sid Conversation SID or unique name.
	 * @param array $filters Optional list filters.
	 * @return array Normalized PHPOG result.
	 */
	public function listConversationMessages($conversation_sid, $filters = array()) {
		$path = '/v1/Conversations/' . rawurlencode((string)$conversation_sid) . '/Messages';
		return $this->request('GET', $path, $filters, array(), false, 'https://conversations.twilio.com');
	}

	/**
	 * Validate a standard Twilio x-www-form-urlencoded webhook signature.
	 *
	 * Sort all request parameters by key, concatenate URL + each value, compute
	 * HMAC-SHA1 with the Auth Token, base64 encode it, and compare to the
	 * X-Twilio-Signature header using a timing-safe comparison.
	 *
	 * @param string $url Full public webhook URL as Twilio requested it.
	 * @param array $parameters GET/POST parameters excluding files.
	 * @param string $signature X-Twilio-Signature header value.
	 * @return bool True when the signature matches.
	 */
	public function validateWebhookSignature($url, $parameters, $signature) {
		if ($this->auth_token === '') {
			return false;
		}

		if (!is_array($parameters)) {
			$parameters = array();
		}

		ksort($parameters, SORT_STRING);
		$base = (string)$url;
		foreach ($parameters as $key => $value) {
			if (is_array($value)) {
				$value = implode('', $value);
			}
			$base .= (string)$value;
		}

		$expected = base64_encode(hash_hmac('sha1', $base, $this->auth_token, true));
		return $this->timingSafeEquals($expected, (string)$signature);
	}

	/**
	 * Validate a JSON webhook using URL signature plus bodySHA256 when present.
	 *
	 * For application/json requests, Twilio includes bodySHA256 in the query
	 * string. This method checks that hash before validating the URL signature.
	 *
	 * @param string $url Full public webhook URL including bodySHA256 query string.
	 * @param string $raw_body Raw request body.
	 * @param string $signature X-Twilio-Signature header value.
	 * @return bool True when body hash and signature match.
	 */
	public function validateJsonWebhookSignature($url, $raw_body, $signature) {
		if ($this->auth_token === '') {
			return false;
		}

		$parts = parse_url((string)$url);
		$query = array();
		if (!empty($parts['query'])) {
			parse_str($parts['query'], $query);
		}

		if (empty($query['bodySHA256'])) {
			return false;
		}

		$expected_body_hash = hash('sha256', (string)$raw_body);
		if (!$this->timingSafeEquals($expected_body_hash, (string)$query['bodySHA256'])) {
			return false;
		}

		$expected = base64_encode(hash_hmac('sha1', (string)$url, $this->auth_token, true));
		return $this->timingSafeEquals($expected, (string)$signature);
	}

	/**
	 * Build a minimal TwiML messaging response.
	 *
	 * @param string $message Message body.
	 * @return string XML response body.
	 */
	public function buildMessagingResponse($message) {
		return '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . '<Response><Message>' . $this->xmlEscape($message) . '</Message></Response>';
	}

	/**
	 * Build a minimal TwiML voice Say response.
	 *
	 * @param string $message Spoken text.
	 * @param array $attributes Optional Say attributes such as voice and language.
	 * @return string XML response body.
	 */
	public function buildVoiceSayResponse($message, $attributes = array()) {
		$attribute_text = $this->buildXmlAttributes($attributes);
		return '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . '<Response><Say' . $attribute_text . '>' . $this->xmlEscape($message) . '</Say></Response>';
	}

	/**
	 * Execute an HTTP request and normalize the result.
	 *
	 * @param string $method HTTP method.
	 * @param string $path Absolute URL or relative API path.
	 * @param array $parameters Request parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether parameters are JSON encoded in the body.
	 * @param string $base_override Optional host override for product APIs.
	 * @return array Normalized PHPOG result.
	 */
	protected function request($method, $path, $parameters = array(), $headers = array(), $send_json = false, $base_override = '') {
		if (!is_array($parameters)) {
			$parameters = array();
		}

		if (!is_array($headers)) {
			$headers = array();
		}

		$method = strtoupper(trim((string)$method));
		if ($method === '') {
			$method = 'GET';
		}

		$auth = $this->getBasicAuthParts();
		if ($auth['username'] === '' || $auth['password'] === '') {
			return $this->buildError('Twilio REST credentials are required.', array('method' => $method, 'path' => $path));
		}

		$url = $this->buildUrl($path, $base_override);
		if ($url === '') {
			return $this->buildError('A valid Twilio API URL could not be built.', array('method' => $method, 'path' => $path));
		}

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

		while ($attempt < $max_attempts) {
			$attempt++;
			$response = $this->sendCurlRequest($method, $url, $parameters, $headers, $send_json, $auth, $attempt);
			if (!$this->shouldRetry($response, $attempt, $max_attempts)) {
				break;
			}
			usleep(250000 * $attempt);
		}

		$this->last_response = $response;
		return $response;
	}

	/**
	 * Send one cURL request attempt.
	 *
	 * @param string $method HTTP method.
	 * @param string $url Full URL.
	 * @param array $parameters Request parameters.
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether parameters are JSON encoded in the body.
	 * @param array $auth Basic auth parts.
	 * @param int $attempt Attempt number.
	 * @return array Normalized PHPOG result.
	 */
	protected function sendCurlRequest($method, $url, $parameters, $headers, $send_json, $auth, $attempt) {
		$curl = curl_init();
		if ($curl === false) {
			return $this->buildError('Unable to initialize cURL.', array('url' => $url, 'attempts' => $attempt));
		}

		$request_url = $url;
		$body = '';
		$request_headers = $this->buildHeaders($headers, $send_json);

		if ($method === 'GET' && !empty($parameters)) {
			$query = http_build_query($parameters, '', '&');
			if (strpos($request_url, '?') === false) {
				$request_url .= '?' . $query;
			} else {
				$request_url .= '&' . $query;
			}
		} elseif ($method !== 'GET' && $method !== 'DELETE') {
			if ($send_json) {
				$body = json_encode($parameters);
				if ($body === false) {
					$body = '{}';
				}
			} else {
				$body = http_build_query($parameters, '', '&');
			}
		}

		$this->last_request = array(
			'method' => $method,
			'url' => $this->redactUrl($request_url),
			'parameters' => $this->redactArray($parameters),
			'headers' => $this->redactHeaderList($request_headers),
			'attempt' => $attempt
		);

		curl_setopt($curl, CURLOPT_URL, $request_url);
		curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
		curl_setopt($curl, CURLOPT_HEADER, true);
		curl_setopt($curl, CURLOPT_USERPWD, $auth['username'] . ':' . $auth['password']);
		curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
		curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeout_seconds);
		curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $this->connect_timeout_seconds);
		curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $this->verify_peer);
		curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
		curl_setopt($curl, CURLOPT_USERAGENT, $this->user_agent);
		curl_setopt($curl, CURLOPT_HTTPHEADER, $request_headers);

		if ($method !== 'GET') {
			curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
			if ($body !== '') {
				curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
			}
		}

		$raw = curl_exec($curl);
		$curl_error = curl_error($curl);
		$curl_errno = curl_errno($curl);
		$http_code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
		$header_size = (int)curl_getinfo($curl, CURLINFO_HEADER_SIZE);
		$content_type = (string)curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
		curl_close($curl);

		$body_text = '';
		if (is_string($raw)) {
			$body_text = substr($raw, $header_size);
		}

		$data = array(
			'http_code' => $http_code,
			'content_type' => $content_type,
			'response' => null,
			'raw_body' => $this->debug ? $body_text : '',
			'curl_errno' => $curl_errno,
			'curl_error' => $curl_error,
			'attempts' => $attempt,
			'request' => $this->debug ? $this->last_request : array()
		);

		if ($curl_errno !== 0) {
			return array(
				'success' => false,
				'message' => 'Twilio transport error: ' . $curl_error,
				'data' => $data
			);
		}

		$response = $this->decodeResponseBody($body_text, $content_type);
		$data['response'] = $response['decoded'];

		$success = ($http_code >= 200 && $http_code < 300);
		$message = $success ? 'Twilio request completed.' : 'Twilio request failed.';

		if (is_array($response['decoded'])) {
			if (!empty($response['decoded']['message'])) {
				$message = (string)$response['decoded']['message'];
			} elseif (!empty($response['decoded']['error_message'])) {
				$message = (string)$response['decoded']['error_message'];
			}
		} elseif ($response['decoded'] !== null && !$success) {
			$message = substr((string)$response['decoded'], 0, 300);
		}

		if (!$response['ok']) {
			$success = false;
			$message = 'Twilio response could not be decoded as JSON.';
		}

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

	/**
	 * Determine whether a failed response should be retried.
	 *
	 * @param array $response Normalized response.
	 * @param int $attempt Current attempt.
	 * @param int $max_attempts Maximum attempts.
	 * @return bool True when another attempt should run.
	 */
	protected function shouldRetry($response, $attempt, $max_attempts) {
		if ($attempt >= $max_attempts) {
			return false;
		}

		if (!is_array($response) || !isset($response['data'])) {
			return false;
		}

		$data = $response['data'];
		if (!empty($data['curl_errno'])) {
			return true;
		}

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

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

		return false;
	}

	/**
	 * Build authentication username/password for Basic auth.
	 *
	 * @return array Username and password keys.
	 */
	protected function getBasicAuthParts() {
		if ($this->api_key_sid !== '' && $this->api_key_secret !== '') {
			return array(
				'username' => $this->api_key_sid,
				'password' => $this->api_key_secret
			);
		}

		return array(
			'username' => $this->account_sid,
			'password' => $this->auth_token
		);
	}

	/**
	 * Build a full URL from absolute or relative input.
	 *
	 * @param string $path Absolute URL or relative path.
	 * @param string $base_override Optional product host override.
	 * @return string Full URL or empty string.
	 */
	protected function buildUrl($path, $base_override = '') {
		$path = trim((string)$path);
		if ($path === '') {
			return '';
		}

		if (stripos($path, 'https://') === 0) {
			return $path;
		}

		if (stripos($path, 'http://') === 0) {
			return '';
		}

		$base = $base_override !== '' ? $this->normalizeBaseUrl($base_override) : $this->buildRegionalBaseUrl($this->base_url);
		return rtrim($base, '/') . '/' . ltrim($path, '/');
	}

	/**
	 * Build a regional/edge-aware base URL when configured.
	 *
	 * @param string $base Base URL.
	 * @return string Normalized base URL.
	 */
	protected function buildRegionalBaseUrl($base) {
		$base = $this->normalizeBaseUrl($base);
		if ($this->region === '' && $this->edge === '') {
			return $base;
		}

		$parts = parse_url($base);
		if (empty($parts['host'])) {
			return $base;
		}

		$prefix = '';
		if ($this->edge !== '') {
			$prefix .= $this->edge . '.';
		}
		if ($this->region !== '') {
			$prefix .= $this->region . '.';
		}

		$scheme = !empty($parts['scheme']) ? $parts['scheme'] : 'https';
		$host = $parts['host'];
		if (substr($host, -11) === '.twilio.com') {
			$host_parts = explode('.', $host);
			$product = array_shift($host_parts);
			$host = $product . '.' . rtrim($prefix, '.') . '.twilio.com';
		} else {
			$host = $prefix . $host;
		}
		$port = !empty($parts['port']) ? ':' . (int)$parts['port'] : '';
		$path = !empty($parts['path']) ? rtrim($parts['path'], '/') : '';
		return $scheme . '://' . $host . $port . $path;
	}

	/**
	 * Normalize a base URL and require HTTPS.
	 *
	 * @param string $url User-supplied base URL.
	 * @return string Normalized base URL.
	 */
	protected function normalizeBaseUrl($url) {
		$url = trim((string)$url);
		if ($url === '') {
			return 'https://api.twilio.com';
		}

		if (stripos($url, 'https://') !== 0) {
			$url = 'https://' . preg_replace('/^http:\/\//i', '', $url);
		}

		return rtrim($url, '/');
	}

	/**
	 * Clean region/edge host fragments.
	 *
	 * @param string $value Region or edge code.
	 * @return string Clean host fragment.
	 */
	protected function cleanHostPart($value) {
		$value = strtolower(trim((string)$value));
		$value = preg_replace('/[^a-z0-9-]/', '', $value);
		return $value;
	}

	/**
	 * Build HTTP headers for a request.
	 *
	 * @param array $headers Extra headers.
	 * @param bool $send_json Whether JSON is used.
	 * @return array Header list.
	 */
	protected function buildHeaders($headers, $send_json) {
		$list = array('Accept: application/json');

		if ($send_json) {
			$list[] = 'Content-Type: application/json';
		} else {
			$list[] = 'Content-Type: application/x-www-form-urlencoded';
		}

		foreach ($headers as $key => $value) {
			if (is_int($key)) {
				$list[] = (string)$value;
			} else {
				$list[] = (string)$key . ': ' . (string)$value;
			}
		}

		return $list;
	}

	/**
	 * Decode Twilio response body.
	 *
	 * @param string $body Raw body.
	 * @param string $content_type Response content type.
	 * @return array Decode status and decoded value.
	 */
	protected function decodeResponseBody($body, $content_type) {
		if ($body === '') {
			return array('ok' => true, 'decoded' => null);
		}

		$looks_json = false;
		if (stripos((string)$content_type, 'json') !== false) {
			$looks_json = true;
		}

		$trimmed = ltrim($body);
		if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
			$looks_json = true;
		}

		if ($looks_json) {
			$decoded = json_decode($body, true);
			if (json_last_error() !== JSON_ERROR_NONE) {
				return array('ok' => false, 'decoded' => null);
			}
			return array('ok' => true, 'decoded' => $decoded);
		}

		return array('ok' => true, 'decoded' => $body);
	}

	/**
	 * Merge default and caller-supplied parameters.
	 *
	 * @param array $defaults Default parameters.
	 * @param array $options Additional parameters.
	 * @return array Merged parameters.
	 */
	protected function mergeParameters($defaults, $options) {
		if (!is_array($options)) {
			$options = array();
		}

		foreach ($options as $key => $value) {
			$defaults[$key] = $value;
		}

		return $defaults;
	}

	/**
	 * Build a normalized error response.
	 *
	 * @param string $message Public-safe message.
	 * @param array $data Extra data.
	 * @return array Normalized PHPOG result.
	 */
	protected function buildError($message, $data = array()) {
		return array(
			'success' => false,
			'message' => (string)$message,
			'data' => $data
		);
	}

	/**
	 * Timing-safe string comparison with hash_equals fallback behavior.
	 *
	 * @param string $expected Expected value.
	 * @param string $actual Actual value.
	 * @return bool True when values match.
	 */
	protected function timingSafeEquals($expected, $actual) {
		$expected = (string)$expected;
		$actual = (string)$actual;

		if (function_exists('hash_equals')) {
			return hash_equals($expected, $actual);
		}

		if (strlen($expected) !== strlen($actual)) {
			return false;
		}

		$result = 0;
		$length = strlen($expected);
		for ($i = 0; $i < $length; $i++) {
			$result |= ord($expected[$i]) ^ ord($actual[$i]);
		}

		return $result === 0;
	}

	/**
	 * XML-escape a value.
	 *
	 * @param string $value Raw value.
	 * @return string Escaped value.
	 */
	protected function xmlEscape($value) {
		return htmlspecialchars((string)$value, ENT_QUOTES | ENT_XML1, 'UTF-8');
	}

	/**
	 * Build safe XML attributes.
	 *
	 * @param array $attributes Attribute map.
	 * @return string Attribute text with leading spaces.
	 */
	protected function buildXmlAttributes($attributes) {
		if (!is_array($attributes)) {
			return '';
		}

		$text = '';
		foreach ($attributes as $key => $value) {
			$key = preg_replace('/[^a-zA-Z0-9:_-]/', '', (string)$key);
			if ($key === '') {
				continue;
			}
			$text .= ' ' . $key . '="' . $this->xmlEscape($value) . '"';
		}

		return $text;
	}

	/**
	 * Redact sensitive fields from arrays before debug storage.
	 *
	 * @param array $data Input array.
	 * @return array Redacted array.
	 */
	protected function redactArray($data) {
		if (!is_array($data)) {
			return array();
		}

		$redacted = array();
		foreach ($data as $key => $value) {
			$key_text = strtolower((string)$key);
			if (strpos($key_text, 'token') !== false || strpos($key_text, 'secret') !== false || strpos($key_text, 'password') !== false || strpos($key_text, 'code') !== false || strpos($key_text, 'auth') !== false) {
				$redacted[$key] = '[redacted]';
			} elseif (is_array($value)) {
				$redacted[$key] = $this->redactArray($value);
			} else {
				$redacted[$key] = $value;
			}
		}

		return $redacted;
	}

	/**
	 * Redact sensitive query-string values from a URL.
	 *
	 * @param string $url URL.
	 * @return string Redacted URL.
	 */
	protected function redactUrl($url) {
		$parts = parse_url((string)$url);
		if (empty($parts['query'])) {
			return (string)$url;
		}

		$query = array();
		parse_str($parts['query'], $query);
		$query = $this->redactArray($query);

		$scheme = !empty($parts['scheme']) ? $parts['scheme'] . '://' : '';
		$host = !empty($parts['host']) ? $parts['host'] : '';
		$port = !empty($parts['port']) ? ':' . (int)$parts['port'] : '';
		$path = !empty($parts['path']) ? $parts['path'] : '';
		return $scheme . $host . $port . $path . '?' . http_build_query($query, '', '&');
	}

	/**
	 * Redact sensitive headers for debug snapshots.
	 *
	 * @param array $headers Header list.
	 * @return array Redacted headers.
	 */
	protected function redactHeaderList($headers) {
		$redacted = array();
		foreach ($headers as $header) {
			if (stripos((string)$header, 'authorization:') === 0) {
				$redacted[] = 'Authorization: [redacted]';
			} else {
				$redacted[] = $header;
			}
		}
		return $redacted;
	}
}