Module: Unitpost::Webhooks

Defined in:
lib/unitpost/webhooks.rb

Overview

Webhook signature verification (svix-compatible). Mirrors the engine signer:

signed content: "<svix-id>.<svix-timestamp>.<raw-body>"
signature:      base64( HMAC_SHA256(secret_bytes, signed_content) )
header:         svix-signature = "v1,<base64>" (space-separated list allowed)
secret:         "whsec_<base64url>" — bytes after the prefix are the key.

Verify the RAW request body string (not a re-serialized hash).

Constant Summary collapse

SECRET_PREFIX =
"whsec_"

Class Method Summary collapse

Class Method Details

.header(headers, name) ⇒ Object



56
57
58
# File 'lib/unitpost/webhooks.rb', line 56

def header(headers, name)
  headers[name] || headers[name.downcase] || headers[name.split("-").map(&:capitalize).join("-")]
end

.secret_key_bytes(secret) ⇒ Object



60
61
62
63
# File 'lib/unitpost/webhooks.rb', line 60

def secret_key_bytes(secret)
  raw = secret.start_with?(SECRET_PREFIX) ? secret[SECRET_PREFIX.length..] : secret
  Base64.urlsafe_decode64(raw + ("=" * ((4 - (raw.length % 4)) % 4)))
end

.secure_compare(a, b) ⇒ Object



65
66
67
68
69
70
71
72
73
74
# File 'lib/unitpost/webhooks.rb', line 65

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  OpenSSL.fixed_length_secure_compare(a, b)
rescue StandardError
  # Fallback for older OpenSSL without fixed_length_secure_compare.
  res = 0
  a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
  res.zero?
end

.verify(payload:, secret:, headers:, tolerance_seconds: 300) ⇒ Object



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/unitpost/webhooks.rb', line 21

def verify(payload:, secret:, headers:, tolerance_seconds: 300)
  msg_id = header(headers, "svix-id")
  timestamp = header(headers, "svix-timestamp")
  signature = header(headers, "svix-signature")

  if msg_id.nil? || timestamp.nil? || signature.nil?
    raise WebhookVerificationError, "Missing svix-id, svix-timestamp, or svix-signature header."
  end

  ts = Integer(timestamp, exception: false)
  raise WebhookVerificationError, "Invalid svix-timestamp header." if ts.nil?

  now = Time.now.to_i
  if (now - ts).abs > tolerance_seconds
    raise WebhookVerificationError, "Webhook timestamp is outside the tolerance window (possible replay)."
  end

  signed_content = "#{msg_id}.#{ts}.#{payload}"
  expected = Base64.strict_encode64(
    OpenSSL::HMAC.digest("SHA256", secret_key_bytes(secret), signed_content)
  )

  matched = signature.split(" ").any? do |part|
    _, sig = part.split(",", 2)
    sig && secure_compare(sig, expected)
  end
  raise WebhookVerificationError, "Webhook signature verification failed." unless matched

  begin
    JSON.parse(payload)
  rescue JSON::ParserError
    raise WebhookVerificationError, "Webhook payload is not valid JSON."
  end
end