Skip to content

Commit e31938a

Browse files
feat(client): add webhook support
1 parent df3f09b commit e31938a

10 files changed

Lines changed: 61 additions & 27 deletions

File tree

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ group :development, :test do
2222
gem "minitest-hooks"
2323
gem "minitest-proveit"
2424
gem "minitest-rg"
25-
gem "standardwebhooks", "~> 1.0", github: "standard-webhooks/standard-webhooks", glob: "libraries/ruby/*.gemspec"
25+
gem "standardwebhooks", "~> 1.0"
2626
gem "webmock"
2727
end
2828

Gemfile.lock

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,6 @@ GIT
88
rbs
99
syntax_tree (>= 2.0.1)
1010

11-
GIT
12-
remote: https://github.com/standard-webhooks/standard-webhooks.git
13-
revision: a173a6c0125ca2b9245bf5ea3f1c61384ccc10a2
14-
glob: libraries/ruby/*.gemspec
15-
specs:
16-
standardwebhooks (1.0.0)
17-
1811
PATH
1912
remote: .
2013
specs:
@@ -150,6 +143,7 @@ GEM
150143
rexml (>= 3.2.6)
151144
sorbet-static-and-runtime (>= 0.5.10187)
152145
thor (>= 0.19.2)
146+
standardwebhooks (1.0.1)
153147
steep (1.10.0)
154148
activesupport (>= 5.1)
155149
concurrent-ruby (>= 1.1.10)
@@ -223,7 +217,7 @@ DEPENDENCIES
223217
redcarpet
224218
rubocop
225219
sorbet
226-
standardwebhooks (~> 1.0)!
220+
standardwebhooks (~> 1.0)
227221
steep
228222
syntax_tree
229223
syntax_tree-rbs!

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ event = lithic.webhooks.parse_unsafe(request.body.read)
9393

9494
#### Installing standardwebhooks (optional)
9595

96-
To use signature verification, install from GitHub:
96+
To use signature verification, add the gem to your Gemfile:
9797

9898
```ruby
99-
gem "standardwebhooks", "~> 1.0", github: "standard-webhooks/standard-webhooks", glob: "libraries/ruby/*.gemspec"
99+
gem "standardwebhooks", "~> 1.0"
100100
```
101101

102102
### Handling errors

lib/lithic.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
# Gems.
3232
require "connection_pool"
33+
require "standardwebhooks"
3334

3435
# Package files.
3536
require_relative "lithic/version"

lib/lithic/client.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ class Client < Lithic::Internal::Transport::BaseClient
2323
# @return [String]
2424
attr_reader :api_key
2525

26+
# @return [String, nil]
27+
attr_reader :webhook_secret
28+
2629
# @return [Lithic::Resources::Accounts]
2730
attr_reader :accounts
2831

@@ -149,6 +152,8 @@ def api_status(params = {})
149152
#
150153
# @param api_key [String, nil] Defaults to `ENV["LITHIC_API_KEY"]`
151154
#
155+
# @param webhook_secret [String, nil] Defaults to `ENV["LITHIC_WEBHOOK_SECRET"]`
156+
#
152157
# @param environment [:production, :sandbox, nil] Specifies the environment to use for the API.
153158
#
154159
# Each environment maps to a different base URL:
@@ -168,6 +173,7 @@ def api_status(params = {})
168173
# @param max_retry_delay [Float]
169174
def initialize(
170175
api_key: ENV["LITHIC_API_KEY"],
176+
webhook_secret: ENV["LITHIC_WEBHOOK_SECRET"],
171177
environment: nil,
172178
base_url: ENV["LITHIC_BASE_URL"],
173179
max_retries: self.class::DEFAULT_MAX_RETRIES,
@@ -185,6 +191,7 @@ def initialize(
185191
end
186192

187193
@api_key = api_key.to_s
194+
@webhook_secret = webhook_secret&.to_s
188195

189196
super(
190197
base_url: base_url,

lib/lithic/resources/webhooks.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class Webhooks
99
# @param headers [Hash{String => String}] The webhook request headers
1010
# @param secret [String, nil] The webhook secret. If not provided, reads from LITHIC_WEBHOOK_SECRET environment variable.
1111
#
12-
# @return [Lithic::Models::AccountHolderCreatedWebhookEvent, Lithic::Models::ParsedWebhookEvent::KYBPayload, Lithic::Models::ParsedWebhookEvent::KYCPayload, Lithic::Models::ParsedWebhookEvent::LegacyPayload, Lithic::Models::AccountHolderVerificationWebhookEvent, Lithic::Models::AccountHolderDocumentUpdatedWebhookEvent, Lithic::Models::AsaRequestWebhookEvent, Lithic::Models::TokenizationDecisioningRequestWebhookEvent, Lithic::Models::AuthRulesBacktestReportCreatedWebhookEvent, Lithic::Models::BalanceUpdatedWebhookEvent, Lithic::Models::BookTransferTransactionCreatedWebhookEvent, Lithic::Models::BookTransferTransactionUpdatedWebhookEvent, Lithic::Models::CardCreatedWebhookEvent, Lithic::Models::CardConvertedWebhookEvent, Lithic::Models::CardRenewedWebhookEvent, Lithic::Models::CardReissuedWebhookEvent, Lithic::Models::CardShippedWebhookEvent, Lithic::Models::CardTransactionUpdatedWebhookEvent, Lithic::Models::CardTransactionEnhancedDataCreatedWebhookEvent, Lithic::Models::CardTransactionEnhancedDataUpdatedWebhookEvent, Lithic::Models::DigitalWalletTokenizationApprovalRequestWebhookEvent, Lithic::Models::DigitalWalletTokenizationResultWebhookEvent, Lithic::Models::DigitalWalletTokenizationTwoFactorAuthenticationCodeWebhookEvent, Lithic::Models::DigitalWalletTokenizationTwoFactorAuthenticationCodeSentWebhookEvent, Lithic::Models::DigitalWalletTokenizationUpdatedWebhookEvent, Lithic::Models::DisputeUpdatedWebhookEvent, Lithic::Models::DisputeEvidenceUploadFailedWebhookEvent, Lithic::Models::ExternalBankAccountCreatedWebhookEvent, Lithic::Models::ExternalBankAccountUpdatedWebhookEvent, Lithic::Models::ExternalPaymentCreatedWebhookEvent, Lithic::Models::ExternalPaymentUpdatedWebhookEvent, Lithic::Models::FinancialAccountCreatedWebhookEvent, Lithic::Models::FinancialAccountUpdatedWebhookEvent, Lithic::Models::FundingEventCreatedWebhookEvent, Lithic::Models::LoanTapeCreatedWebhookEvent, Lithic::Models::LoanTapeUpdatedWebhookEvent, Lithic::Models::ManagementOperationCreatedWebhookEvent, Lithic::Models::ManagementOperationUpdatedWebhookEvent, Lithic::Models::InternalTransactionCreatedWebhookEvent, Lithic::Models::InternalTransactionUpdatedWebhookEvent, Lithic::Models::NetworkTotalCreatedWebhookEvent, Lithic::Models::NetworkTotalUpdatedWebhookEvent, Lithic::Models::PaymentTransactionCreatedWebhookEvent, Lithic::Models::PaymentTransactionUpdatedWebhookEvent, Lithic::Models::SettlementReportUpdatedWebhookEvent, Lithic::Models::StatementsCreatedWebhookEvent, Lithic::Models::ThreeDSAuthenticationCreatedWebhookEvent, Lithic::Models::ThreeDSAuthenticationUpdatedWebhookEvent, Lithic::Models::ThreeDSAuthenticationChallengeWebhookEvent, Lithic::Models::TokenizationApprovalRequestWebhookEvent, Lithic::Models::TokenizationResultWebhookEvent, Lithic::Models::TokenizationTwoFactorAuthenticationCodeWebhookEvent, Lithic::Models::TokenizationTwoFactorAuthenticationCodeSentWebhookEvent, Lithic::Models::TokenizationUpdatedWebhookEvent, Lithic::Models::ThreeDSAuthentication, Lithic::Models::DisputeTransactionCreatedWebhookEvent, Lithic::Models::DisputeTransactionUpdatedWebhookEvent]
12+
# @return [Lithic::ParsedWebhookEvent::Variants]
1313
#
1414
# @raise [ArgumentError] if secret is not provided and LITHIC_WEBHOOK_SECRET env var is not set
1515
# @raise [Lithic::Errors::MissingDependencyError] if the standardwebhooks gem is not installed

rbi/lithic/client.rbi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ module Lithic
2222
sig { returns(String) }
2323
attr_reader :api_key
2424

25+
sig { returns(T.nilable(String)) }
26+
attr_reader :webhook_secret
27+
2528
sig { returns(Lithic::Resources::Accounts) }
2629
attr_reader :accounts
2730

@@ -142,6 +145,7 @@ module Lithic
142145
sig do
143146
params(
144147
api_key: T.nilable(String),
148+
webhook_secret: T.nilable(String),
145149
environment: T.nilable(T.any(Symbol, String)),
146150
base_url: T.nilable(String),
147151
max_retries: Integer,
@@ -153,6 +157,8 @@ module Lithic
153157
def self.new(
154158
# Defaults to `ENV["LITHIC_API_KEY"]`
155159
api_key: ENV["LITHIC_API_KEY"],
160+
# Defaults to `ENV["LITHIC_WEBHOOK_SECRET"]`
161+
webhook_secret: ENV["LITHIC_WEBHOOK_SECRET"],
156162
# Specifies the environment to use for the API.
157163
#
158164
# Each environment maps to a different base URL:

rbi/lithic/resources/webhooks.rbi

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,14 @@ module Lithic
7171
)
7272
)
7373
end
74-
def parse(payload, headers:, secret: nil)
74+
def parse(
75+
# The raw webhook payload as a string
76+
payload,
77+
# The raw HTTP headers that came with the payload
78+
headers:,
79+
# The webhook signing secret
80+
secret: nil
81+
)
7582
end
7683

7784
sig { params(payload: String).returns(Lithic::ParsedWebhookEvent::Variants) }

sig/lithic/client.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ module Lithic
1515

1616
attr_reader api_key: String
1717

18+
attr_reader webhook_secret: String?
19+
1820
attr_reader accounts: Lithic::Resources::Accounts
1921

2022
attr_reader account_holders: Lithic::Resources::AccountHolders
@@ -91,6 +93,7 @@ module Lithic
9193

9294
def initialize: (
9395
?api_key: String?,
96+
?webhook_secret: String?,
9497
?environment: :production | :sandbox | nil,
9598
?base_url: String?,
9699
?max_retries: Integer,

test/lithic/resources/webhooks_test.rb

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ def test_parse_unsafe_webhook_payload
1414
}.to_json
1515

1616
event = @lithic.webhooks.parse_unsafe(payload)
17-
# Verify the event was parsed successfully
1817
assert(event)
1918
assert_equal("card.created", event.event_type)
2019
end
@@ -28,7 +27,6 @@ def test_parse_with_valid_signature
2827
payload = '{"event_type":"card.created","card_token":"test_card_token_123"}'
2928
timestamp = Time.now.to_i.to_s
3029

31-
# Generate a valid signature
3230
wh = StandardWebhooks::Webhook.new(secret)
3331
signature = wh.sign("test_msg_id", timestamp, payload)
3432

@@ -38,12 +36,10 @@ def test_parse_with_valid_signature
3836
"webhook-signature" => signature
3937
}
4038

41-
# Should verify and return typed event
4239
event = @lithic.webhooks.parse(payload, headers: headers, secret: secret)
4340
assert(event)
4441
assert_equal(:"card.created", event.event_type)
4542

46-
# Verify we can pattern match on the typed event
4743
case event
4844
when Lithic::Models::CardCreatedWebhookEvent
4945
assert_equal("test_card_token_123", event.card_token)
@@ -52,6 +48,36 @@ def test_parse_with_valid_signature
5248
end
5349
end
5450

51+
def test_parse_with_invalid_signatures
52+
skip("standardwebhooks gem not installed") unless gem_installed?("standardwebhooks")
53+
54+
require("standardwebhooks")
55+
56+
secret = "whsec_test_secret"
57+
payload = '{"event_type":"card.created"}'
58+
timestamp = Time.now.to_i.to_s
59+
60+
wh = StandardWebhooks::Webhook.new(secret)
61+
signature = wh.sign("test_msg_id", timestamp, payload)
62+
63+
headers = {
64+
"webhook-id" => "test_msg_id",
65+
"webhook-timestamp" => timestamp,
66+
"webhook-signature" => signature
67+
}
68+
69+
bad_headers = [
70+
headers.merge("webhook-id" => "bad"),
71+
headers.merge("webhook-timestamp" => "0"),
72+
headers.merge("webhook-signature" => wh.sign("test_msg_id", timestamp, "xxx"))
73+
]
74+
bad_headers.each do |bad_header|
75+
assert_raises(StandardWebhooks::WebhookVerificationError) do
76+
@lithic.webhooks.parse(payload, headers: bad_header, secret: secret)
77+
end
78+
end
79+
end
80+
5581
def test_parse_with_env_var_secret
5682
skip("standardwebhooks gem not installed") unless gem_installed?("standardwebhooks")
5783

@@ -61,7 +87,6 @@ def test_parse_with_env_var_secret
6187
payload = '{"event_type":"card.created","data":{"token":"test_token"}}'
6288
timestamp = Time.now.to_i.to_s
6389

64-
# Generate a valid signature
6590
wh = StandardWebhooks::Webhook.new(secret)
6691
signature = wh.sign("test_msg_id", timestamp, payload)
6792

@@ -71,10 +96,8 @@ def test_parse_with_env_var_secret
7196
"webhook-signature" => signature
7297
}
7398

74-
# Set env var
7599
ENV["LITHIC_WEBHOOK_SECRET"] = secret
76100
begin
77-
# Should use env var when secret not provided
78101
event = @lithic.webhooks.parse(payload, headers: headers)
79102
assert(event)
80103
assert_equal("card.created", event.event_type)
@@ -93,7 +116,6 @@ def test_parse_raises_when_secret_missing
93116
"webhook-signature" => "v1,invalid"
94117
}
95118

96-
# Ensure env var is not set
97119
ENV.delete("LITHIC_WEBHOOK_SECRET")
98120

99121
error = assert_raises(ArgumentError) do
@@ -112,7 +134,6 @@ def test_verify_signature_with_valid_signature
112134
payload = '{"event_type":"card.created"}'
113135
timestamp = Time.now.to_i.to_s
114136

115-
# Generate a valid signature
116137
wh = StandardWebhooks::Webhook.new(secret)
117138
signature = wh.sign("test_msg_id", timestamp, payload)
118139

@@ -122,14 +143,12 @@ def test_verify_signature_with_valid_signature
122143
"webhook-signature" => signature
123144
}
124145

125-
# Should not raise an error and returns the parsed JSON
126146
result = @lithic.webhooks.verify_signature(
127147
payload: payload,
128148
headers: headers,
129149
secret: secret
130150
)
131151

132-
# verify returns the parsed JSON content with symbolized keys
133152
assert_equal("card.created", result[:event_type])
134153
end
135154

@@ -165,7 +184,6 @@ def test_verify_signature_with_env_var
165184
payload = '{"event_type":"card.created"}'
166185
timestamp = Time.now.to_i.to_s
167186

168-
# Generate a valid signature
169187
wh = StandardWebhooks::Webhook.new(secret)
170188
signature = wh.sign("test_msg_id", timestamp, payload)
171189

@@ -175,10 +193,8 @@ def test_verify_signature_with_env_var
175193
"webhook-signature" => signature
176194
}
177195

178-
# Set env var
179196
ENV["LITHIC_WEBHOOK_SECRET"] = secret
180197
begin
181-
# Should use env var when secret not provided
182198
result = @lithic.webhooks.verify_signature(payload: payload, headers: headers)
183199
assert_equal("card.created", result[:event_type])
184200
ensure

0 commit comments

Comments
 (0)