hooksmith 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Rails
5
+ # A concern for Rails controllers that handle webhooks.
6
+ #
7
+ # This concern provides standardized webhook handling with:
8
+ # - Automatic request verification (if configured)
9
+ # - Consistent response codes (200 for success, 400 for bad request, 500 for errors)
10
+ # - Error logging and instrumentation
11
+ # - Skip CSRF protection for webhook endpoints
12
+ #
13
+ # @example Basic usage
14
+ # class WebhooksController < ApplicationController
15
+ # include Hooksmith::Rails::WebhooksController
16
+ #
17
+ # def stripe
18
+ # handle_webhook(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h)
19
+ # end
20
+ # end
21
+ #
22
+ # @example With custom error handling
23
+ # class WebhooksController < ApplicationController
24
+ # include Hooksmith::Rails::WebhooksController
25
+ #
26
+ # def stripe
27
+ # handle_webhook(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h) do |result|
28
+ # # Custom success handling
29
+ # render json: { processed: true, result: result }
30
+ # end
31
+ # rescue Hooksmith::VerificationError => e
32
+ # render json: { error: 'Invalid signature' }, status: :unauthorized
33
+ # end
34
+ # end
35
+ #
36
+ # @example Async processing with ActiveJob
37
+ # class WebhooksController < ApplicationController
38
+ # include Hooksmith::Rails::WebhooksController
39
+ #
40
+ # def stripe
41
+ # handle_webhook_async(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h)
42
+ # end
43
+ # end
44
+ #
45
+ module WebhooksController
46
+ extend ActiveSupport::Concern
47
+
48
+ included do
49
+ skip_before_action :verify_authenticity_token, raise: false
50
+ before_action :verify_webhook_signature, if: :hooksmith_verification_enabled?
51
+ end
52
+
53
+ # Handles a webhook synchronously.
54
+ #
55
+ # @param provider [String, Symbol] the webhook provider
56
+ # @param event [String, Symbol] the event type
57
+ # @param payload [Hash] the webhook payload
58
+ # @yield [result] optional block for custom success handling
59
+ # @yieldparam result [Object] the result from the processor
60
+ # @return [void]
61
+ def handle_webhook(provider:, event:, payload:)
62
+ result = Hooksmith::Dispatcher.new(provider:, event:, payload:).run!
63
+
64
+ if block_given?
65
+ yield(result)
66
+ else
67
+ head :ok
68
+ end
69
+ rescue Hooksmith::MultipleProcessorsError => e
70
+ Hooksmith.logger.error("Webhook error: #{e.message}")
71
+ head :internal_server_error
72
+ rescue StandardError => e
73
+ Hooksmith.logger.error("Webhook processing failed: #{e.message}")
74
+ head :internal_server_error
75
+ end
76
+
77
+ # Handles a webhook asynchronously using ActiveJob.
78
+ #
79
+ # Requires Hooksmith::Jobs::DispatcherJob to be available.
80
+ #
81
+ # @param provider [String, Symbol] the webhook provider
82
+ # @param event [String, Symbol] the event type
83
+ # @param payload [Hash] the webhook payload
84
+ # @param queue [Symbol, String] the queue to use (default: :default)
85
+ # @return [void]
86
+ def handle_webhook_async(provider:, event:, payload:, queue: :default)
87
+ unless defined?(Hooksmith::Jobs::DispatcherJob)
88
+ raise 'Hooksmith::Jobs::DispatcherJob is not available. Ensure ActiveJob is loaded.'
89
+ end
90
+
91
+ Hooksmith::Jobs::DispatcherJob.set(queue:).perform_later(
92
+ provider: provider.to_s,
93
+ event: event.to_s,
94
+ payload: payload.as_json
95
+ )
96
+
97
+ head :ok
98
+ end
99
+
100
+ private
101
+
102
+ # Verifies the webhook signature using the provider's configured verifier.
103
+ #
104
+ # Override this method to customize verification behavior.
105
+ #
106
+ # @return [void]
107
+ # @raise [Hooksmith::VerificationError] if verification fails
108
+ def verify_webhook_signature
109
+ provider = hooksmith_provider_name
110
+ return unless provider
111
+
112
+ verifier = Hooksmith.configuration.verifier_for(provider)
113
+ return unless verifier&.enabled?
114
+
115
+ hooksmith_request = Hooksmith::Request.new(
116
+ headers: request.headers.to_h,
117
+ body: request.raw_post
118
+ )
119
+
120
+ verifier.verify!(hooksmith_request)
121
+ rescue Hooksmith::VerificationError => e
122
+ Hooksmith.logger.warn("Webhook verification failed for #{provider}: #{e.message}")
123
+ head :unauthorized
124
+ end
125
+
126
+ # Returns the provider name for the current action.
127
+ #
128
+ # Override this method to customize provider detection.
129
+ # By default, uses the action name.
130
+ #
131
+ # @return [String, nil] the provider name
132
+ def hooksmith_provider_name
133
+ action_name
134
+ end
135
+
136
+ # Checks if webhook verification is enabled for the current provider.
137
+ #
138
+ # Override this method to customize when verification runs.
139
+ #
140
+ # @return [Boolean] true if verification should run
141
+ def hooksmith_verification_enabled?
142
+ return false unless Hooksmith.configuration.respond_to?(:verifier_for)
143
+
144
+ provider = hooksmith_provider_name
145
+ return false unless provider
146
+
147
+ verifier = Hooksmith.configuration.verifier_for(provider)
148
+ verifier&.enabled? || false
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ # Wrapper for incoming webhook request data.
5
+ #
6
+ # This class provides a consistent interface for accessing request data
7
+ # regardless of the underlying web framework (Rails, Rack, etc.).
8
+ #
9
+ # @example Creating a request from Rails controller
10
+ # request = Hooksmith::Request.new(
11
+ # headers: request.headers.to_h,
12
+ # body: request.raw_post,
13
+ # method: request.request_method,
14
+ # path: request.path
15
+ # )
16
+ #
17
+ # @example Creating a request from Rack env
18
+ # request = Hooksmith::Request.from_rack_env(env)
19
+ #
20
+ class Request
21
+ # @return [Hash] the request headers
22
+ attr_reader :headers
23
+ # @return [String] the raw request body
24
+ attr_reader :body
25
+ # @return [String] the HTTP method (GET, POST, etc.)
26
+ attr_reader :method
27
+ # @return [String] the request path
28
+ attr_reader :path
29
+ # @return [Hash] the parsed payload (optional, for convenience)
30
+ attr_reader :payload
31
+
32
+ # Initializes a new Request.
33
+ #
34
+ # @param headers [Hash] the request headers
35
+ # @param body [String] the raw request body
36
+ # @param method [String] the HTTP method
37
+ # @param path [String] the request path
38
+ # @param payload [Hash] the parsed payload (optional)
39
+ def initialize(headers:, body:, method: 'POST', path: '/', payload: nil)
40
+ @headers = normalize_headers(headers)
41
+ @body = body.to_s
42
+ @method = method.to_s.upcase
43
+ @path = path.to_s
44
+ @payload = payload
45
+ end
46
+
47
+ # Creates a Request from a Rack environment hash.
48
+ #
49
+ # @param env [Hash] the Rack environment
50
+ # @return [Request] a new Request instance
51
+ def self.from_rack_env(env)
52
+ headers = extract_headers_from_rack(env)
53
+ body = env['rack.input']&.read || ''
54
+ env['rack.input']&.rewind
55
+
56
+ new(
57
+ headers:,
58
+ body:,
59
+ method: env['REQUEST_METHOD'],
60
+ path: env['PATH_INFO']
61
+ )
62
+ end
63
+
64
+ # Gets a header value by name (case-insensitive).
65
+ #
66
+ # @param name [String] the header name
67
+ # @return [String, nil] the header value or nil if not found
68
+ def header(name)
69
+ normalized_name = normalize_header_name(name)
70
+ @headers[normalized_name]
71
+ end
72
+
73
+ # Alias for {#header}.
74
+ #
75
+ # @param name [String] the header name
76
+ # @return [String, nil] the header value or nil if not found
77
+ def [](name)
78
+ header(name)
79
+ end
80
+
81
+ # Extracts headers from a Rack environment hash.
82
+ #
83
+ # @param env [Hash] the Rack environment
84
+ # @return [Hash] the extracted headers
85
+ def self.extract_headers_from_rack(env)
86
+ env.select { |k, _| k.start_with?('HTTP_') || %w[CONTENT_TYPE CONTENT_LENGTH].include?(k) }
87
+ end
88
+ private_class_method :extract_headers_from_rack
89
+
90
+ private
91
+
92
+ # Normalizes headers to use consistent key format.
93
+ #
94
+ # @param headers [Hash] the raw headers
95
+ # @return [Hash] normalized headers
96
+ def normalize_headers(headers)
97
+ return {} unless headers.is_a?(Hash)
98
+
99
+ headers.transform_keys { |k| normalize_header_name(k) }
100
+ end
101
+
102
+ # Normalizes a header name to a consistent format.
103
+ #
104
+ # @param name [String] the header name
105
+ # @return [String] the normalized header name
106
+ def normalize_header_name(name)
107
+ name.to_s.upcase.tr('-', '_').sub(/^HTTP_/, '')
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Verifiers
5
+ # Base class for webhook request verifiers.
6
+ #
7
+ # Verifiers are responsible for authenticating incoming webhook requests
8
+ # before they are processed. Each provider can have its own verifier
9
+ # configured to handle provider-specific authentication schemes.
10
+ #
11
+ # @abstract Subclass and override {#verify!} to implement custom verification.
12
+ #
13
+ # @example Creating a custom verifier
14
+ # class MyCustomVerifier < Hooksmith::Verifiers::Base
15
+ # def verify!(request)
16
+ # token = request.headers['X-Custom-Token']
17
+ # raise Hooksmith::VerificationError, 'Invalid token' unless valid_token?(token)
18
+ # end
19
+ #
20
+ # private
21
+ #
22
+ # def valid_token?(token)
23
+ # token == @options[:expected_token]
24
+ # end
25
+ # end
26
+ #
27
+ class Base
28
+ # @return [Hash] options passed to the verifier
29
+ attr_reader :options
30
+
31
+ # Initializes the verifier with options.
32
+ #
33
+ # @param options [Hash] verifier-specific options
34
+ def initialize(**options)
35
+ @options = options
36
+ end
37
+
38
+ # Verifies the incoming webhook request.
39
+ #
40
+ # @param request [Hooksmith::Request] the incoming request to verify
41
+ # @raise [Hooksmith::VerificationError] if verification fails
42
+ # @return [void]
43
+ def verify!(request)
44
+ raise NotImplementedError, 'Subclasses must implement #verify!'
45
+ end
46
+
47
+ # Returns whether the verifier is configured and should be used.
48
+ #
49
+ # @return [Boolean] true if the verifier should be applied
50
+ def enabled?
51
+ true
52
+ end
53
+
54
+ protected
55
+
56
+ # Performs a constant-time string comparison to prevent timing attacks.
57
+ #
58
+ # @param expected [String] expected string
59
+ # @param actual [String] actual string
60
+ # @return [Boolean] true if strings are equal
61
+ def secure_compare(expected, actual)
62
+ return false if expected.nil? || actual.nil?
63
+ return false if expected.bytesize != actual.bytesize
64
+
65
+ # Use OpenSSL's secure comparison if available (Ruby 2.5+)
66
+ if OpenSSL.respond_to?(:secure_compare)
67
+ OpenSSL.secure_compare(expected, actual)
68
+ else
69
+ # Fallback to manual constant-time comparison
70
+ left = expected.unpack('C*')
71
+ right = actual.unpack('C*')
72
+ result = 0
73
+ left.zip(right) { |x, y| result |= x ^ y }
74
+ result.zero?
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Hooksmith
6
+ module Verifiers
7
+ # Bearer token webhook verifier.
8
+ #
9
+ # This verifier validates webhook requests using a simple bearer token
10
+ # in the Authorization header or a custom header.
11
+ #
12
+ # @example Basic bearer token verification
13
+ # verifier = Hooksmith::Verifiers::BearerToken.new(
14
+ # token: ENV['WEBHOOK_TOKEN']
15
+ # )
16
+ #
17
+ # @example Custom header
18
+ # verifier = Hooksmith::Verifiers::BearerToken.new(
19
+ # token: ENV['WEBHOOK_TOKEN'],
20
+ # header: 'X-Webhook-Token'
21
+ # )
22
+ #
23
+ class BearerToken < Base
24
+ # Default header for bearer tokens
25
+ DEFAULT_HEADER = 'Authorization'
26
+
27
+ # Initializes the bearer token verifier.
28
+ #
29
+ # @param token [String] the expected token value
30
+ # @param header [String] the header containing the token (default: Authorization)
31
+ # @param strip_bearer_prefix [Boolean] whether to strip 'Bearer ' prefix (default: true)
32
+ def initialize(token:, header: DEFAULT_HEADER, strip_bearer_prefix: true, **options)
33
+ super(**options)
34
+ @token = token
35
+ @header = header
36
+ @strip_bearer_prefix = strip_bearer_prefix
37
+ end
38
+
39
+ # Verifies the bearer token in the request.
40
+ #
41
+ # @param request [Hooksmith::Request] the incoming request
42
+ # @raise [Hooksmith::VerificationError] if verification fails
43
+ # @return [void]
44
+ def verify!(request)
45
+ provided_token = extract_token(request)
46
+
47
+ if provided_token.nil? || provided_token.empty?
48
+ raise VerificationError.new('Missing authentication token', reason: 'missing_token')
49
+ end
50
+
51
+ return if secure_compare(@token, provided_token)
52
+
53
+ raise VerificationError.new('Invalid authentication token', reason: 'invalid_token')
54
+ end
55
+
56
+ # Returns whether the verifier is properly configured.
57
+ #
58
+ # @return [Boolean] true if token is present
59
+ def enabled?
60
+ !@token.nil? && !@token.empty?
61
+ end
62
+
63
+ private
64
+
65
+ # Extracts the token from the request headers.
66
+ #
67
+ # @param request [Hooksmith::Request] the incoming request
68
+ # @return [String, nil] the extracted token
69
+ def extract_token(request)
70
+ raw_token = request.header(@header)
71
+ return nil if raw_token.nil?
72
+
73
+ token = raw_token.to_s.strip
74
+ token = token.sub(/\ABearer\s+/i, '') if @strip_bearer_prefix
75
+ token.empty? ? nil : token
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module Hooksmith
7
+ module Verifiers
8
+ # HMAC-based webhook signature verifier.
9
+ #
10
+ # This verifier validates webhook requests using HMAC signatures,
11
+ # which is a common authentication method used by providers like
12
+ # Stripe, GitHub, Shopify, and many others.
13
+ #
14
+ # @example Basic HMAC verification
15
+ # verifier = Hooksmith::Verifiers::Hmac.new(
16
+ # secret: ENV['WEBHOOK_SECRET'],
17
+ # header: 'X-Signature'
18
+ # )
19
+ #
20
+ # @example HMAC with timestamp validation (like Stripe)
21
+ # verifier = Hooksmith::Verifiers::Hmac.new(
22
+ # secret: ENV['STRIPE_WEBHOOK_SECRET'],
23
+ # header: 'Stripe-Signature',
24
+ # timestamp_options: { header: 'Stripe-Signature', tolerance: 300 }
25
+ # )
26
+ #
27
+ # @example HMAC with custom signature format
28
+ # verifier = Hooksmith::Verifiers::Hmac.new(
29
+ # secret: ENV['WEBHOOK_SECRET'],
30
+ # header: 'X-Hub-Signature-256',
31
+ # algorithm: 'sha256',
32
+ # signature_prefix: 'sha256='
33
+ # )
34
+ #
35
+ class Hmac < Base
36
+ # Supported HMAC algorithms
37
+ ALGORITHMS = %w[sha1 sha256 sha384 sha512].freeze
38
+
39
+ # Default signature encoding
40
+ DEFAULT_ENCODING = :hex
41
+
42
+ # Initializes the HMAC verifier.
43
+ #
44
+ # @param secret [String] the shared secret key
45
+ # @param header [String] the header containing the signature
46
+ # @param algorithm [String] the HMAC algorithm (sha1, sha256, sha384, sha512)
47
+ # @param encoding [Symbol] the signature encoding (:hex or :base64)
48
+ # @param signature_prefix [String, nil] prefix to strip from signature (e.g., 'sha256=')
49
+ # @param timestamp_options [Hash] timestamp validation options
50
+ # @option timestamp_options [String] :header header containing the timestamp
51
+ # @option timestamp_options [Integer] :tolerance max age of request in seconds (default: 300)
52
+ # @option timestamp_options [Symbol] :format timestamp format (:unix or :iso8601)
53
+ def initialize(secret:, header:, **options)
54
+ # Extract known options and pass remaining to parent (avoids ActiveSupport dependency)
55
+ known_keys = %i[algorithm encoding signature_prefix timestamp_options]
56
+ # rubocop:disable Style/HashExcept -- intentionally avoiding ActiveSupport's Hash#except
57
+ parent_options = options.reject { |k, _| known_keys.include?(k) }
58
+ # rubocop:enable Style/HashExcept
59
+ super(**parent_options)
60
+ @secret = secret
61
+ @header = header
62
+ @algorithm = validate_algorithm(options.fetch(:algorithm, 'sha256'))
63
+ @encoding = options.fetch(:encoding, DEFAULT_ENCODING)
64
+ @signature_prefix = options[:signature_prefix]
65
+ configure_timestamp_options(options[:timestamp_options])
66
+ end
67
+
68
+ # Verifies the HMAC signature of the request.
69
+ #
70
+ # @param request [Hooksmith::Request] the incoming request
71
+ # @raise [Hooksmith::VerificationError] if verification fails
72
+ # @return [void]
73
+ def verify!(request)
74
+ signature = extract_signature(request)
75
+ raise VerificationError.new('Missing signature header', reason: 'missing_signature') if signature.nil?
76
+
77
+ verify_timestamp!(request) if @timestamp_header
78
+
79
+ expected = compute_signature(request.body)
80
+
81
+ return if secure_compare(expected, signature)
82
+
83
+ raise VerificationError.new('Invalid signature', reason: 'signature_mismatch')
84
+ end
85
+
86
+ # Returns whether the verifier is properly configured.
87
+ #
88
+ # @return [Boolean] true if secret and header are present
89
+ def enabled?
90
+ !@secret.nil? && !@secret.empty? && !@header.nil? && !@header.empty?
91
+ end
92
+
93
+ private
94
+
95
+ # Configures timestamp validation options.
96
+ #
97
+ # @param timestamp_options [Hash, nil] timestamp options hash
98
+ def configure_timestamp_options(timestamp_options)
99
+ return unless timestamp_options
100
+
101
+ @timestamp_header = timestamp_options[:header]
102
+ @timestamp_tolerance = timestamp_options.fetch(:tolerance, 300)
103
+ @timestamp_format = timestamp_options.fetch(:format, :unix)
104
+ end
105
+
106
+ # Validates the HMAC algorithm.
107
+ #
108
+ # @param algorithm [String] the algorithm name
109
+ # @return [String] the validated algorithm
110
+ # @raise [ArgumentError] if the algorithm is not supported
111
+ def validate_algorithm(algorithm)
112
+ algo = algorithm.to_s.downcase
113
+ unless ALGORITHMS.include?(algo)
114
+ raise ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{ALGORITHMS.join(', ')}"
115
+ end
116
+
117
+ algo
118
+ end
119
+
120
+ # Extracts the signature from the request headers.
121
+ #
122
+ # @param request [Hooksmith::Request] the incoming request
123
+ # @return [String, nil] the extracted signature
124
+ def extract_signature(request)
125
+ raw_signature = request.header(@header)
126
+ return nil if raw_signature.nil? || raw_signature.empty?
127
+
128
+ signature = raw_signature.to_s
129
+ signature = signature.sub(/\A#{Regexp.escape(@signature_prefix)}/, '') if @signature_prefix
130
+ signature.strip
131
+ end
132
+
133
+ # Computes the expected HMAC signature for the body.
134
+ #
135
+ # @param body [String] the request body
136
+ # @return [String] the computed signature
137
+ def compute_signature(body)
138
+ digest = OpenSSL::HMAC.digest(@algorithm, @secret, body)
139
+
140
+ case @encoding
141
+ when :base64
142
+ Base64.strict_encode64(digest)
143
+ else
144
+ digest.unpack1('H*')
145
+ end
146
+ end
147
+
148
+ # Verifies the timestamp is within tolerance.
149
+ #
150
+ # @param request [Hooksmith::Request] the incoming request
151
+ # @raise [Hooksmith::VerificationError] if timestamp is invalid or expired
152
+ def verify_timestamp!(request)
153
+ timestamp_value = request.header(@timestamp_header)
154
+ raise VerificationError.new('Missing timestamp header', reason: 'missing_timestamp') if timestamp_value.nil?
155
+
156
+ timestamp = parse_timestamp(timestamp_value)
157
+ raise VerificationError.new('Invalid timestamp format', reason: 'invalid_timestamp') if timestamp.nil?
158
+
159
+ age = (Time.now - timestamp).abs
160
+ return unless age > @timestamp_tolerance
161
+
162
+ raise VerificationError.new(
163
+ "Request timestamp too old (#{age.to_i}s > #{@timestamp_tolerance}s)",
164
+ reason: 'timestamp_expired'
165
+ )
166
+ end
167
+
168
+ # Parses a timestamp value.
169
+ #
170
+ # @param value [String] the timestamp value
171
+ # @return [Time, nil] the parsed time or nil if invalid
172
+ def parse_timestamp(value)
173
+ case @timestamp_format
174
+ when :iso8601
175
+ Time.iso8601(value)
176
+ else
177
+ Time.at(value.to_i)
178
+ end
179
+ rescue ArgumentError, TypeError
180
+ nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hooksmith
4
- VERSION = '0.2.0'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/hooksmith.rb CHANGED
@@ -1,14 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'hooksmith/version'
4
+ require 'hooksmith/errors'
4
5
  require 'hooksmith/configuration'
5
6
  require 'hooksmith/config/provider'
6
7
  require 'hooksmith/config/event_store'
8
+ require 'hooksmith/request'
9
+ require 'hooksmith/instrumentation'
7
10
  require 'hooksmith/dispatcher'
8
11
  require 'hooksmith/logger'
9
12
  require 'hooksmith/event_recorder'
13
+ require 'hooksmith/idempotency'
10
14
  require 'hooksmith/processor/base'
11
- require 'hooksmith/railtie' if defined?(Rails)
15
+ require 'hooksmith/jobs/dispatcher_job' if defined?(ActiveJob)
16
+ require 'hooksmith/verifiers/base'
17
+ require 'hooksmith/verifiers/hmac'
18
+ require 'hooksmith/verifiers/bearer_token'
19
+
20
+ if defined?(Rails)
21
+ require 'hooksmith/railtie'
22
+ require 'hooksmith/rails/webhooks_controller'
23
+ end
12
24
 
13
25
  # Main entry point for the Hooksmith gem.
14
26
  #
@@ -21,6 +33,22 @@ require 'hooksmith/railtie' if defined?(Rails)
21
33
  #
22
34
  # Hooksmith::Dispatcher.new(provider: :stripe, event: :charge_succeeded, payload: payload).run!
23
35
  #
36
+ # @example With request verification:
37
+ # Hooksmith.configure do |config|
38
+ # config.provider(:stripe) do |stripe|
39
+ # stripe.verifier = Hooksmith::Verifiers::Hmac.new(
40
+ # secret: ENV['STRIPE_WEBHOOK_SECRET'],
41
+ # header: 'Stripe-Signature'
42
+ # )
43
+ # stripe.register(:charge_succeeded, MyStripeProcessor)
44
+ # end
45
+ # end
46
+ #
47
+ # # In your controller:
48
+ # request = Hooksmith::Request.new(headers: request.headers, body: request.raw_post)
49
+ # Hooksmith.verify!(provider: :stripe, request: request)
50
+ # Hooksmith::Dispatcher.new(provider: :stripe, event: event, payload: payload).run!
51
+ #
24
52
  module Hooksmith
25
53
  # Returns the configuration instance.
26
54
  # @return [Configuration] the configuration instance.
@@ -39,4 +67,26 @@ module Hooksmith
39
67
  def self.logger
40
68
  Logger.instance
41
69
  end
70
+
71
+ # Verifies an incoming webhook request for a provider.
72
+ #
73
+ # @param provider [Symbol, String] the provider name
74
+ # @param request [Hooksmith::Request] the incoming request
75
+ # @raise [Hooksmith::VerificationError] if verification fails
76
+ # @return [void]
77
+ def self.verify!(provider:, request:)
78
+ verifier = configuration.verifier_for(provider)
79
+ return unless verifier&.enabled?
80
+
81
+ verifier.verify!(request)
82
+ end
83
+
84
+ # Checks if a provider has a verifier configured.
85
+ #
86
+ # @param provider [Symbol, String] the provider name
87
+ # @return [Boolean] true if a verifier is configured
88
+ def self.verifier_configured?(provider)
89
+ verifier = configuration.verifier_for(provider)
90
+ verifier&.enabled? || false
91
+ end
42
92
  end