Skip to content
← Back to Objects
Code

RESO MLS Data Helper

Use PHP to connect to a RESO MLS feed, read listings and related real-estate data, page through results, and keep local records in sync.

Object signature

new PhpogResoWebApiClient($config)

Classification

TypeReal Estate Data Helper ObjectUsage levelProduction

Categories

  • APIs and Webhooks
  • JSON and XML
  • Search and Discovery

Compatibility

Works with MLS or real-estate data providers that expose a RESO Web API service. Use the simple methods for common resources and paging, and use rawODataRequest() for provider-specific fields or approved advanced queries.

Constructor parameters

Service root URLOData service root URL supplied by the MLS/provider, usually ending in /odata, /OData, or a provider-specific RESO root.Token URLOAuth token endpoint URL for providers requiring token exchange.Client IDOAuth client ID from the provider.Client secretOAuth client secret from the provider. Store outside public web root.Access tokenPre-issued bearer token when the provider or token manager supplies one.Refresh tokenRefresh token for providers using refresh_token grant.Authentication modeAuthentication mode: bearer, client_credentials, password, refresh_token, none, or custom_header.Custom headersAdditional provider-specific headers. Sensitive headers are redacted in debug snapshots.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

Read an approved MLS feedUse this when your MLS or data provider gives your PHP app a RESO Web API service URL and credentials.Query real-estate dataGood fits include listings, agents, offices, photos, open houses, and lookup values your provider exposes.Build sync jobsUse paging and timestamp helpers to move data in controlled batches through cron, queue, or CLI jobs.Inspect provider fields firstUse service and metadata helpers to see what the provider exposes before hardcoding fields in your app.Write only when approvedUse create, update, or delete helpers only when your provider, token, and agreement clearly allow write operations.

When not to use it

No data agreementDo not query, store, display, cache, or redistribute MLS data without valid provider permission.Raw public query formsDo not let visitors send arbitrary filter, select, expand, or sort values directly to the provider.Ignoring MLS display rulesYour app must still enforce attribution, refresh frequency, photo rules, sold-data rules, IDX and VOW rules, cache limits, and redistribution limits.Large sync during page loadDo not run big imports from a normal browser request. Use cron, queues, checkpoints, and retry limits.Assuming edits are enabledDo not call write methods unless the provider clearly allows them for that resource and token.

How it works

Add provider settingsPass the service URL and either a bearer token, OAuth settings, or provider-specific headers from private config.Discover available dataRead the service document and metadata so your app knows which resources and fields are available.Build readable queriesPass options such as selected fields, filters, sort order, page size, and related data in a controlled PHP array.Read common MLS resourcesUse simple methods for listings, members, offices, media, open houses, lookup values, and individual records.Sync in batchesPaging and timestamp helpers help cron or queue jobs keep local records current without losing place.Advanced calls stay possiblerawODataRequest() lets experienced users call provider-specific resources, fields, or approved write endpoints.

Integration notes

Start with discoveryRead the service document and metadata before choosing fields, filters, and sort orders.Keep MLS rules in your appThis object moves data. Your application must still enforce display rules, attribution, refresh rules, photo rules, cache limits, and user access policy.Use allow-lists for searchConvert public search forms into approved fields and filters instead of passing raw visitor input to the provider.Sync in batchesRun large imports through cron or queue jobs with saved checkpoints and retry handling.

Security notes

  • Never hardcode MLS/OAuth credentials in public PHP files or examples.
  • Use field allow-lists before exposing OData filter, select, expand, or orderby inputs to public requests.
  • Respect MLS display, caching, attribution, media, sold-data, IDX and VOW, and redistribution rules.
  • Use checkpointed cron/queue workers for large replication jobs.
  • Keep raw request/response logging disabled unless sensitive fields and tokens are redacted.

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

/**
 * RESO MLS Data Helper for approved real-estate data feeds.
 *
 * This object helps PHP projects connect to approved MLS data sources, read listings and related real-estate data, page through results, and keep local records in sync.
 *
 * Authentication:
 * - Most live RESO Web API providers use OAuth 2.0 bearer tokens.
 * - Some providers issue static access tokens; others require client
 *   credentials, password grant, refresh token, or a provider-specific token
 *   exchange.
 * - This object supports pre-issued bearer tokens, client credentials,
 *   password grant, refresh token grant, and custom headers.
 *
 * Safety notes:
 * - Store MLS credentials outside public web roots.
 * - Keep TLS verification enabled in production.
 * - Respect MLS license terms, display rules, attribution rules, sold-data
 *   restrictions, refresh limits, replication rules, and downstream caching
 *   limits.
 * - Do not expose raw query builders to public requests without an allow-list,
 *   throttling, audit logs, and field-level policy checks.
 * - Use rawODataRequest() for provider-specific resources not represented by a
 *   named helper.
 *
 * @package PHPOG\Objects
 */
class PhpogResoWebApiClient {
	/**
	 * OData service root, normally ending in /odata or /OData.
	 *
	 * @var string
	 */
	protected $service_root_url = '';

	/**
	 * OAuth token endpoint URL.
	 *
	 * @var string
	 */
	protected $token_url = '';

	/**
	 * OAuth client identifier.
	 *
	 * @var string
	 */
	protected $client_id = '';

	/**
	 * OAuth client secret.
	 *
	 * @var string
	 */
	protected $client_secret = '';

	/**
	 * Optional resource-owner username for providers that still allow it.
	 *
	 * @var string
	 */
	protected $username = '';

	/**
	 * Optional resource-owner password for providers that still allow it.
	 *
	 * @var string
	 */
	protected $password = '';

	/**
	 * Optional OAuth scope.
	 *
	 * @var string
	 */
	protected $scope = '';

	/**
	 * Current bearer access token.
	 *
	 * @var string
	 */
	protected $access_token = '';

	/**
	 * Optional refresh token.
	 *
	 * @var string
	 */
	protected $refresh_token = '';

	/**
	 * Unix timestamp when the access token expires.
	 *
	 * @var int
	 */
	protected $token_expires_at = 0;

	/**
	 * Current authentication mode: bearer, client_credentials, password,
	 * refresh_token, none, or custom_header.
	 *
	 * @var string
	 */
	protected $auth_mode = 'bearer';

	/**
	 * Extra headers appended to every request after safe validation.
	 *
	 * @var array
	 */
	protected $custom_headers = array();

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

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

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

	/**
	 * HTTP user agent.
	 *
	 * @var string
	 */
	protected $user_agent = 'PHPOG RESO Web API Client/1.0';

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

	/**
	 * Automatic retry count for transient 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 RESO Web API client.
	 *
	 * Recognized config keys: service_root_url, token_url, client_id,
	 * client_secret, username, password, scope, access_token, refresh_token,
	 * token_expires_at, auth_mode, custom_headers, 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['service_root_url']) && is_string($config['service_root_url'])) {
			$this->service_root_url = rtrim(trim($config['service_root_url']), '/');
		}

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

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

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

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

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

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

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

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

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

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

		if (isset($config['custom_headers']) && is_array($config['custom_headers'])) {
			$this->custom_headers = $this->normalizeHeaderArray($config['custom_headers']);
		}

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

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

		if (array_key_exists('verify_peer', $config)) {
			$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 (array_key_exists('debug', $config)) {
			$this->debug = (bool) $config['debug'];
		}

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

		if ($this->max_retries < 0) {
			$this->max_retries = 0;
		}
	}

	/**
	 * Replace the current access token without rebuilding the client.
	 *
	 * @param string $access_token Bearer token value.
	 * @param int $expires_at Optional Unix expiration timestamp.
	 * @return void
	 */
	public function setAccessToken($access_token, $expires_at = 0) {
		if (is_string($access_token)) {
			$this->access_token = $access_token;
		}

		if (is_numeric($expires_at)) {
			$this->token_expires_at = (int) $expires_at;
		}
	}

	/**
	 * 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;
	}

	/**
	 * Request an OAuth access token using the configured grant mode.
	 *
	 * Supported grant types are client_credentials, password, and refresh_token.
	 * The normalized response includes token values when the provider returns
	 * them. Callers should persist the new token outside the public web root.
	 *
	 * @param string $grant_type Optional override grant type.
	 * @param array $extra_parameters Additional token request parameters.
	 * @return array Normalized response array.
	 */
	public function requestAccessToken($grant_type = '', $extra_parameters = array()) {
		if ($this->token_url === '') {
			return $this->buildErrorResponse('OAuth token URL is required.');
		}

		$grant = trim((string) $grant_type);
		if ($grant === '') {
			if ($this->auth_mode === 'password') {
				$grant = 'password';
			} elseif ($this->auth_mode === 'refresh_token') {
				$grant = 'refresh_token';
			} else {
				$grant = 'client_credentials';
			}
		}

		$parameters = array('grant_type' => $grant);

		if ($grant === 'client_credentials') {
			if ($this->client_id === '' || $this->client_secret === '') {
				return $this->buildErrorResponse('Client ID and client secret are required for client_credentials.');
			}
			$parameters['client_id'] = $this->client_id;
			$parameters['client_secret'] = $this->client_secret;
		} elseif ($grant === 'password') {
			if ($this->client_id === '' || $this->client_secret === '' || $this->username === '' || $this->password === '') {
				return $this->buildErrorResponse('Client ID, client secret, username, and password are required for password grant.');
			}
			$parameters['client_id'] = $this->client_id;
			$parameters['client_secret'] = $this->client_secret;
			$parameters['username'] = $this->username;
			$parameters['password'] = $this->password;
		} elseif ($grant === 'refresh_token') {
			if ($this->client_id === '' || $this->client_secret === '' || $this->refresh_token === '') {
				return $this->buildErrorResponse('Client ID, client secret, and refresh token are required for refresh_token grant.');
			}
			$parameters['client_id'] = $this->client_id;
			$parameters['client_secret'] = $this->client_secret;
			$parameters['refresh_token'] = $this->refresh_token;
		}

		if ($this->scope !== '') {
			$parameters['scope'] = $this->scope;
		}

		if (is_array($extra_parameters)) {
			foreach ($extra_parameters as $key => $value) {
				if (is_string($key) && $key !== '') {
					$parameters[$key] = $value;
				}
			}
		}

		$response = $this->sendHttpRequest('POST', $this->token_url, array(), $parameters, array('Content-Type' => 'application/x-www-form-urlencoded'), false, true);

		if ($response['success'] === true && isset($response['data']) && is_array($response['data'])) {
			if (isset($response['data']['access_token']) && is_string($response['data']['access_token'])) {
				$this->access_token = $response['data']['access_token'];
			}
			if (isset($response['data']['refresh_token']) && is_string($response['data']['refresh_token'])) {
				$this->refresh_token = $response['data']['refresh_token'];
			}
			if (isset($response['data']['expires_in']) && is_numeric($response['data']['expires_in'])) {
				$this->token_expires_at = time() + (int) $response['data']['expires_in'];
			}
		}

		return $response;
	}

	/**
	 * Refresh the access token when a refresh token is configured.
	 *
	 * @return array Normalized response array.
	 */
	public function refreshAccessToken() {
		return $this->requestAccessToken('refresh_token');
	}

	/**
	 * Fetch the OData service document.
	 *
	 * @return array Normalized response array.
	 */
	public function getServiceDocument() {
		return $this->rawODataRequest('GET', '', array(), null, array(), false);
	}

	/**
	 * Fetch the RESO/OData metadata document.
	 *
	 * @return array Normalized response array.
	 */
	public function getMetadata() {
		return $this->rawODataRequest('GET', '$metadata', array(), null, array('Accept' => 'application/xml'), false);
	}

	/**
	 * Discover resource names from the service document or metadata.
	 *
	 * This helper first tries the JSON service document. When the provider does
	 * not expose a useful service document, it falls back to parsing common
	 * EntitySet names from the metadata XML string.
	 *
	 * @return array Normalized response with resources in data.resources.
	 */
	public function discoverResources() {
		$service = $this->getServiceDocument();
		$resources = array();

		if ($service['success'] === true && isset($service['data']['value']) && is_array($service['data']['value'])) {
			foreach ($service['data']['value'] as $entry) {
				if (is_array($entry) && isset($entry['name']) && is_string($entry['name'])) {
					$resources[] = $entry['name'];
				}
			}
		}

		if (count($resources) === 0) {
			$metadata = $this->getMetadata();
			if ($metadata['success'] === true && isset($metadata['raw_body']) && is_string($metadata['raw_body'])) {
				$resources = $this->parseEntitySetsFromMetadata($metadata['raw_body']);
			}
		}

		return array(
			'success' => true,
			'message' => 'Resource discovery complete.',
			'http_code' => 200,
			'data' => array('resources' => $resources),
			'raw_body' => '',
			'error' => '',
			'last_request' => $this->last_request,
			'last_response' => $this->last_response
		);
	}

	/**
	 * Query any RESO resource with OData options.
	 *
	 * @param string $resource Resource name such as Property, Member, Office, or Media.
	 * @param array $options OData options. Accepts select, filter, orderby,
	 * count, expand, top, skip, search, format, and custom dollar-prefixed keys.
	 * @return array Normalized response array.
	 */
	public function queryResource($resource, $options = array()) {
		$resource_path = $this->cleanResourceName($resource);
		if ($resource_path === '') {
			return $this->buildErrorResponse('Resource name is required.');
		}

		$query = $this->buildODataQuery($options);
		return $this->rawODataRequest('GET', $resource_path, $query, null, array(), false);
	}

	/**
	 * Fetch one entity by key.
	 *
	 * @param string $resource Resource name.
	 * @param string $key OData key value.
	 * @param array $options Optional select/expand options.
	 * @return array Normalized response array.
	 */
	public function fetchEntity($resource, $key, $options = array()) {
		$resource_path = $this->cleanResourceName($resource);
		$key_path = $this->formatEntityKey($key);

		if ($resource_path === '' || $key_path === '') {
			return $this->buildErrorResponse('Resource name and key are required.');
		}

		$query = $this->buildODataQuery($options);
		return $this->rawODataRequest('GET', $resource_path.'('.$key_path.')', $query, null, array(), false);
	}

	/**
	 * Query Property records.
	 *
	 * @param array $options OData query options.
	 * @return array Normalized response array.
	 */
	public function listProperties($options = array()) {
		return $this->queryResource('Property', $options);
	}

	/**
	 * Fetch one Property record by provider key.
	 *
	 * @param string $listing_key Listing key or provider primary key.
	 * @param array $options Optional select/expand options.
	 * @return array Normalized response array.
	 */
	public function fetchProperty($listing_key, $options = array()) {
		return $this->fetchEntity('Property', $listing_key, $options);
	}

	/**
	 * Query Member records.
	 *
	 * @param array $options OData query options.
	 * @return array Normalized response array.
	 */
	public function listMembers($options = array()) {
		return $this->queryResource('Member', $options);
	}

	/**
	 * Fetch one Member record.
	 *
	 * @param string $member_key Member key or provider primary key.
	 * @param array $options Optional select/expand options.
	 * @return array Normalized response array.
	 */
	public function fetchMember($member_key, $options = array()) {
		return $this->fetchEntity('Member', $member_key, $options);
	}

	/**
	 * Query Office records.
	 *
	 * @param array $options OData query options.
	 * @return array Normalized response array.
	 */
	public function listOffices($options = array()) {
		return $this->queryResource('Office', $options);
	}

	/**
	 * Fetch one Office record.
	 *
	 * @param string $office_key Office key or provider primary key.
	 * @param array $options Optional select/expand options.
	 * @return array Normalized response array.
	 */
	public function fetchOffice($office_key, $options = array()) {
		return $this->fetchEntity('Office', $office_key, $options);
	}

	/**
	 * Query Media records.
	 *
	 * @param array $options OData query options.
	 * @return array Normalized response array.
	 */
	public function listMedia($options = array()) {
		return $this->queryResource('Media', $options);
	}

	/**
	 * Query OpenHouse records.
	 *
	 * @param array $options OData query options.
	 * @return array Normalized response array.
	 */
	public function listOpenHouses($options = array()) {
		return $this->queryResource('OpenHouse', $options);
	}

	/**
	 * Query Lookup records when exposed as a resource.
	 *
	 * @param array $options OData query options.
	 * @return array Normalized response array.
	 */
	public function listLookups($options = array()) {
		return $this->queryResource('Lookup', $options);
	}

	/**
	 * Query a resource by modification timestamp for replication/sync workflows.
	 *
	 * @param string $resource Resource name.
	 * @param string $modification_field Field name, such as ModificationTimestamp.
	 * @param string $since_iso ISO-8601 timestamp.
	 * @param array $options Additional OData options.
	 * @return array Normalized response array.
	 */
	public function syncByModificationTimestamp($resource, $modification_field, $since_iso, $options = array()) {
		$field = $this->cleanFieldName($modification_field);
		$since = trim((string) $since_iso);

		if ($field === '' || $since === '') {
			return $this->buildErrorResponse('Modification field and timestamp are required.');
		}

		$filter = $field.' ge '.$this->formatODataDateTime($since);
		if (isset($options['filter']) && is_string($options['filter']) && trim($options['filter']) !== '') {
			$filter = '('.trim($options['filter']).') and ('.$filter.')';
		}

		$options['filter'] = $filter;
		if (!isset($options['orderby'])) {
			$options['orderby'] = $field.' asc';
		}
		if (!isset($options['top'])) {
			$options['top'] = 100;
		}

		return $this->queryResource($resource, $options);
	}

	/**
	 * Fetch the next OData page from an @odata.nextLink value.
	 *
	 * Providers may return absolute nextLink URLs. This helper preserves those
	 * links and still applies the configured authorization headers.
	 *
	 * @param string $next_link Absolute or service-root-relative nextLink.
	 * @return array Normalized response array.
	 */
	public function fetchNextLink($next_link) {
		$link = trim((string) $next_link);
		if ($link === '') {
			return $this->buildErrorResponse('NextLink URL is required.');
		}

		if (preg_match('/^https?:\/\//i', $link)) {
			return $this->sendHttpRequest('GET', $link, array(), null, array(), false, false);
		}

		return $this->rawODataRequest('GET', ltrim($link, '/'), array(), null, array(), false);
	}

	/**
	 * Fetch multiple OData pages for a resource with a safe page cap.
	 *
	 * This helper is intentionally conservative. Large MLS replication jobs
	 * should persist checkpoints and run from a queue/cron worker instead of a
	 * public web request.
	 *
	 * @param string $resource Resource name.
	 * @param array $options OData query options.
	 * @param int $max_pages Maximum number of pages to request.
	 * @return array Normalized response with data.items and data.pages.
	 */
	public function fetchAllPages($resource, $options = array(), $max_pages = 5) {
		$limit = (int) $max_pages;
		if ($limit < 1) {
			$limit = 1;
		}
		if ($limit > 100) {
			$limit = 100;
		}

		$items = array();
		$pages = array();
		$response = $this->queryResource($resource, $options);
		$page_count = 0;

		while ($response['success'] === true) {
			$page_count++;
			$pages[] = array(
				'http_code' => $response['http_code'],
				'count' => $this->countODataItems($response)
			);

			if (isset($response['data']['value']) && is_array($response['data']['value'])) {
				foreach ($response['data']['value'] as $row) {
					$items[] = $row;
				}
			}

			if ($page_count >= $limit) {
				break;
			}

			$next_link = '';
			if (isset($response['data']['@odata.nextLink']) && is_string($response['data']['@odata.nextLink'])) {
				$next_link = $response['data']['@odata.nextLink'];
			} elseif (isset($response['data']['odata.nextLink']) && is_string($response['data']['odata.nextLink'])) {
				$next_link = $response['data']['odata.nextLink'];
			}

			if ($next_link === '') {
				break;
			}

			$response = $this->fetchNextLink($next_link);
		}

		return array(
			'success' => true,
			'message' => 'Paged OData fetch complete.',
			'http_code' => 200,
			'data' => array(
				'items' => $items,
				'pages' => $pages,
				'page_count' => $page_count,
				'item_count' => count($items)
			),
			'raw_body' => '',
			'error' => '',
			'last_request' => $this->last_request,
			'last_response' => $this->last_response
		);
	}

	/**
	 * Create a new entity when the provider supports RESO Add/Edit.
	 *
	 * @param string $resource Resource name.
	 * @param array $payload Entity payload.
	 * @return array Normalized response array.
	 */
	public function createEntity($resource, $payload = array()) {
		$resource_path = $this->cleanResourceName($resource);
		if ($resource_path === '') {
			return $this->buildErrorResponse('Resource name is required.');
		}
		if (!is_array($payload) || count($payload) === 0) {
			return $this->buildErrorResponse('Create payload is required.');
		}

		return $this->rawODataRequest('POST', $resource_path, array(), $payload, array(), true);
	}

	/**
	 * Update an existing entity when the provider supports RESO Add/Edit.
	 *
	 * @param string $resource Resource name.
	 * @param string $key OData key value.
	 * @param array $payload Patch payload.
	 * @return array Normalized response array.
	 */
	public function updateEntity($resource, $key, $payload = array()) {
		$resource_path = $this->cleanResourceName($resource);
		$key_path = $this->formatEntityKey($key);
		if ($resource_path === '' || $key_path === '') {
			return $this->buildErrorResponse('Resource name and key are required.');
		}
		if (!is_array($payload) || count($payload) === 0) {
			return $this->buildErrorResponse('Update payload is required.');
		}

		return $this->rawODataRequest('PATCH', $resource_path.'('.$key_path.')', array(), $payload, array(), true);
	}

	/**
	 * Delete an existing entity when the provider supports RESO Add/Edit.
	 *
	 * @param string $resource Resource name.
	 * @param string $key OData key value.
	 * @return array Normalized response array.
	 */
	public function deleteEntity($resource, $key) {
		$resource_path = $this->cleanResourceName($resource);
		$key_path = $this->formatEntityKey($key);
		if ($resource_path === '' || $key_path === '') {
			return $this->buildErrorResponse('Resource name and key are required.');
		}

		return $this->rawODataRequest('DELETE', $resource_path.'('.$key_path.')', array(), null, array(), false);
	}

	/**
	 * Build a normalized OData query array from friendly option keys.
	 *
	 * Friendly aliases such as select/filter/orderby/top/skip are converted to
	 * OData system query names such as $select/$filter/$orderby/$top/$skip.
	 * Existing dollar-prefixed keys are preserved.
	 *
	 * @param array $options OData options.
	 * @return array Query parameters.
	 */
	public function buildODataQuery($options = array()) {
		$query = array();
		if (!is_array($options)) {
			return $query;
		}

		$map = array(
			'select' => '$select',
			'filter' => '$filter',
			'orderby' => '$orderby',
			'order_by' => '$orderby',
			'expand' => '$expand',
			'top' => '$top',
			'limit' => '$top',
			'skip' => '$skip',
			'offset' => '$skip',
			'count' => '$count',
			'format' => '$format',
			'search' => '$search'
		);

		foreach ($options as $key => $value) {
			if (!is_string($key) || $key === '') {
				continue;
			}

			$query_key = $key;
			$lower_key = strtolower($key);
			if (isset($map[$lower_key])) {
				$query_key = $map[$lower_key];
			}

			if (is_array($value)) {
				$value = implode(',', $this->cleanValueList($value));
			}

			if ($value === null || $value === '') {
				continue;
			}

			if ($query_key === '$top' || $query_key === '$skip') {
				$value = (int) $value;
				if ($value < 0) {
					$value = 0;
				}
			}

			if ($query_key === '$count') {
				$value = $value ? 'true' : 'false';
			}

			$query[$query_key] = $value;
		}

		return $query;
	}

	/**
	 * Build a RESO Data Dictionary-style select list.
	 *
	 * @param array $fields Field names.
	 * @return string Comma-separated field list.
	 */
	public function buildSelectList($fields) {
		if (!is_array($fields)) {
			return '';
		}

		return implode(',', $this->cleanValueList($fields));
	}

	/**
	 * Build a simple equals filter with safe OData literal formatting.
	 *
	 * @param string $field Field name.
	 * @param mixed $value Value.
	 * @return string OData filter expression.
	 */
	public function buildEqualsFilter($field, $value) {
		$name = $this->cleanFieldName($field);
		if ($name === '') {
			return '';
		}

		return $name.' eq '.$this->formatODataLiteral($value);
	}

	/**
	 * Build a contains() string filter.
	 *
	 * @param string $field Field name.
	 * @param string $value Search value.
	 * @return string OData filter expression.
	 */
	public function buildContainsFilter($field, $value) {
		$name = $this->cleanFieldName($field);
		$text = trim((string) $value);
		if ($name === '' || $text === '') {
			return '';
		}

		return 'contains('.$name.', '.$this->formatODataLiteral($text).')';
	}

	/**
	 * Build an in-style OR expression for providers that do not support in().
	 *
	 * @param string $field Field name.
	 * @param array $values Values.
	 * @return string OData filter expression.
	 */
	public function buildAnyEqualsFilter($field, $values) {
		$name = $this->cleanFieldName($field);
		if ($name === '' || !is_array($values) || count($values) === 0) {
			return '';
		}

		$parts = array();
		foreach ($values as $value) {
			$parts[] = $name.' eq '.$this->formatODataLiteral($value);
		}

		return '('.implode(' or ', $parts).')';
	}

	/**
	 * Execute any RESO/OData endpoint path with shared auth/error handling.
	 *
	 * @param string $method HTTP method.
	 * @param string $path Resource path relative to service root or absolute URL.
	 * @param array $query Query parameters.
	 * @param mixed $body Optional body payload.
	 * @param array $headers Additional headers.
	 * @param bool $send_json Whether body should be encoded as JSON.
	 * @return array Normalized response array.
	 */
	public function rawODataRequest($method, $path, $query = array(), $body = null, $headers = array(), $send_json = false) {
		if ($this->service_root_url === '' && !preg_match('/^https?:\/\//i', (string) $path)) {
			return $this->buildErrorResponse('Service root URL is required.');
		}

		$url = $this->buildRequestUrl($path, $query);
		return $this->sendHttpRequest($method, $url, array(), $body, $headers, $send_json, false);
	}

	/**
	 * Normalize friendly headers into a key/value array.
	 *
	 * @param array $headers Header values.
	 * @return array Normalized headers.
	 */
	protected function normalizeHeaderArray($headers) {
		$normalized = array();
		foreach ($headers as $key => $value) {
			if (is_string($key) && $key !== '' && is_scalar($value)) {
				$normalized[$key] = (string) $value;
			}
		}

		return $normalized;
	}

	/**
	 * Parse EntitySet names from OData metadata XML using a lightweight regex.
	 *
	 * @param string $metadata_xml Raw metadata XML.
	 * @return array Resource names.
	 */
	protected function parseEntitySetsFromMetadata($metadata_xml) {
		$resources = array();
		$matches = array();

		if (preg_match_all('/<\s*EntitySet\s+[^>]*Name="([^"]+)"/i', $metadata_xml, $matches)) {
			foreach ($matches[1] as $name) {
				if (is_string($name) && $name !== '') {
					$resources[] = $name;
				}
			}
		}

		$resources = array_values(array_unique($resources));
		sort($resources);

		return $resources;
	}

	/**
	 * Clean an OData resource name or path segment.
	 *
	 * @param string $resource Resource name.
	 * @return string Clean resource name.
	 */
	protected function cleanResourceName($resource) {
		$name = trim((string) $resource);
		$name = str_replace('..', '', $name);
		$name = trim($name, '/');

		return $name;
	}

	/**
	 * Clean a field name used in OData filters/select lists.
	 *
	 * @param string $field Field name.
	 * @return string Clean field name.
	 */
	protected function cleanFieldName($field) {
		$name = trim((string) $field);
		$name = preg_replace('/[^A-Za-z0-9_\.\/]/', '', $name);

		return $name;
	}

	/**
	 * Clean a list of OData field/resource names.
	 *
	 * @param array $values Input values.
	 * @return array Clean values.
	 */
	protected function cleanValueList($values) {
		$list = array();
		foreach ($values as $value) {
			$clean = $this->cleanFieldName($value);
			if ($clean !== '') {
				$list[] = $clean;
			}
		}

		return $list;
	}

	/**
	 * Format a provider key for OData entity lookup syntax.
	 *
	 * @param string $key Entity key.
	 * @return string Formatted key.
	 */
	protected function formatEntityKey($key) {
		$value = trim((string) $key);
		if ($value === '') {
			return '';
		}

		if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*\s*=/', $value)) {
			return $value;
		}

		if (is_numeric($value) && preg_match('/^[0-9]+$/', $value)) {
			return $value;
		}

		return $this->formatODataLiteral($value);
	}

	/**
	 * Format a scalar as an OData literal.
	 *
	 * @param mixed $value Scalar value.
	 * @return string OData literal.
	 */
	protected function formatODataLiteral($value) {
		if (is_bool($value)) {
			return $value ? 'true' : 'false';
		}

		if (is_int($value) || is_float($value)) {
			return (string) $value;
		}

		if (is_string($value) && is_numeric($value) && preg_match('/^-?[0-9]+(\.[0-9]+)?$/', $value)) {
			return $value;
		}

		$text = (string) $value;
		$text = str_replace("'", "''", $text);

		return "'".$text."'";
	}

	/**
	 * Format an ISO timestamp for OData filters.
	 *
	 * @param string $value ISO-8601 timestamp.
	 * @return string OData datetime literal.
	 */
	protected function formatODataDateTime($value) {
		$timestamp = trim((string) $value);
		if ($timestamp === '') {
			return "''";
		}

		if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $timestamp)) {
			return $timestamp;
		}

		$parsed = strtotime($timestamp);
		if ($parsed === false) {
			return $this->formatODataLiteral($timestamp);
		}

		return gmdate('Y-m-d\TH:i:s\Z', $parsed);
	}

	/**
	 * Build an absolute URL for a service-root-relative request.
	 *
	 * @param string $path Relative path or absolute URL.
	 * @param array $query Query parameters.
	 * @return string Request URL.
	 */
	protected function buildRequestUrl($path, $query = array()) {
		$path_value = trim((string) $path);

		if (preg_match('/^https?:\/\//i', $path_value)) {
			$url = $path_value;
		} else {
			$url = $this->service_root_url;
			if ($path_value !== '') {
				$url .= '/'.ltrim($path_value, '/');
			}
		}

		if (is_array($query) && count($query) > 0) {
			$joiner = '?';
			if (strpos($url, '?') !== false) {
				$joiner = '&';
			}
			$url .= $joiner.$this->buildQueryString($query);
		}

		return $url;
	}

	/**
	 * Build an RFC3986 query string.
	 *
	 * @param array $query Query parameters.
	 * @return string Query string.
	 */
	protected function buildQueryString($query) {
		$parts = array();
		foreach ($query as $key => $value) {
			if (!is_string($key) || $key === '' || $value === null) {
				continue;
			}
			$parts[] = rawurlencode($key).'='.rawurlencode((string) $value);
		}

		return implode('&', $parts);
	}

	/**
	 * Send an HTTP request with retry, auth, JSON, and error normalization.
	 *
	 * @param string $method HTTP method.
	 * @param string $url Request URL.
	 * @param array $query Unused extra query parameter slot for consistency.
	 * @param mixed $body Optional body payload.
	 * @param array $headers Additional headers.
	 * @param bool $send_json Encode body as JSON.
	 * @param bool $token_request Whether request is an OAuth token request.
	 * @return array Normalized response array.
	 */
	protected function sendHttpRequest($method, $url, $query = array(), $body = null, $headers = array(), $send_json = false, $token_request = false) {
		$method = strtoupper(trim((string) $method));
		if ($method === '') {
			$method = 'GET';
		}

		if (!function_exists('curl_init')) {
			return $this->buildErrorResponse('cURL extension is required.');
		}

		if (!$token_request) {
			$token_ready = $this->prepareAuthorization();
			if ($token_ready['success'] === false) {
				return $token_ready;
			}
		}

		$headers = $this->prepareHeaders($headers, $send_json, $token_request);
		$request_body = $body;

		if ($send_json && $body !== null) {
			$request_body = json_encode($body);
			if ($request_body === false) {
				return $this->buildErrorResponse('Unable to encode request body as JSON.');
			}
		} elseif (!$send_json && is_array($body)) {
			$request_body = $this->buildQueryString($body);
		}

		if ($method === 'GET' && is_array($query) && count($query) > 0) {
			$joiner = '?';
			if (strpos($url, '?') !== false) {
				$joiner = '&';
			}
			$url .= $joiner.$this->buildQueryString($query);
		}

		$attempt = 0;
		$response = $this->buildErrorResponse('Request not attempted.');
		$max_attempts = $this->max_retries + 1;

		while ($attempt < $max_attempts) {
			$attempt++;
			$ch = curl_init();

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

			if (count($headers) > 0) {
				curl_setopt($ch, CURLOPT_HTTPHEADER, $this->formatHeadersForCurl($headers));
			}

			if ($request_body !== null && $method !== 'GET') {
				curl_setopt($ch, CURLOPT_POSTFIELDS, $request_body);
			}

			$this->last_request = array(
				'method' => $method,
				'url' => $this->redactUrl($url),
				'headers' => $this->redactHeaders($headers),
				'body' => $this->debug ? $this->redactBody($request_body) : '[debug disabled]',
				'attempt' => $attempt
			);

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

			$response = $this->normalizeResponse($raw_body, $http_code, $curl_errno, $curl_error, $attempt);

			if (!$this->shouldRetry($response, $attempt, $max_attempts)) {
				break;
			}

			sleep(1);
		}

		$this->last_response = $response;

		return $response;
	}

	/**
	 * Prepare authorization, refreshing or requesting a token when needed.
	 *
	 * @return array Success/error array.
	 */
	protected function prepareAuthorization() {
		if ($this->auth_mode === 'none' || $this->auth_mode === 'custom_header') {
			return array('success' => true);
		}

		if ($this->auth_mode === 'bearer') {
			if ($this->access_token === '') {
				return $this->buildErrorResponse('Bearer access token is required.');
			}
			return array('success' => true);
		}

		if ($this->token_expires_at > 0 && $this->access_token !== '' && $this->token_expires_at > time() + 60) {
			return array('success' => true);
		}

		if ($this->auth_mode === 'client_credentials') {
			$token_response = $this->requestAccessToken('client_credentials');
			if ($token_response['success'] === true && $this->access_token !== '') {
				return array('success' => true);
			}
			return $token_response;
		}

		if ($this->auth_mode === 'password') {
			$token_response = $this->requestAccessToken('password');
			if ($token_response['success'] === true && $this->access_token !== '') {
				return array('success' => true);
			}
			return $token_response;
		}

		if ($this->auth_mode === 'refresh_token') {
			$token_response = $this->refreshAccessToken();
			if ($token_response['success'] === true && $this->access_token !== '') {
				return array('success' => true);
			}
			return $token_response;
		}

		return $this->buildErrorResponse('Unsupported authentication mode.');
	}

	/**
	 * Prepare request headers.
	 *
	 * @param array $headers Caller headers.
	 * @param bool $send_json Whether JSON body is being sent.
	 * @param bool $token_request Whether this is a token request.
	 * @return array Header map.
	 */
	protected function prepareHeaders($headers, $send_json, $token_request) {
		$prepared = array(
			'Accept' => 'application/json'
		);

		foreach ($this->custom_headers as $key => $value) {
			$prepared[$key] = $value;
		}

		if (!$token_request && $this->auth_mode !== 'none' && $this->auth_mode !== 'custom_header' && $this->access_token !== '') {
			$prepared['Authorization'] = 'Bearer '.$this->access_token;
		}

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

		if (is_array($headers)) {
			foreach ($headers as $key => $value) {
				if (is_string($key) && $key !== '' && is_scalar($value)) {
					$prepared[$key] = (string) $value;
				}
			}
		}

		return $prepared;
	}

	/**
	 * Convert a header map into cURL header lines.
	 *
	 * @param array $headers Header map.
	 * @return array Header lines.
	 */
	protected function formatHeadersForCurl($headers) {
		$lines = array();
		foreach ($headers as $key => $value) {
			$lines[] = $key.': '.$value;
		}

		return $lines;
	}

	/**
	 * Normalize an HTTP response.
	 *
	 * @param string|bool $raw_body Raw cURL body.
	 * @param int $http_code HTTP status.
	 * @param int $curl_errno cURL error number.
	 * @param string $curl_error cURL error string.
	 * @param int $attempt Attempt count.
	 * @return array Normalized response array.
	 */
	protected function normalizeResponse($raw_body, $http_code, $curl_errno, $curl_error, $attempt) {
		$body = '';
		if ($raw_body !== false && $raw_body !== null) {
			$body = (string) $raw_body;
		}

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

		$success = false;
		$message = 'HTTP request failed.';
		$error = '';

		if ($curl_errno !== 0) {
			$error = $curl_error;
			$message = 'Transport error: '.$curl_error;
		} elseif ($http_code >= 200 && $http_code < 300) {
			$success = true;
			$message = 'Request completed successfully.';
		} else {
			$error = $this->extractApiErrorMessage($decoded, $body);
			if ($error === '') {
				$error = 'HTTP status '.$http_code;
			}
			$message = $error;
		}

		return array(
			'success' => $success,
			'message' => $message,
			'http_code' => $http_code,
			'data' => $decoded,
			'raw_body' => $body,
			'error' => $error,
			'curl_errno' => $curl_errno,
			'curl_error' => $curl_error,
			'attempts' => $attempt,
			'last_request' => $this->last_request
		);
	}

	/**
	 * Extract a useful provider error message from decoded JSON or raw body.
	 *
	 * @param mixed $decoded Decoded JSON response.
	 * @param string $body Raw body.
	 * @return string Error message.
	 */
	protected function extractApiErrorMessage($decoded, $body) {
		if (is_array($decoded)) {
			if (isset($decoded['error_description']) && is_scalar($decoded['error_description'])) {
				return (string) $decoded['error_description'];
			}
			if (isset($decoded['error']['message']) && is_scalar($decoded['error']['message'])) {
				return (string) $decoded['error']['message'];
			}
			if (isset($decoded['message']) && is_scalar($decoded['message'])) {
				return (string) $decoded['message'];
			}
			if (isset($decoded['error']) && is_scalar($decoded['error'])) {
				return (string) $decoded['error'];
			}
		}

		$text = trim(strip_tags((string) $body));
		if ($text !== '') {
			return substr($text, 0, 300);
		}

		return '';
	}

	/**
	 * Decide whether a failed request should be retried.
	 *
	 * @param array $response Normalized response.
	 * @param int $attempt Current attempt.
	 * @param int $max_attempts Maximum attempts.
	 * @return bool Whether to retry.
	 */
	protected function shouldRetry($response, $attempt, $max_attempts) {
		if ($attempt >= $max_attempts) {
			return false;
		}

		if (isset($response['curl_errno']) && (int) $response['curl_errno'] !== 0) {
			return true;
		}

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

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

		return false;
	}

	/**
	 * Count OData value array items safely.
	 *
	 * @param array $response Normalized response.
	 * @return int Item count.
	 */
	protected function countODataItems($response) {
		if (isset($response['data']['value']) && is_array($response['data']['value'])) {
			return count($response['data']['value']);
		}

		return 0;
	}

	/**
	 * Build a normalized error response.
	 *
	 * @param string $message Error message.
	 * @return array Error response.
	 */
	protected function buildErrorResponse($message) {
		return array(
			'success' => false,
			'message' => $message,
			'http_code' => 0,
			'data' => null,
			'raw_body' => '',
			'error' => $message,
			'last_request' => $this->last_request,
			'last_response' => $this->last_response
		);
	}

	/**
	 * Redact sensitive URL query values.
	 *
	 * @param string $url URL.
	 * @return string Redacted URL.
	 */
	protected function redactUrl($url) {
		$redacted = (string) $url;
		$redacted = preg_replace('/(access_token|refresh_token|client_secret|password)=([^&]+)/i', '$1=[redacted]', $redacted);

		return $redacted;
	}

	/**
	 * Redact sensitive request headers.
	 *
	 * @param array $headers Headers.
	 * @return array Redacted headers.
	 */
	protected function redactHeaders($headers) {
		$redacted = array();
		foreach ($headers as $key => $value) {
			if (preg_match('/authorization|token|secret|password/i', $key)) {
				$redacted[$key] = '[redacted]';
			} else {
				$redacted[$key] = $value;
			}
		}

		return $redacted;
	}

	/**
	 * Redact sensitive body values for debug snapshots.
	 *
	 * @param mixed $body Body value.
	 * @return mixed Redacted body value.
	 */
	protected function redactBody($body) {
		if (!is_string($body)) {
			return $body;
		}

		$body = preg_replace('/(access_token|refresh_token|client_secret|password)=([^&]+)/i', '$1=[redacted]', $body);
		$body = preg_replace('/("(?:access_token|refresh_token|client_secret|password)"\s*:\s*")([^"]+)(")/i', '$1[redacted]$3', $body);

		return $body;
	}
}