attio-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +164 -0
  4. data/.simplecov +17 -0
  5. data/.yardopts +9 -0
  6. data/CHANGELOG.md +27 -0
  7. data/CONTRIBUTING.md +333 -0
  8. data/INTEGRATION_TEST_STATUS.md +149 -0
  9. data/LICENSE +21 -0
  10. data/README.md +638 -0
  11. data/Rakefile +8 -0
  12. data/attio-ruby.gemspec +61 -0
  13. data/docs/CODECOV_SETUP.md +34 -0
  14. data/examples/basic_usage.rb +149 -0
  15. data/examples/oauth_flow.rb +843 -0
  16. data/examples/oauth_flow_README.md +84 -0
  17. data/examples/typed_records_example.rb +167 -0
  18. data/examples/webhook_server.rb +463 -0
  19. data/lib/attio/api_resource.rb +539 -0
  20. data/lib/attio/builders/name_builder.rb +181 -0
  21. data/lib/attio/client.rb +160 -0
  22. data/lib/attio/errors.rb +126 -0
  23. data/lib/attio/internal/record.rb +359 -0
  24. data/lib/attio/oauth/client.rb +219 -0
  25. data/lib/attio/oauth/scope_validator.rb +162 -0
  26. data/lib/attio/oauth/token.rb +158 -0
  27. data/lib/attio/resources/attribute.rb +332 -0
  28. data/lib/attio/resources/comment.rb +114 -0
  29. data/lib/attio/resources/company.rb +224 -0
  30. data/lib/attio/resources/entry.rb +208 -0
  31. data/lib/attio/resources/list.rb +196 -0
  32. data/lib/attio/resources/meta.rb +113 -0
  33. data/lib/attio/resources/note.rb +213 -0
  34. data/lib/attio/resources/object.rb +66 -0
  35. data/lib/attio/resources/person.rb +294 -0
  36. data/lib/attio/resources/task.rb +147 -0
  37. data/lib/attio/resources/thread.rb +99 -0
  38. data/lib/attio/resources/typed_record.rb +98 -0
  39. data/lib/attio/resources/webhook.rb +224 -0
  40. data/lib/attio/resources/workspace_member.rb +136 -0
  41. data/lib/attio/util/configuration.rb +166 -0
  42. data/lib/attio/util/id_extractor.rb +115 -0
  43. data/lib/attio/util/webhook_signature.rb +175 -0
  44. data/lib/attio/version.rb +6 -0
  45. data/lib/attio/webhook/event.rb +114 -0
  46. data/lib/attio/webhook/signature_verifier.rb +73 -0
  47. data/lib/attio.rb +123 -0
  48. metadata +402 -0
@@ -0,0 +1,84 @@
1
+ # Attio OAuth Flow Example
2
+
3
+ This example demonstrates how to implement the OAuth 2.0 authorization code flow with the Attio API.
4
+
5
+ ## Setup
6
+
7
+ 1. Create an OAuth app in your Attio workspace settings
8
+ 2. Set up your environment variables in `.env`:
9
+ ```
10
+ ATTIO_CLIENT_ID=your_client_id
11
+ ATTIO_CLIENT_SECRET=your_client_secret
12
+ ATTIO_REDIRECT_URI=http://localhost:4567/callback
13
+ ```
14
+
15
+ 3. Add redirect URIs to your Attio app:
16
+ - `http://localhost:4567/callback` (for local development)
17
+ - `https://your-ngrok-domain.ngrok.dev/callback` (if using ngrok)
18
+
19
+ ## Running the Example
20
+
21
+ ```bash
22
+ # Run the OAuth example server
23
+ ruby examples/oauth_flow.rb
24
+
25
+ # Or with bundler
26
+ bundle exec ruby examples/oauth_flow.rb
27
+
28
+ # Or using rackup
29
+ bundle exec rackup config.ru -p 4567
30
+ ```
31
+
32
+ ## Available Routes
33
+
34
+ ### Public Routes (No Authentication Required)
35
+ - `GET /` - Home page with login/status
36
+ - `GET /auth` - Start OAuth flow (redirects to Attio)
37
+ - `GET /callback` - OAuth callback handler
38
+
39
+ ### Protected Routes (Requires Authentication)
40
+ - `GET /test` - Basic API test (lists objects, people, companies, etc.)
41
+ - `GET /test-all` - Comprehensive API test with CRUD operations
42
+ - `GET /introspect` - View detailed token information
43
+ - `GET /revoke` - Revoke token on Attio's servers
44
+ - `GET /logout` - Clear local session only
45
+
46
+ ## Features Demonstrated
47
+
48
+ 1. **OAuth Authorization Flow**
49
+ - Generating authorization URLs with state parameter
50
+ - Handling OAuth callbacks
51
+ - Exchanging authorization codes for tokens
52
+ - Token storage (in-memory for demo)
53
+
54
+ 2. **API Testing**
55
+ - Basic test: Read operations across multiple endpoints
56
+ - Comprehensive test: Full CRUD operations with cleanup
57
+ - Error handling and permission checking
58
+
59
+ 3. **Token Management**
60
+ - Token introspection
61
+ - Token revocation
62
+ - Session management
63
+ - Token refresh on 401 (automatic retry)
64
+
65
+ ## Using with ngrok
66
+
67
+ If you need to test with ngrok (for example, to test with a real Attio OAuth app):
68
+
69
+ ```bash
70
+ # Start ngrok
71
+ ngrok http 4567
72
+
73
+ # Update your .env with the ngrok URL
74
+ ATTIO_REDIRECT_URI=https://your-subdomain.ngrok.dev/callback
75
+ ```
76
+
77
+ ## Security Notes
78
+
79
+ This example uses in-memory token storage for simplicity. In production:
80
+ - Use secure session storage
81
+ - Encrypt tokens at rest
82
+ - Implement CSRF protection
83
+ - Use secure cookies with httpOnly flag
84
+ - Implement proper state validation
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "attio"
6
+
7
+ # Configure the client
8
+ Attio.configure do |config|
9
+ config.api_key = ENV["ATTIO_API_KEY"]
10
+ end
11
+
12
+ puts "=== Typed Records Example ==="
13
+ puts
14
+
15
+ # Old way vs New way comparison
16
+ puts "OLD WAY - Using generic Record class:"
17
+ puts "-------------------------------------"
18
+ puts <<~OLD
19
+ # Creating a person (verbose and error-prone)
20
+ person = Attio::Record.create(
21
+ object: "people",
22
+ values: {
23
+ name: [{
24
+ first_name: "John",
25
+ last_name: "Doe",
26
+ full_name: "John Doe"
27
+ }],
28
+ email_addresses: ["john@example.com"],
29
+ phone_numbers: [{
30
+ original_phone_number: "+12125551234",
31
+ country_code: "US"
32
+ }]
33
+ }
34
+ )
35
+
36
+ # Listing people
37
+ people = Attio::Record.list(object: "people", params: { q: "john" })
38
+
39
+ # Creating a company
40
+ company = Attio::Record.create(
41
+ object: "companies",
42
+ values: {
43
+ name: "Acme Corp",
44
+ domains: ["acme.com"]
45
+ }
46
+ )
47
+ OLD
48
+
49
+ puts
50
+ puts "NEW WAY - Using typed Person and Company classes:"
51
+ puts "-------------------------------------------------"
52
+ puts <<~NEW
53
+ # Creating a person (simple and intuitive)
54
+ person = Attio::Person.create(
55
+ first_name: "John",
56
+ last_name: "Doe",
57
+ email: "john@example.com",
58
+ phone: "+12125551234",
59
+ job_title: "Software Engineer"
60
+ )
61
+
62
+ # Or use the People alias
63
+ person = Attio::People.create(
64
+ first_name: "Jane",
65
+ last_name: "Smith"
66
+ )
67
+
68
+ # Convenient name setters
69
+ person.set_name(first: "Jane", last: "Johnson")
70
+
71
+ # Easy access to attributes
72
+ puts person.full_name # => "Jane Johnson"
73
+ puts person.email # => "john@example.com"
74
+ puts person.phone # => "+12125551234"
75
+
76
+ # Searching is simpler
77
+ people = Attio::Person.search("john")
78
+ people = Attio::Person.find_by_email("john@example.com")
79
+ people = Attio::Person.find_by_name("John Doe")
80
+
81
+ # Creating a company (no more array wrapping for simple names!)
82
+ company = Attio::Company.create(
83
+ name: "Acme Corp",
84
+ domain: "acme.com",
85
+ description: "Leading widget manufacturer",
86
+ employee_count: "50-100"
87
+ )
88
+
89
+ # Or use the Companies alias
90
+ company = Attio::Companies.create(
91
+ name: "Tech Startup",
92
+ domains: ["techstartup.com", "techstartup.io"]
93
+ )
94
+
95
+ # Simple attribute access
96
+ company.name = "Acme Corporation"
97
+ company.add_domain("acme.org")
98
+
99
+ # Associate person with company
100
+ person.company = company
101
+ person.save
102
+
103
+ # Find company's team members
104
+ team = company.team_members
105
+
106
+ # Find companies by various criteria
107
+ company = Attio::Company.find_by_domain("acme.com")
108
+ company = Attio::Company.find_by_name("Acme")
109
+ large_companies = Attio::Company.find_by_size(100) # 100+ employees
110
+ NEW
111
+
112
+ # Working example (if API key is set)
113
+ puts
114
+ if ENV["ATTIO_API_KEY"]
115
+ puts "Running live examples..."
116
+ puts
117
+
118
+ begin
119
+ # Create a person the new way
120
+ person = Attio::Person.create(
121
+ first_name: "Test",
122
+ last_name: "User-#{Time.now.to_i}",
123
+ email: "test#{Time.now.to_i}@example.com",
124
+ job_title: "Developer"
125
+ )
126
+
127
+ puts "Created person:"
128
+ puts " ID: #{person.id["record_id"]}"
129
+ puts " Name: #{person.full_name}"
130
+ puts " Email: #{person.email}"
131
+ puts " Job: #{person[:job_title]}"
132
+
133
+ # Create a company the new way
134
+ company = Attio::Company.create(
135
+ name: "Test Company #{Time.now.to_i}",
136
+ domain: "test#{Time.now.to_i}.com"
137
+ )
138
+
139
+ puts
140
+ puts "Created company:"
141
+ puts " ID: #{company.id["record_id"]}"
142
+ puts " Name: #{company.name}"
143
+ puts " Domain: #{company.domain}"
144
+
145
+ # Update person's name using helper
146
+ person.set_name(first: "Updated", last: "Name")
147
+ person.save
148
+
149
+ puts
150
+ puts "Updated person name: #{person.full_name}"
151
+
152
+ # Search for people
153
+ results = Attio::Person.search("test")
154
+ puts
155
+ puts "Found #{results.count} people matching 'test'"
156
+
157
+ # Clean up
158
+ person.destroy
159
+ company.destroy
160
+ puts
161
+ puts "Cleaned up test data"
162
+ rescue Attio::Error => e
163
+ puts "Error: #{e.message}"
164
+ end
165
+ else
166
+ puts "To run live examples, set ATTIO_API_KEY environment variable"
167
+ end
@@ -0,0 +1,463 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "attio"
6
+ require "sinatra"
7
+ require "json"
8
+ require "openssl"
9
+ require "dotenv/load"
10
+
11
+ # Webhook server example for Attio Ruby gem
12
+ # Demonstrates webhook creation, management, and event handling
13
+
14
+ Attio.configure do |config|
15
+ config.api_key = ENV["ATTIO_API_KEY"]
16
+ end
17
+
18
+ # Store for webhook events (use Redis or database in production)
19
+ $webhook_events = []
20
+
21
+ # Webhook signature verification
22
+ def verify_webhook_signature(payload_body, signature_header)
23
+ return false unless signature_header
24
+
25
+ # Extract timestamp and signatures
26
+ elements = signature_header.split(" ")
27
+ timestamp = nil
28
+ signatures = []
29
+
30
+ elements.each do |element|
31
+ key, value = element.split("=", 2)
32
+ case key
33
+ when "t"
34
+ timestamp = value
35
+ when "v1"
36
+ signatures << value
37
+ end
38
+ end
39
+
40
+ return false unless timestamp
41
+
42
+ # Verify timestamp is recent (within 5 minutes)
43
+ current_time = Time.now.to_i
44
+ if (current_time - timestamp.to_i).abs > 300
45
+ return false
46
+ end
47
+
48
+ # Compute expected signature
49
+ signed_payload = "#{timestamp}.#{payload_body}"
50
+ expected_signature = OpenSSL::HMAC.hexdigest(
51
+ "SHA256",
52
+ ENV["ATTIO_WEBHOOK_SECRET"] || "test_secret",
53
+ signed_payload
54
+ )
55
+
56
+ # Check if computed signature matches any of the signatures
57
+ signatures.any? { |sig| Rack::Utils.secure_compare(expected_signature, sig) }
58
+ end
59
+
60
+ # Webhook endpoint
61
+ post "/webhooks/attio" do
62
+ # Get raw body for signature verification
63
+ request.body.rewind
64
+ payload_body = request.body.read
65
+
66
+ # Verify webhook signature
67
+ signature = request.env["HTTP_ATTIO_SIGNATURE"]
68
+
69
+ unless verify_webhook_signature(payload_body, signature)
70
+ halt 401, {error: "Invalid signature"}.to_json
71
+ end
72
+
73
+ # Parse webhook data
74
+ begin
75
+ webhook_data = JSON.parse(payload_body)
76
+ rescue JSON::ParserError
77
+ halt 400, {error: "Invalid JSON"}.to_json
78
+ end
79
+
80
+ # Store event
81
+ event = {
82
+ id: webhook_data["id"],
83
+ type: webhook_data["type"],
84
+ occurred_at: webhook_data["occurred_at"],
85
+ data: webhook_data["data"],
86
+ received_at: Time.now.iso8601
87
+ }
88
+
89
+ $webhook_events << event
90
+
91
+ # Process webhook based on type
92
+ case webhook_data["type"]
93
+ when "record.created"
94
+ process_record_created(webhook_data["data"])
95
+ when "record.updated"
96
+ process_record_updated(webhook_data["data"])
97
+ when "record.deleted"
98
+ process_record_deleted(webhook_data["data"])
99
+ when "list_entry.created"
100
+ process_list_entry_created(webhook_data["data"])
101
+ when "note.created"
102
+ process_note_created(webhook_data["data"])
103
+ else
104
+ puts "Unknown webhook type: #{webhook_data["type"]}"
105
+ end
106
+
107
+ # Return success
108
+ status 200
109
+ {received: true}.to_json
110
+ end
111
+
112
+ # Webhook event processors
113
+ # Process record.created webhook events
114
+ # @param data [Hash] Webhook event data
115
+ def process_record_created(data)
116
+ puts "New #{data["object"]} created: #{data["record"]["id"]}"
117
+
118
+ # Example: Send welcome email for new people
119
+ if data["object"] == "people" && data["record"]["email_addresses"]
120
+ puts " Would send welcome email to: #{data["record"]["email_addresses"]}"
121
+ end
122
+
123
+ # Example: Enrich company data
124
+ if data["object"] == "companies" && data["record"]["domains"]
125
+ puts " Would enrich company data for: #{data["record"]["domains"]}"
126
+ end
127
+ end
128
+
129
+ # Process record.updated webhook events
130
+ # @param data [Hash] Webhook event data
131
+ def process_record_updated(data)
132
+ puts "#{data["object"]} updated: #{data["record"]["id"]}"
133
+
134
+ # Example: Sync changes to CRM
135
+ changed_fields = data["changes"]&.keys || []
136
+ if changed_fields.any?
137
+ puts " Changed fields: #{changed_fields.join(", ")}"
138
+ puts " Would sync to external CRM"
139
+ end
140
+ end
141
+
142
+ # Process record.deleted webhook events
143
+ # @param data [Hash] Webhook event data
144
+ def process_record_deleted(data)
145
+ puts "#{data["object"]} deleted: #{data["record_id"]}"
146
+
147
+ # Example: Clean up related data
148
+ puts " Would clean up related data in external systems"
149
+ end
150
+
151
+ # Process list_entry.created webhook events
152
+ # @param data [Hash] Webhook event data
153
+ def process_list_entry_created(data)
154
+ puts "Record added to list: #{data["list"]["name"]}"
155
+
156
+ # Example: Trigger marketing automation
157
+ if /leads|prospects/i.match?(data["list"]["name"])
158
+ puts " Would trigger marketing automation workflow"
159
+ end
160
+ end
161
+
162
+ # Process note.created webhook events
163
+ # @param data [Hash] Webhook event data
164
+ def process_note_created(data)
165
+ puts "New note created on #{data["parent_object"]}"
166
+
167
+ # Example: Notify team members
168
+ if /urgent|important|asap/i.match?(data["content"])
169
+ puts " Would notify team members of urgent note"
170
+ end
171
+ end
172
+
173
+ # Webhook management endpoints
174
+ get "/" do
175
+ <<~HTML
176
+ <h1>Attio Webhook Server</h1>
177
+ <p>Webhook endpoint: POST /webhooks/attio</p>
178
+ <p>Events received: #{$webhook_events.size}</p>
179
+ <hr>
180
+ <a href="/webhooks">Manage Webhooks</a> |
181
+ <a href="/events">View Events</a> |
182
+ <a href="/test">Test Webhook</a>
183
+ HTML
184
+ end
185
+
186
+ # List webhooks
187
+ get "/webhooks" do
188
+ webhooks = Attio::Webhook.list
189
+
190
+ html = "<h1>Configured Webhooks</h1>"
191
+ html += "<a href='/webhooks/new'>Create New Webhook</a><br><br>"
192
+
193
+ webhooks.each do |webhook|
194
+ html += <<~HTML
195
+ <div style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
196
+ <strong>#{webhook.name}</strong><br>
197
+ URL: #{webhook.url}<br>
198
+ Events: #{webhook.subscriptions.join(", ")}<br>
199
+ Status: #{webhook.active ? "✅ Active" : "❌ Inactive"}<br>
200
+ <a href="/webhooks/#{webhook.id}/test">Test</a> |
201
+ <a href="/webhooks/#{webhook.id}/toggle">#{webhook.active ? "Disable" : "Enable"}</a> |
202
+ <a href="/webhooks/#{webhook.id}/delete" onclick="return confirm('Delete webhook?')">Delete</a>
203
+ </div>
204
+ HTML
205
+ end
206
+
207
+ html += "<br><a href='/'>Back</a>"
208
+ html
209
+ end
210
+
211
+ # Create webhook form
212
+ get "/webhooks/new" do
213
+ <<~HTML
214
+ <h1>Create Webhook</h1>
215
+ <form method="post" action="/webhooks/create">
216
+ <label>Name: <input name="name" value="Test Webhook" required></label><br><br>
217
+ <label>URL: <input name="url" value="#{request.base_url}/webhooks/attio" required size="50"></label><br><br>
218
+
219
+ <label>Events to subscribe:</label><br>
220
+ <label><input type="checkbox" name="events[]" value="record.created" checked> record.created</label><br>
221
+ <label><input type="checkbox" name="events[]" value="record.updated" checked> record.updated</label><br>
222
+ <label><input type="checkbox" name="events[]" value="record.deleted"> record.deleted</label><br>
223
+ <label><input type="checkbox" name="events[]" value="list_entry.created"> list_entry.created</label><br>
224
+ <label><input type="checkbox" name="events[]" value="list_entry.deleted"> list_entry.deleted</label><br>
225
+ <label><input type="checkbox" name="events[]" value="note.created"> note.created</label><br><br>
226
+
227
+ <button type="submit">Create Webhook</button>
228
+ <a href="/webhooks">Cancel</a>
229
+ </form>
230
+ HTML
231
+ end
232
+
233
+ # Create webhook
234
+ post "/webhooks/create" do
235
+ Attio::Webhook.create(
236
+ name: params[:name],
237
+ url: params[:url],
238
+ subscriptions: params[:events] || []
239
+ )
240
+
241
+ redirect "/webhooks"
242
+ rescue => e
243
+ "Error creating webhook: #{e.message}"
244
+ end
245
+
246
+ # Test webhook
247
+ get "/webhooks/:id/test" do
248
+ webhook = Attio::Webhook.retrieve(params[:id])
249
+
250
+ # Trigger a test event
251
+ test_data = {
252
+ id: "test_#{SecureRandom.hex(8)}",
253
+ type: "record.created",
254
+ occurred_at: Time.now.iso8601,
255
+ data: {
256
+ object: "people",
257
+ record: {
258
+ id: "test_person_#{SecureRandom.hex(8)}",
259
+ name: "Test Person",
260
+ email_addresses: "test@example.com"
261
+ }
262
+ }
263
+ }
264
+
265
+ # In a real scenario, Attio would send this
266
+ # For testing, we'll simulate it
267
+ uri = URI(webhook.url)
268
+ http = Net::HTTP.new(uri.host, uri.port)
269
+ http.use_ssl = uri.scheme == "https"
270
+
271
+ request = Net::HTTP::Post.new(uri.path)
272
+ request["Content-Type"] = "application/json"
273
+ request["Attio-Signature"] = "t=#{Time.now.to_i} v1=test_signature"
274
+ request.body = test_data.to_json
275
+
276
+ response = http.request(request)
277
+
278
+ <<~HTML
279
+ <h1>Webhook Test Result</h1>
280
+ <p>Webhook: #{webhook.name}</p>
281
+ <p>URL: #{webhook.url}</p>
282
+ <p>Response Status: #{response.code}</p>
283
+ <p>Response Body: #{response.body}</p>
284
+ <a href="/webhooks">Back to Webhooks</a>
285
+ HTML
286
+ rescue => e
287
+ "Error testing webhook: #{e.message}"
288
+ end
289
+
290
+ # Toggle webhook
291
+ get "/webhooks/:id/toggle" do
292
+ webhook = Attio::Webhook.retrieve(params[:id])
293
+ webhook.active = !webhook.active
294
+ webhook.save
295
+
296
+ redirect "/webhooks"
297
+ end
298
+
299
+ # Delete webhook
300
+ get "/webhooks/:id/delete" do
301
+ Attio::Webhook.delete(params[:id])
302
+ redirect "/webhooks"
303
+ end
304
+
305
+ # View events
306
+ get "/events" do
307
+ html = "<h1>Webhook Events (#{$webhook_events.size})</h1>"
308
+
309
+ if $webhook_events.empty?
310
+ html += "<p>No events received yet.</p>"
311
+ else
312
+ html += "<table border='1' cellpadding='5'>"
313
+ html += "<tr><th>Time</th><th>Type</th><th>Object</th><th>Data</th></tr>"
314
+
315
+ $webhook_events.last(50).reverse_each do |event|
316
+ html += <<~HTML
317
+ <tr>
318
+ <td>#{event[:received_at]}</td>
319
+ <td>#{event[:type]}</td>
320
+ <td>#{event[:data]["object"] if event[:data]}</td>
321
+ <td><pre>#{JSON.pretty_generate(event[:data])}</pre></td>
322
+ </tr>
323
+ HTML
324
+ end
325
+
326
+ html += "</table>"
327
+ end
328
+
329
+ html += "<br><a href='/'>Back</a> | <a href='/events/clear'>Clear Events</a>"
330
+ html
331
+ end
332
+
333
+ # Clear events
334
+ get "/events/clear" do
335
+ $webhook_events.clear
336
+ redirect "/events"
337
+ end
338
+
339
+ # Test webhook locally
340
+ get "/test" do
341
+ <<~HTML
342
+ <h1>Test Webhook Locally</h1>
343
+ <button onclick="testCreate()">Test Create Event</button>
344
+ <button onclick="testUpdate()">Test Update Event</button>
345
+ <button onclick="testDelete()">Test Delete Event</button>
346
+ <br><br>
347
+ <div id="result"></div>
348
+ <br>
349
+ <a href="/">Back</a>
350
+
351
+ <script>
352
+ function sendTest(data) {
353
+ fetch('/webhooks/attio', {
354
+ method: 'POST',
355
+ headers: {
356
+ 'Content-Type': 'application/json',
357
+ 'Attio-Signature': 't=' + Math.floor(Date.now()/1000) + ' v1=test'
358
+ },
359
+ body: JSON.stringify(data)
360
+ })
361
+ .then(r => r.json())
362
+ .then(data => {
363
+ document.getElementById('result').innerHTML =
364
+ '<pre>' + JSON.stringify(data, null, 2) + '</pre>';
365
+ });
366
+ }
367
+
368
+ function testCreate() {
369
+ sendTest({
370
+ id: 'evt_' + Date.now(),
371
+ type: 'record.created',
372
+ occurred_at: new Date().toISOString(),
373
+ data: {
374
+ object: 'people',
375
+ record: {
376
+ id: 'person_test',
377
+ name: 'Test Person',
378
+ email_addresses: 'test@example.com'
379
+ }
380
+ }
381
+ });
382
+ }
383
+
384
+ function testUpdate() {
385
+ sendTest({
386
+ id: 'evt_' + Date.now(),
387
+ type: 'record.updated',
388
+ occurred_at: new Date().toISOString(),
389
+ data: {
390
+ object: 'people',
391
+ record: {
392
+ id: 'person_test',
393
+ name: 'Updated Person',
394
+ email_addresses: 'updated@example.com'
395
+ },
396
+ changes: {
397
+ name: { old: 'Test Person', new: 'Updated Person' }
398
+ }
399
+ }
400
+ });
401
+ }
402
+
403
+ function testDelete() {
404
+ sendTest({
405
+ id: 'evt_' + Date.now(),
406
+ type: 'record.deleted',
407
+ occurred_at: new Date().toISOString(),
408
+ data: {
409
+ object: 'people',
410
+ record_id: 'person_test'
411
+ }
412
+ });
413
+ }
414
+ </script>
415
+ HTML
416
+ end
417
+
418
+ # Webhook statistics
419
+ get "/stats" do
420
+ event_types = $webhook_events.group_by { |e| e[:type] }
421
+
422
+ html = "<h1>Webhook Statistics</h1>"
423
+ html += "<p>Total events: #{$webhook_events.size}</p>"
424
+
425
+ html += "<h2>Events by Type</h2>"
426
+ html += "<ul>"
427
+ event_types.each do |type, events|
428
+ html += "<li>#{type}: #{events.size}</li>"
429
+ end
430
+ html += "</ul>"
431
+
432
+ html += "<h2>Recent Activity</h2>"
433
+ html += "<ul>"
434
+ $webhook_events.last(10).reverse_each do |event|
435
+ html += "<li>#{event[:received_at]} - #{event[:type]}</li>"
436
+ end
437
+ html += "</ul>"
438
+
439
+ html += "<br><a href='/'>Back</a>"
440
+ html
441
+ end
442
+
443
+ # Run the server
444
+ if __FILE__ == $0
445
+ puts "=== Attio Webhook Server Example ==="
446
+ puts "Starting server on http://localhost:4568"
447
+ puts
448
+ puts "This example demonstrates:"
449
+ puts "- Creating and managing webhooks"
450
+ puts "- Receiving and processing webhook events"
451
+ puts "- Signature verification"
452
+ puts "- Event handling patterns"
453
+ puts
454
+ puts "Visit http://localhost:4568 to get started"
455
+ puts
456
+
457
+ # Configure Sinatra
458
+ set :port, 4568
459
+ set :bind, "0.0.0.0"
460
+
461
+ # Run the server
462
+ run!
463
+ end