straddle_pay 0.1.3 → 0.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 +29 -0
- data/README.md +55 -0
- data/lib/straddle_pay/client.rb +4 -0
- data/lib/straddle_pay/errors.rb +11 -0
- data/lib/straddle_pay/resources/account_capability_requests.rb +31 -0
- data/lib/straddle_pay/resources/account_settings.rb +18 -0
- data/lib/straddle_pay/resources/bridge.rb +14 -0
- data/lib/straddle_pay/resources/charges.rb +17 -3
- data/lib/straddle_pay/resources/embed_accounts.rb +3 -0
- data/lib/straddle_pay/resources/embed_linked_bank_accounts.rb +2 -2
- data/lib/straddle_pay/resources/funding_event_payments.rb +19 -0
- data/lib/straddle_pay/resources/funding_events.rb +12 -2
- data/lib/straddle_pay/resources/paykeys.rb +43 -0
- data/lib/straddle_pay/resources/payouts.rb +11 -0
- data/lib/straddle_pay/version.rb +1 -1
- data/lib/straddle_pay/webhook.rb +181 -0
- data/lib/straddle_pay.rb +5 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cea0fc12d897f53acbe7d6d8d16f3b9be978e688603d3d6ba47228fc51ac5364
|
|
4
|
+
data.tar.gz: 8ddb9535d91df18cfef8bc5ded89dc1fe73d0f12f762e9e95b0b14e9c95b754b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 797ba62b6cbe4024e39c930411ed8a0298c1f691b7b2d0156429c98dddc15a930886d55bed9377535838dfcddb69fa7e6e62348256d80b59ee33320e96684051
|
|
7
|
+
data.tar.gz: 8f4a5ae928ff8213d04367eaaed803b34cd4ce4cc20fead2fd2605741b5cbe9f118824fef28ba947156c4979302b33b9f8b747e94b4565229f564130e2280a8b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## [0.1.5] - 2026-02-20
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- Aligned `charges.create` with required request fields and fixed `config` parameter coverage.
|
|
5
|
+
- Added missing endpoints from the official API spec:
|
|
6
|
+
- Account settings accessor and endpoint: `GET /v1/account_settings/{account_id}`
|
|
7
|
+
- Account capability requests: `POST /v1/accounts/{account_id}/capability_requests`, `GET /v1/accounts/{account_id}/capability_requests`
|
|
8
|
+
- Funding events:
|
|
9
|
+
- Corrected paths to `GET /v1/funding_events` and `GET /v1/funding_events/{id}`
|
|
10
|
+
- Added `POST /v1/funding_events/simulate`
|
|
11
|
+
- Bridge speedchex: `POST /v1/bridge/speedchex`
|
|
12
|
+
- Charge and payout re-submission:
|
|
13
|
+
- `POST /v1/charges/{id}/resubmit`
|
|
14
|
+
- `POST /v1/payouts/{id}/resubmit`
|
|
15
|
+
- Paykey lifecycle actions:
|
|
16
|
+
- `GET /v1/paykeys/{id}/review`
|
|
17
|
+
- `PUT /v1/paykeys/{id}/refresh_review`
|
|
18
|
+
- `PUT /v1/paykeys/{id}/refresh_balance`
|
|
19
|
+
- `PATCH /v1/paykeys/{id}/unblock`
|
|
20
|
+
- Funding event payments:
|
|
21
|
+
- `GET /v1/funding_event_payments/{id}`
|
|
22
|
+
- Fixed linked bank account create signature to match API optional `description`.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- New client accessors and resource classes for:
|
|
26
|
+
- `client.account_settings`
|
|
27
|
+
- `client.funding_event_payments`
|
|
28
|
+
- `client.embed.accounts.capability_requests`
|
|
29
|
+
|
|
1
30
|
## [0.1.0] - 2026-02-19
|
|
2
31
|
|
|
3
32
|
### Added
|
data/README.md
CHANGED
|
@@ -189,6 +189,61 @@ client.charges.create(
|
|
|
189
189
|
|
|
190
190
|
Headers `request_id`, `correlation_id`, and `idempotency_key` are also supported on all methods.
|
|
191
191
|
|
|
192
|
+
### Webhooks
|
|
193
|
+
|
|
194
|
+
Straddle uses [Svix](https://www.svix.com/) for webhook delivery. Use `StraddlePay::Webhook.construct_event` to verify signatures and parse the payload. You'll find your webhook signing secret (starts with `whsec_`) in the Straddle Dashboard.
|
|
195
|
+
|
|
196
|
+
In a Rails controller:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class WebhooksController < ApplicationController
|
|
200
|
+
skip_before_action :verify_authenticity_token
|
|
201
|
+
|
|
202
|
+
def create
|
|
203
|
+
event = StraddlePay::Webhook.construct_event(
|
|
204
|
+
request.body.read,
|
|
205
|
+
request.headers,
|
|
206
|
+
ENV.fetch("STRADDLE_WEBHOOK_SECRET")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
case event["event_type"]
|
|
210
|
+
when StraddlePay::Webhook::Events::CHARGE_CREATED_V1
|
|
211
|
+
handle_charge_created(event["data"])
|
|
212
|
+
when StraddlePay::Webhook::Events::PAYOUT_EVENT_V1
|
|
213
|
+
handle_payout_event(event["data"])
|
|
214
|
+
when StraddlePay::Webhook::Events::CUSTOMER_EVENT_V1
|
|
215
|
+
handle_customer_event(event["data"])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
head :ok
|
|
219
|
+
rescue StraddlePay::SignatureVerificationError => e
|
|
220
|
+
head :bad_request
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The default timestamp tolerance is 300 seconds. Pass `tolerance: nil` to skip the time check, or a custom value in seconds:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
# Skip timestamp validation
|
|
229
|
+
event = StraddlePay::Webhook.construct_event(payload, headers, secret, tolerance: nil)
|
|
230
|
+
|
|
231
|
+
# Custom tolerance (10 minutes)
|
|
232
|
+
event = StraddlePay::Webhook.construct_event(payload, headers, secret, tolerance: 600)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`Webhook::Signature.generate_header` is available for generating valid headers in tests:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
headers = StraddlePay::Webhook::Signature.generate_header(
|
|
239
|
+
msg_id: "msg_test123",
|
|
240
|
+
timestamp: Time.now.to_i,
|
|
241
|
+
payload: body,
|
|
242
|
+
secret: "whsec_test_secret"
|
|
243
|
+
)
|
|
244
|
+
post webhooks_path, params: body, headers: headers
|
|
245
|
+
```
|
|
246
|
+
|
|
192
247
|
## Error Handling
|
|
193
248
|
|
|
194
249
|
```ruby
|
data/lib/straddle_pay/client.rb
CHANGED
|
@@ -36,6 +36,8 @@ module StraddlePay
|
|
|
36
36
|
def customers = @customers ||= Resources::Customers.new(self)
|
|
37
37
|
# @return [Resources::Bridge]
|
|
38
38
|
def bridge = @bridge ||= Resources::Bridge.new(self)
|
|
39
|
+
# @return [Resources::AccountSettings]
|
|
40
|
+
def account_settings = @account_settings ||= Resources::AccountSettings.new(self)
|
|
39
41
|
# @return [Resources::Paykeys]
|
|
40
42
|
def paykeys = @paykeys ||= Resources::Paykeys.new(self)
|
|
41
43
|
# @return [Resources::Charges]
|
|
@@ -46,6 +48,8 @@ module StraddlePay
|
|
|
46
48
|
def payments = @payments ||= Resources::Payments.new(self)
|
|
47
49
|
# @return [Resources::FundingEvents]
|
|
48
50
|
def funding_events = @funding_events ||= Resources::FundingEvents.new(self)
|
|
51
|
+
# @return [Resources::FundingEventPayments]
|
|
52
|
+
def funding_event_payments = @funding_event_payments ||= Resources::FundingEventPayments.new(self)
|
|
49
53
|
# @return [Resources::Reports]
|
|
50
54
|
def reports = @reports ||= Resources::Reports.new(self)
|
|
51
55
|
# @return [Resources::Embed]
|
data/lib/straddle_pay/errors.rb
CHANGED
|
@@ -45,4 +45,15 @@ module StraddlePay
|
|
|
45
45
|
|
|
46
46
|
# Raised on timeout or connection failure.
|
|
47
47
|
class NetworkError < Error; end
|
|
48
|
+
|
|
49
|
+
# Raised when webhook signature verification fails.
|
|
50
|
+
class SignatureVerificationError < Error
|
|
51
|
+
# @return [String, nil] the signature header that failed verification
|
|
52
|
+
attr_reader :sig_header
|
|
53
|
+
|
|
54
|
+
def initialize(message, sig_header: nil, **)
|
|
55
|
+
super(message, **)
|
|
56
|
+
@sig_header = sig_header
|
|
57
|
+
end
|
|
58
|
+
end
|
|
48
59
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StraddlePay
|
|
4
|
+
module Resources
|
|
5
|
+
# Manage capability requests for embedded accounts.
|
|
6
|
+
# Accessed via {EmbedAccounts#capability_requests}.
|
|
7
|
+
class AccountCapabilityRequests < Base
|
|
8
|
+
# Request a capability for an account.
|
|
9
|
+
#
|
|
10
|
+
# @param account_id [String] account ID
|
|
11
|
+
# @param options [Hash] request body or header params
|
|
12
|
+
# @return [Hash] created capability request
|
|
13
|
+
def create(account_id, **options)
|
|
14
|
+
payload = options.compact
|
|
15
|
+
headers = extract_headers(payload)
|
|
16
|
+
@client.post("v1/accounts/#{account_id}/capability_requests", payload.empty? ? nil : payload, headers: headers)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# List capability requests for an account.
|
|
20
|
+
#
|
|
21
|
+
# @param account_id [String] account ID
|
|
22
|
+
# @param options [Hash] filter params or header params
|
|
23
|
+
# @return [Hash] paginated capability requests
|
|
24
|
+
def list(account_id, **options)
|
|
25
|
+
query = options.dup
|
|
26
|
+
headers = extract_headers(query)
|
|
27
|
+
@client.get("v1/accounts/#{account_id}/capability_requests", params: query, headers: headers)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StraddlePay
|
|
4
|
+
module Resources
|
|
5
|
+
# Retrieve account settings.
|
|
6
|
+
class AccountSettings < Base
|
|
7
|
+
# Get resolved settings for an account.
|
|
8
|
+
#
|
|
9
|
+
# @param account_id [String] account ID
|
|
10
|
+
# @param options [Hash] header params
|
|
11
|
+
# @return [Hash] resolved account settings
|
|
12
|
+
def get(account_id, **options)
|
|
13
|
+
headers = extract_headers(options)
|
|
14
|
+
@client.get("v1/account_settings/#{account_id}", headers: headers)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -16,6 +16,20 @@ module StraddlePay
|
|
|
16
16
|
headers = extract_headers(payload)
|
|
17
17
|
@client.post("v1/bridge/initialize", payload, headers: headers)
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
# Create a paykey from a Speedchex token.
|
|
21
|
+
#
|
|
22
|
+
# @param customer_id [String] customer ID
|
|
23
|
+
# @param speedchex_token [String] Speedchex token
|
|
24
|
+
# @param options [Hash] additional fields or header params
|
|
25
|
+
# @return [Hash] created paykey
|
|
26
|
+
def speedchex(customer_id:, speedchex_token:, **options)
|
|
27
|
+
payload = {
|
|
28
|
+
customer_id: customer_id, speedchex_token: speedchex_token, **options
|
|
29
|
+
}.compact
|
|
30
|
+
headers = extract_headers(payload)
|
|
31
|
+
@client.post("v1/bridge/speedchex", payload, headers: headers)
|
|
32
|
+
end
|
|
19
33
|
end
|
|
20
34
|
end
|
|
21
35
|
end
|
|
@@ -16,13 +16,16 @@ module StraddlePay
|
|
|
16
16
|
# @param consent_type [String] consent type (e.g. "internet")
|
|
17
17
|
# @param device [Hash] device info (must include :ip_address)
|
|
18
18
|
# @param external_id [String] your external reference ID
|
|
19
|
+
# @param config [Hash] charge processing configuration
|
|
19
20
|
# @param options [Hash] additional fields or header params
|
|
20
21
|
# @return [Hash] created charge
|
|
21
|
-
def create(
|
|
22
|
-
|
|
22
|
+
def create(
|
|
23
|
+
paykey:, amount:, currency:, description:, payment_date:, consent_type:,
|
|
24
|
+
device:, external_id:, config:, **options
|
|
25
|
+
)
|
|
23
26
|
payload = {
|
|
24
27
|
paykey: paykey, amount: amount, currency: currency, description: description,
|
|
25
|
-
payment_date: payment_date, consent_type: consent_type, device: device,
|
|
28
|
+
payment_date: payment_date, consent_type: consent_type, device: device, config: config,
|
|
26
29
|
external_id: external_id, **options
|
|
27
30
|
}.compact
|
|
28
31
|
headers = extract_headers(payload)
|
|
@@ -82,6 +85,17 @@ module StraddlePay
|
|
|
82
85
|
headers = extract_headers(options)
|
|
83
86
|
@client.get("v1/charges/#{id}/unmask", headers: headers)
|
|
84
87
|
end
|
|
88
|
+
|
|
89
|
+
# Resubmit a failed or reversed charge.
|
|
90
|
+
#
|
|
91
|
+
# @param id [String] charge ID
|
|
92
|
+
# @param options [Hash] optional request body or header params
|
|
93
|
+
# @return [Hash] resubmitted charge
|
|
94
|
+
def resubmit(id, **options)
|
|
95
|
+
payload = options.compact
|
|
96
|
+
headers = extract_headers(payload)
|
|
97
|
+
@client.post("v1/charges/#{id}/resubmit", payload.empty? ? nil : payload, headers: headers)
|
|
98
|
+
end
|
|
85
99
|
end
|
|
86
100
|
end
|
|
87
101
|
end
|
|
@@ -5,6 +5,9 @@ module StraddlePay
|
|
|
5
5
|
# Manage embedded accounts for platform/marketplace use.
|
|
6
6
|
# Accessed via {Embed#accounts}.
|
|
7
7
|
class EmbedAccounts < Base
|
|
8
|
+
# @return [AccountCapabilityRequests]
|
|
9
|
+
def capability_requests = @capability_requests ||= AccountCapabilityRequests.new(@client)
|
|
10
|
+
|
|
8
11
|
# Create an embedded account.
|
|
9
12
|
#
|
|
10
13
|
# @param organization_id [String] parent organization ID
|
|
@@ -9,9 +9,9 @@ module StraddlePay
|
|
|
9
9
|
#
|
|
10
10
|
# @param account_id [String] parent account ID
|
|
11
11
|
# @param bank_account [Hash] bank details (account_number, routing_number)
|
|
12
|
-
# @param description [String] account description
|
|
12
|
+
# @param description [String, nil] account description
|
|
13
13
|
# @return [Hash] created linked bank account
|
|
14
|
-
def create(account_id:, bank_account:, description
|
|
14
|
+
def create(account_id:, bank_account:, description: nil, **options)
|
|
15
15
|
payload = {
|
|
16
16
|
account_id: account_id, bank_account: bank_account,
|
|
17
17
|
description: description, **options
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StraddlePay
|
|
4
|
+
module Resources
|
|
5
|
+
# Query funding event payment records.
|
|
6
|
+
class FundingEventPayments < Base
|
|
7
|
+
# List payments for a funding event.
|
|
8
|
+
#
|
|
9
|
+
# @param id [String] funding event ID
|
|
10
|
+
# @param options [Hash] filter/pagination params or header params
|
|
11
|
+
# @return [Hash] funding event payment list
|
|
12
|
+
def get(id, **options)
|
|
13
|
+
query = options.dup
|
|
14
|
+
headers = extract_headers(query)
|
|
15
|
+
@client.get("v1/funding_event_payments/#{id}", params: query, headers: headers)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -10,7 +10,7 @@ module StraddlePay
|
|
|
10
10
|
# @return [Hash] paginated funding event list
|
|
11
11
|
def list(**options)
|
|
12
12
|
headers = extract_headers(options)
|
|
13
|
-
@client.get("v1/
|
|
13
|
+
@client.get("v1/funding_events", params: options, headers: headers)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Retrieve a funding event by ID.
|
|
@@ -19,7 +19,17 @@ module StraddlePay
|
|
|
19
19
|
# @return [Hash] funding event details
|
|
20
20
|
def get(id, **options)
|
|
21
21
|
headers = extract_headers(options)
|
|
22
|
-
@client.get("v1/
|
|
22
|
+
@client.get("v1/funding_events/#{id}", headers: headers)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Simulate a funding event in sandbox.
|
|
26
|
+
#
|
|
27
|
+
# @param options [Hash] simulation payload
|
|
28
|
+
# @return [Hash] simulated funding event
|
|
29
|
+
def simulate(**options)
|
|
30
|
+
payload = options.compact
|
|
31
|
+
headers = extract_headers(payload)
|
|
32
|
+
@client.post("v1/funding_events/simulate", payload.empty? ? nil : payload, headers: headers)
|
|
23
33
|
end
|
|
24
34
|
end
|
|
25
35
|
end
|
|
@@ -52,10 +52,53 @@ module StraddlePay
|
|
|
52
52
|
#
|
|
53
53
|
# @param id [String] paykey ID
|
|
54
54
|
# @return [Hash] review result
|
|
55
|
+
# @note Backward compatible method for status updates while in review state.
|
|
55
56
|
def review(id, **options)
|
|
56
57
|
headers = extract_headers(options)
|
|
57
58
|
@client.patch("v1/paykeys/#{id}/review", options, headers: headers)
|
|
58
59
|
end
|
|
60
|
+
|
|
61
|
+
# Get the current review details for a paykey.
|
|
62
|
+
#
|
|
63
|
+
# @param id [String] paykey ID
|
|
64
|
+
# @return [Hash] review details
|
|
65
|
+
def get_review(id, **options)
|
|
66
|
+
headers = extract_headers(options)
|
|
67
|
+
@client.get("v1/paykeys/#{id}/review", headers: headers)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Refresh a paykey's identity review decision.
|
|
71
|
+
#
|
|
72
|
+
# @param id [String] paykey ID
|
|
73
|
+
# @param options [Hash] request body or header params
|
|
74
|
+
# @return [Hash] refreshed paykey details
|
|
75
|
+
def refresh_review(id, **options)
|
|
76
|
+
payload = options.compact
|
|
77
|
+
headers = extract_headers(payload)
|
|
78
|
+
@client.put("v1/paykeys/#{id}/refresh_review", payload.empty? ? nil : payload, headers: headers)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Refresh a paykey's balance.
|
|
82
|
+
#
|
|
83
|
+
# @param id [String] paykey ID
|
|
84
|
+
# @param options [Hash] optional request body or header params
|
|
85
|
+
# @return [Hash] refreshed paykey details
|
|
86
|
+
def refresh_balance(id, **options)
|
|
87
|
+
payload = options.compact
|
|
88
|
+
headers = extract_headers(payload)
|
|
89
|
+
@client.put("v1/paykeys/#{id}/refresh_balance", payload.empty? ? nil : payload, headers: headers)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Unblock a paykey (R29 unblock flow).
|
|
93
|
+
#
|
|
94
|
+
# @param id [String] paykey ID
|
|
95
|
+
# @param options [Hash] optional request body or header params
|
|
96
|
+
# @return [Hash] unblocked paykey details
|
|
97
|
+
def unblock(id, **options)
|
|
98
|
+
payload = options.compact
|
|
99
|
+
headers = extract_headers(payload)
|
|
100
|
+
@client.patch("v1/paykeys/#{id}/unblock", payload.empty? ? nil : payload, headers: headers)
|
|
101
|
+
end
|
|
59
102
|
end
|
|
60
103
|
end
|
|
61
104
|
end
|
|
@@ -84,6 +84,17 @@ module StraddlePay
|
|
|
84
84
|
headers = extract_headers(options)
|
|
85
85
|
@client.get("v1/payouts/#{id}/unmask", headers: headers)
|
|
86
86
|
end
|
|
87
|
+
|
|
88
|
+
# Resubmit a failed or reversed payout.
|
|
89
|
+
#
|
|
90
|
+
# @param id [String] payout ID
|
|
91
|
+
# @param options [Hash] optional request body or header params
|
|
92
|
+
# @return [Hash] resubmitted payout
|
|
93
|
+
def resubmit(id, **options)
|
|
94
|
+
payload = options.compact
|
|
95
|
+
headers = extract_headers(payload)
|
|
96
|
+
@client.post("v1/payouts/#{id}/resubmit", payload.empty? ? nil : payload, headers: headers)
|
|
97
|
+
end
|
|
87
98
|
end
|
|
88
99
|
end
|
|
89
100
|
end
|
data/lib/straddle_pay/version.rb
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module StraddlePay
|
|
7
|
+
# Verifies webhook signatures and parses events from Straddle (Svix) webhooks.
|
|
8
|
+
#
|
|
9
|
+
# @example Verifying a webhook in a Rails controller
|
|
10
|
+
# event = StraddlePay::Webhook.construct_event(
|
|
11
|
+
# request.body.read,
|
|
12
|
+
# request.headers,
|
|
13
|
+
# ENV["STRADDLE_WEBHOOK_SECRET"]
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# case event["event_type"]
|
|
17
|
+
# when StraddlePay::Webhook::Events::CHARGE_CREATED_V1
|
|
18
|
+
# handle_charge(event["data"])
|
|
19
|
+
# end
|
|
20
|
+
module Webhook
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Verifies the webhook signature and returns the parsed event payload.
|
|
24
|
+
#
|
|
25
|
+
# @param payload [String] raw request body
|
|
26
|
+
# @param headers [Hash, #[]] request headers (must include svix-id, svix-timestamp, svix-signature)
|
|
27
|
+
# @param secret [String] webhook signing secret (e.g. "whsec_...")
|
|
28
|
+
# @param tolerance [Integer, nil] max age in seconds (default 300, nil to skip)
|
|
29
|
+
# @return [Hash] parsed event with string keys
|
|
30
|
+
# @raise [SignatureVerificationError] if verification fails
|
|
31
|
+
def construct_event(payload, headers, secret, tolerance: 300)
|
|
32
|
+
Signature.verify_header(payload, headers, secret, tolerance: tolerance)
|
|
33
|
+
JSON.parse(payload)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Low-level signature verification following the Svix protocol.
|
|
37
|
+
module Signature
|
|
38
|
+
HEADER_PREFIXES = %w[svix webhook].freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Verifies the webhook signature header against the payload.
|
|
43
|
+
#
|
|
44
|
+
# @param payload [String] raw request body
|
|
45
|
+
# @param headers [Hash, #[]] request headers
|
|
46
|
+
# @param secret [String] webhook signing secret
|
|
47
|
+
# @param tolerance [Integer, nil] max age in seconds (nil to skip time check)
|
|
48
|
+
# @return [true]
|
|
49
|
+
# @raise [SignatureVerificationError] if verification fails
|
|
50
|
+
def verify_header(payload, headers, secret, tolerance: nil)
|
|
51
|
+
msg_id, timestamp, signature = extract_headers(headers)
|
|
52
|
+
verify_timestamp(timestamp, tolerance) if tolerance
|
|
53
|
+
expected = compute_signature(msg_id, timestamp, payload, secret)
|
|
54
|
+
verify_signature(expected, signature)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Computes the expected HMAC-SHA256 signature for a webhook payload.
|
|
58
|
+
#
|
|
59
|
+
# @param msg_id [String] the message ID
|
|
60
|
+
# @param timestamp [String] Unix timestamp
|
|
61
|
+
# @param payload [String] raw request body
|
|
62
|
+
# @param secret [String] webhook signing secret
|
|
63
|
+
# @return [String] Base64-encoded signature
|
|
64
|
+
def compute_signature(msg_id, timestamp, payload, secret)
|
|
65
|
+
key = decode_secret(secret)
|
|
66
|
+
signed_content = "#{msg_id}.#{timestamp}.#{payload}"
|
|
67
|
+
digest = OpenSSL::HMAC.digest("SHA256", key, signed_content)
|
|
68
|
+
[digest].pack("m0")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Generates Svix-compatible headers for testing.
|
|
72
|
+
#
|
|
73
|
+
# @param msg_id [String] message ID
|
|
74
|
+
# @param timestamp [String, Integer] Unix timestamp
|
|
75
|
+
# @param payload [String] raw request body
|
|
76
|
+
# @param secret [String] webhook signing secret
|
|
77
|
+
# @return [Hash] headers hash with svix-id, svix-timestamp, svix-signature
|
|
78
|
+
def generate_header(msg_id:, timestamp:, payload:, secret:)
|
|
79
|
+
ts = timestamp.to_s
|
|
80
|
+
sig = compute_signature(msg_id, ts, payload, secret)
|
|
81
|
+
{
|
|
82
|
+
"svix-id" => msg_id,
|
|
83
|
+
"svix-timestamp" => ts,
|
|
84
|
+
"svix-signature" => "v1,#{sig}"
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --- private helpers ---
|
|
89
|
+
|
|
90
|
+
def extract_headers(headers)
|
|
91
|
+
prefix = HEADER_PREFIXES.find { |p| header_value(headers, "#{p}-id") }
|
|
92
|
+
raise SignatureVerificationError, "Missing required webhook headers" unless prefix
|
|
93
|
+
|
|
94
|
+
msg_id = header_value(headers, "#{prefix}-id")
|
|
95
|
+
timestamp = header_value(headers, "#{prefix}-timestamp")
|
|
96
|
+
signature = header_value(headers, "#{prefix}-signature")
|
|
97
|
+
|
|
98
|
+
raise SignatureVerificationError, "Missing required webhook headers" unless msg_id && timestamp && signature
|
|
99
|
+
|
|
100
|
+
[msg_id, timestamp, signature]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def header_value(headers, key)
|
|
104
|
+
normalized = key.upcase.tr("-", "_")
|
|
105
|
+
headers[key] || headers[normalized] ||
|
|
106
|
+
(headers.respond_to?(:env) && headers.env["HTTP_#{normalized}"])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verify_timestamp(timestamp, tolerance)
|
|
110
|
+
ts = Integer(timestamp)
|
|
111
|
+
now = Time.now.to_i
|
|
112
|
+
diff = (now - ts).abs
|
|
113
|
+
|
|
114
|
+
return if diff <= tolerance
|
|
115
|
+
|
|
116
|
+
raise SignatureVerificationError, "Timestamp outside tolerance zone (#{diff}s > #{tolerance}s)"
|
|
117
|
+
rescue ArgumentError
|
|
118
|
+
raise SignatureVerificationError, "Invalid timestamp: #{timestamp}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def verify_signature(expected_b64, signature_header)
|
|
122
|
+
signatures = signature_header.split
|
|
123
|
+
v1_sigs = signatures.filter_map { |s| s.delete_prefix("v1,") if s.start_with?("v1,") }
|
|
124
|
+
|
|
125
|
+
if v1_sigs.empty?
|
|
126
|
+
raise SignatureVerificationError.new("No v1 signatures found",
|
|
127
|
+
sig_header: signature_header)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
expected_bytes = expected_b64.unpack1("m0")
|
|
131
|
+
return true if v1_sigs.any? { |sig| secure_compare(expected_bytes, sig) }
|
|
132
|
+
|
|
133
|
+
raise SignatureVerificationError.new("No matching signature found",
|
|
134
|
+
sig_header: signature_header)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def decode_secret(secret)
|
|
138
|
+
key = secret.start_with?("whsec_") ? secret[6..] : secret
|
|
139
|
+
key.unpack1("m0")
|
|
140
|
+
rescue ArgumentError
|
|
141
|
+
raise SignatureVerificationError, "Invalid webhook secret format"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def secure_compare(expected_bytes, actual_b64)
|
|
145
|
+
OpenSSL.fixed_length_secure_compare(expected_bytes, actual_b64.unpack1("m0"))
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private_class_method :extract_headers, :header_value, :verify_timestamp,
|
|
151
|
+
:verify_signature, :decode_secret, :secure_compare
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Webhook event type constants for the Straddle API.
|
|
155
|
+
module Events
|
|
156
|
+
# Embed
|
|
157
|
+
ACCOUNT_CREATED_V1 = "account.created.v1"
|
|
158
|
+
ACCOUNT_EVENT_V1 = "account.event.v1"
|
|
159
|
+
REPRESENTATIVE_CREATED_V1 = "representative.created.v1"
|
|
160
|
+
REPRESENTATIVE_EVENT_V1 = "representative.event.v1"
|
|
161
|
+
LINKED_BANK_ACCOUNT_CREATED_V1 = "linked_bank_account.created.v1"
|
|
162
|
+
LINKED_BANK_ACCOUNT_EVENT_V1 = "linked_bank_account.event.v1"
|
|
163
|
+
CAPABILITY_REQUEST_CREATED_V1 = "capability_request.created.v1"
|
|
164
|
+
CAPABILITY_REQUEST_EVENT_V1 = "capability_request.event.v1"
|
|
165
|
+
|
|
166
|
+
# Core
|
|
167
|
+
CUSTOMER_CREATED_V1 = "customer.created.v1"
|
|
168
|
+
CUSTOMER_EVENT_V1 = "customer.event.v1"
|
|
169
|
+
PAYKEY_CREATED_V1 = "paykey.created.v1"
|
|
170
|
+
PAYKEY_EVENT_V1 = "paykey.event.v1"
|
|
171
|
+
CHARGE_CREATED_V1 = "charge.created.v1"
|
|
172
|
+
CHARGE_EVENT_V1 = "charge.event.v1"
|
|
173
|
+
PAYOUT_CREATED_V1 = "payout.created.v1"
|
|
174
|
+
PAYOUT_EVENT_V1 = "payout.event.v1"
|
|
175
|
+
|
|
176
|
+
# Funding
|
|
177
|
+
FUNDING_EVENT_CREATED_V1 = "funding_event.created.v1"
|
|
178
|
+
FUNDING_EVENT_EVENT_V1 = "funding_event.event.v1"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/straddle_pay.rb
CHANGED
|
@@ -23,8 +23,12 @@ module StraddlePay
|
|
|
23
23
|
autoload :RateLimitError, "straddle_pay/errors"
|
|
24
24
|
autoload :ServerError, "straddle_pay/errors"
|
|
25
25
|
autoload :NetworkError, "straddle_pay/errors"
|
|
26
|
+
autoload :SignatureVerificationError, "straddle_pay/errors"
|
|
27
|
+
autoload :Webhook, "straddle_pay/webhook"
|
|
26
28
|
|
|
27
29
|
module Resources
|
|
30
|
+
autoload :AccountSettings, "straddle_pay/resources/account_settings"
|
|
31
|
+
autoload :AccountCapabilityRequests, "straddle_pay/resources/account_capability_requests"
|
|
28
32
|
autoload :Base, "straddle_pay/resources/base"
|
|
29
33
|
autoload :Charges, "straddle_pay/resources/charges"
|
|
30
34
|
autoload :Payouts, "straddle_pay/resources/payouts"
|
|
@@ -35,6 +39,7 @@ module StraddlePay
|
|
|
35
39
|
autoload :Paykeys, "straddle_pay/resources/paykeys"
|
|
36
40
|
autoload :Payments, "straddle_pay/resources/payments"
|
|
37
41
|
autoload :FundingEvents, "straddle_pay/resources/funding_events"
|
|
42
|
+
autoload :FundingEventPayments, "straddle_pay/resources/funding_event_payments"
|
|
38
43
|
autoload :Reports, "straddle_pay/resources/reports"
|
|
39
44
|
autoload :Embed, "straddle_pay/resources/embed"
|
|
40
45
|
autoload :EmbedAccounts, "straddle_pay/resources/embed_accounts"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: straddle_pay
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dpaluy
|
|
@@ -44,6 +44,8 @@ files:
|
|
|
44
44
|
- lib/straddle_pay/config.rb
|
|
45
45
|
- lib/straddle_pay/engine.rb
|
|
46
46
|
- lib/straddle_pay/errors.rb
|
|
47
|
+
- lib/straddle_pay/resources/account_capability_requests.rb
|
|
48
|
+
- lib/straddle_pay/resources/account_settings.rb
|
|
47
49
|
- lib/straddle_pay/resources/base.rb
|
|
48
50
|
- lib/straddle_pay/resources/bridge.rb
|
|
49
51
|
- lib/straddle_pay/resources/bridge_links.rb
|
|
@@ -55,12 +57,14 @@ files:
|
|
|
55
57
|
- lib/straddle_pay/resources/embed_linked_bank_accounts.rb
|
|
56
58
|
- lib/straddle_pay/resources/embed_organizations.rb
|
|
57
59
|
- lib/straddle_pay/resources/embed_representatives.rb
|
|
60
|
+
- lib/straddle_pay/resources/funding_event_payments.rb
|
|
58
61
|
- lib/straddle_pay/resources/funding_events.rb
|
|
59
62
|
- lib/straddle_pay/resources/paykeys.rb
|
|
60
63
|
- lib/straddle_pay/resources/payments.rb
|
|
61
64
|
- lib/straddle_pay/resources/payouts.rb
|
|
62
65
|
- lib/straddle_pay/resources/reports.rb
|
|
63
66
|
- lib/straddle_pay/version.rb
|
|
67
|
+
- lib/straddle_pay/webhook.rb
|
|
64
68
|
homepage: https://github.com/dpaluy/straddle_pay
|
|
65
69
|
licenses:
|
|
66
70
|
- MIT
|