zai_payment 1.0.2 → 1.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -1
- data/IMPLEMENTATION.md +201 -0
- data/README.md +81 -13
- data/docs/ARCHITECTURE.md +232 -0
- data/docs/AUTHENTICATION.md +647 -0
- data/docs/README.md +81 -0
- data/docs/WEBHOOKS.md +417 -0
- data/docs/WEBHOOK_SECURITY_QUICKSTART.md +141 -0
- data/docs/WEBHOOK_SIGNATURE.md +244 -0
- data/examples/webhooks.md +635 -0
- data/lib/zai_payment/client.rb +116 -0
- data/lib/zai_payment/config.rb +2 -0
- data/lib/zai_payment/errors.rb +19 -0
- data/lib/zai_payment/resources/webhook.rb +331 -0
- data/lib/zai_payment/response.rb +77 -0
- data/lib/zai_payment/version.rb +1 -1
- data/lib/zai_payment.rb +10 -0
- metadata +40 -1
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
# Webhook Examples
|
|
2
|
+
|
|
3
|
+
This file demonstrates how to use the ZaiPayment webhook functionality.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require 'zai_payment'
|
|
9
|
+
|
|
10
|
+
# Configure the gem
|
|
11
|
+
ZaiPayment.configure do |config|
|
|
12
|
+
config.environment = :prelive # or :production
|
|
13
|
+
config.client_id = 'your_client_id'
|
|
14
|
+
config.client_secret = 'your_client_secret'
|
|
15
|
+
config.scope = 'your_scope'
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Webhook Security: Complete Setup Guide
|
|
20
|
+
|
|
21
|
+
### Step 1: Generate and Register a Secret Key
|
|
22
|
+
|
|
23
|
+
Before setting up webhooks, you should establish a secure secret key for signature verification:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require 'securerandom'
|
|
27
|
+
|
|
28
|
+
# Generate a cryptographically secure secret key (minimum 32 bytes)
|
|
29
|
+
secret_key = SecureRandom.alphanumeric(32)
|
|
30
|
+
# Example output: "aB3xYz9mKpQrTuVwXy2zAbCdEfGhIjKl"
|
|
31
|
+
|
|
32
|
+
# Store this in your environment variables or secure vault
|
|
33
|
+
# NEVER commit this to version control!
|
|
34
|
+
puts "Add this to your environment variables:"
|
|
35
|
+
puts "ZAI_WEBHOOK_SECRET=#{secret_key}"
|
|
36
|
+
|
|
37
|
+
# Register the secret key with Zai
|
|
38
|
+
response = ZaiPayment.webhooks.create_secret_key(secret_key: secret_key)
|
|
39
|
+
|
|
40
|
+
if response.success?
|
|
41
|
+
puts "✅ Secret key registered successfully with Zai!"
|
|
42
|
+
puts "Store this key securely - you'll need it to verify webhook signatures"
|
|
43
|
+
else
|
|
44
|
+
puts "❌ Failed to register secret key"
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Step 2: Create a Webhook
|
|
49
|
+
|
|
50
|
+
Now create a webhook to receive notifications:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
response = ZaiPayment.webhooks.create(
|
|
54
|
+
url: 'https://your-app.com/webhooks/zai',
|
|
55
|
+
object_type: 'transactions',
|
|
56
|
+
enabled: true,
|
|
57
|
+
description: 'Production webhook for transaction updates'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
webhook = response.data
|
|
61
|
+
puts "Created webhook: #{webhook['id']}"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Step 3: Implement Webhook Endpoint
|
|
65
|
+
|
|
66
|
+
Here's a complete Rails controller example with signature verification:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# app/controllers/webhooks_controller.rb
|
|
70
|
+
class WebhooksController < ApplicationController
|
|
71
|
+
# Skip CSRF token verification for webhook endpoints
|
|
72
|
+
skip_before_action :verify_authenticity_token
|
|
73
|
+
|
|
74
|
+
# Add basic rate limiting (if using Rack::Attack or similar)
|
|
75
|
+
# throttle('webhooks/ip', limit: 100, period: 1.minute)
|
|
76
|
+
|
|
77
|
+
def zai_webhook
|
|
78
|
+
# Read the raw request body - IMPORTANT: Don't parse it first!
|
|
79
|
+
payload = request.body.read
|
|
80
|
+
signature_header = request.headers['Webhooks-signature']
|
|
81
|
+
secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
82
|
+
|
|
83
|
+
# Verify the signature
|
|
84
|
+
unless verify_webhook_signature(payload, signature_header, secret_key)
|
|
85
|
+
Rails.logger.warn "Invalid webhook signature received from #{request.remote_ip}"
|
|
86
|
+
return render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parse and process the webhook
|
|
90
|
+
webhook_data = JSON.parse(payload)
|
|
91
|
+
process_webhook(webhook_data)
|
|
92
|
+
|
|
93
|
+
# Return 200 to acknowledge receipt
|
|
94
|
+
render json: { status: 'success' }, status: :ok
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
Rails.logger.error "Invalid JSON in webhook: #{e.message}"
|
|
97
|
+
render json: { error: 'Invalid JSON' }, status: :bad_request
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
Rails.logger.error "Webhook processing error: #{e.message}"
|
|
100
|
+
render json: { error: 'Processing error' }, status: :internal_server_error
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def verify_webhook_signature(payload, signature_header, secret_key)
|
|
106
|
+
return false if signature_header.blank?
|
|
107
|
+
|
|
108
|
+
ZaiPayment.webhooks.verify_signature(
|
|
109
|
+
payload: payload,
|
|
110
|
+
signature_header: signature_header,
|
|
111
|
+
secret_key: secret_key,
|
|
112
|
+
tolerance: 300 # 5 minutes - adjust based on your needs
|
|
113
|
+
)
|
|
114
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
115
|
+
Rails.logger.warn "Webhook signature validation failed: #{e.message}"
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def process_webhook(data)
|
|
120
|
+
# Log the webhook for debugging
|
|
121
|
+
Rails.logger.info "Processing Zai webhook: #{data['event']}"
|
|
122
|
+
|
|
123
|
+
# Handle different webhook events
|
|
124
|
+
case data['event']
|
|
125
|
+
when 'transaction.created'
|
|
126
|
+
handle_transaction_created(data)
|
|
127
|
+
when 'transaction.updated'
|
|
128
|
+
handle_transaction_updated(data)
|
|
129
|
+
when 'transaction.completed'
|
|
130
|
+
handle_transaction_completed(data)
|
|
131
|
+
else
|
|
132
|
+
Rails.logger.info "Unhandled webhook event: #{data['event']}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_transaction_created(data)
|
|
137
|
+
# Your logic here
|
|
138
|
+
Rails.logger.info "Transaction created: #{data['transaction']['id']}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def handle_transaction_updated(data)
|
|
142
|
+
# Your logic here
|
|
143
|
+
Rails.logger.info "Transaction updated: #{data['transaction']['id']}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_transaction_completed(data)
|
|
147
|
+
# Your logic here
|
|
148
|
+
Rails.logger.info "Transaction completed: #{data['transaction']['id']}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Step 4: Configure Routes
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# config/routes.rb
|
|
157
|
+
Rails.application.routes.draw do
|
|
158
|
+
# Webhook endpoint
|
|
159
|
+
post '/webhooks/zai', to: 'webhooks#zai_webhook'
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Step 5: Test Your Webhook
|
|
164
|
+
|
|
165
|
+
Create a test to verify your implementation:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# spec/controllers/webhooks_controller_spec.rb
|
|
169
|
+
require 'rails_helper'
|
|
170
|
+
|
|
171
|
+
RSpec.describe WebhooksController, type: :controller do
|
|
172
|
+
let(:secret_key) { SecureRandom.alphanumeric(32) }
|
|
173
|
+
let(:webhook_payload) do
|
|
174
|
+
{
|
|
175
|
+
event: 'transaction.updated',
|
|
176
|
+
transaction: {
|
|
177
|
+
id: 'txn_123',
|
|
178
|
+
state: 'completed',
|
|
179
|
+
amount: 1000
|
|
180
|
+
}
|
|
181
|
+
}.to_json
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
before do
|
|
185
|
+
ENV['ZAI_WEBHOOK_SECRET'] = secret_key
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe 'POST #zai_webhook' do
|
|
189
|
+
context 'with valid signature' do
|
|
190
|
+
it 'processes the webhook successfully' do
|
|
191
|
+
timestamp = Time.now.to_i
|
|
192
|
+
signature = ZaiPayment::Resources::Webhook.new.generate_signature(
|
|
193
|
+
webhook_payload, secret_key, timestamp
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
request.headers['Webhooks-signature'] = "t=#{timestamp},v=#{signature}"
|
|
197
|
+
post :zai_webhook, body: webhook_payload
|
|
198
|
+
|
|
199
|
+
expect(response).to have_http_status(:ok)
|
|
200
|
+
expect(JSON.parse(response.body)['status']).to eq('success')
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
context 'with invalid signature' do
|
|
205
|
+
it 'rejects the webhook' do
|
|
206
|
+
timestamp = Time.now.to_i
|
|
207
|
+
request.headers['Webhooks-signature'] = "t=#{timestamp},v=invalid_signature"
|
|
208
|
+
post :zai_webhook, body: webhook_payload
|
|
209
|
+
|
|
210
|
+
expect(response).to have_http_status(:unauthorized)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
context 'with missing signature header' do
|
|
215
|
+
it 'rejects the webhook' do
|
|
216
|
+
post :zai_webhook, body: webhook_payload
|
|
217
|
+
|
|
218
|
+
expect(response).to have_http_status(:unauthorized)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
context 'with expired timestamp' do
|
|
223
|
+
it 'rejects old webhooks to prevent replay attacks' do
|
|
224
|
+
old_timestamp = Time.now.to_i - 600 # 10 minutes ago
|
|
225
|
+
signature = ZaiPayment::Resources::Webhook.new.generate_signature(
|
|
226
|
+
webhook_payload, secret_key, old_timestamp
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
request.headers['Webhooks-signature'] = "t=#{old_timestamp},v=#{signature}"
|
|
230
|
+
post :zai_webhook, body: webhook_payload
|
|
231
|
+
|
|
232
|
+
expect(response).to have_http_status(:unauthorized)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Basic Webhook Operations
|
|
240
|
+
|
|
241
|
+
## List Webhooks
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Get all webhooks
|
|
245
|
+
response = ZaiPayment.webhooks.list
|
|
246
|
+
puts response.data # Array of webhooks
|
|
247
|
+
puts response.meta # Pagination metadata
|
|
248
|
+
|
|
249
|
+
# With pagination
|
|
250
|
+
response = ZaiPayment.webhooks.list(limit: 20, offset: 10)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Show a Specific Webhook
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
webhook_id = 'webhook_123'
|
|
257
|
+
response = ZaiPayment.webhooks.show(webhook_id)
|
|
258
|
+
|
|
259
|
+
webhook = response.data
|
|
260
|
+
puts webhook['id']
|
|
261
|
+
puts webhook['url']
|
|
262
|
+
puts webhook['object_type']
|
|
263
|
+
puts webhook['enabled']
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Create a Webhook
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
response = ZaiPayment.webhooks.create(
|
|
270
|
+
url: 'https://example.com/webhooks/zai',
|
|
271
|
+
object_type: 'transactions',
|
|
272
|
+
enabled: true,
|
|
273
|
+
description: 'Production webhook for transactions'
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
new_webhook = response.data
|
|
277
|
+
puts "Created webhook with ID: #{new_webhook['id']}"
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Update a Webhook
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
webhook_id = 'webhook_123'
|
|
284
|
+
|
|
285
|
+
# Update specific fields
|
|
286
|
+
response = ZaiPayment.webhooks.update(
|
|
287
|
+
webhook_id,
|
|
288
|
+
enabled: false,
|
|
289
|
+
description: 'Temporarily disabled'
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Or update multiple fields
|
|
293
|
+
response = ZaiPayment.webhooks.update(
|
|
294
|
+
webhook_id,
|
|
295
|
+
url: 'https://example.com/webhooks/zai-v2',
|
|
296
|
+
object_type: 'items',
|
|
297
|
+
enabled: true
|
|
298
|
+
)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Delete a Webhook
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
webhook_id = 'webhook_123'
|
|
305
|
+
response = ZaiPayment.webhooks.delete(webhook_id)
|
|
306
|
+
|
|
307
|
+
if response.success?
|
|
308
|
+
puts "Webhook deleted successfully"
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Error Handling
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
begin
|
|
316
|
+
response = ZaiPayment.webhooks.create(
|
|
317
|
+
url: 'https://example.com/webhook',
|
|
318
|
+
object_type: 'transactions'
|
|
319
|
+
)
|
|
320
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
321
|
+
puts "Validation error: #{e.message}"
|
|
322
|
+
rescue ZaiPayment::Errors::UnauthorizedError => e
|
|
323
|
+
puts "Authentication failed: #{e.message}"
|
|
324
|
+
rescue ZaiPayment::Errors::NotFoundError => e
|
|
325
|
+
puts "Resource not found: #{e.message}"
|
|
326
|
+
rescue ZaiPayment::Errors::ApiError => e
|
|
327
|
+
puts "API error: #{e.message}"
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Using Custom Client Instance
|
|
332
|
+
|
|
333
|
+
If you need more control, you can create your own client instance:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
config = ZaiPayment::Config.new
|
|
337
|
+
config.environment = :prelive
|
|
338
|
+
config.client_id = 'your_client_id'
|
|
339
|
+
config.client_secret = 'your_client_secret'
|
|
340
|
+
config.scope = 'your_scope'
|
|
341
|
+
|
|
342
|
+
token_provider = ZaiPayment::Auth::TokenProvider.new(config: config)
|
|
343
|
+
client = ZaiPayment::Client.new(config: config, token_provider: token_provider)
|
|
344
|
+
|
|
345
|
+
webhooks = ZaiPayment::Resources::Webhook.new(client: client)
|
|
346
|
+
response = webhooks.list
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## Response Object
|
|
350
|
+
|
|
351
|
+
All webhook methods return a `ZaiPayment::Response` object with the following methods:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
response = ZaiPayment.webhooks.list
|
|
355
|
+
|
|
356
|
+
# Check status
|
|
357
|
+
response.success? # => true/false (2xx status)
|
|
358
|
+
response.client_error? # => true/false (4xx status)
|
|
359
|
+
response.server_error? # => true/false (5xx status)
|
|
360
|
+
|
|
361
|
+
# Access data
|
|
362
|
+
response.data # => Main response data (array or hash)
|
|
363
|
+
response.meta # => Pagination metadata (if available)
|
|
364
|
+
response.body # => Raw response body
|
|
365
|
+
response.headers # => Response headers
|
|
366
|
+
response.status # => HTTP status code
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Additional Webhook Security Examples
|
|
370
|
+
|
|
371
|
+
### Generate Signature for Testing
|
|
372
|
+
|
|
373
|
+
You can generate signatures for testing your webhook implementation:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
# Useful for integration tests or webhook simulation
|
|
377
|
+
payload = '{"event": "transaction.updated", "id": "txn_123"}'
|
|
378
|
+
secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
379
|
+
timestamp = Time.now.to_i
|
|
380
|
+
|
|
381
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
382
|
+
signature = webhook.generate_signature(payload, secret_key, timestamp)
|
|
383
|
+
|
|
384
|
+
puts "Signature header: t=#{timestamp},v=#{signature}"
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Verify Signature Manually
|
|
388
|
+
|
|
389
|
+
If you need more control over the verification process:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
393
|
+
|
|
394
|
+
begin
|
|
395
|
+
is_valid = webhook.verify_signature(
|
|
396
|
+
payload: request_body,
|
|
397
|
+
signature_header: request.headers['Webhooks-signature'],
|
|
398
|
+
secret_key: ENV['ZAI_WEBHOOK_SECRET'],
|
|
399
|
+
tolerance: 300
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if is_valid
|
|
403
|
+
puts "✅ Webhook signature is valid"
|
|
404
|
+
else
|
|
405
|
+
puts "❌ Webhook signature is invalid"
|
|
406
|
+
end
|
|
407
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
408
|
+
puts "⚠️ Validation error: #{e.message}"
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Sinatra Example
|
|
413
|
+
|
|
414
|
+
If you're using Sinatra instead of Rails:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
require 'sinatra'
|
|
418
|
+
require 'json'
|
|
419
|
+
require 'zai_payment'
|
|
420
|
+
|
|
421
|
+
# Configure ZaiPayment
|
|
422
|
+
ZaiPayment.configure do |config|
|
|
423
|
+
config.environment = :prelive
|
|
424
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
425
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
426
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
post '/webhooks/zai' do
|
|
430
|
+
# Read raw request body
|
|
431
|
+
payload = request.body.read
|
|
432
|
+
signature_header = request.env['HTTP_WEBHOOKS_SIGNATURE']
|
|
433
|
+
secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
434
|
+
|
|
435
|
+
# Verify signature
|
|
436
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
437
|
+
|
|
438
|
+
begin
|
|
439
|
+
unless webhook.verify_signature(
|
|
440
|
+
payload: payload,
|
|
441
|
+
signature_header: signature_header,
|
|
442
|
+
secret_key: secret_key
|
|
443
|
+
)
|
|
444
|
+
halt 401, { error: 'Invalid signature' }.to_json
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Process webhook
|
|
448
|
+
data = JSON.parse(payload)
|
|
449
|
+
logger.info "Received webhook: #{data['event']}"
|
|
450
|
+
|
|
451
|
+
# Your processing logic here
|
|
452
|
+
|
|
453
|
+
status 200
|
|
454
|
+
{ status: 'success' }.to_json
|
|
455
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
456
|
+
logger.error "Webhook validation failed: #{e.message}"
|
|
457
|
+
halt 401, { error: e.message }.to_json
|
|
458
|
+
rescue StandardError => e
|
|
459
|
+
logger.error "Webhook processing error: #{e.message}"
|
|
460
|
+
halt 500, { error: 'Processing error' }.to_json
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Rack Middleware Example
|
|
466
|
+
|
|
467
|
+
Create reusable middleware for webhook verification:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
# lib/middleware/zai_webhook_verifier.rb
|
|
471
|
+
module Middleware
|
|
472
|
+
class ZaiWebhookVerifier
|
|
473
|
+
def initialize(app, options = {})
|
|
474
|
+
@app = app
|
|
475
|
+
@secret_key = options[:secret_key] || ENV['ZAI_WEBHOOK_SECRET']
|
|
476
|
+
@tolerance = options[:tolerance] || 300
|
|
477
|
+
@webhook_path = options[:path] || '/webhooks/zai'
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def call(env)
|
|
481
|
+
request = Rack::Request.new(env)
|
|
482
|
+
|
|
483
|
+
# Only verify requests to the webhook path
|
|
484
|
+
if request.path == @webhook_path && request.post?
|
|
485
|
+
unless verify_request(request)
|
|
486
|
+
return [401, { 'Content-Type' => 'application/json' },
|
|
487
|
+
[{ error: 'Invalid webhook signature' }.to_json]]
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
@app.call(env)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
private
|
|
495
|
+
|
|
496
|
+
def verify_request(request)
|
|
497
|
+
body = request.body.read
|
|
498
|
+
request.body.rewind # Important: rewind for downstream processing
|
|
499
|
+
|
|
500
|
+
signature_header = request.env['HTTP_WEBHOOKS_SIGNATURE']
|
|
501
|
+
return false unless signature_header
|
|
502
|
+
|
|
503
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
504
|
+
webhook.verify_signature(
|
|
505
|
+
payload: body,
|
|
506
|
+
signature_header: signature_header,
|
|
507
|
+
secret_key: @secret_key,
|
|
508
|
+
tolerance: @tolerance
|
|
509
|
+
)
|
|
510
|
+
rescue ZaiPayment::Errors::ValidationError
|
|
511
|
+
false
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Usage in config.ru or Rails application.rb:
|
|
517
|
+
use Middleware::ZaiWebhookVerifier,
|
|
518
|
+
secret_key: ENV['ZAI_WEBHOOK_SECRET'],
|
|
519
|
+
tolerance: 300,
|
|
520
|
+
path: '/webhooks/zai'
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Background Job Processing
|
|
524
|
+
|
|
525
|
+
For production systems, process webhooks asynchronously:
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
# app/controllers/webhooks_controller.rb
|
|
529
|
+
class WebhooksController < ApplicationController
|
|
530
|
+
skip_before_action :verify_authenticity_token
|
|
531
|
+
|
|
532
|
+
def zai_webhook
|
|
533
|
+
payload = request.body.read
|
|
534
|
+
signature_header = request.headers['Webhooks-signature']
|
|
535
|
+
|
|
536
|
+
# Quick verification
|
|
537
|
+
unless verify_signature(payload, signature_header)
|
|
538
|
+
return render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Enqueue for background processing
|
|
542
|
+
ZaiWebhookJob.perform_later(payload)
|
|
543
|
+
|
|
544
|
+
# Return immediately
|
|
545
|
+
render json: { status: 'accepted' }, status: :accepted
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
private
|
|
549
|
+
|
|
550
|
+
def verify_signature(payload, signature_header)
|
|
551
|
+
ZaiPayment.webhooks.verify_signature(
|
|
552
|
+
payload: payload,
|
|
553
|
+
signature_header: signature_header,
|
|
554
|
+
secret_key: ENV['ZAI_WEBHOOK_SECRET']
|
|
555
|
+
)
|
|
556
|
+
rescue ZaiPayment::Errors::ValidationError
|
|
557
|
+
false
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# app/jobs/zai_webhook_job.rb
|
|
562
|
+
class ZaiWebhookJob < ApplicationJob
|
|
563
|
+
queue_as :webhooks
|
|
564
|
+
|
|
565
|
+
# Retry logic for transient failures
|
|
566
|
+
retry_on StandardError, wait: :exponentially_longer, attempts: 5
|
|
567
|
+
|
|
568
|
+
def perform(payload)
|
|
569
|
+
data = JSON.parse(payload)
|
|
570
|
+
|
|
571
|
+
# Idempotent processing - check if already processed
|
|
572
|
+
return if WebhookEvent.exists?(external_id: data['id'])
|
|
573
|
+
|
|
574
|
+
# Process the webhook
|
|
575
|
+
WebhookEvent.create!(
|
|
576
|
+
external_id: data['id'],
|
|
577
|
+
event_type: data['event'],
|
|
578
|
+
payload: data,
|
|
579
|
+
processed_at: Time.current
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Handle event
|
|
583
|
+
case data['event']
|
|
584
|
+
when 'transaction.completed'
|
|
585
|
+
TransactionProcessor.process_completion(data['transaction'])
|
|
586
|
+
when 'transaction.failed'
|
|
587
|
+
TransactionProcessor.process_failure(data['transaction'])
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Idempotency Pattern
|
|
594
|
+
|
|
595
|
+
Ensure webhooks are processed only once:
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# app/models/webhook_event.rb
|
|
599
|
+
class WebhookEvent < ApplicationRecord
|
|
600
|
+
# Columns: external_id, event_type, payload (jsonb), processed_at, created_at
|
|
601
|
+
|
|
602
|
+
validates :external_id, presence: true, uniqueness: true
|
|
603
|
+
|
|
604
|
+
def self.process_if_new(webhook_data)
|
|
605
|
+
# Use database constraint to ensure atomicity
|
|
606
|
+
transaction do
|
|
607
|
+
event = create!(
|
|
608
|
+
external_id: webhook_data['id'],
|
|
609
|
+
event_type: webhook_data['event'],
|
|
610
|
+
payload: webhook_data
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
yield event if block_given?
|
|
614
|
+
|
|
615
|
+
event.update!(processed_at: Time.current)
|
|
616
|
+
end
|
|
617
|
+
rescue ActiveRecord::RecordNotUnique
|
|
618
|
+
Rails.logger.info "Webhook already processed: #{webhook_data['id']}"
|
|
619
|
+
false
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Usage:
|
|
624
|
+
def process_webhook(data)
|
|
625
|
+
WebhookEvent.process_if_new(data) do |event|
|
|
626
|
+
# Your processing logic here
|
|
627
|
+
# This block only runs if the webhook is new
|
|
628
|
+
case event.event_type
|
|
629
|
+
when 'transaction.completed'
|
|
630
|
+
handle_completion(data)
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
```
|
|
635
|
+
|