Module: Dinie::Webhooks

Defined in:
lib/dinie/runtime/webhooks.rb,
sig/dinie/runtime/webhooks.rbs

Overview

Dinie::Webhooks.extract — webhook verification + per-type deserialization, a module-function (RB11, architecture §8, §18.2). Implements the Standard Webhooks v1 contract Dinie inherited:

signed_payload = "{webhook-id}.{webhook-timestamp}.{body}"
expected       = base64(HMAC-SHA256(decode_secret(secret), signed_payload))
header         = "v1,<sig1> v1,<sig2>"   # space-separated, rotation-capable

extract does verification AND deserialization in one call: it reads the three webhook-* headers (case-insensitive, symbol/string), enforces a bidirectional timestamp window (replay guard), decodes the secret (whsec_ → base64), HMAC-SHA256s the signed payload, and compares — in CONSTANT time via OpenSSL.secure_compare — against every v1,<sig> in the header. Any match → Events::DESERIALIZERS[type] hydrates the snake-native body into the typed Events member; an unknown type → UnknownWebhookEventError; no match → WebhookSignatureError. It NEVER returns an unverified event (security by behavior — the name extract returns the event, not a boolean).

Both secret and the signature header accept multiple values, so secret rotation works from either side. Verification needs no OAuth credentials, hence a module-function (not a client method). Mirrors sdk-js src/runtime/webhooks.ts (@ 19c9bca), in Ruby (openssl stdlib; base64 via String#unpack1("m") / Array#pack("m0") — no extra dependency).

── runtime ↔ generated boundary (controlled inverse import — openapi-SoT, §4) ── The general rule is "runtime/ never imports generated/". webhooks.rb is one of two declared exceptions (the other is http.rb → ERROR_REGISTRY). The webhook event catalog's SoT is openapi.yaml (webhooks:), so the typed members AND the Events::DESERIALIZERS table live in generated/events/. This module references the table at call time; the barrel (lib/dinie.rb) loads generated/events before any call. The reference is the forcing-function: an event not in openapi is not in generated/events, not in the table, so extract raises UnknownWebhookEventError — forcing the contract conversation.

── Header normalization (deferred decision §21.5 — RESOLVED: case-insensitive + symbol/string) ── Standard Webhooks headers are lowercase dashed (webhook-id). Inbound frameworks vary in capitalization, so the lookup is case-insensitive and accepts both String and Symbol keys. The partner passes a Hash keyed by the webhook-* names; framework env normalization (Rack's HTTP_WEBHOOK_ID) is the partner's responsibility (a framework adapter is out of scope, §1):

# Rails:   Dinie::Webhooks.extract(headers: request.headers.to_h, body: request.raw_post, secret:)
# Sinatra: Dinie::Webhooks.extract(headers: { "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
#            "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
#            "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"] }, body: request.body.read, secret:)
# Rack:    headers = env.select { |k, _| k.start_with?("HTTP_") }
#            .transform_keys { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") }

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =

Default replay tolerance, in seconds (5 minutes), applied in both directions.

Returns:

  • (Integer)
300
WHSEC_PREFIX =

Secrets carrying this prefix are base64-encoded; the remainder is decoded before HMAC.

Returns:

  • (String)
"whsec_"
SIGNATURE_VERSION =

Only v1 signature tokens are honored.

Returns:

  • (String)
"v1"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.decode_secret(secret) ⇒ 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.

whsec_-prefixed secrets are base64; everything else is treated as raw bytes.



175
176
177
178
179
# File 'lib/dinie/runtime/webhooks.rb', line 175

def decode_secret(secret)
  return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX)

  secret.b
end

.deserialize_verified_event(body) ⇒ 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.

Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are authentic). Dispatch is table-driven through Events::DESERIALIZERS; a type absent from the catalog raises UnknownWebhookEventError (forces the contract conversation).



185
186
187
188
189
190
191
192
# File 'lib/dinie/runtime/webhooks.rb', line 185

def deserialize_verified_event(body)
  raw = JSON.parse(body, symbolize_names: true)
  event_type = raw[:type]
  deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil
  raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil?

  deserializer.call(raw)
end

.extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Dinie::Events::WebhookEventBase

Module-function aliases (callable as Dinie::Webhooks.extract).

Parameters:

  • headers: (Hash[untyped, untyped])
  • body: (String)
  • secret: (String, Array[String])
  • tolerance_seconds: (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

Returns:



77
78
79
80
81
82
83
84
85
# File 'lib/dinie/runtime/webhooks.rb', line 77

def extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
  webhook_id = required_header(headers, "webhook-id", Dinie::WebhookSignatureError)
  timestamp = required_header(headers, "webhook-timestamp", Dinie::WebhookTimestampError)
  signature = required_header(headers, "webhook-signature", Dinie::WebhookSignatureError)

  verify_timestamp!(timestamp, tolerance_seconds)
  verify_signature!(signed_payload(webhook_id, timestamp, body), secret, signature)
  deserialize_verified_event(body)
end

.fetch_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.



112
113
114
115
116
117
118
119
# File 'lib/dinie/runtime/webhooks.rb', line 112

def fetch_header(headers, name)
  return headers[name] if headers.key?(name)
  return headers[name.to_sym] if headers.key?(name.to_sym)

  target = name.downcase
  headers.each { |key, value| return value if key.to_s.downcase == target }
  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.

Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields its first element.



106
107
108
109
# File 'lib/dinie/runtime/webhooks.rb', line 106

def lookup_header(headers, name)
  value = fetch_header(headers, name)
  value.is_a?(Array) ? value.first : value
end

.normalize_secrets(secret) ⇒ 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.



147
148
149
# File 'lib/dinie/runtime/webhooks.rb', line 147

def normalize_secrets(secret)
  Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? }
end

.parse_signature_header(header) ⇒ 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.

Split the webhook-signature header into decoded v1 signatures. Space-separated v1,<base64> tokens; non-v1 and malformed tokens are dropped.



154
155
156
157
158
159
160
161
# File 'lib/dinie/runtime/webhooks.rb', line 154

def parse_signature_header(header)
  header.split.filter_map do |token|
    version, separator, value = token.partition(",")
    next if separator.empty? || value.empty? || version != SIGNATURE_VERSION

    value.unpack1("m")
  end
end

.required_header(headers, name, error_class) ⇒ 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.

Fetch a required header or raise error_class. Missing and empty both fail.

Raises:

  • (error_class)


96
97
98
99
100
101
# File 'lib/dinie/runtime/webhooks.rb', line 96

def required_header(headers, name, error_class)
  value = lookup_header(headers, name)
  return value unless value.nil? || value == ""

  raise error_class, "Missing required webhook header: #{name}."
end

.signatures_match?(payload, secrets, provided_signatures) ⇒ 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.

True when any (secret, signature) pair matches. OpenSSL.secure_compare is constant-time and safe on unequal-length inputs (it digests both first), so no length guard is needed.

Returns:

  • (Boolean)


166
167
168
169
170
171
# File 'lib/dinie/runtime/webhooks.rb', line 166

def signatures_match?(payload, secrets, provided_signatures)
  secrets.any? do |secret|
    expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload)
    provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) }
  end
end

.signed_payload(webhook_id, timestamp, body) ⇒ 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.

Build the byte-faithful signed payload "{id}.{timestamp}.{body}" (binary-concatenated so a non-UTF-8 body never raises an encoding error).



90
91
92
# File 'lib/dinie/runtime/webhooks.rb', line 90

def signed_payload(webhook_id, timestamp, body)
  "#{webhook_id}.#{timestamp}.".b + body.to_s.b
end

.verify_signature!(payload, secret, signature_header) ⇒ 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.

Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).



137
138
139
140
141
142
143
144
# File 'lib/dinie/runtime/webhooks.rb', line 137

def verify_signature!(payload, secret, signature_header)
  secrets = normalize_secrets(secret)
  raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty?
  return if signatures_match?(payload, secrets, parse_signature_header(signature_header))

  raise Dinie::WebhookSignatureError,
        "No matching webhook signature found; the payload may have been tampered with."
end

.verify_timestamp!(timestamp, tolerance_seconds) ⇒ 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.

Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.



124
125
126
127
128
129
130
131
132
133
# File 'lib/dinie/runtime/webhooks.rb', line 124

def verify_timestamp!(timestamp, tolerance_seconds)
  seconds = Integer(timestamp.to_s.strip, 10, exception: false)
  raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{timestamp.inspect}." if seconds.nil?

  skew = Time.now.to_i - seconds
  raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds
  return unless skew < -tolerance_seconds

  raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future."
end

Instance Method Details

#decode_secret(secret) ⇒ 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.

whsec_-prefixed secrets are base64; everything else is treated as raw bytes.

Parameters:

  • secret (String)

Returns:

  • (Object)


175
176
177
178
179
# File 'lib/dinie/runtime/webhooks.rb', line 175

def decode_secret(secret)
  return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX)

  secret.b
end

#deserialize_verified_event(body) ⇒ Dinie::Events::WebhookEventBase

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.

Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are authentic). Dispatch is table-driven through Events::DESERIALIZERS; a type absent from the catalog raises UnknownWebhookEventError (forces the contract conversation).

Parameters:

  • body (String)

Returns:



185
186
187
188
189
190
191
192
# File 'lib/dinie/runtime/webhooks.rb', line 185

def deserialize_verified_event(body)
  raw = JSON.parse(body, symbolize_names: true)
  event_type = raw[:type]
  deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil
  raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil?

  deserializer.call(raw)
end

#fetch_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.

Parameters:

  • headers (Hash[untyped, untyped])
  • name (String)

Returns:

  • (Object)


112
113
114
115
116
117
118
119
# File 'lib/dinie/runtime/webhooks.rb', line 112

def fetch_header(headers, name)
  return headers[name] if headers.key?(name)
  return headers[name.to_sym] if headers.key?(name.to_sym)

  target = name.downcase
  headers.each { |key, value| return value if key.to_s.downcase == target }
  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.

Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields its first element.

Parameters:

  • headers (Hash[untyped, untyped])
  • name (String)

Returns:

  • (Object)


106
107
108
109
# File 'lib/dinie/runtime/webhooks.rb', line 106

def lookup_header(headers, name)
  value = fetch_header(headers, name)
  value.is_a?(Array) ? value.first : value
end

#normalize_secrets(secret) ⇒ Array[untyped]

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.

Parameters:

  • secret (String, Array[String])

Returns:

  • (Array[untyped])


147
148
149
# File 'lib/dinie/runtime/webhooks.rb', line 147

def normalize_secrets(secret)
  Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? }
end

#parse_signature_header(header) ⇒ Array[untyped]

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.

Split the webhook-signature header into decoded v1 signatures. Space-separated v1,<base64> tokens; non-v1 and malformed tokens are dropped.

Parameters:

  • header (String)

Returns:

  • (Array[untyped])


154
155
156
157
158
159
160
161
# File 'lib/dinie/runtime/webhooks.rb', line 154

def parse_signature_header(header)
  header.split.filter_map do |token|
    version, separator, value = token.partition(",")
    next if separator.empty? || value.empty? || version != SIGNATURE_VERSION

    value.unpack1("m")
  end
end

#required_header(headers, name, error_class) ⇒ 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.

Fetch a required header or raise error_class. Missing and empty both fail.

Parameters:

  • headers (Hash[untyped, untyped])
  • name (String)
  • error_class (Object)

Returns:

  • (Object)


96
97
98
99
100
101
# File 'lib/dinie/runtime/webhooks.rb', line 96

def required_header(headers, name, error_class)
  value = lookup_header(headers, name)
  return value unless value.nil? || value == ""

  raise error_class, "Missing required webhook header: #{name}."
end

#signatures_match?(payload, secrets, provided_signatures) ⇒ 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.

True when any (secret, signature) pair matches. OpenSSL.secure_compare is constant-time and safe on unequal-length inputs (it digests both first), so no length guard is needed.

Parameters:

  • payload (String)
  • secrets (Array[untyped])
  • provided_signatures (Array[untyped])

Returns:

  • (Boolean)


166
167
168
169
170
171
# File 'lib/dinie/runtime/webhooks.rb', line 166

def signatures_match?(payload, secrets, provided_signatures)
  secrets.any? do |secret|
    expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload)
    provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) }
  end
end

#signed_payload(webhook_id, timestamp, body) ⇒ String

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.

Build the byte-faithful signed payload "{id}.{timestamp}.{body}" (binary-concatenated so a non-UTF-8 body never raises an encoding error).

Parameters:

  • webhook_id (Object)
  • timestamp (Object)
  • body (String)

Returns:

  • (String)


90
91
92
# File 'lib/dinie/runtime/webhooks.rb', line 90

def signed_payload(webhook_id, timestamp, body)
  "#{webhook_id}.#{timestamp}.".b + body.to_s.b
end

#verify_signature!(payload, secret, signature_header) ⇒ void

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.

This method returns an undefined value.

Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).

Parameters:

  • payload (String)
  • secret (String, Array[String])
  • signature_header (String)


137
138
139
140
141
142
143
144
# File 'lib/dinie/runtime/webhooks.rb', line 137

def verify_signature!(payload, secret, signature_header)
  secrets = normalize_secrets(secret)
  raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty?
  return if signatures_match?(payload, secrets, parse_signature_header(signature_header))

  raise Dinie::WebhookSignatureError,
        "No matching webhook signature found; the payload may have been tampered with."
end

#verify_timestamp!(timestamp, tolerance_seconds) ⇒ void

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.

This method returns an undefined value.

Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.

Parameters:

  • timestamp (Object)
  • tolerance_seconds (Integer)


124
125
126
127
128
129
130
131
132
133
# File 'lib/dinie/runtime/webhooks.rb', line 124

def verify_timestamp!(timestamp, tolerance_seconds)
  seconds = Integer(timestamp.to_s.strip, 10, exception: false)
  raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{timestamp.inspect}." if seconds.nil?

  skew = Time.now.to_i - seconds
  raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds
  return unless skew < -tolerance_seconds

  raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future."
end