class Webhooks::Verifier
def initialize(secret: Rails.application.credentials.webhooks_secret)
@secret = secret
end
def valid?(request)
timestamp = request.headers['X-Timestamp'].to_i
return false if timestamp.zero? || (Time.current.to_i - timestamp).abs > 300
signature = request.headers['X-Signature'].to_s
body = request.raw_post
expected = OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{timestamp}.#{body}")
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
end
class WebhooksController < ActionController::API
def receive
head :unauthorized and return unless Webhooks::Verifier.new.valid?(request)
payload = JSON.parse(request.raw_post)
event_id = payload.fetch('id')
return head(:ok) if ProcessedWebhookEvent.exists?(event_id: event_id)
ProcessedWebhookEvent.create!(event_id: event_id)
Webhooks::DispatchJob.perform_later(event_id, payload)
head :accepted
end
end
Webhooks are a security boundary. Verify signatures with constant-time compare, include a timestamp window to prevent replay, and store processed event IDs to make handlers idempotent.