SignalWire Messaging, Voice, and Fax Helper
Use PHP to send messages, make calls, manage phone numbers, send faxes, build cXML replies, work with queues, and validate SignalWire webhooks.
Object signature
new PhpogSignalWireApiClient($config)
Classification
TypeCommunications Helper ObjectUsage levelProductionCategories
- APIs and Webhooks
- Email and Messaging
- Developer Utilities
Compatibility
Works with SignalWire Spaces using Project ID and API Token authentication. Use the simple methods for common messaging, voice, fax, and webhook work, and use raw requests for advanced Space-enabled endpoints.
Constructor parameters
Project IDSignalWire Project ID used as the HTTP Basic auth username.API tokenSignalWire API Token used as the HTTP Basic auth password. Store outside the public web root.Signing keySignalWire Signing Key from the Space API Credentials page, used for inbound webhook validation.Space URLSignalWire Space base URL.compatibility_versionCompatibility API version segment used for /api/laml/{version}/Accounts/{ProjectId}/ resources.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 messages or make callsUse this for private PHP tools that need SignalWire messaging, voice, recordings, conferences, and number workflows.Handle fax and cXML workGood fits include fax helpers, simple cXML replies, queue workflows, and application routing.Bridge older and newer APIsUse the compatibility helpers for Twilio-style workflows and raw REST helpers for newer SignalWire endpoints.Check webhook trustUse signature helpers before trusting inbound message, call, or fax webhook data.When not to use it
No sender or consent policyDo not send messages or calls until your app handles consent, sender rules, and throttling.Anonymous send formsDo not expose message, call, or fax helpers directly to public forms.Replacing account configurationThis object does not replace SignalWire Space setup, project permissions, number assignment, or product enablement.How it works
Add SignalWire settingsPass the Space URL, Project ID, API Token, and optional signing key from private config.Call a workflow helperUse simple methods for messages, calls, recordings, conferences, phone numbers, fax, applications, queues, cXML, and webhooks.The object sends HTTPS requestsIt handles authentication, 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 possibleRaw request helpers let experienced users call Space-enabled endpoints that do not need a named method yet.Integration notes
Keep credentials privateStore Project ID, API Token, and signing key outside public web files.Validate webhooks firstCheck inbound signatures before updating local messages, calls, faxes, users, or orders.Protect send actionsPut send, call, fax, and number actions behind login, authorization, CSRF checks, rate limits, and audit logs.Keep account rules in your appYour application still controls consent, sender policy, throttling, logging, and retention.Security notes
- Store Project ID, API Token, and Signing Key outside public web roots.
- Keep TLS verification enabled in production.
- Validate inbound webhook signatures before trusting message, call, or fax data.
- Keep message/call endpoints behind application authorization, CSRF checks, rate limits, and audit logging.
- Do not log tokens, signing keys, raw webhook bodies, or full customer phone numbers unless a written policy requires it.
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.
*/
/**
* SignalWire Messaging, Voice, and Fax Helper for PHP communication workflows.
*
* This object helps PHP projects send messages, make calls, manage numbers, send faxes, build cXML replies, work with queues, validate webhooks, and reach advanced SignalWire endpoints when needed.
*
* Authentication:
* - SignalWire REST calls use HTTP Basic authentication with Project ID as the
* username and API Token as the password.
* - Webhook validation uses the SignalWire Signing Key from the Space API
* Credentials page, not the REST API token.
*
* Safety notes:
* - Store Project ID, API Token, and Signing Key outside public web roots.
* - Keep TLS verification enabled in production.
* - Validate opt-in, sender registration, country rules, emergency-call policy,
* and user permissions before sending messages or placing calls.
* - Do not log tokens, signing keys, full customer phone numbers, message
* bodies, call recordings, or raw webhook bodies unless policy allows it.
* - SignalWire capabilities, endpoints, scopes, and parameter names can vary by
* product generation, account permissions, and documentation version. The raw
* passthrough methods are the coverage escape hatch for new or uncommon
* endpoints.
*
* @package PHPOG\Objects
*/
class PhpogSignalWireApiClient {
/**
* SignalWire Project ID used as the Basic auth username.
*
* @var string
*/
protected $project_id = '';
/**
* SignalWire API token used as the Basic auth password.
*
* @var string
*/
protected $api_token = '';
/**
* SignalWire Signing Key used for inbound webhook validation.
*
* @var string
*/
protected $signing_key = '';
/**
* SignalWire Space URL, such as https://example.signalwire.com.
*
* @var string
*/
protected $space_url = '';
/**
* Compatibility API version used for Twilio-compatible resources.
*
* @var string
*/
protected $compatibility_version = '2010-04-01';
/**
* 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 SignalWire.
*
* @var string
*/
protected $user_agent = 'PHPOG SignalWire 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 SignalWire API client.
*
* Recognized config keys: project_id, api_token, signing_key, space_url,
* compatibility_version, 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 (isset($config['project_id']) && is_string($config['project_id'])) {
$this->project_id = trim($config['project_id']);
}
if (isset($config['api_token']) && is_string($config['api_token'])) {
$this->api_token = $config['api_token'];
}
if (isset($config['signing_key']) && is_string($config['signing_key'])) {
$this->signing_key = $config['signing_key'];
}
if (isset($config['space_url']) && is_string($config['space_url'])) {
$this->space_url = rtrim(trim($config['space_url']), '/');
}
if (isset($config['compatibility_version']) && is_string($config['compatibility_version'])) {
$this->compatibility_version = trim($config['compatibility_version']);
}
if (isset($config['timeout_seconds'])) {
$this->timeout_seconds = $this->cleanInteger($config['timeout_seconds'], 30, 1, 300);
}
if (isset($config['connect_timeout_seconds'])) {
$this->connect_timeout_seconds = $this->cleanInteger($config['connect_timeout_seconds'], 10, 1, 120);
}
if (isset($config['verify_peer'])) {
$this->verify_peer = (bool) $config['verify_peer'];
}
if (isset($config['user_agent']) && is_string($config['user_agent']) && trim($config['user_agent']) !== '') {
$this->user_agent = trim($config['user_agent']);
}
if (isset($config['debug'])) {
$this->debug = (bool) $config['debug'];
}
if (isset($config['max_retries'])) {
$this->max_retries = $this->cleanInteger($config['max_retries'], 1, 0, 5);
}
}
/**
* Send an SMS, MMS, or channel message through the Compatibility API.
*
* Typical parameters: From, To, Body, MediaUrl, StatusCallback,
* ApplicationSid, MaxPrice, and ProvideFeedback. Sender/channel compliance is
* the caller's responsibility.
*
* @param string $from Sender number/channel address.
* @param string $to Recipient number/channel address.
* @param string $body Message body.
* @param array $options Additional Compatibility API parameters.
* @return array Normalized response.
*/
public function sendMessage($from, $to, $body, $options = array()) {
$parameters = $options;
$parameters['From'] = $from;
$parameters['To'] = $to;
$parameters['Body'] = $body;
return $this->rawCompatibilityRequest('POST', 'Messages', $parameters);
}
/**
* Send a message to a WhatsApp recipient using SignalWire/Twilio-style channel notation.
*
* @param string $from Sender WhatsApp address without or with whatsapp: prefix.
* @param string $to Recipient WhatsApp address without or with whatsapp: prefix.
* @param string $body Message body.
* @param array $options Additional message parameters.
* @return array Normalized response.
*/
public function sendWhatsappMessage($from, $to, $body, $options = array()) {
return $this->sendMessage($this->prefixChannelAddress($from, 'whatsapp'), $this->prefixChannelAddress($to, 'whatsapp'), $body, $options);
}
/**
* List message records.
*
* @param array $filters Optional Compatibility API filters.
* @return array Normalized response.
*/
public function listMessages($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Messages', $filters);
}
/**
* Fetch a single message record.
*
* @param string $message_sid Message SID.
* @return array Normalized response.
*/
public function fetchMessage($message_sid) {
return $this->rawCompatibilityRequest('GET', 'Messages/'.$this->pathSegment($message_sid));
}
/**
* Delete a message record when the account/API permits deletion.
*
* @param string $message_sid Message SID.
* @return array Normalized response.
*/
public function deleteMessage($message_sid) {
return $this->rawCompatibilityRequest('DELETE', 'Messages/'.$this->pathSegment($message_sid));
}
/**
* List media attached to a message.
*
* @param string $message_sid Message SID.
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listMessageMedia($message_sid, $filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Messages/'.$this->pathSegment($message_sid).'/Media', $filters);
}
/**
* Fetch one message media record.
*
* @param string $message_sid Message SID.
* @param string $media_sid Media SID.
* @return array Normalized response.
*/
public function fetchMessageMedia($message_sid, $media_sid) {
return $this->rawCompatibilityRequest('GET', 'Messages/'.$this->pathSegment($message_sid).'/Media/'.$this->pathSegment($media_sid));
}
/**
* Create an outbound call.
*
* Typical options: Url, Method, FallbackUrl, StatusCallback,
* StatusCallbackEvent, Timeout, Record, MachineDetection, and SipAuthUsername.
*
* @param string $from Caller ID/source.
* @param string $to Destination number/SIP address.
* @param array $options Call parameters. Url or Twiml/cXML instructions are usually required.
* @return array Normalized response.
*/
public function createCall($from, $to, $options = array()) {
$parameters = $options;
$parameters['From'] = $from;
$parameters['To'] = $to;
return $this->rawCompatibilityRequest('POST', 'Calls', $parameters);
}
/**
* List calls.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listCalls($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Calls', $filters);
}
/**
* Fetch a single call.
*
* @param string $call_sid Call SID.
* @return array Normalized response.
*/
public function fetchCall($call_sid) {
return $this->rawCompatibilityRequest('GET', 'Calls/'.$this->pathSegment($call_sid));
}
/**
* Update an active call, such as redirecting it to new cXML or completing it.
*
* @param string $call_sid Call SID.
* @param array $parameters Update parameters.
* @return array Normalized response.
*/
public function updateCall($call_sid, $parameters = array()) {
return $this->rawCompatibilityRequest('POST', 'Calls/'.$this->pathSegment($call_sid), $parameters);
}
/**
* List recordings.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listRecordings($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Recordings', $filters);
}
/**
* Fetch a recording record.
*
* @param string $recording_sid Recording SID.
* @return array Normalized response.
*/
public function fetchRecording($recording_sid) {
return $this->rawCompatibilityRequest('GET', 'Recordings/'.$this->pathSegment($recording_sid));
}
/**
* Delete a recording record and associated media where supported.
*
* @param string $recording_sid Recording SID.
* @return array Normalized response.
*/
public function deleteRecording($recording_sid) {
return $this->rawCompatibilityRequest('DELETE', 'Recordings/'.$this->pathSegment($recording_sid));
}
/**
* List conferences.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listConferences($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Conferences', $filters);
}
/**
* Fetch a conference.
*
* @param string $conference_sid Conference SID.
* @return array Normalized response.
*/
public function fetchConference($conference_sid) {
return $this->rawCompatibilityRequest('GET', 'Conferences/'.$this->pathSegment($conference_sid));
}
/**
* Update a conference.
*
* @param string $conference_sid Conference SID.
* @param array $parameters Update parameters.
* @return array Normalized response.
*/
public function updateConference($conference_sid, $parameters = array()) {
return $this->rawCompatibilityRequest('POST', 'Conferences/'.$this->pathSegment($conference_sid), $parameters);
}
/**
* List conference participants.
*
* @param string $conference_sid Conference SID.
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listConferenceParticipants($conference_sid, $filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Conferences/'.$this->pathSegment($conference_sid).'/Participants', $filters);
}
/**
* Fetch one conference participant.
*
* @param string $conference_sid Conference SID.
* @param string $participant_sid Participant call SID.
* @return array Normalized response.
*/
public function fetchConferenceParticipant($conference_sid, $participant_sid) {
return $this->rawCompatibilityRequest('GET', 'Conferences/'.$this->pathSegment($conference_sid).'/Participants/'.$this->pathSegment($participant_sid));
}
/**
* Update one conference participant.
*
* @param string $conference_sid Conference SID.
* @param string $participant_sid Participant call SID.
* @param array $parameters Update parameters such as Muted or Hold.
* @return array Normalized response.
*/
public function updateConferenceParticipant($conference_sid, $participant_sid, $parameters = array()) {
return $this->rawCompatibilityRequest('POST', 'Conferences/'.$this->pathSegment($conference_sid).'/Participants/'.$this->pathSegment($participant_sid), $parameters);
}
/**
* Search available local phone numbers through the Compatibility API.
*
* @param string $country ISO country code, usually US or CA.
* @param array $filters Search filters.
* @return array Normalized response.
*/
public function searchAvailableLocalNumbers($country = 'US', $filters = array()) {
return $this->rawCompatibilityRequest('GET', 'AvailablePhoneNumbers/'.$this->pathSegment($country).'/Local', $filters);
}
/**
* Search available toll-free phone numbers through the Compatibility API.
*
* @param string $country ISO country code, usually US or CA.
* @param array $filters Search filters.
* @return array Normalized response.
*/
public function searchAvailableTollFreeNumbers($country = 'US', $filters = array()) {
return $this->rawCompatibilityRequest('GET', 'AvailablePhoneNumbers/'.$this->pathSegment($country).'/TollFree', $filters);
}
/**
* Purchase/provision an incoming phone number through the Compatibility API.
*
* @param string $phone_number E.164 phone number.
* @param array $options Voice/SMS/fax webhook and handler options.
* @return array Normalized response.
*/
public function createIncomingPhoneNumber($phone_number, $options = array()) {
$parameters = $options;
$parameters['PhoneNumber'] = $phone_number;
return $this->rawCompatibilityRequest('POST', 'IncomingPhoneNumbers', $parameters);
}
/**
* List incoming phone numbers.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listIncomingPhoneNumbers($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'IncomingPhoneNumbers', $filters);
}
/**
* Fetch an incoming phone number record.
*
* @param string $phone_number_sid Phone-number SID.
* @return array Normalized response.
*/
public function fetchIncomingPhoneNumber($phone_number_sid) {
return $this->rawCompatibilityRequest('GET', 'IncomingPhoneNumbers/'.$this->pathSegment($phone_number_sid));
}
/**
* Update an incoming phone number.
*
* @param string $phone_number_sid Phone-number SID.
* @param array $parameters Update parameters.
* @return array Normalized response.
*/
public function updateIncomingPhoneNumber($phone_number_sid, $parameters = array()) {
return $this->rawCompatibilityRequest('POST', 'IncomingPhoneNumbers/'.$this->pathSegment($phone_number_sid), $parameters);
}
/**
* Release/delete an incoming phone number where supported and authorized.
*
* @param string $phone_number_sid Phone-number SID.
* @return array Normalized response.
*/
public function releaseIncomingPhoneNumber($phone_number_sid) {
return $this->rawCompatibilityRequest('DELETE', 'IncomingPhoneNumbers/'.$this->pathSegment($phone_number_sid));
}
/**
* List cXML/LAML applications.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listApplications($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Applications', $filters);
}
/**
* Create a cXML/LAML application.
*
* @param string $friendly_name Friendly application name.
* @param array $options Voice/SMS/Fax URL options.
* @return array Normalized response.
*/
public function createApplication($friendly_name, $options = array()) {
$parameters = $options;
$parameters['FriendlyName'] = $friendly_name;
return $this->rawCompatibilityRequest('POST', 'Applications', $parameters);
}
/**
* Fetch one application.
*
* @param string $application_sid Application SID.
* @return array Normalized response.
*/
public function fetchApplication($application_sid) {
return $this->rawCompatibilityRequest('GET', 'Applications/'.$this->pathSegment($application_sid));
}
/**
* Update one application.
*
* @param string $application_sid Application SID.
* @param array $parameters Update parameters.
* @return array Normalized response.
*/
public function updateApplication($application_sid, $parameters = array()) {
return $this->rawCompatibilityRequest('POST', 'Applications/'.$this->pathSegment($application_sid), $parameters);
}
/**
* Delete one application.
*
* @param string $application_sid Application SID.
* @return array Normalized response.
*/
public function deleteApplication($application_sid) {
return $this->rawCompatibilityRequest('DELETE', 'Applications/'.$this->pathSegment($application_sid));
}
/**
* Send a fax where the account/product exposes Compatibility API fax support.
*
* @param string $from Sender fax number.
* @param string $to Recipient fax number.
* @param string $media_url PDF/TIFF/document media URL.
* @param array $options Additional fax parameters.
* @return array Normalized response.
*/
public function sendFax($from, $to, $media_url, $options = array()) {
$parameters = $options;
$parameters['From'] = $from;
$parameters['To'] = $to;
$parameters['MediaUrl'] = $media_url;
return $this->rawCompatibilityRequest('POST', 'Faxes', $parameters);
}
/**
* List faxes.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listFaxes($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Faxes', $filters);
}
/**
* Fetch one fax.
*
* @param string $fax_sid Fax SID.
* @return array Normalized response.
*/
public function fetchFax($fax_sid) {
return $this->rawCompatibilityRequest('GET', 'Faxes/'.$this->pathSegment($fax_sid));
}
/**
* Delete one fax record where supported.
*
* @param string $fax_sid Fax SID.
* @return array Normalized response.
*/
public function deleteFax($fax_sid) {
return $this->rawCompatibilityRequest('DELETE', 'Faxes/'.$this->pathSegment($fax_sid));
}
/**
* List queues.
*
* @param array $filters Optional filters.
* @return array Normalized response.
*/
public function listQueues($filters = array()) {
return $this->rawCompatibilityRequest('GET', 'Queues', $filters);
}
/**
* Fetch a queue.
*
* @param string $queue_sid Queue SID.
* @return array Normalized response.
*/
public function fetchQueue($queue_sid) {
return $this->rawCompatibilityRequest('GET', 'Queues/'.$this->pathSegment($queue_sid));
}
/**
* Delete a queue where supported.
*
* @param string $queue_sid Queue SID.
* @return array Normalized response.
*/
public function deleteQueue($queue_sid) {
return $this->rawCompatibilityRequest('DELETE', 'Queues/'.$this->pathSegment($queue_sid));
}
/**
* List phone numbers through the modern REST API.
*
* @param array $filters Optional filters accepted by SignalWire REST.
* @return array Normalized response.
*/
public function restListPhoneNumbers($filters = array()) {
return $this->rawRestRequest('GET', '/phone_numbers', $filters);
}
/**
* Search available phone numbers through the modern REST API.
*
* @param array $filters Search filters accepted by SignalWire REST.
* @return array Normalized response.
*/
public function restSearchAvailablePhoneNumbers($filters = array()) {
return $this->rawRestRequest('GET', '/phone_numbers/search', $filters);
}
/**
* Purchase a phone number through the modern REST API.
*
* @param string $number E.164 number to purchase.
* @param array $options Phone-number configuration options.
* @return array Normalized response.
*/
public function restPurchasePhoneNumber($number, $options = array()) {
$payload = $options;
$payload['number'] = $number;
return $this->rawRestRequest('POST', '/phone_numbers', $payload, array(), true);
}
/**
* Fetch a phone number through the modern REST API.
*
* @param string $phone_number_id Phone-number ID.
* @return array Normalized response.
*/
public function restFetchPhoneNumber($phone_number_id) {
return $this->rawRestRequest('GET', '/phone_numbers/'.$this->pathSegment($phone_number_id));
}
/**
* Update a phone number through the modern REST API.
*
* @param string $phone_number_id Phone-number ID.
* @param array $payload Update body.
* @return array Normalized response.
*/
public function restUpdatePhoneNumber($phone_number_id, $payload = array()) {
return $this->rawRestRequest('PATCH', '/phone_numbers/'.$this->pathSegment($phone_number_id), $payload, array(), true);
}
/**
* Delete/release a phone number through the modern REST API.
*
* @param string $phone_number_id Phone-number ID.
* @return array Normalized response.
*/
public function restDeletePhoneNumber($phone_number_id) {
return $this->rawRestRequest('DELETE', '/phone_numbers/'.$this->pathSegment($phone_number_id));
}
/**
* Send a modern REST API request to a SignalWire Space endpoint.
*
* This method is the advanced passthrough for current and future
* SignalWire REST endpoints under /api/rest, including resources not yet
* represented by named wrappers.
*
* @param string $method HTTP method.
* @param string $path REST path, with or without a leading slash.
* @param array $parameters Query/body parameters.
* @param array $headers Additional headers.
* @param bool $send_json Whether to JSON-encode the request body.
* @return array Normalized response.
*/
public function rawRestRequest($method, $path, $parameters = array(), $headers = array(), $send_json = false) {
$base = $this->buildSpaceUrl().'/api/rest';
$url = $base.'/'.$this->cleanRelativePath($path);
return $this->request($method, $url, $parameters, $headers, $send_json);
}
/**
* Send a Compatibility API request to a SignalWire Space endpoint.
*
* This is the advanced passthrough for Twilio-compatible SignalWire REST
* resources. The resource path should omit the .json suffix; it is added by
* this method when missing.
*
* @param string $method HTTP method.
* @param string $resource_path Compatibility resource path.
* @param array $parameters Query/body parameters.
* @param array $headers Additional headers.
* @return array Normalized response.
*/
public function rawCompatibilityRequest($method, $resource_path, $parameters = array(), $headers = array()) {
$path = $this->cleanRelativePath($resource_path);
if (substr($path, -5) !== '.json') {
$path .= '.json';
}
$url = $this->buildSpaceUrl().'/api/laml/'.$this->pathSegment($this->compatibility_version).'/Accounts/'.$this->pathSegment($this->project_id).'/'.$path;
return $this->request($method, $url, $parameters, $headers, false);
}
/**
* Validate a SignalWire form-encoded Compatibility API webhook signature.
*
* SignalWire's Compatibility API validator signs the exact public URL and the
* request parameters/body. This helper follows the Twilio-compatible form
* validation pattern for POST/GET parameter arrays.
*
* @param string $url Exact public URL SignalWire requested.
* @param array $parameters Request parameters.
* @param string $signature Value from X-SignalWire-Signature or X-Twilio-Signature.
* @param string $signing_key Optional override signing key.
* @return bool True when the signature matches.
*/
public function validateRequestSignature($url, $parameters, $signature, $signing_key = '') {
$key = $signing_key;
if ($key === '') {
$key = $this->signing_key;
}
if ($url === '' || $signature === '' || $key === '') {
return false;
}
if (!is_array($parameters)) {
return false;
}
ksort($parameters);
$payload = $url;
foreach ($parameters as $name => $value) {
if (is_array($value)) {
$value = implode(',', $value);
}
$payload .= (string) $name.(string) $value;
}
$expected = base64_encode(hash_hmac('sha1', $payload, $key, true));
return $this->safeCompare($expected, $signature);
}
/**
* Validate a SignalWire raw-body webhook signature.
*
* Newer SignalWire platform docs reference raw-body validation for some JSON
* webhooks. This helper computes an HMAC-SHA256 over URL + raw body. If your
* account/product documentation states a different exact signing string, use
* validateCustomWebhookSignature() or the official server SDK for that product.
*
* @param string $url Exact public URL SignalWire requested.
* @param string $raw_body Unmodified raw request body.
* @param string $signature Value from X-SignalWire-Signature.
* @param string $signing_key Optional override signing key.
* @return bool True when the signature matches.
*/
public function validateRawBodySignature($url, $raw_body, $signature, $signing_key = '') {
$key = $signing_key;
if ($key === '') {
$key = $this->signing_key;
}
if ($url === '' || $signature === '' || $key === '') {
return false;
}
$expected_hex = hash_hmac('sha256', $url.$raw_body, $key);
$expected_base64 = base64_encode(hash_hmac('sha256', $url.$raw_body, $key, true));
if ($this->safeCompare($expected_hex, $signature)) {
return true;
}
return $this->safeCompare($expected_base64, $signature);
}
/**
* Validate a custom webhook signature with a caller-supplied payload string.
*
* Use this for product-specific webhook formats when documentation specifies
* the exact signed payload differently from Compatibility API validation.
*
* @param string $payload Exact string to sign.
* @param string $signature Received signature.
* @param string $algorithm Hash algorithm, usually sha1 or sha256.
* @param string $encoding hex or base64.
* @param string $signing_key Optional override signing key.
* @return bool True when the signature matches.
*/
public function validateCustomWebhookSignature($payload, $signature, $algorithm = 'sha256', $encoding = 'base64', $signing_key = '') {
$key = $signing_key;
if ($key === '') {
$key = $this->signing_key;
}
if ($payload === '' || $signature === '' || $key === '') {
return false;
}
if (!in_array($algorithm, hash_hmac_algos(), true)) {
return false;
}
if ($encoding === 'hex') {
$expected = hash_hmac($algorithm, $payload, $key);
} else {
$expected = base64_encode(hash_hmac($algorithm, $payload, $key, true));
}
return $this->safeCompare($expected, $signature);
}
/**
* Parse common inbound message webhook fields into a stable local shape.
*
* @param array $parameters Raw webhook parameters.
* @return array Parsed message fields.
*/
public function parseInboundMessageWebhook($parameters) {
return array(
'message_sid' => $this->arrayValue($parameters, 'MessageSid'),
'account_sid' => $this->arrayValue($parameters, 'AccountSid'),
'from' => $this->arrayValue($parameters, 'From'),
'to' => $this->arrayValue($parameters, 'To'),
'body' => $this->arrayValue($parameters, 'Body'),
'num_media' => $this->arrayValue($parameters, 'NumMedia'),
'message_status' => $this->arrayValue($parameters, 'MessageStatus'),
'raw' => $parameters,
);
}
/**
* Parse common voice webhook fields into a stable local shape.
*
* @param array $parameters Raw webhook parameters.
* @return array Parsed voice fields.
*/
public function parseVoiceWebhook($parameters) {
return array(
'call_sid' => $this->arrayValue($parameters, 'CallSid'),
'account_sid' => $this->arrayValue($parameters, 'AccountSid'),
'from' => $this->arrayValue($parameters, 'From'),
'to' => $this->arrayValue($parameters, 'To'),
'call_status' => $this->arrayValue($parameters, 'CallStatus'),
'direction' => $this->arrayValue($parameters, 'Direction'),
'raw' => $parameters,
);
}
/**
* Build a cXML Messaging Response document.
*
* @param string $message Message text.
* @param array $attributes Optional Message element attributes.
* @return string XML response.
*/
public function buildMessagingResponse($message, $attributes = array()) {
$xml = '<Response>';
$xml .= '<Message'.$this->xmlAttributes($attributes).'>'.$this->escapeXml($message).'</Message>';
$xml .= '</Response>';
return $xml;
}
/**
* Build a cXML Voice Response document that speaks text.
*
* @param string $text Text to speak.
* @param array $attributes Optional Say element attributes.
* @return string XML response.
*/
public function buildVoiceSayResponse($text, $attributes = array()) {
$xml = '<Response>';
$xml .= '<Say'.$this->xmlAttributes($attributes).'>'.$this->escapeXml($text).'</Say>';
$xml .= '</Response>';
return $xml;
}
/**
* Build a cXML Voice Response document that dials a destination.
*
* @param string $destination Number, SIP address, client, or other cXML Dial target.
* @param array $attributes Optional Dial element attributes.
* @return string XML response.
*/
public function buildVoiceDialResponse($destination, $attributes = array()) {
$xml = '<Response>';
$xml .= '<Dial'.$this->xmlAttributes($attributes).'>'.$this->escapeXml($destination).'</Dial>';
$xml .= '</Response>';
return $xml;
}
/**
* Build a cXML redirect response.
*
* @param string $url URL containing the next cXML document.
* @param array $attributes Optional Redirect element attributes.
* @return string XML response.
*/
public function buildRedirectResponse($url, $attributes = array()) {
$xml = '<Response>';
$xml .= '<Redirect'.$this->xmlAttributes($attributes).'>'.$this->escapeXml($url).'</Redirect>';
$xml .= '</Response>';
return $xml;
}
/**
* Return the last redacted request snapshot.
*
* @return array Request snapshot.
*/
public function getLastRequest() {
return $this->last_request;
}
/**
* Return the last normalized response snapshot.
*
* @return array Response snapshot.
*/
public function getLastResponse() {
return $this->last_response;
}
/**
* Send an HTTP request and normalize cURL, JSON, and HTTP failures.
*
* @param string $method HTTP method.
* @param string $url Target URL.
* @param array $parameters Query/body parameters.
* @param array $headers Additional headers.
* @param bool $send_json Whether to JSON-encode the request body.
* @return array Normalized response.
*/
protected function request($method, $url, $parameters = array(), $headers = array(), $send_json = false) {
$method = strtoupper(trim($method));
$response = array();
$attempt = 0;
$max_attempts = $this->max_retries + 1;
if ($method === '') {
return $this->failure('HTTP method is required.', array('http_code' => 0));
}
if ($url === '') {
return $this->failure('Request URL is required.', array('http_code' => 0));
}
if (!$this->hasCredentials()) {
return $this->failure('SignalWire project_id, api_token, and space_url are required.', array('http_code' => 0));
}
while ($attempt < $max_attempts) {
$attempt++;
$response = $this->executeCurl($method, $url, $parameters, $headers, $send_json, $attempt);
if (!$this->shouldRetry($response) || $attempt >= $max_attempts) {
break;
}
usleep(200000 * $attempt);
}
$response['data']['attempts'] = $attempt;
$this->last_response = $response;
return $response;
}
/**
* Execute one cURL request attempt.
*
* @param string $method HTTP method.
* @param string $url Target URL.
* @param array $parameters Query/body parameters.
* @param array $headers Additional headers.
* @param bool $send_json Whether to JSON-encode the request body.
* @param int $attempt Current attempt number.
* @return array Normalized response.
*/
protected function executeCurl($method, $url, $parameters, $headers, $send_json, $attempt) {
$curl = curl_init();
$request_url = $url;
$body = '';
$content_type = '';
$header_list = array();
foreach ($headers as $header_name => $header_value) {
if (is_int($header_name)) {
$header_list[] = $header_value;
} else {
$header_list[] = $header_name.': '.$header_value;
}
}
if ($method === 'GET' && !empty($parameters)) {
$request_url .= $this->urlSeparator($request_url).http_build_query($parameters, '', '&');
} elseif ($send_json) {
$body = json_encode($parameters);
if ($body === false) {
curl_close($curl);
return $this->failure('Failed to JSON encode SignalWire request body.', array('http_code' => 0, 'json_error' => json_last_error_msg()));
}
$header_list[] = 'Content-Type: application/json';
} elseif (!empty($parameters)) {
$body = http_build_query($parameters, '', '&');
$header_list[] = 'Content-Type: application/x-www-form-urlencoded';
}
$header_list[] = 'Accept: application/json';
$this->last_request = array(
'method' => $method,
'url' => $this->redactUrl($request_url),
'parameters' => $this->redactArray($parameters),
'headers' => $this->redactHeaders($header_list),
'send_json' => $send_json,
'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_CUSTOMREQUEST, $method);
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, $this->verify_peer ? 2 : 0);
curl_setopt($curl, CURLOPT_USERAGENT, $this->user_agent);
curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($curl, CURLOPT_USERPWD, $this->project_id.':'.$this->api_token);
curl_setopt($curl, CURLOPT_HTTPHEADER, $header_list);
if ($method !== 'GET' && $method !== 'HEAD') {
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($curl);
$curl_errno = curl_errno($curl);
$curl_error = curl_error($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);
if ($raw === false) {
return $this->failure('SignalWire cURL request failed.', array(
'http_code' => $http_code,
'curl_errno' => $curl_errno,
'curl_error' => $curl_error,
'request' => $this->last_request,
));
}
$raw_body = substr($raw, $header_size);
$decoded = null;
$json_error = '';
if ($raw_body !== '') {
$decoded = json_decode($raw_body, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
$json_error = json_last_error_msg();
}
}
$data = array(
'http_code' => $http_code,
'content_type' => $content_type,
'response' => $decoded,
'raw_body' => $raw_body,
'curl_errno' => $curl_errno,
'curl_error' => $curl_error,
'request' => $this->debug ? $this->last_request : array(),
);
if ($json_error !== '') {
$data['json_error'] = $json_error;
}
if ($curl_errno !== 0) {
return $this->failure('SignalWire cURL transport error.', $data);
}
if ($http_code < 200 || $http_code >= 300) {
return $this->failure($this->extractErrorMessage($decoded, $http_code), $data);
}
return $this->success('SignalWire request completed.', $data);
}
/**
* Determine whether a failed request should be retried.
*
* @param array $response Normalized response.
* @return bool True if retryable.
*/
protected function shouldRetry($response) {
if (!is_array($response)) {
return false;
}
if (isset($response['success']) && $response['success']) {
return false;
}
$http_code = 0;
$curl_errno = 0;
if (isset($response['data']['http_code'])) {
$http_code = (int) $response['data']['http_code'];
}
if (isset($response['data']['curl_errno'])) {
$curl_errno = (int) $response['data']['curl_errno'];
}
if ($curl_errno !== 0) {
return true;
}
return in_array($http_code, array(408, 429, 500, 502, 503, 504), true);
}
/**
* Build the normalized success response contract.
*
* @param string $message Public-safe message.
* @param array $data Response data.
* @return array Response contract.
*/
protected function success($message, $data = array()) {
return array(
'success' => true,
'message' => $message,
'data' => $data,
);
}
/**
* Build the normalized failure response contract.
*
* @param string $message Public-safe message.
* @param array $data Response data.
* @return array Response contract.
*/
protected function failure($message, $data = array()) {
return array(
'success' => false,
'message' => $message,
'data' => $data,
);
}
/**
* Extract a public-safe API error message.
*
* @param mixed $decoded Decoded response body.
* @param int $http_code HTTP status code.
* @return string Public-safe message.
*/
protected function extractErrorMessage($decoded, $http_code) {
if (is_array($decoded)) {
foreach (array('message', 'error', 'error_message', 'detail', 'title') as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
return 'SignalWire API error: '.trim($decoded[$key]);
}
}
if (isset($decoded['errors']) && is_array($decoded['errors']) && isset($decoded['errors'][0])) {
if (is_string($decoded['errors'][0])) {
return 'SignalWire API error: '.$decoded['errors'][0];
}
if (is_array($decoded['errors'][0]) && isset($decoded['errors'][0]['message'])) {
return 'SignalWire API error: '.$decoded['errors'][0]['message'];
}
}
}
return 'SignalWire API returned HTTP '.$http_code.'.';
}
/**
* Check whether core REST credentials are configured.
*
* @return bool True when usable.
*/
protected function hasCredentials() {
return $this->project_id !== '' && $this->api_token !== '' && $this->space_url !== '';
}
/**
* Build and validate the SignalWire Space base URL.
*
* @return string Space URL.
*/
protected function buildSpaceUrl() {
return rtrim($this->space_url, '/');
}
/**
* Clean a relative path for REST/Compatibility API calls.
*
* @param string $path Relative path.
* @return string Clean path.
*/
protected function cleanRelativePath($path) {
$path = trim((string) $path);
$path = ltrim($path, '/');
return $path;
}
/**
* URL-encode one path segment while preserving path assembly readability.
*
* @param string $segment Segment value.
* @return string Encoded path segment.
*/
protected function pathSegment($segment) {
return rawurlencode((string) $segment);
}
/**
* Prefix a channel address if needed.
*
* @param string $address Address value.
* @param string $channel Channel prefix.
* @return string Prefixed address.
*/
protected function prefixChannelAddress($address, $channel) {
$address = trim((string) $address);
$prefix = $channel.':';
if (stripos($address, $prefix) === 0) {
return $address;
}
return $prefix.$address;
}
/**
* Determine whether a URL needs ? or & before adding a query string.
*
* @param string $url URL.
* @return string Separator.
*/
protected function urlSeparator($url) {
if (strpos($url, '?') === false) {
return '?';
}
return '&';
}
/**
* Clean an integer setting inside a safe range.
*
* @param mixed $value Candidate value.
* @param int $default Default value.
* @param int $min Minimum value.
* @param int $max Maximum value.
* @return int Clean value.
*/
protected function cleanInteger($value, $default, $min, $max) {
if (!is_numeric($value)) {
return $default;
}
$value = (int) $value;
if ($value < $min) {
return $min;
}
if ($value > $max) {
return $max;
}
return $value;
}
/**
* Safely read an array value as a string.
*
* @param array $array Source array.
* @param string $key Array key.
* @return string Value or blank string.
*/
protected function arrayValue($array, $key) {
if (isset($array[$key])) {
return (string) $array[$key];
}
return '';
}
/**
* Escape XML text.
*
* @param string $value Untrusted value.
* @return string Escaped value.
*/
protected function escapeXml($value) {
return htmlspecialchars((string) $value, ENT_XML1 | ENT_COMPAT, 'UTF-8');
}
/**
* Build escaped XML attributes.
*
* @param array $attributes Attribute map.
* @return string Attribute string with leading spaces.
*/
protected function xmlAttributes($attributes) {
$text = '';
foreach ($attributes as $name => $value) {
if ($value === null) {
continue;
}
$text .= ' '.$this->escapeXml($name).'="'.$this->escapeXml($value).'"';
}
return $text;
}
/**
* Timing-safe string comparison.
*
* @param string $known Expected value.
* @param string $user Supplied value.
* @return bool True when equal.
*/
protected function safeCompare($known, $user) {
$known = (string) $known;
$user = (string) $user;
if (function_exists('hash_equals')) {
return hash_equals($known, $user);
}
if (strlen($known) !== strlen($user)) {
return false;
}
$result = 0;
$length = strlen($known);
for ($i = 0; $i < $length; $i++) {
$result |= ord($known[$i]) ^ ord($user[$i]);
}
return $result === 0;
}
/**
* Redact credentials in URLs before debug storage.
*
* @param string $url URL.
* @return string Redacted URL.
*/
protected function redactUrl($url) {
return preg_replace('/(token|api_token|auth_token|signing_key|password)=([^&]+)/i', '$1=REDACTED', $url);
}
/**
* Redact sensitive arrays before debug storage.
*
* @param array $values Source values.
* @return array Redacted values.
*/
protected function redactArray($values) {
$redacted = array();
foreach ($values as $key => $value) {
$lower = strtolower((string) $key);
if (strpos($lower, 'token') !== false || strpos($lower, 'secret') !== false || strpos($lower, 'password') !== false || strpos($lower, 'key') !== false) {
$redacted[$key] = 'REDACTED';
} elseif (is_array($value)) {
$redacted[$key] = $this->redactArray($value);
} else {
$redacted[$key] = $value;
}
}
return $redacted;
}
/**
* Redact sensitive headers before debug storage.
*
* @param array $headers Header lines.
* @return array Redacted header lines.
*/
protected function redactHeaders($headers) {
$redacted = array();
foreach ($headers as $header) {
if (stripos($header, 'Authorization:') === 0) {
$redacted[] = 'Authorization: REDACTED';
} else {
$redacted[] = $header;
}
}
return $redacted;
}
}