# routeapp/2.4.2/includes/class-routeapp-api-client.php

Route ‑ Shipping Protection, version 2.4.2. 858 lines.

- Page: https://pluginprobe.com/plugins/routeapp/2.4.2/code/includes/class-routeapp-api-client.php
- Raw: https://pluginprobe.com/plugins/routeapp/2.4.2/raw/includes/class-routeapp-api-client.php
- Modified: 2026-04-13T19:55:40+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/routeapp/2.4.2/code/includes/class-routeapp-api-client.php#L10-L20`.

```php
<?php
/**
 * WooCommerce Routeapp API Client Class
 *
 * @link       https://route.com/
 * @since      1.0.0
 *
 * @package    Routeapp
 * @subpackage Routeapp/includes
 */

class Routeapp_API_Client
{
    /**
     * API base endpoint v1
     */
    const API_ENDPOINT_V1 = 'https://api.route.com/v1/';

    /**
     * API stage base endpoint v1
     */
    const API_STAGE_ENDPOINT_V1 = 'https://api-stage.route.com/v1/';

    /**
     * API base endpoint v2
     */
    const API_ENDPOINT_V2 = 'https://api.route.com/v2/';

    /**
     * API stage base endpoint v2
     */
    const API_STAGE_ENDPOINT_V2 = 'https://api-stage.route.com/v2/';

    /**
     * Merchant ID Option name
     */
    const ROUTEAPP_MERCHANT_ID = 'routeapp_merchant_id';

    /**
     * The v1 API URL
     * @var string
     */
    private $_api_url;

    /**
     * The v2 API URL
     * @var string
     */
    private $_api_url_v2;

    /**
     * The WooCommerce Merchant Key
     * @var string
     */
    private $_public_token;

    /**
     * The WooCommerce Merchant Key
     * @var string
     */
    private $_secret_token;


    /**
     * Merchant
     */
    private $_merchant;

    /**
     * Data used for API Call
     * @var array
     */
    protected $_extraData = [];

    /**
     * Cache API calls
     */
    private $_cachedApiCallsSessionKey = 'route_get_quote';

    private static $instances = [];

    /**
     * Default contructor
     * @param string  $public_token   The consumer key
     * @param string  $secret_token   The consumer key
     */
    public function __construct($public_token = null, $secret_token = null)
    {
        $this->set_public_token($public_token);
        $this->set_secret_token($secret_token);

        $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT');
        if (is_null($custom_env) || !$custom_env) {
            $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
        }
        if ($custom_env == 'stage') {
            $this->_api_url = rtrim($this->_api_url ?? '', '/') . self::API_STAGE_ENDPOINT_V1;
            $this->_api_url_v2 = rtrim($this->_api_url_v2 ?? '', '/') . self::API_STAGE_ENDPOINT_V2;
        } else {
            $this->_api_url = rtrim($this->_api_url ?? '', '/') . self::API_ENDPOINT_V1;
            $this->_api_url_v2 = rtrim($this->_api_url_v2 ?? '', '/') . self::API_ENDPOINT_V2;
        }
    }

    /**
     * Singletons should not be cloneable.
     */
    protected function __clone()
    {}

    public static function getInstance()
    {
        $cls = static::class;
        if (!isset(static::$instances[$cls])) {
            static::$instances[$cls] = new static;
        }
        return static::$instances[$cls];
    }

    /**
     * Set the public token
     * @param string $token
     */
    public function set_public_token($token)
    {
        $this->_public_token = $token;
    }

    /**
     * Set the secret token
     * @param string $token
     */
    public function set_secret_token($token)
    {
        $this->_secret_token = $token;
    }

    /**
     * Get the public token
     * @return string string
     */
    public function get_public_token()
    {
        return !empty($this->_public_token) ? $this->_public_token : get_option('routeapp_public_token');
    }

    public function get_cache_api_session_key()
    {
        return $this->_cachedApiCallsSessionKey;
    }

    /**
     * Get the secret token
     * @return string string
     */
    public function get_secret_token()
    {
        return !empty($this->_secret_token) ? $this->_secret_token : get_option('routeapp_secret_token');
    }

    /**
     * Get the user token
     * @return string string
     */
    public function get_user_token()
    {
        return get_option('routeapp_user_token');
    }
    /**
     * Get the user id
     * @return string string
     */
    public function get_user_id()
    {
        return get_option('routeapp_user_id');
    }

    /**
     * Get current quote price based on subtotal
     * Optimized to store only essential data in session
     * @param $cartRef
     * @param $cartTotal
     * @param $currency
     * @param $cartItems
     * @return array|mixed
     */
    public function get_quote($cartRef, $cartTotal, $currency, $cartItems)
    {
        $currency = !$currency ? get_woocommerce_currency() : $currency;
        $cartTotal = !is_null($cartTotal) && $cartTotal > 0 ? $cartTotal : 0;
        $merchant_id = $this->get_merchant_id();

        //empty subtotal or merchant_id just return zero
        if ($cartTotal==0 || !$merchant_id) return ['body' => json_encode(['premium' => ['amount' => '0']])];

        //check values on cache
        $cached = false;
        $key = $this->get_cache_api_session_key() . '-' . $cartRef;
        if (WC()->session) {
            $cached = WC()->session->get($key);
        }
        if ($cached) {
            if (time() - $cached['createdAt'] > 1800) {
                //if creation date is more than 30 minutes, we unset it
                WC()->session->__unset($key);
                $lastCalledMade = $key . '-latest';
                WC()->session->__unset($lastCalledMade);
                // Clean up all old entries when we find an expired one
                $this->_cleanup_old_quote_sessions();
            } else {
                return $cached['result'];
            }
        }

        // Make API call
        $api_response = $this->_make_private_api_call('quotes', array(
            'merchant_id' => $merchant_id,
            'cart' => [
                'cart_ref' => strval($cartRef),
                'covered' => [
                    'currency' => strval($currency),
                    'amount' => strval($cartTotal)
                ],
                'cart_items' => $cartItems,
            ],
        ), 'POST', 'v2');

        // Extract only essential data from API response to minimize session storage
        $essential_data = $this->_extract_essential_quote_data($api_response);

        // Store only essential data in session (not the full HTTP response)
        if (WC()->session) {
            // Clean up old entries BEFORE adding new one to enforce limit
            // This ensures we don't exceed max_entries even when adding a new quote
            $this->_cleanup_old_quote_sessions();
            
            $created_at = time();
            $cached = array(
                'createdAt' => $created_at,
                'result' => $essential_data
            );
            WC()->session->set($key, $cached);
            
            // Store latest quote data (also minimal)
            // Format: array with 'body' key for compatibility with routeapp_save_quote_to_order
            $lastCalledMade = $key . '-latest';
            WC()->session->set($lastCalledMade, $essential_data);
            
            // Track this key for cleanup and limit total entries
            $this->_track_quote_session_key($key, $created_at);
            
            // Clean up again after adding to ensure limit is strictly enforced
            // This handles the case where we had exactly max_entries before adding
            $this->_cleanup_old_quote_sessions();
        }

        return $essential_data;
    }

    /**
     * Extract only essential data from API response
     * Prevents storing full HTTP response objects in session
     * Stores only: id, premium.amount, premium.currency, payment_responsible.type, payment_responsible.ToggleState
     * 
     * @param array|WP_Error $api_response The full API response from wp_remote_request
     * @return array Minimal quote data in expected format (compatible with existing code)
     */
    private function _extract_essential_quote_data($api_response)
    {
        // Handle errors - return in expected format
        if (is_wp_error($api_response)) {
            return array(
                'response' => array('code' => 500),
                'body' => json_encode(array('premium' => array('amount' => '0')))
            );
        }

        // Extract body from response
        $response_code = wp_remote_retrieve_response_code($api_response);
        $response_body = wp_remote_retrieve_body($api_response);

        // If API call failed, return original format for error handling
        if ($response_code !== 200 && $response_code !== 201) {
            return $api_response; // Return original for error handling
        }

        // Parse JSON body
        $body_data = json_decode($response_body, true);
        
        if (!$body_data || empty($body_data)) {
            return array(
                'response' => array('code' => $response_code),
                'body' => json_encode(array('premium' => array('amount' => '0')))
            );
        }

        $essential_quote = array();
        
        // Extract essential fields
        if (isset($body_data['id'])) {
            $essential_quote['id'] = $body_data['id'];
        }
        
        if (isset($body_data['premium'])) {
            $essential_quote['premium'] = array(
                'currency' => isset($body_data['premium']['currency']) ? $body_data['premium']['currency'] : 'USD',
                'amount' => isset($body_data['premium']['amount']) ? $body_data['premium']['amount'] : '0'
            );
        }
        
        if (isset($body_data['payment_responsible'])) {
            $payment = $body_data['payment_responsible'];
            $essential_quote['payment_responsible'] = array(
                'type' => isset($payment['type']) ? $payment['type'] : 'paid_by_merchant'
            );
           
            // Convert to boolean ToggleState for compatibility with existing code
            if (array_key_exists('toggle_state', $payment)) {
                $toggle_state_value = $payment['toggle_state'];
                // Convert "checked" -> true, "unchecked" -> false
                $essential_quote['payment_responsible']['ToggleState'] = ($toggle_state_value === 'checked' || $toggle_state_value === true || $toggle_state_value === 1);
            } elseif (array_key_exists('ToggleState', $payment)) {
                // Fallback for camelCase format (if API changes)
                $essential_quote['payment_responsible']['ToggleState'] = $payment['ToggleState'];
            }
        }

        // Return in expected format (compatible with routeapp_get_quote_from_api)
        return array(
            'response' => array('code' => $response_code),
            'body' => json_encode($essential_quote, JSON_FORCE_OBJECT)
        );
    }

    /**
     * Track quote session keys for cleanup management
     * 
     * @param string $key Session key
     * @param int $created_at Timestamp
     */
    private function _track_quote_session_key($key, $created_at)
    {
        if (!WC()->session) {
            return;
        }

        $tracker_key = $this->get_cache_api_session_key() . '_keys';
        $tracked_keys = WC()->session->get($tracker_key);
        
        if (!is_array($tracked_keys)) {
            $tracked_keys = array();
        }
        
        // Add current key to tracker (avoid duplicates)
        $key_exists = false;
        foreach ($tracked_keys as $index => $tracked) {
            if (isset($tracked['key']) && $tracked['key'] === $key) {
                $tracked_keys[$index]['time'] = $created_at; // Update timestamp
                $key_exists = true;
                break;
            }
        }
        
        if (!$key_exists) {
            $tracked_keys[] = array('key' => $key, 'time' => $created_at);
        }
        
        WC()->session->set($tracker_key, $tracked_keys);
    }

    /**
     * Clean up old quote session entries to prevent session bloat
     * Removes entries older than 30 minutes and limits total entries per session
     * This prevents accumulation of hundreds of quote entries
     */
    private function _cleanup_old_quote_sessions()
    {
        if (!WC()->session) {
            return;
        }

        $cache_prefix = $this->get_cache_api_session_key();
        $current_time = time();
        $max_age = 1800; // 30 minutes
        $max_entries = 5; // Maximum number of quote entries per session (reduced from potential hundreds)
        
        // Get tracked keys
        $tracker_key = $cache_prefix . '_keys';
        $tracked_keys = WC()->session->get($tracker_key);
        
        if (!is_array($tracked_keys) || empty($tracked_keys)) {
            return;
        }
        
        // Clean up old entries (expired) and verify they still exist in session
        $valid_keys = array();
        foreach ($tracked_keys as $key_with_timestamp) {
            if (!is_array($key_with_timestamp) || !isset($key_with_timestamp['key']) || !isset($key_with_timestamp['time'])) {
                continue;
            }
            
            $key = $key_with_timestamp['key'];
            $created_at = $key_with_timestamp['time'];
            $age = $current_time - $created_at;
            
            // Check if entry still exists in session (might have been manually removed)
            $session_entry = WC()->session->get($key);
            
            if ($age > $max_age || !$session_entry) {
                // Remove expired or missing entry
                WC()->session->__unset($key);
                WC()->session->__unset($key . '-latest');
            } else {
                // Keep valid entry
                $valid_keys[] = $key_with_timestamp;
            }
        }
        
        // Always sort by time (newest first) to prepare for limiting
        usort($valid_keys, function($a, $b) {
            return $b['time'] - $a['time'];
        });
        
        // Limit total entries (keep most recent) - STRICTLY enforce max_entries
        if (count($valid_keys) > $max_entries) {
            // Keep only max_entries (most recent)
            $keys_to_keep = array_slice($valid_keys, 0, $max_entries);
            
            // Remove excess entries (older ones beyond limit)
            $keys_to_remove = array_slice($valid_keys, $max_entries);
            foreach ($keys_to_remove as $excess_entry) {
                if (isset($excess_entry['key'])) {
                    $excess_key = $excess_entry['key'];
                    WC()->session->__unset($excess_key);
                    WC()->session->__unset($excess_key . '-latest');
                }
            }
            
            // Update tracker with only kept keys (maintain sorted order)
            $valid_keys = $keys_to_keep;
        }
        
        // Always update tracker to maintain correct order and remove any orphaned entries
        WC()->session->set($tracker_key, $valid_keys);
    }

    /**
     * Create the order shipment, currently only status update suported by API
     * @param  integer $tracking_id
     * @param  array  $data
     * @return mixed|json string
     */
    public function create_shipment($tracking_id, $data = array())
    {
        if (empty($tracking_id)) return false;
        return $this->_make_private_api_call('shipments', array(
            'tracking_number' => $this->sanitize_value($tracking_id),
            'source_order_id' => $data['source_order_id'],
            'source_product_ids' => $data['source_product_ids'],
            'courier_id' => $this->sanitize_value($data['courier_id']),
        ), 'POST');
    }

    /**
     *
     * Sanitize shipstation tracking numbers. Moved to here from the
     * class-routeapp-shipstation.php script because it is sometimes
     * getting bypassed and orders are coming through with "-(SHIPSTATION)"
     * at the end. Also added a more specific check for both a shipstation
     * prefix and suffix WITH the dash since shipstation has moved the
     * shipstation label to the back AND orders were coming through with
     * either a leading or trailing "-"
     *
     * @param $value
     * @return array|string
     */

    private function sanitize_value($value) {
        $value = str_replace(['-(SHIPSTATION)', '(SHIPSTATION)-', '(Shipstation)'], '', $value);
        $value = str_replace('.', '', $value);
        $value = trim($value);
        return $value;
    }

    /**
     * Get the order shipment
     * @param  integer $tracking_id
     * @param  integer $order_id
     * @param  array  $data
     * @return mixed|json string
     */
    public function get_shipment($tracking_id, $order_id)
    {
        if (empty($tracking_id) || empty($order_id)) return false;
        return $this->_make_private_api_call('shipments/' . $tracking_id . '?source_order_id=' . $order_id);
    }

    /**
     * Update the order shipment, currently only status update suported by API
     * @param  integer $tracking_id
     * @param  integer $order_id
     * @param  array  $data
     * @return mixed|json string
     */
    public function update_shipment($tracking_id, $order_id, $data = array())
    {
        if (empty($tracking_id) || empty($order_id)) return false;
        return $this->_make_private_api_call('shipments/' . $tracking_id . '?source_order_id=' . $order_id, array(
            'source_order_id' => $data['source_order_id'],
            'source_product_ids' => $data['source_product_ids'],
            'courier_id' => $data['courier_id'],
        ), 'POST');
    }

    /**
     * Cancel the order shipment, currently only status update suported by API
     * @param  integer $tracking_id
     * @param  integer $order_id
     * @param  array  $data
     * @return mixed|json string
     */
    public function cancel_shipment($tracking_id, $data = array())
    {
        if (empty($tracking_id)) return false;
        return $this->_make_private_api_call('shipments/' . $tracking_id . '/cancel' . '?source_order_id=' . $data['source_order_id'], array(
            'source_order_id' => $data['source_order_id'],
            'source_product_ids' => $data['source_product_ids'],
        ), 'POST');
    }

    /**
     * Create the order, currently only status update suported by API
     * @param  integer $data
     * @return mixed|json string
     */
    public function create_order($data)
    {
        return $this->_make_private_api_call('orders', $data, 'POST', 'v2');
    }

    /**
     * Get the order
     * @param  integer $source_order_id
     * @return mixed|json string
     */
    public function get_order($source_order_id)
    {
        return $this->_make_private_api_call('orders/' . $source_order_id, 'GET');
    }

    /**
     * Update the order, currently only status update suported by API
     * @param  integer $data
     * @return mixed|json string
     */
    public function update_order($order_id, $data)
    {
        return $this->_make_private_api_call('orders/' . $order_id, $data, 'POST', 'v2');
    }

    /**
     * Cancel the order, currently only status update suported by API
     * @param  integer $order_id
     * @return mixed|json string
     */
    public function cancel_order($order_id)
    {
        return $this->_make_private_api_call('orders/' . $order_id . '/cancel', array(), 'POST');
    }

    /**
     * Get user billing status settings
     * @param  integer $order_id
     * @return mixed|json string
     */
    public function get_billing()
    {
        return $this->_make_private_api_call('billing', array(), 'GET');
    }

    /**
     * Get the Route Merchant ID

     * @return mixed
     */
    public function get_merchant_id()
    {
      return get_option(self::ROUTEAPP_MERCHANT_ID);
    }

    /**
     * Get the Route Merchant ID
     *
     * @param $merchant_id
     * @param $blog_id
     *
     * @return mixed
     */
    public function set_merchant_id($merchant_id, $blog_id = null){
        if (isset($blog_id) && is_multisite()) {
            return update_blog_option($blog_id, self::ROUTEAPP_MERCHANT_ID, $merchant_id);
        }
        return update_option(self::ROUTEAPP_MERCHANT_ID , $merchant_id);
    }

    public static function get_route_public_instance(){
        global $routeapp_public;
        return $routeapp_public;
    }

    /**
     * Get merchant

     * @return mixed
     */
    public function get_merchant()
    {
        if (!empty($this->_merchant)) {
            return $this->_merchant;
        }

        $endpoint = 'merchants';
        $merchantResponse =  $this->get_merchant_id() ? 
          $this->_make_private_api_call($endpoint . '/' . $this->get_merchant_id(), array(), 'GET') : 
          $this->_make_private_api_call($endpoint, array(), 'GET');

        try {
          $response_code = wp_remote_retrieve_response_code($merchantResponse);

          if ( is_wp_error($merchantResponse) || $response_code != 200 ) {
            if ($response_code !== 403) {
              $errorMsg = is_wp_error($merchantResponse) ? $merchantResponse->get_error_message() : $response_code;
              throw new Exception("Route API Error while getting merchant data: " . $errorMsg);
            }

            $merchantResponse = $this->_make_private_api_call($endpoint, array(), 'GET');
          }
        } catch(Exception $exception) {
            $routeapp_public = self::get_route_public_instance();
            $routeapp_public->routeapp_log($exception, $this->_extraData);
            return false;
        }

        if ($merchantResponse) {
            if ($merchantResponse["body"]) {
                $body = json_decode($merchantResponse["body"]);
                $merchant = is_array($body) ? $body[0] : $body;

                if ($merchant) {
                  $this->_merchant = $merchant;

                  if (empty($this->get_merchant_id()) || (isset($merchant->id) && $merchant->id !== $this->get_merchant_id())) {
                      $this->set_merchant_id($merchant->id, get_current_blog_id());
                  }
                  return $this->_merchant;
                }
            }

        }
    }
    
    /**
     * Create user account
     * @param array $data
     * @return mixed|json string
     */
    public function create_user($data) {
        return $this->_make_private_api_call( 'users', $data, 'POST' );
    }

    /**
     * Create user account
     * @param $username
     * @param $password
     * @return mixed|json string
     */
    public function login_user($username, $password) {
        return $this->_make_private_api_call( 'login', [
            "username" => $username,
            "password" => $password
        ], 'POST' );
    }

    /**
     * Create merchant account
     * @param array $data
     * @return mixed|json string
     */
    public function create_merchant($data) {
        return $this->_make_private_api_call_using_user_token( 'merchants', $data, 'POST' );
    }

    /**
     * Get merchant account by user
     * @return mixed|json string
     */
    public function get_merchants() {
        return $this->_make_private_api_call_using_user_token( "users/" . $this->get_user_id() . "/merchants", [], 'GET' );
    }

    /**
     * Get activate account link at Route API
     *
     * @param array $email
     * @return mixed|json string
     */
    public function activate_account($email) {
        return $this->_make_private_api_call( 'activate_account', $email, 'POST' );
    }

    /**
     * Get asset settings
     * @param $apiHost
     * @return mixed|json string
     */
    public function asset_settings($apiHost) {
        return $this->_make_public_api_call("asset-settings/$apiHost", array(), 'GET');
    }

    /**
     * Update the account status at Route API
     *
     * @return mixed|json string
     */
    public function update_merchant_status($status) {
      $endpoint = 'merchants/' . $this->get_merchant_id();
      $params = ['status' => $status];
      return $this->_make_private_api_call( $endpoint, $params, 'POST' );
    }

    /*
     * Make the call to the API
     * @param  string $endpoint
     * @param  array  $params
     * @param  string $method
     * @param  string $version
     * @return mixed|json string
     */
    private function _make_api_call($token, $endpoint, $params = array(), $method = 'GET', $version='v1')
    {
        $url = $version=='v1' ? $this->_api_url : $this->_api_url_v2;
        $url.= $endpoint;

        $extraData = array(
            'params' => $params,
            'method' => $method,
            'endpoint' => $url
        );
        $this->_extraData = $extraData;

        $headers = [
            'Content-Type' => 'application/json',
            'token' => $token,
        ];
        if ($version=='v2') {
            $headers['Protect-Widget-Version'] = 'route-widget-core';
        }
        //platform
        $headers['platform'] = 'woocommerce';

        //woocommerce + wordpress version
        $wooVersion = '';
        if ( in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ) ) &&
            defined('WC_VERSION') ) {
            $wooVersion = WC_VERSION;
        }
        $wordpressVersion = '';
        if (function_exists('get_bloginfo')) {
            $wordpressVersion = get_bloginfo('version');
        }
        $headers['platform_version'] = 'WooCommerce: ' . $wooVersion . ' WordPress: ' . $wordpressVersion;

        //route module version
        $module_version= defined('ROUTEAPP_VERSION') ? ROUTEAPP_VERSION :'';
        $headers['module_version'] = $module_version;

        $args = array(
            'timeout' => 6,
            'method' => $method,
            'headers' => $headers,
            'body' => $method === 'POST' ? json_encode($params) : null
        );

        return wp_remote_request($url, $args);
    }

    private function _make_public_api_call($endpoint, $params = array(), $method = 'GET', $version='v1')
    {
        return $this->_make_api_call($this->get_public_token(), $endpoint, $params, $method, $version);
    }

    protected function _make_private_api_call($endpoint, $params = array(), $method = 'GET', $version='v1')
    {
        return $this->_make_api_call($this->get_secret_token(), $endpoint, $params, $method, $version);
    }

    protected function _make_private_api_call_using_user_token($endpoint, $params = array(), $method = 'GET', $version='v1')
    {
        return $this->_make_api_call($this->get_user_token(), $endpoint, $params, $method, $version);
    }

    /**
     * Exchange a one-time token for merchant data (no merchant API auth; Route v1/otp/verify).
     *
     * @param string $token OTP from Route Dashboard after signing in with platformUrl.
     * @return array|\WP_Error Merchant payload with id, public_api_key, prod_api_secret, store_domain on success.
     */
    public function verify_otp( $token ) {
        $url = $this->_api_url . 'otp/verify';
        $headers = array(
            'Content-Type' => 'application/json',
            'platform' => 'woocommerce',
        );
        $wooVersion = '';
        if ( in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ), true ) &&
            defined( 'WC_VERSION' ) ) {
            $wooVersion = WC_VERSION;
        }
        $wordpressVersion = function_exists( 'get_bloginfo' ) ? get_bloginfo( 'version' ) : '';
        $headers['platform_version'] = 'WooCommerce: ' . $wooVersion . ' WordPress: ' . $wordpressVersion;
        $headers['module_version'] = defined( 'ROUTEAPP_VERSION' ) ? ROUTEAPP_VERSION : '';

        $args = array(
            'timeout' => 15,
            'method' => 'POST',
            'headers' => $headers,
            'body' => wp_json_encode( array( 'token' => $token ) ),
        );

        $response = wp_remote_request( $url, $args );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        $body_raw = wp_remote_retrieve_body( $response );
        $data = json_decode( $body_raw, true );

        if ( 200 !== (int) $code ) {
            $message = is_array( $data ) && ! empty( $data['error'] ) ? $data['error'] : 'OTP verification failed';
            return new \WP_Error( 'route_otp_verify_failed', $message, array( 'status' => $code ) );
        }

        if ( ! is_array( $data ) ) {
            return new \WP_Error( 'route_otp_invalid_response', 'Unexpected response from Route API' );
        }

        $result = isset( $data['result'] ) && is_array( $data['result'] ) ? $data['result'] : $data;

        if ( empty( $result['id'] ) || empty( $result['public_api_key'] ) || empty( $result['prod_api_secret'] ) ) {
            return new \WP_Error( 'route_otp_incomplete', 'OTP response missing merchant credentials' );
        }

        return $result;
    }
}

```
