Module: Blueticks::Webhooks

Defined in:
lib/blueticks/webhooks.rb

Overview

Webhook signature verification. Use this in your webhook handler to confirm requests really came from Blueticks before trusting the payload.

event = Blueticks::Webhooks.verify(
  payload: raw_body,
  headers: request.headers,
  secret: ENV["BLUETICKS_WEBHOOK_SECRET"]
)

Returns a Blueticks::Types::WebhookEvent. Raises Blueticks::Errors::WebhookVerificationError on any mismatch.

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =
300

Class Method Summary collapse

Class Method Details

.extract_v1_signature(signature_raw) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



86
87
88
89
90
91
92
# File 'lib/blueticks/webhooks.rb', line 86

def extract_v1_signature(signature_raw)
  signature_raw.split(",").each do |part|
    trimmed = part.strip
    return trimmed[3..] if trimmed.start_with?("v1=")
  end
  nil
end

.lookup_header(headers, name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/blueticks/webhooks.rb', line 58

def lookup_header(headers, name)
  return nil if headers.nil?

  direct = headers[name] || headers[name.to_sym]
  return normalize_header(direct) unless direct.nil?

  lower = name.downcase
  headers.each do |k, v|
    return normalize_header(v) if k.to_s.downcase == lower
  end
  nil
end

.normalize_header(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



72
73
74
75
76
# File 'lib/blueticks/webhooks.rb', line 72

def normalize_header(value)
  return value[0] if value.is_a?(Array)

  value.to_s
end

.parse_payload(payload_str) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



95
96
97
98
99
100
101
102
# File 'lib/blueticks/webhooks.rb', line 95

def parse_payload(payload_str)
  data = JSON.parse(payload_str)
  raise Errors::WebhookVerificationError, "invalid_signature: payload not JSON object" unless data.is_a?(Hash)

  data
rescue JSON::ParserError
  raise Errors::WebhookVerificationError, "invalid_signature: payload not JSON"
end

.parse_timestamp(raw) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



79
80
81
82
83
# File 'lib/blueticks/webhooks.rb', line 79

def parse_timestamp(raw)
  Integer(raw.to_s)
rescue ArgumentError, TypeError
  raise Errors::WebhookVerificationError, "invalid timestamp"
end

.secure_equal?(expected, supplied) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Constant-time comparison to avoid timing leaks.

Returns:

  • (Boolean)


106
107
108
109
110
# File 'lib/blueticks/webhooks.rb', line 106

def secure_equal?(expected, supplied)
  return false if expected.bytesize != supplied.bytesize

  OpenSSL.fixed_length_secure_compare(expected, supplied)
end

.verify(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS) ⇒ Blueticks::Types::WebhookEvent

Verify a webhook signature and return the parsed event.

Parameters:

  • payload (String)

    the raw request body (bytes), exactly as received

  • headers (Hash)

    the HTTP request headers (case-insensitive lookup)

  • secret (String)

    the webhook signing secret (whsec_…)

  • tolerance (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    max age of timestamp in seconds (default 300)

Returns:

Raises:



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/blueticks/webhooks.rb', line 33

def verify(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS)
  timestamp_raw = lookup_header(headers, "Blueticks-Webhook-Timestamp")
  signature_raw = lookup_header(headers, "Blueticks-Webhook-Signature")

  if timestamp_raw.nil? || timestamp_raw.empty? || signature_raw.nil? || signature_raw.empty?
    raise Errors::WebhookVerificationError, "missing required headers"
  end

  timestamp = parse_timestamp(timestamp_raw)
  raise Errors::WebhookVerificationError, "expired timestamp" if (Time.now.to_i - timestamp).abs > tolerance

  payload_str = payload.is_a?(String) ? payload : payload.to_s
  signed = "#{timestamp}.#{payload_str}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)

  supplied = extract_v1_signature(signature_raw)
  raise Errors::WebhookVerificationError, "invalid_signature: missing v1 scheme" if supplied.nil?

  raise Errors::WebhookVerificationError, "invalid_signature: mismatch" unless secure_equal?(expected, supplied)

  parsed = parse_payload(payload_str)
  Types::WebhookEvent.from_hash(parsed)
end