Module: HookSniff

Defined in:
lib/hooksniff.rb,
lib/hooksniff/client.rb,
lib/hooksniff/errors.rb,
lib/hooksniff/models.rb,
lib/hooksniff/version.rb,
lib/hooksniff/verification.rb

Defined Under Namespace

Modules: Models Classes: AuthenticationError, Client, EndpointsResource, Error, NotFoundError, PayloadTooLargeError, RateLimitError, ValidationError, WebhooksResource

Constant Summary collapse

DEFAULT_BASE_URL =

Default API base URL

"https://hooksniff-api-1046140057667.europe-west1.run.app/v1"
DEFAULT_TIMEOUT =

Default request timeout in seconds

30
VERSION =
"0.1.0"

Class Method Summary collapse

Class Method Details

.secure_compare(a, b) ⇒ Object

Constant-time string comparison



126
127
128
129
130
131
132
133
# File 'lib/hooksniff/verification.rb', line 126

def self.secure_compare(a, b)
  return false if a.nil? || b.nil?
  return false if a.bytesize != b.bytesize

  result = 0
  a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
  result == 0
end

.verify_signature(payload, signature, secret) ⇒ Boolean

Verify a webhook signature using HMAC-SHA256.

Parameters:

  • payload (String)

    The raw request body

  • signature (String)

    The signature from the X-Hookrelay-Signature header

  • secret (String)

    The endpoint’s signing secret (starts with “whsec_”)

Returns:

  • (Boolean)

    true if the signature is valid



10
11
12
13
14
15
16
17
18
19
20
21
22
23
# File 'lib/hooksniff/verification.rb', line 10

def self.verify_signature(payload, signature, secret)
  return false if payload.nil? || payload.empty?
  return false if signature.nil? || signature.empty?
  return false if secret.nil? || secret.empty?

  expected_hex = signature.start_with?("sha256=") ? signature[7..] : signature

  computed = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)

  # Constant-time comparison to prevent timing attacks
  secure_compare(computed, expected_hex)
rescue
  false
end

.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300) ⇒ Hash

Verify a webhook signature from an incoming request (Standard Webhooks compatible).

Parameters:

  • payload (String)

    The raw request body

  • msg_id (String)

    The webhook-id header

  • timestamp (String)

    The webhook-timestamp header

  • signature_header (String)

    The webhook-signature header

  • secret (String)

    The endpoint’s signing secret

  • tolerance_secs (Integer) (defaults to: 300)

    Max age in seconds (default: 300)

Returns:

  • (Hash)

    { valid: bool, payload: parsed_data, error: string }



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/hooksniff/verification.rb', line 69

def self.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300)
  return { valid: false, error: "Missing webhook-id header" } if msg_id.nil? || msg_id.empty?
  return { valid: false, error: "Missing webhook-timestamp header" } if timestamp.nil? || timestamp.empty?
  return { valid: false, error: "Missing webhook-signature header" } if signature_header.nil? || signature_header.empty?
  return { valid: false, error: "Missing request body" } if payload.nil? || payload.empty?

  ts = timestamp.to_i
  return { valid: false, error: "Invalid webhook timestamp" } if ts == 0

  now = Time.now.to_i

  if now - ts > tolerance_secs
    return { valid: false, error: "Message timestamp too old" }
  end
  if ts > now + tolerance_secs
    return { valid: false, error: "Message timestamp too new" }
  end

  # Compute expected signature
  signed_content = "#{msg_id}.#{timestamp}.#{payload}"
  secret_bytes = decode_secret(secret)

  expected_sig = Base64.strict_encode64(
    OpenSSL::HMAC.digest("SHA256", secret_bytes, signed_content)
  )
  expected_full = "v1,#{expected_sig}"

  # Check each signature in the header (space-separated)
  signatures = signature_header.split(" ")
  verified = signatures.any? do |sig|
    sig_stripped = sig.strip
    next unless sig_stripped.start_with?("v1,")
    secure_compare(sig_stripped, expected_full)
  end

  unless verified
    return { valid: false, error: "Invalid webhook signature" }
  end

  # Parse the payload
  begin
    parsed = JSON.parse(payload)
    { valid: true, payload: parsed }
  rescue JSON::ParserError
    { valid: true, payload: payload }
  end
end

.verify_webhook_from_headers(payload:, headers:, secret:, tolerance_secs: 300) ⇒ Hash

Verify a webhook signature from an incoming request (Standard Webhooks + Svix compatible).

Supports both Standard Webheaders headers (webhook-id, webhook-signature, webhook-timestamp) and Svix headers (svix-id, svix-signature, svix-timestamp) as fallback.

Parameters:

  • payload (String)

    The raw request body

  • headers (Hash)

    The request headers (symbol or string keys)

  • secret (String)

    The endpoint’s signing secret

  • tolerance_secs (Integer) (defaults to: 300)

    Max age in seconds (default: 300)

Returns:

  • (Hash)

    { valid: bool, payload: parsed_data, error: string }



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/hooksniff/verification.rb', line 35

def self.verify_webhook_from_headers(payload:, headers:, secret:, tolerance_secs: 300)
  # Normalize header keys to lowercase strings
  normalized = headers.transform_keys { |k| k.to_s.downcase }

  msg_id = normalized["webhook-id"]
  timestamp = normalized["webhook-timestamp"]
  signature_header = normalized["webhook-signature"]

  # Fallback to Svix headers
  unless msg_id && timestamp && signature_header
    msg_id ||= normalized["svix-id"]
    timestamp ||= normalized["svix-timestamp"]
    signature_header ||= normalized["svix-signature"]
  end

  verify_webhook(
    payload: payload,
    msg_id: msg_id,
    timestamp: timestamp,
    signature_header: signature_header,
    secret: secret,
    tolerance_secs: tolerance_secs,
  )
end