# routeapp/2.4.19/admin/class-routeapp-webhooks.php

Route ‑ Shipping Protection, version 2.4.19. 516 lines.

- Page: https://pluginprobe.com/plugins/routeapp/2.4.19/code/admin/class-routeapp-webhooks.php
- Raw: https://pluginprobe.com/plugins/routeapp/2.4.19/raw/admin/class-routeapp-webhooks.php
- Modified: 2026-10-05T17:34:26+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.19/code/admin/class-routeapp-webhooks.php#L10-L20`.

```php
<?php

/**
 * Handle webhook upserts.
 *
 *
 * @package    Routeapp
 * @subpackage Routeapp/admin
 * @author     Route App <support@route.com>
 */
class Routeapp_Webhooks {

    const API_GATEWAY_ENDPOINT = 'https://distributed-api.route.com';
    const API_GATEWAY_ENDPOINT_STAGING = 'https://distributed-api-stage.route.com';
    const WEBHOOK_USER_OPTION = '_routeapp_webhook_user_id';

    const webhooksList = [
        // order.updated will trigger both order update and order create events
        'RouteApp Order Upsert' => [
            'name' => 'RouteApp Order Upsert',
            'status' => 'active',
            'topic' => 'order.updated',
            'delivery_url' => '/webhooks/',
            'update' => false,
        ],
        'RouteApp Order Deleted' => [
            'name' => 'RouteApp Order Deleted',
            'status' => 'active',
            'topic' => 'order.deleted',
            'delivery_url' => '/webhooks/',
            'update' => false,
        ],
    ];
    private $_webhooksList;

    public function __construct() {
        $this->buildWebhookList();
    }

    private function buildWebhookList()
    {
        $webhooksList = self::webhooksList;
        foreach ($webhooksList as $webhookName => $values) {
            $webhooksList[$webhookName]['delivery_url'] = $this->generate_delivery_url($values['delivery_url']);
        }
        $this->_webhooksList = $webhooksList;
    }

    private function generate_delivery_url($url) {
        $custom_env = getenv('ROUTEAPP_ENVIRONMENT_ENDPOINT');
        if (is_null($custom_env) || !$custom_env) {
            $custom_env = isset($_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT']) ? $_SERVER['ROUTEAPP_ENVIRONMENT_ENDPOINT'] : '';
        }

        $delivery_url = $custom_env == 'stage' ? self::API_GATEWAY_ENDPOINT_STAGING : self::API_GATEWAY_ENDPOINT;
        $delivery_url.= $url . get_option('routeapp_merchant_id');
        return $delivery_url;
    }

    private function generate_default_secret() {
        $merchant_id = get_option('routeapp_merchant_id');
        $secret_token = get_option('routeapp_secret_token');
        $string = $merchant_id . '-' . $secret_token;
        return hash('sha256', $string);
    }

    public function set_secret() {
        $secret = $this->generate_default_secret();
        update_option( '_routeapp_webhooks_secret', $secret);
        return $secret;
    }

    public function get_secret() {
        $secret = get_option('_routeapp_webhooks_secret', false);
        if (!$secret) {
            $secret = $this->set_secret();
        }
        return $secret;
    }

    private function _is_routedata_complete()
    {
        $merchant_id = get_option('routeapp_merchant_id', false);
        $secret_token = get_option('routeapp_secret_token', false);
        return $merchant_id && $secret_token;
    }

    /**
     * Whether this WP user can authorize WooCommerce order webhook payloads.
     *
     * WooCommerce delivers order.updated by making an internal REST request as
     * the webhook user_id. user_id 0 returns woocommerce_rest_cannot_view (401).
     *
     * @param int $user_id WordPress user ID.
     * @return bool
     */
    public static function user_can_deliver_webhooks($user_id) {
        $user_id = (int) $user_id;
        if ($user_id < 1) {
            return false;
        }
        if (!function_exists('user_can')) {
            return false;
        }
        return user_can($user_id, 'read_shop_order') || user_can($user_id, 'manage_woocommerce');
    }

    /**
     * Resolve a user_id for webhook delivery. Never returns a logged-out (0) user
     * when an administrator or shop manager exists.
     *
     * @return int
     */
    public static function get_webhook_user_id() {
        $current = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
        if (self::user_can_deliver_webhooks($current)) {
            update_option(self::WEBHOOK_USER_OPTION, $current);
            return $current;
        }

        $stored = (int) get_option(self::WEBHOOK_USER_OPTION, 0);
        if (self::user_can_deliver_webhooks($stored)) {
            return $stored;
        }

        $resolved = self::find_webhook_user_id();
        if ($resolved) {
            update_option(self::WEBHOOK_USER_OPTION, $resolved);
        }
        return $resolved;
    }

    /**
     * First administrator, then shop manager.
     *
     * @return int
     */
    public static function find_webhook_user_id() {
        if (!function_exists('get_users')) {
            return 0;
        }
        foreach (array('administrator', 'shop_manager') as $role) {
            $users = get_users(array(
                'role'    => $role,
                'number'  => 1,
                'fields'  => 'ID',
                'orderby' => 'ID',
                'order'   => 'ASC',
            ));
            if (!empty($users)) {
                $id = (int) $users[0];
                if ($id > 0) {
                    return $id;
                }
            }
        }
        return 0;
    }

    /**
     * Whether $webhook has the methods upsert/score need. Filters stray false
     * from wc_get_webhook() that a truthy check can miss (e.g. unexpected objects).
     *
     * @param mixed $webhook Candidate.
     * @param bool  $need_name Also require get_name().
     * @return bool
     */
    public static function is_webhook_like($webhook, $need_name = false) {
        if (!is_object($webhook)) {
            return false;
        }
        $methods = array('get_id', 'get_user_id', 'get_status');
        if ($need_name) {
            $methods[] = 'get_name';
        }
        foreach ($methods as $method) {
            if (!method_exists($webhook, $method) || !is_callable(array($webhook, $method))) {
                return false;
            }
        }
        return true;
    }

    /**
     * Prefer a webhook that can actually deliver (valid user, active), then oldest ID.
     *
     * @param array $webhooks WC_Webhook objects.
     * @return object|null
     */
    public static function pick_keeper_webhook($webhooks) {
        if (!is_array($webhooks) || empty($webhooks)) {
            return null;
        }
        $webhooks = array_values(array_filter($webhooks, array('Routeapp_Webhooks', 'is_webhook_like')));
        if (empty($webhooks)) {
            return null;
        }
        usort($webhooks, function ($a, $b) {
            $score_a = self::score_webhook($a);
            $score_b = self::score_webhook($b);
            if ($score_a !== $score_b) {
                return ($score_b > $score_a) ? 1 : -1;
            }
            $id_a = (int) $a->get_id();
            $id_b = (int) $b->get_id();
            if ($id_a === $id_b) {
                return 0;
            }
            return ($id_a < $id_b) ? -1 : 1;
        });
        return $webhooks[0];
    }

    /**
     * @param mixed $webhook Webhook-like object.
     * @return int
     */
    public static function score_webhook($webhook) {
        if (!self::is_webhook_like($webhook)) {
            return 0;
        }
        $score = 0;
        if ((int) $webhook->get_user_id() > 0) {
            $score += 4;
        }
        if ('active' === $webhook->get_status()) {
            $score += 2;
        }
        return $score;
    }

    public function upsert_webhooks() {
        //rebuild webhooks list to avoid issues with merchant_id on construct
        $this->buildWebhookList();

        if ( class_exists( 'WC_Data_Store' ) && $this->_is_routedata_complete() ) {
            $secret = $this->get_secret();
            $user_id = self::get_webhook_user_id();
            $grouped = $this->get_existing_route_webhooks();

            $missing = array();
            foreach ($this->_webhooksList as $name => $values) {
                $existing = isset($grouped[$name]) ? $grouped[$name] : array();
                if (empty($existing)) {
                    if ($user_id) {
                        $this->_create_webhook($values, $user_id, $secret);
                    } else {
                        $missing[] = $name;
                    }
                    continue;
                }

                $keeper = self::pick_keeper_webhook($existing);
                if (!$keeper) {
                    if ($user_id) {
                        $this->_create_webhook($values, $user_id, $secret);
                    } else {
                        $missing[] = $name;
                    }
                    continue;
                }
                foreach ($existing as $webhook) {
                    if (!self::is_webhook_like($webhook)) {
                        continue;
                    }
                    if ((int) $webhook->get_id() !== (int) $keeper->get_id()) {
                        $this->_remove_webhook($webhook->get_id());
                    }
                }
                // Paused is a manual opt-out: quotes stay on, order posting stays off.
                // If any copy is paused, the survivor stays paused so an active
                // duplicate cannot keep delivering. Disabled is WooCommerce's
                // automatic status after delivery failures, and we still heal that.
                if (self::group_has_paused_webhook($existing)) {
                    $values['status'] = 'paused';
                }
                if (!$this->sync_webhook($keeper, $values, $secret, $user_id)) {
                    $missing[] = $name;
                }
            }

            self::finalize_webhooks_created_flag($missing);
        }
    }

    /**
     * Mark webhooks created only when every required hook exists.
     * Leaving the flag unset lets admin_init retry on the next logged-in request.
     *
     * @param array $missing_names Names that could not be created (no delivery user).
     * @return bool True if the created flag was set.
     */
    public static function finalize_webhooks_created_flag($missing_names) {
        if (!empty($missing_names)) {
            self::log_webhook_upsert(
                'did not mark webhooks created; no WordPress user can authorize order payloads. Missing: ' . implode(', ', $missing_names)
            );
            if (function_exists('delete_option')) {
                delete_option('_routeapp_webhooks_created');
            }
            return false;
        }
        update_option('_routeapp_webhooks_created', 1);
        return true;
    }

    /**
     * @param string $message Log line.
     * @return void
     */
    public static function log_webhook_upsert($message) {
        $line = '[routeapp] ' . $message;
        if (function_exists('wc_get_logger')) {
            $logger = wc_get_logger();
            if (is_object($logger) && method_exists($logger, 'warning')) {
                $logger->warning($line, array('source' => 'routeapp'));
                return;
            }
        }
        if (defined('PHPUNIT_COMPOSER_INSTALL')) {
            return;
        }
        if (function_exists('error_log')) {
            error_log($line);
        }
    }

    /**
     * All Route webhooks, including disabled duplicates. Woo search_webhooks()
     * defaults to limit 10, which missed existing hooks and created more.
     *
     * @return array
     */
    private function get_existing_route_webhooks() {
        $grouped = array();
        $data_store = \WC_Data_Store::load('webhook');
        $ids = $data_store->search_webhooks(array('limit' => -1));
        if (!is_array($ids)) {
            return $grouped;
        }
        foreach ($ids as $id) {
            $webhook = wc_get_webhook($id);
            if (!self::is_webhook_like($webhook, true)) {
                continue;
            }
            $name = $webhook->get_name();
            if (!array_key_exists($name, $this->_webhooksList)) {
                continue;
            }
            $grouped[$name][] = $webhook;
        }
        return $grouped;
    }

    /**
     * User that can authorize this webhook's payloads, or 0.
     * Logs when neither the existing webhook user nor $user_id is valid.
     *
     * @param object $webhook Webhook-like object.
     * @param int    $user_id Resolved delivery user.
     * @return int
     */
    public static function resolve_effective_webhook_user($webhook, $user_id) {
        $existing_user = (int) $webhook->get_user_id();
        if (self::user_can_deliver_webhooks($existing_user)) {
            return $existing_user;
        }

        $user_id = (int) $user_id;
        if (self::user_can_deliver_webhooks($user_id)) {
            return $user_id;
        }

        $id = method_exists($webhook, 'get_id') ? $webhook->get_id() : 0;
        $name = method_exists($webhook, 'get_name') ? $webhook->get_name() : '';
        self::log_webhook_upsert(
            'cannot activate webhook id=' . $id . ' name="' . $name . '"; no WordPress user can authorize order payloads (existing user_id=' . $existing_user . ')'
        );
        return 0;
    }

    /**
     * Whether the merchant paused any copy of this webhook.
     *
     * @param array $webhooks Webhook-like objects for one name.
     * @return bool
     */
    private static function group_has_paused_webhook($webhooks) {
        foreach ($webhooks as $webhook) {
            if (self::is_webhook_like($webhook) && 'paused' === $webhook->get_status()) {
                return true;
            }
        }
        return false;
    }

    /**
     * Merchant opted out of plugin order posting.
     *
     * True only when every RouteApp Order Upsert webhook is paused. Missing
     * webhooks, and Active or Disabled ones, still reconcile: Disabled is
     * WooCommerce's automatic status after delivery failures.
     *
     * @return bool
     */
    public static function is_order_upsert_paused() {
        if (!class_exists('WC_Data_Store')) {
            return false;
        }
        $data_store = \WC_Data_Store::load('webhook');
        $ids = $data_store->search_webhooks(array('limit' => -1));
        if (!is_array($ids) || empty($ids)) {
            return false;
        }

        $found = false;
        foreach ($ids as $id) {
            $webhook = function_exists('wc_get_webhook') ? wc_get_webhook($id) : false;
            if (!self::is_webhook_like($webhook, true)) {
                continue;
            }
            if ('RouteApp Order Upsert' !== $webhook->get_name()) {
                continue;
            }
            $found = true;
            if ('paused' !== $webhook->get_status()) {
                return false;
            }
        }
        return $found;
    }

    /**
     * Update in place. Do not delete+recreate (that is what duplicated hooks).
     *
     * @param object $webhook WC_Webhook.
     * @param array  $values Expected webhook config.
     * @param string $secret Delivery secret.
     * @param int    $user_id Resolved delivery user.
     * @return bool True when a valid delivery user is in place.
     */
    private function sync_webhook($webhook, $values, $secret, $user_id) {
        $changed = false;
        $existing_user = (int) $webhook->get_user_id();
        $effective_user = self::resolve_effective_webhook_user($webhook, $user_id);

        if ($effective_user && $effective_user !== $existing_user) {
            $webhook->set_user_id($effective_user);
            $changed = true;
        }

        $current_status = $webhook->get_status();
        if ($effective_user && 'paused' !== $current_status && $current_status !== $values['status']) {
            $webhook->set_status($values['status']);
            $changed = true;
            if ('active' === $values['status'] && method_exists($webhook, 'set_failure_count')) {
                $webhook->set_failure_count(0);
            }
        }

        if ($webhook->get_topic() !== $values['topic']) {
            $webhook->set_topic($values['topic']);
            $changed = true;
        }
        if ($webhook->get_delivery_url() !== $values['delivery_url']) {
            $webhook->set_delivery_url($values['delivery_url']);
            $changed = true;
        }
        if ($webhook->get_secret() !== $secret) {
            $webhook->set_secret($secret);
            $changed = true;
        }

        if ($changed) {
            $webhook->save();
        }

        return $effective_user > 0;
    }

    private function _create_webhook($webhookData, $user_id, $secret) {
        $user_id = (int) $user_id;
        if ($user_id < 1) {
            return;
        }
        $webhook = new \WC_Webhook();
        $webhook->set_name($webhookData['name']);
        $webhook->set_user_id($user_id);
        $webhook->set_topic( $webhookData['topic'] ); // Event used to trigger a webhook.
        $webhook->set_secret( $secret ); // Secret to validate webhook when received.
        $webhook->set_delivery_url( $webhookData['delivery_url'] ); // URL where webhook should be sent.
        $webhook->set_status( $webhookData['status'] ); // Webhook status.
        $webhook->save();
    }

    /**
     * Permanently delete a duplicate Route webhook.
     *
     * Force-delete is required: we are collapsing failed copies, not moving one
     * to trash. A soft delete would leave disabled rows that search_webhooks()
     * would pick up again and recreate the duplication loop. WooCommerce webhook
     * storage (wc_webhooks table) also has no trash restore path.
     *
     * @param int $hookId Webhook ID.
     * @return void
     */
    private function _remove_webhook($hookId) {
        $wh = function_exists('wc_get_webhook') ? wc_get_webhook($hookId) : false;
        if (!$wh) {
            $wh = new \WC_Webhook();
            $wh->set_id($hookId);
        }
        $wh->delete(true);
    }
}

```
