Module: Mailkube::Webhooks

Defined in:
lib/mailkube/webhooks.rb,
sig/mailkube/webhooks.rbs

Overview

Webhook signature verification.

Verification is pure and dependency-free: no client instance, no configuration, so you call it directly inside your webhook handler.

Constant Summary collapse

DEFAULT_TOLERANCE =

How stale a webhook timestamp may be, in seconds, before it is rejected.

Returns:

  • (Integer)
300
SIGNATURE_PREFIX =

The prefix the server puts before the hex digest in X-Webhook-Sig.

Returns:

  • (String)
"sha256="
PARSE_OPTIONS =

Decode webhook payloads deep-frozen, so a receiver cannot mutate an event it is about to forward or log. Passed positionally because that is the shape JSON.parse declares ((source, opts)); as keywords Steep reports an unexpected keyword.

Returns:

  • (Hash[Symbol, untyped])
{ freeze: true }.freeze

Class Method Summary collapse

Class Method Details

.parse_event(payload) ⇒ Events::Event

Parse a raw webhook body into a typed event.

An event type this release has never heard of comes back as Events::UnknownEvent rather than raising: a receiver keeps working when the platform adds a type, with no SDK upgrade. That is the contract's deliberate inversion of the response-model rules, and it is why the dispatch is a fetch with a default rather than a conditional — an unknown type is the last row of the table, not an error path.

Unknown fields survive too, at every depth: see Events::Node.

Parameters:

  • payload (String)

    the raw request body.

  • (String)

Returns:

  • (Events::Event)

    the parsed event; narrow it with case or is_a?.

Raises:

  • (Error)

    when the body is not a JSON object.



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

def self.parse_event(payload)
  body = JSON.parse(payload, PARSE_OPTIONS)
  raise Error, "webhook payload is not a JSON object" unless body.is_a?(Hash)

  Events::REGISTRY.fetch(body["type"], Events::UnknownEvent).new(body)
rescue JSON::ParserError => e
  raise Error, "webhook payload is not valid JSON: #{e.message}"
end

.verify(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE) ⇒ Events::Event

Verify a webhook's signature and return the parsed event.

The combinator most handlers actually want. It composes cleanly only because verify_signature returns the verified payload rather than true.

Parameters:

  • payload (String)

    the raw request body.

  • headers (Hash{String => String}, Enumerable)

    the request headers, in any casing.

  • secret (String)

    the endpoint's signing secret.

  • tolerance (Integer) (defaults to: DEFAULT_TOLERANCE)

    the freshness window in seconds.

Returns:

Raises:



88
89
90
# File 'lib/mailkube/webhooks.rb', line 88

def self.verify(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE)
  parse_event(verify_signature(payload: payload, headers: headers, secret: secret, tolerance: tolerance))
end

.verify_signature(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE) ⇒ Object

headers is any mapping that yields name/value pairs, not just a Hash: ActionDispatch::Http::Headers is Enumerable-only and yields CGI env names, and a Rails receiver passes it straight through from request.headers.



40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/mailkube/webhooks.rb', line 40

def self.verify_signature(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE)
  lookup = normalize_headers(headers)
  id = lookup["x-webhook-id"]
  timestamp = lookup["x-webhook-ts"]
  signature = lookup["x-webhook-sig"]
  if id.nil? || timestamp.nil? || signature.nil?
    raise SignatureVerificationError, "missing required webhook signature headers"
  end

  check_freshness(timestamp, tolerance)
  check_signature(payload, id, timestamp, signature, secret)
  payload
end