* This class handles storing and retrieving webhook data from the associated.
* Webhooks are enqueued to their associated actions, delivered, and logged.
* @package WooCommerce\Webhooks
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\RestApiUtil;
use Automattic\WooCommerce\Utilities\LoggingUtil;
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/legacy/class-wc-legacy-webhook.php';
class WC_Webhook extends WC_Legacy_Webhook {
* Store which object IDs this webhook has processed (ie scheduled to be delivered)
* within the current page request.
protected $processed = array();
'pending_delivery' => false,
* Load webhook data based on how WC_Webhook is called.
* @param WC_Webhook|int $data Webhook ID or data.
* @throws Exception If webhook cannot be read/found and $data is set.
public function __construct( $data = 0 ) {
parent::__construct( $data );
if ( $data instanceof WC_Webhook ) {
$this->set_id( absint( $data->get_id() ) );
} elseif ( is_numeric( $data ) ) {
$this->data_store = WC_Data_Store::load( 'webhook' );
// If we have an ID, load the webhook from the DB.
$this->data_store->read( $this );
} catch ( Exception $e ) {
$this->set_object_read( true );
$this->set_object_read( true );
* Enqueue the hooks associated with the webhook.
public function enqueue() {
$hooks = $this->get_hooks();
$url = $this->get_delivery_url();
if ( is_array( $hooks ) && ! empty( $url ) ) {
foreach ( $hooks as $hook ) {
add_action( $hook, array( $this, 'process' ) );
* Process the webhook for delivery by verifying that it should be delivered.
* and scheduling the delivery (in the background by default, or immediately).
* @param mixed $arg The first argument provided from the associated hooks.
* @return mixed $arg Returns the argument in case the webhook was hooked into a filter.
public function process( $arg ) {
// Verify that webhook should be processed for delivery.
if ( ! $this->should_deliver( $arg ) ) {
// Mark this $arg as processed to ensure it doesn't get processed again within the current request.
$this->processed[] = $arg;
* Process webhook delivery.
* @hooked wc_webhook_process_delivery - 10
do_action( 'woocommerce_webhook_process_delivery', $this, $arg );
* Helper to check if the webhook should be delivered, as some hooks.
* (like `wp_trash_post`) will fire for every post type, not just ours.
* @param mixed $arg First hook argument.
* @return bool True if webhook should be delivered, false otherwise.
private function should_deliver( $arg ) {
$should_deliver = $this->is_active() && $this->is_valid_topic() && $this->is_valid_action( $arg ) && $this->is_valid_resource( $arg ) && ! $this->is_already_processed( $arg );
* Let other plugins intercept deliver for some messages queue like rabbit/zeromq.
* @param bool $should_deliver True if the webhook should be sent, or false to not send it.
* @param WC_Webhook $this The current webhook class.
* @param mixed $arg First hook argument.
return apply_filters( 'woocommerce_webhook_should_deliver', $should_deliver, $this, $arg );
* Returns if webhook is active.
* @return bool True if validation passes.
private function is_active() {
return 'active' === $this->get_status();
* Returns if topic is valid.
* @return bool True if validation passes.
private function is_valid_topic() {
return wc_is_webhook_valid_topic( $this->get_topic() );
* Validates the criteria for certain actions.
* @param mixed $arg First hook argument.
* @return bool True if validation passes.
private function is_valid_action( $arg ) {
$current_action = current_action();
switch ( $current_action ) {
$return = $this->is_valid_post_action( $arg );
$return = $this->is_valid_user_action( $arg );
if ( 0 === strpos( $current_action, 'woocommerce_process_shop' ) || 0 === strpos( $current_action, 'woocommerce_process_product' ) ) {
$return = $this->is_valid_processing_action( $arg );
* Validates post actions.
* @param mixed $arg First hook argument.
* @return bool True if validation passes.
private function is_valid_post_action( $arg ) {
// Only deliver deleted/restored event for coupons, orders, and products.
if ( isset( $GLOBALS['post_type'] ) && ! in_array( $GLOBALS['post_type'], array( 'shop_coupon', 'shop_order', 'product' ), true ) ) {
// Check if is delivering for the correct resource.
if ( isset( $GLOBALS['post_type'] ) && str_replace( 'shop_', '', $GLOBALS['post_type'] ) !== $this->get_resource() ) {
* Validates user actions.
* @param mixed $arg First hook argument.
* @return bool True if validation passes.
private function is_valid_user_action( $arg ) {
$user = get_userdata( absint( $arg ) );
// Only deliver deleted customer event for users with customer role.
if ( ! $user || ! in_array( 'customer', (array) $user->roles, true ) ) {
* Validates WC processing actions.
* @param mixed $arg First hook argument.
* @return bool True if validation passes.
private function is_valid_processing_action( $arg ) {
// The `woocommerce_process_shop_*` and `woocommerce_process_product_*` hooks
// fire for create and update of products and orders, so check the post
// creation date to determine the actual event.
$resource = get_post( absint( $arg ) );
// Drafts don't have post_date_gmt so calculate it here.
$gmt_date = get_gmt_from_date( $resource->post_date );
// A resource is considered created when the hook is executed within 10 seconds of the post creation date.
$resource_created = ( ( time() - 10 ) <= strtotime( $gmt_date ) );
if ( 'created' === $this->get_event() && ! $resource_created ) {
} elseif ( 'updated' === $this->get_event() && $resource_created ) {
* Checks the resource for this webhook is valid e.g. valid post status.
* @param mixed $arg First hook argument.
* @return bool True if validation passes.
private function is_valid_resource( $arg ) {
$resource = $this->get_resource();
if ( in_array( $resource, array( 'product', 'coupon' ), true ) ) {
$status = get_post_status( absint( $arg ) );
// Ignore auto drafts for all resources.
if ( in_array( $status, array( 'auto-draft', 'new' ), true ) ) {
if ( 'order' === $resource ) {
// Check registered order types for order types args.
if ( ! OrderUtil::is_order( absint( $arg ), wc_get_order_types( 'order-webhooks' ) ) ) {
$order = wc_get_order( absint( $arg ) );
// Ignore standard drafts for orders.
if ( in_array( $order->get_status(), array( OrderStatus::DRAFT, OrderStatus::AUTO_DRAFT, 'new' ), true ) ) {
* Checks if the specified resource has already been queued for delivery within the current request.
* Helps avoid duplication of data being sent for topics that have more than one hook defined.
* @param mixed $arg First hook argument.
protected function is_already_processed( $arg ) {
return false !== array_search( $arg, $this->processed, true );
* Deliver the webhook payload using wp_safe_remote_request().
* @param mixed $arg First hook argument.
public function deliver( $arg ) {
$start_time = microtime( true );
$payload = $this->build_payload( $arg );
'timeout' => MINUTE_IN_SECONDS,
'user-agent' => sprintf( 'WooCommerce/%s Hookshot (WordPress/%s)', Constants::get_constant( 'WC_VERSION' ), $GLOBALS['wp_version'] ),
'body' => trim( wp_json_encode( $payload ) ),
'Content-Type' => 'application/json',
$http_args = apply_filters( 'woocommerce_webhook_http_args', $http_args, $arg, $this->get_id() );
$delivery_id = $this->get_new_delivery_id();
$http_args['headers']['X-WC-Webhook-Source'] = home_url( '/' ); // Since 2.6.0.
$http_args['headers']['X-WC-Webhook-Topic'] = $this->get_topic();
$http_args['headers']['X-WC-Webhook-Resource'] = $this->get_resource();
$http_args['headers']['X-WC-Webhook-Event'] = $this->get_event();
$http_args['headers']['X-WC-Webhook-Signature'] = $this->generate_signature( $http_args['body'] );
$http_args['headers']['X-WC-Webhook-ID'] = $this->get_id();
$http_args['headers']['X-WC-Webhook-Delivery-ID'] = $delivery_id;
$response = wp_safe_remote_request( $this->get_delivery_url(), $http_args );
$duration = NumberUtil::round( microtime( true ) - $start_time, 5 );
$this->log_delivery( $delivery_id, $http_args, $response, $duration );
do_action( 'woocommerce_webhook_delivery', $http_args, $response, $duration, $arg, $this->get_id() );
* Get WP API integration payload.
* @param string $resource Resource type.
* @param int $resource_id Resource ID.
* @param string $event Event type.
private function get_wp_api_payload( $resource, $resource_id, $event ) {
// Bulk and quick edit action hooks return a product object instead of an ID.
if ( 'product' === $resource && 'updated' === $event && is_a( $resource_id, 'WC_Product' ) ) {
$resource_id = $resource_id->get_id();
$version = str_replace( 'wp_api_', '', $this->get_api_version() );
$payload = wc_get_container()->get( RestApiUtil::class )->get_endpoint_data( "/wc/{$version}/{$resource}s/{$resource_id}" );
// Custom topics include the first hook argument.
'action' => current( $this->get_hooks() ),
* Build the payload data for the webhook.
* @param mixed $resource_id First hook argument, typically the resource ID.
* @return mixed Payload data.
* @throws \Exception The webhook is configured to use the Legacy REST API, but the Legacy REST API plugin is not available.
public function build_payload( $resource_id ) {
// Build the payload with the same user context as the user who created
// the webhook -- this avoids permission errors as background processing
// runs with no user context.
$current_user = get_current_user_id();
wp_set_current_user( $this->get_user_id() );
$resource = $this->get_resource();
$event = $this->get_event();
// If a resource has been deleted, just include the ID.
if ( 'deleted' === $event ) {
} elseif ( in_array( $this->get_api_version(), wc_get_webhook_rest_api_versions(), true ) ) {
$payload = $this->get_wp_api_payload( $resource, $resource_id, $event );
if ( ! WC()->legacy_rest_api_is_available() ) {
throw new \Exception( 'The Legacy REST API plugin is not installed on this site. More information: https://developer.woocommerce.com/2023/10/03/the-legacy-rest-api-will-move-to-a-dedicated-extension-in-woocommerce-9-0/ ' );
$payload = wc()->api->get_webhook_api_payload( $resource, $resource_id, $event );
// Restore the current user.
wp_set_current_user( $current_user );
return apply_filters( 'woocommerce_webhook_payload', $payload, $resource, $resource_id, $this->get_id() );
* Generate a base64-encoded HMAC-SHA256 signature of the payload body so the
* recipient can verify the authenticity of the webhook. Note that the signature
* is calculated after the body has already been encoded (JSON by default).
* @param string $payload Payload data to hash.
public function generate_signature( $payload ) {
$hash_algo = apply_filters( 'woocommerce_webhook_hash_algorithm', 'sha256', $payload, $this->get_id() );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return base64_encode( hash_hmac( $hash_algo, $payload, wp_specialchars_decode( $this->get_secret(), ENT_QUOTES ), true ) );
* Generate a new unique hash as a delivery id based on current time and wehbook id.
* Return the hash for inclusion in the webhook request.
public function get_new_delivery_id() {
// Since we no longer use comments to store delivery logs, we generate a unique hash instead based on current time and webhook ID.
return wp_hash( $this->get_id() . strtotime( 'now' ) );
* Log the delivery request/response.
* @param string $delivery_id Previously created hash.
* @param array $request Request data.
* @param array|WP_Error $response Response data.
* @param float $duration Request duration.
public function log_delivery( $delivery_id, $request, $response, $duration ) {
$logger = wc_get_logger();
'Webhook Delivery' => array(
'Delivery ID' => $delivery_id,
'Date' => date_i18n( __( 'M j, Y @ G:i', 'woocommerce' ), strtotime( 'now' ), true ),
'URL' => $this->get_delivery_url(),
'Method' => $request['method'],
'Headers' => array_merge(
'User-Agent' => $request['user-agent'],
'Body' => wp_slash( $request['body'] ),