Class: Attio::Webhooks

Inherits:
Object
  • Object
show all
Defined in:
lib/attio/webhooks.rb

Overview

Webhook handling for Attio events

Examples:

Configure webhooks

webhooks = Attio::Webhooks.new(secret: ENV['ATTIO_WEBHOOK_SECRET'])

webhooks.on('record.created') do |event|
  puts "New record: #{event.data['id']}"
end

# In your webhook endpoint
webhooks.process(request.body.read, request.headers)

Since:

  • 1.0.0

Defined Under Namespace

Classes: Event, InvalidSignatureError, InvalidTimestampError, MissingHeaderError

Constant Summary collapse

DEFAULT_TOLERANCE =

Default time window for timestamp validation (5 minutes)

Since:

  • 1.0.0

300

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(secret:, tolerance: DEFAULT_TOLERANCE) ⇒ Webhooks

Initialize webhook handler

Parameters:

  • secret (String)

    Webhook signing secret from Attio

  • tolerance (Integer) (defaults to: DEFAULT_TOLERANCE)

    Maximum age of webhook in seconds

Since:

  • 1.0.0



33
34
35
36
37
38
# File 'lib/attio/webhooks.rb', line 33

def initialize(secret:, tolerance: DEFAULT_TOLERANCE)
  @secret = secret
  @tolerance = tolerance
  @handlers = {}
  @global_handlers = []
end

Instance Attribute Details

#handlersObject (readonly)

Since:

  • 1.0.0



27
28
29
# File 'lib/attio/webhooks.rb', line 27

def handlers
  @handlers
end

#secretObject (readonly)

Since:

  • 1.0.0



27
28
29
# File 'lib/attio/webhooks.rb', line 27

def secret
  @secret
end

#toleranceObject (readonly)

Since:

  • 1.0.0



27
28
29
# File 'lib/attio/webhooks.rb', line 27

def tolerance
  @tolerance
end

Instance Method Details

#dispatch_event(event) ⇒ Object (private)

Since:

  • 1.0.0



123
124
125
126
127
128
129
# File 'lib/attio/webhooks.rb', line 123

private def dispatch_event(event)
  # Call specific handlers
  @handlers[event.type].each { |handler| handler.call(event) } if @handlers[event.type]

  # Call global handlers
  @global_handlers.each { |handler| handler.call(event) }
end

#extract_header(headers, name) ⇒ Object (private)

Raises:

Since:

  • 1.0.0



102
103
104
105
106
107
108
109
110
111
# File 'lib/attio/webhooks.rb', line 102

private def extract_header(headers, name)
  # Handle different header formats (Rack, Rails, etc.)
  value = headers[name] ||
          headers[name.downcase] ||
          headers[name.upcase.gsub("-", "_")]

  raise MissingHeaderError, "Missing required header: #{name}" unless value

  value
end

#on(event_type) {|event| ... } ⇒ Object

Register an event handler

Parameters:

  • event_type (String)

    The event type to handle (e.g., 'record.created')

Yields:

  • (event)

    Block to execute when event is received

Yield Parameters:

  • event (Event)

    The webhook event

Since:

  • 1.0.0



45
46
47
48
# File 'lib/attio/webhooks.rb', line 45

def on(event_type, &block)
  @handlers[event_type] ||= []
  @handlers[event_type] << block
end

#on_any {|event| ... } ⇒ Object

Register a global handler for all events

Yields:

  • (event)

    Block to execute for any event

Since:

  • 1.0.0



53
54
55
# File 'lib/attio/webhooks.rb', line 53

def on_any(&block)
  @global_handlers << block
end

#process(payload, headers) ⇒ Event

Process incoming webhook

Parameters:

  • payload (String)

    Raw request body

  • headers (Hash)

    Request headers

Returns:

  • (Event)

    Processed webhook event

Raises:

Since:

  • 1.0.0



64
65
66
67
68
69
70
# File 'lib/attio/webhooks.rb', line 64

def process(payload, headers)
  verify_webhook!(payload, headers)

  event = Event.new(JSON.parse(payload))
  dispatch_event(event)
  event
end

#secure_compare?(expected, actual) ⇒ Boolean (private)

Returns:

  • (Boolean)

Since:

  • 1.0.0



131
132
133
134
135
136
137
138
139
140
# File 'lib/attio/webhooks.rb', line 131

private def secure_compare?(expected, actual)
  return false unless expected.bytesize == actual.bytesize

  expected_bytes = expected.unpack("C*")
  actual_bytes = actual.unpack("C*")
  result = 0

  expected_bytes.zip(actual_bytes) { |x, y| result |= x ^ y }
  result == 0
end

#verify_signature?(payload, signature) ⇒ Boolean

Verify webhook authenticity using HMAC-SHA256

Parameters:

  • payload (String)

    Raw request body

  • signature (String)

    Signature from headers

Returns:

  • (Boolean)

    True if valid

Since:

  • 1.0.0



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

def verify_signature?(payload, signature)
  expected = OpenSSL::HMAC.hexdigest(
    OpenSSL::Digest.new("sha256"),
    @secret,
    payload
  )

  # Use secure comparison to prevent timing attacks
  secure_compare?(expected, signature)
end

#verify_timestamp!(timestamp) ⇒ Object (private)

Raises:

Since:

  • 1.0.0



113
114
115
116
117
118
119
120
121
# File 'lib/attio/webhooks.rb', line 113

private def verify_timestamp!(timestamp)
  webhook_time = Time.at(timestamp.to_i)
  current_time = Time.now

  return unless (current_time - webhook_time).abs > tolerance

  raise InvalidTimestampError,
        "Webhook timestamp outside of tolerance (#{tolerance}s)"
end

#verify_webhook!(payload, headers) ⇒ Object (private)

Raises:

Since:

  • 1.0.0



88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/attio/webhooks.rb', line 88

private def verify_webhook!(payload, headers)
  signature = extract_header(headers, "Attio-Signature")
  timestamp = extract_header(headers, "Attio-Timestamp")

  # Verify timestamp to prevent replay attacks
  verify_timestamp!(timestamp)

  # Verify signature
  signed_payload = "#{timestamp}.#{payload}"
  return if verify_signature?(signed_payload, signature)

  raise InvalidSignatureError, "Webhook signature verification failed"
end