From e75e8a77f6ef6bdcc61e6847ed8157395f801bbc Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 17 Feb 2026 16:52:13 +0530 Subject: [PATCH] feat(shopify): Add mandatory compliance webhooks with HMAC verification (#13549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://linear.app/chatwoot/issue/CW-6494/add-shopify-mandatory-compliance-webhooks-for-app-store-listing Shopify requires all public apps to handle three GDPR compliance webhooks before they can be listed on the App Store. Their automated review checks for these endpoints and verifies that apps validate HMAC signatures on incoming requests. We were failing both checks. This PR adds a single webhook endpoint at `POST /webhooks/shopify` that receives all three compliance events. When Shopify sends a webhook, it signs the payload with our app's client secret and includes the signature in the `X-Shopify-Hmac-SHA256` header. Our controller reads the raw body, computes the expected HMAC-SHA256 digest, and rejects mismatched requests with a 401. Shopify identifies the event type through the `X-Shopify-Topic` header. For `customers/data_request` and `customers/redact`, we simply acknowledge with a 200—Chatwoot doesn't persist any Shopify customer data. All order lookups happen as live API calls at query time. For `shop/redact`, which Shopify sends after a merchant uninstalls the app, we delete the integration hook for that shop domain and remove the stored access token and configuration. ### How to test via Rails console ``` secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) body = '{"shop_domain":"test.myshopify.com"}' valid_hmac = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, body)) ``` #### Test 1: No HMAC → 401 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Topic' => 'customers/data_request' } app.response.code # => "401" ``` #### Test 2: Invalid HMAC → 401 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => 'invalid', 'X-Shopify-Topic' => 'customers/data_request' } app.response.code # => "401" ``` #### Test 3: Valid HMAC, customers/data_request → 200 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/data_request' } app.response.code # => "200" ``` #### Test 4: Valid HMAC, customers/redact → 200 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/redact' } app.response.code # => "200" ``` #### Test 5: Valid HMAC, shop/redact → 200 (deletes hook) ``` # First check if a hook exists for this domain: Integrations::Hook.where(app_id: 'shopify', reference_id: 'test.myshopify.com').count app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'shop/redact' } app.response.code # => "200" ``` --------- Co-authored-by: Shivam Mishra --- .../webhooks/shopify_controller.rb | 35 +++++++++++++++++++ config/routes.rb | 1 + 2 files changed, 36 insertions(+) create mode 100644 app/controllers/webhooks/shopify_controller.rb diff --git a/app/controllers/webhooks/shopify_controller.rb b/app/controllers/webhooks/shopify_controller.rb new file mode 100644 index 000000000..efc6a5122 --- /dev/null +++ b/app/controllers/webhooks/shopify_controller.rb @@ -0,0 +1,35 @@ +class Webhooks::ShopifyController < ActionController::API + before_action :verify_hmac! + + def events + case request.headers['X-Shopify-Topic'] + when 'shop/redact' + handle_shop_redact + end + + head :ok + end + + private + + def verify_hmac! + secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) + return head :unauthorized if secret.blank? + + data = request.body.read + request.body.rewind + + hmac_header = request.headers['X-Shopify-Hmac-SHA256'] + return head :unauthorized if hmac_header.blank? + + computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data)) + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header) + end + + def handle_shop_redact + shop_domain = params[:shop_domain] + return if shop_domain.blank? + + Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all + end +end diff --git a/config/routes.rb b/config/routes.rb index cab069201..38c93b91e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -564,6 +564,7 @@ Rails.application.routes.draw do get 'webhooks/instagram', to: 'webhooks/instagram#verify' post 'webhooks/instagram', to: 'webhooks/instagram#events' post 'webhooks/tiktok', to: 'webhooks/tiktok#events' + post 'webhooks/shopify', to: 'webhooks/shopify#events' namespace :twitter do resource :callback, only: [:show]