From 34c98fdfd87413d3668b49df5698e672d497f146 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Tue, 10 Feb 2026 17:08:16 -0600 Subject: [PATCH] Support new shopify-* webhook header format Update Webhooks::Request to accept both the new `shopify-*` header format (e.g., `shopify-hmac-sha256`) and the legacy `x-shopify-*` format, with the new format taking precedence when both are present. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/shopify_api/webhooks/request.rb | 27 +++++++++------ test/webhooks/registry_test.rb | 38 +++++++++++++++++++++ test/webhooks/request_test.rb | 52 +++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/lib/shopify_api/webhooks/request.rb b/lib/shopify_api/webhooks/request.rb index 23e077353..ec16a4f08 100644 --- a/lib/shopify_api/webhooks/request.rb +++ b/lib/shopify_api/webhooks/request.rb @@ -9,27 +9,27 @@ class Request sig { override.returns(String) } def hmac - Digest.hexencode(Base64.decode64(T.cast(@headers["x-shopify-hmac-sha256"], String))) + Digest.hexencode(Base64.decode64(T.cast(shopify_header("hmac-sha256"), String))) end sig { returns(String) } def topic - T.cast(@headers["x-shopify-topic"], String) + T.cast(shopify_header("topic"), String) end sig { returns(String) } def shop - T.cast(@headers["x-shopify-shop-domain"], String) + T.cast(shopify_header("shop-domain"), String) end sig { returns(String) } def api_version - T.cast(@headers["x-shopify-api-version"], String) + T.cast(shopify_header("api-version"), String) end sig { returns(String) } def webhook_id - T.cast(@headers["x-shopify-webhook-id"], String) + T.cast(shopify_header("webhook-id"), String) end sig { override.returns(String) } @@ -48,11 +48,11 @@ def initialize(raw_body:, headers:) headers = headers.to_h { |k, v| [k.to_s.downcase.sub("http_", "").gsub("_", "-"), v] } missing_headers = [] - [ - "x-shopify-topic", - "x-shopify-hmac-sha256", - "x-shopify-shop-domain", - ].each { |header| missing_headers << header unless headers.include?(header) } + ["topic", "hmac-sha256", "shop-domain"].each do |name| + unless headers.key?("shopify-#{name}") || headers.key?("x-shopify-#{name}") + missing_headers << "shopify-#{name} or x-shopify-#{name}" + end + end unless missing_headers.empty? raise Errors::InvalidWebhookError, "Missing one or more of the required HTTP headers to process webhooks: #{missing_headers}" @@ -61,6 +61,13 @@ def initialize(raw_body:, headers:) @headers = headers @raw_body = raw_body end + + private + + sig { params(name: String).returns(T.untyped) } + def shopify_header(name) + @headers["shopify-#{name}"] || @headers["x-shopify-#{name}"] + end end end end diff --git a/test/webhooks/registry_test.rb b/test/webhooks/registry_test.rb index ed22283cd..db36602e5 100644 --- a/test/webhooks/registry_test.rb +++ b/test/webhooks/registry_test.rb @@ -263,6 +263,44 @@ def test_process_with_response_as_struct assert(handler_called) end + def test_process_with_new_format_headers + handler_called = false + + handler = TestHelpers::FakeWebhookHandler.new( + lambda do |data| + assert_equal(@topic, data.topic) + assert_equal(@shop, data.shop) + assert_equal({}, data.body) + assert_equal("b1234-eefd-4c9e-9520-049845a02082", data.webhook_id) + assert_equal("2024-01", data.api_version) + handler_called = true + end, + ) + + ShopifyAPI::Webhooks::Registry.add_registration( + topic: @topic, path: "path", delivery_method: :http, handler: handler, + ) + + hmac = OpenSSL::HMAC.digest( + OpenSSL::Digest.new("sha256"), + ShopifyAPI::Context.api_secret_key, + "{}", + ) + + new_format_headers = { + "shopify-topic" => @topic, + "shopify-hmac-sha256" => Base64.encode64(hmac), + "shopify-shop-domain" => @shop, + "shopify-webhook-id" => "b1234-eefd-4c9e-9520-049845a02082", + "shopify-api-version" => "2024-01", + } + + webhook_request = ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: new_format_headers) + ShopifyAPI::Webhooks::Registry.process(webhook_request) + + assert(handler_called) + end + def test_process_hmac_validation_fails headers = { "x-shopify-topic" => "some/topic", diff --git a/test/webhooks/request_test.rb b/test/webhooks/request_test.rb index c242e520d..544a2d979 100644 --- a/test/webhooks/request_test.rb +++ b/test/webhooks/request_test.rb @@ -32,6 +32,58 @@ def test_with_symbol_headers assert(ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: headers)) end + + def test_create_webhook_request_with_new_format_headers + headers = { + "shopify-topic" => "some/topic", + "shopify-hmac-sha256" => "some_hmac", + "shopify-shop-domain" => "shop.myshopify.com", + } + + assert(ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: headers)) + end + + def test_create_webhook_request_with_both_header_formats + headers = { + "x-shopify-topic" => "some/topic", + "x-shopify-hmac-sha256" => "some_hmac", + "x-shopify-shop-domain" => "shop.myshopify.com", + "shopify-topic" => "some/topic", + "shopify-hmac-sha256" => "some_hmac", + "shopify-shop-domain" => "shop.myshopify.com", + } + + assert(ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: headers)) + end + + def test_accessor_values_with_new_format_headers + hmac_value = Base64.encode64("test_hmac_bytes") + headers = { + "shopify-topic" => "orders/create", + "shopify-hmac-sha256" => hmac_value, + "shopify-shop-domain" => "test-shop.myshopify.com", + "shopify-api-version" => "2024-01", + "shopify-webhook-id" => "b1234-eefd-4c9e-9520-049845a02082", + } + + request = ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: headers) + + assert_equal("orders/create", request.topic) + assert_equal("test-shop.myshopify.com", request.shop) + assert_equal("2024-01", request.api_version) + assert_equal("b1234-eefd-4c9e-9520-049845a02082", request.webhook_id) + assert_equal(Digest.hexencode(Base64.decode64(hmac_value)), request.hmac) + end + + def test_error_when_headers_missing_in_either_format + error = assert_raises(ShopifyAPI::Errors::InvalidWebhookError) do + ShopifyAPI::Webhooks::Request.new(raw_body: "{}", headers: {}) + end + + assert_includes(error.message, "shopify-topic or x-shopify-topic") + assert_includes(error.message, "shopify-hmac-sha256 or x-shopify-hmac-sha256") + assert_includes(error.message, "shopify-shop-domain or x-shopify-shop-domain") + end end end end