pago-sdk 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/lib/pago/base_client.rb +116 -0
- data/lib/pago/errors.rb +83 -0
- data/lib/pago/http.rb +78 -0
- data/lib/pago/model.rb +86 -0
- data/lib/pago/paginator.rb +67 -0
- data/lib/pago/serde.rb +186 -0
- data/lib/pago/service.rb +17 -0
- data/lib/pago/v2026_04/client.rb +124 -0
- data/lib/pago/v2026_04/enums.rb +4552 -0
- data/lib/pago/v2026_04/errors.rb +1121 -0
- data/lib/pago/v2026_04/models.rb +46344 -0
- data/lib/pago/v2026_04/services/benefit_grants.rb +48 -0
- data/lib/pago/v2026_04/services/benefits.rb +179 -0
- data/lib/pago/v2026_04/services/checkout_links.rb +131 -0
- data/lib/pago/v2026_04/services/checkouts.rb +185 -0
- data/lib/pago/v2026_04/services/custom_fields.rb +132 -0
- data/lib/pago/v2026_04/services/customer_meters.rb +69 -0
- data/lib/pago/v2026_04/services/customer_portal.rb +1181 -0
- data/lib/pago/v2026_04/services/customer_seats.rb +137 -0
- data/lib/pago/v2026_04/services/customer_sessions.rb +35 -0
- data/lib/pago/v2026_04/services/customers.rb +556 -0
- data/lib/pago/v2026_04/services/discounts.rb +131 -0
- data/lib/pago/v2026_04/services/disputes.rb +93 -0
- data/lib/pago/v2026_04/services/event_types.rb +74 -0
- data/lib/pago/v2026_04/services/events.rb +126 -0
- data/lib/pago/v2026_04/services/files.rb +135 -0
- data/lib/pago/v2026_04/services/license_keys.rb +183 -0
- data/lib/pago/v2026_04/services/members.rb +47 -0
- data/lib/pago/v2026_04/services/meters.rb +142 -0
- data/lib/pago/v2026_04/services/metrics.rb +188 -0
- data/lib/pago/v2026_04/services/oauth2.rb +175 -0
- data/lib/pago/v2026_04/services/orders.rb +238 -0
- data/lib/pago/v2026_04/services/organizations.rb +113 -0
- data/lib/pago/v2026_04/services/payments.rb +72 -0
- data/lib/pago/v2026_04/services/products.rb +142 -0
- data/lib/pago/v2026_04/services/refunds.rb +73 -0
- data/lib/pago/v2026_04/services/subscriptions.rb +171 -0
- data/lib/pago/v2026_04/services/webhooks.rb +212 -0
- data/lib/pago/v2026_04/unions.rb +739 -0
- data/lib/pago/v2026_04/webhooks.rb +86 -0
- data/lib/pago/version.rb +5 -0
- data/lib/pago/webhooks.rb +159 -0
- data/lib/pago.rb +39 -0
- data/sig/pago/v2026_04/generated.rbs +12401 -0
- data/sig/pago.rbs +204 -0
- metadata +91 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pago
|
|
4
|
+
module V2026_04
|
|
5
|
+
# Webhook verification and deserialization for the 2026-04 API.
|
|
6
|
+
#
|
|
7
|
+
# @example Rack / Rails
|
|
8
|
+
# event = Pago::Webhooks.validate_event(
|
|
9
|
+
# body: request.body.read,
|
|
10
|
+
# headers: request.headers,
|
|
11
|
+
# secret: ENV.fetch("PAGO_WEBHOOK_SECRET")
|
|
12
|
+
# )
|
|
13
|
+
# case event
|
|
14
|
+
# when Pago::Models::WebhookOrderPaidPayload then fulfill(event.data)
|
|
15
|
+
# end
|
|
16
|
+
module Webhooks
|
|
17
|
+
# @return [Hash{String => Class}] event type to payload model.
|
|
18
|
+
def self.event_types
|
|
19
|
+
@event_types ||= {
|
|
20
|
+
"benefit.created" => Models::WebhookBenefitCreatedPayload,
|
|
21
|
+
"benefit.updated" => Models::WebhookBenefitUpdatedPayload,
|
|
22
|
+
"benefit_grant.created" => Models::WebhookBenefitGrantCreatedPayload,
|
|
23
|
+
"benefit_grant.cycled" => Models::WebhookBenefitGrantCycledPayload,
|
|
24
|
+
"benefit_grant.revoked" => Models::WebhookBenefitGrantRevokedPayload,
|
|
25
|
+
"benefit_grant.updated" => Models::WebhookBenefitGrantUpdatedPayload,
|
|
26
|
+
"checkout.created" => Models::WebhookCheckoutCreatedPayload,
|
|
27
|
+
"checkout.expired" => Models::WebhookCheckoutExpiredPayload,
|
|
28
|
+
"checkout.updated" => Models::WebhookCheckoutUpdatedPayload,
|
|
29
|
+
"customer.created" => Models::WebhookCustomerCreatedPayload,
|
|
30
|
+
"customer.deleted" => Models::WebhookCustomerDeletedPayload,
|
|
31
|
+
"customer.state_changed" => Models::WebhookCustomerStateChangedPayload,
|
|
32
|
+
"customer.updated" => Models::WebhookCustomerUpdatedPayload,
|
|
33
|
+
"customer_seat.assigned" => Models::WebhookCustomerSeatAssignedPayload,
|
|
34
|
+
"customer_seat.claimed" => Models::WebhookCustomerSeatClaimedPayload,
|
|
35
|
+
"customer_seat.revoked" => Models::WebhookCustomerSeatRevokedPayload,
|
|
36
|
+
"member.created" => Models::WebhookMemberCreatedPayload,
|
|
37
|
+
"member.deleted" => Models::WebhookMemberDeletedPayload,
|
|
38
|
+
"member.updated" => Models::WebhookMemberUpdatedPayload,
|
|
39
|
+
"order.created" => Models::WebhookOrderCreatedPayload,
|
|
40
|
+
"order.paid" => Models::WebhookOrderPaidPayload,
|
|
41
|
+
"order.refunded" => Models::WebhookOrderRefundedPayload,
|
|
42
|
+
"order.updated" => Models::WebhookOrderUpdatedPayload,
|
|
43
|
+
"organization.updated" => Models::WebhookOrganizationUpdatedPayload,
|
|
44
|
+
"product.created" => Models::WebhookProductCreatedPayload,
|
|
45
|
+
"product.updated" => Models::WebhookProductUpdatedPayload,
|
|
46
|
+
"refund.created" => Models::WebhookRefundCreatedPayload,
|
|
47
|
+
"refund.updated" => Models::WebhookRefundUpdatedPayload,
|
|
48
|
+
"subscription.active" => Models::WebhookSubscriptionActivePayload,
|
|
49
|
+
"subscription.canceled" => Models::WebhookSubscriptionCanceledPayload,
|
|
50
|
+
"subscription.created" => Models::WebhookSubscriptionCreatedPayload,
|
|
51
|
+
"subscription.past_due" => Models::WebhookSubscriptionPastDuePayload,
|
|
52
|
+
"subscription.paused" => Models::WebhookSubscriptionPausedPayload,
|
|
53
|
+
"subscription.resumed" => Models::WebhookSubscriptionResumedPayload,
|
|
54
|
+
"subscription.revoked" => Models::WebhookSubscriptionRevokedPayload,
|
|
55
|
+
"subscription.uncanceled" => Models::WebhookSubscriptionUncanceledPayload,
|
|
56
|
+
"subscription.updated" => Models::WebhookSubscriptionUpdatedPayload
|
|
57
|
+
}.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<String>] every event type known to this SDK version.
|
|
61
|
+
def self.known_event_types = event_types.keys
|
|
62
|
+
|
|
63
|
+
# Verify a raw webhook request and deserialize its payload.
|
|
64
|
+
#
|
|
65
|
+
# @param body [String] the raw request body, exactly as received.
|
|
66
|
+
# @param headers [Hash{String => String}] the request headers.
|
|
67
|
+
# @param secret [String] the endpoint secret, in `whsec_<base64>` form.
|
|
68
|
+
# @raise [Pago::WebhookSecretError] when the secret is not a valid secret.
|
|
69
|
+
# @raise [Pago::WebhookVerificationError] when the signature does not match.
|
|
70
|
+
# @raise [Pago::WebhookUnknownTypeError] when the event type is unknown.
|
|
71
|
+
# @raise [Pago::WebhookError] when the payload cannot be parsed.
|
|
72
|
+
# @return [Pago::Model] the deserialized event payload.
|
|
73
|
+
def self.validate_event(body:, headers:, secret:)
|
|
74
|
+
data = ::Pago::WebhookVerifier.verify(body: body, headers: headers, secret: secret)
|
|
75
|
+
event_type = data.is_a?(Hash) ? data["type"] : nil
|
|
76
|
+
payload_class = event_types[event_type]
|
|
77
|
+
raise ::Pago::WebhookUnknownTypeError, event_type if payload_class.nil?
|
|
78
|
+
|
|
79
|
+
payload = payload_class.from_json(data)
|
|
80
|
+
raise ::Pago::WebhookError, "Failed to parse webhook payload" if payload.nil?
|
|
81
|
+
|
|
82
|
+
payload
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/pago/version.rb
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pago
|
|
4
|
+
# Base error raised while processing a Pago webhook.
|
|
5
|
+
class WebhookError < Error; end
|
|
6
|
+
|
|
7
|
+
# Raised when a webhook signature cannot be verified.
|
|
8
|
+
class WebhookVerificationError < WebhookError; end
|
|
9
|
+
|
|
10
|
+
# Raised when the endpoint secret is not a Standard Webhooks secret.
|
|
11
|
+
#
|
|
12
|
+
# A secret is the `whsec_` prefix followed by the base64 encoding of the
|
|
13
|
+
# signing key. Anything else — a missing prefix, an empty or malformed
|
|
14
|
+
# base64 body — is a configuration mistake, so it is reported instead of
|
|
15
|
+
# being silently treated as a signature mismatch.
|
|
16
|
+
class WebhookSecretError < WebhookVerificationError
|
|
17
|
+
def initialize(reason = nil)
|
|
18
|
+
message = "Invalid webhook secret: expected the \"whsec_\" prefix followed by base64 encoded bytes"
|
|
19
|
+
super(reason.nil? ? message : "#{message} (#{reason})")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when a verified webhook type is unknown to this SDK version.
|
|
24
|
+
class WebhookUnknownTypeError < WebhookError
|
|
25
|
+
# @return [String, nil] the event type carried by the payload.
|
|
26
|
+
attr_reader :event_type
|
|
27
|
+
|
|
28
|
+
def initialize(event_type)
|
|
29
|
+
@event_type = event_type
|
|
30
|
+
super("Unknown webhook event type: #{event_type.inspect}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Signature verification for incoming webhooks.
|
|
35
|
+
#
|
|
36
|
+
# Implements the Standard Webhooks specification: the `webhook-id`,
|
|
37
|
+
# `webhook-timestamp` and `webhook-signature` headers are required, the
|
|
38
|
+
# timestamp must sit within a five minute window, and the signature is an
|
|
39
|
+
# HMAC-SHA256 of `id.timestamp.body` keyed with the *decoded* secret — the
|
|
40
|
+
# base64 body of `whsec_<base64>`, never the secret string itself. The
|
|
41
|
+
# header may carry several space separated `v1,<base64>` signatures — any
|
|
42
|
+
# match accepts the payload, which is what makes secret rotation possible.
|
|
43
|
+
module WebhookVerifier
|
|
44
|
+
TOLERANCE_SECONDS = 5 * 60
|
|
45
|
+
SIGNATURE_VERSION = "v1"
|
|
46
|
+
SECRET_PREFIX = "whsec_"
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Verify a raw webhook request and return its parsed payload.
|
|
51
|
+
#
|
|
52
|
+
# @param body [String] the raw request body, exactly as received.
|
|
53
|
+
# @param headers [Hash{String => String}] the request headers, case insensitive.
|
|
54
|
+
# @param secret [String] the endpoint secret, in `whsec_<base64>` form.
|
|
55
|
+
# @raise [Pago::WebhookSecretError] when the secret is not a valid secret.
|
|
56
|
+
# @raise [Pago::WebhookVerificationError] when the signature does not match.
|
|
57
|
+
# @raise [Pago::WebhookError] when the body is not valid JSON.
|
|
58
|
+
# @return [Hash] the parsed payload.
|
|
59
|
+
def verify(body:, headers:, secret:)
|
|
60
|
+
payload = normalize_body(body)
|
|
61
|
+
verify_signature(payload, headers, secret)
|
|
62
|
+
parse(payload)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_body(body)
|
|
66
|
+
payload = body.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
67
|
+
raise WebhookError, "Failed to parse webhook payload" unless payload.valid_encoding?
|
|
68
|
+
|
|
69
|
+
payload
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse(payload)
|
|
73
|
+
JSON.parse(payload)
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
raise WebhookError, "Failed to parse webhook payload"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def verify_signature(body, headers, secret)
|
|
79
|
+
key = derive_key(secret)
|
|
80
|
+
|
|
81
|
+
normalized = (headers || {}).each_with_object({}) do |(name, value), result|
|
|
82
|
+
result[name.to_s.downcase] = value
|
|
83
|
+
end
|
|
84
|
+
id = normalized["webhook-id"]
|
|
85
|
+
timestamp = normalized["webhook-timestamp"]
|
|
86
|
+
signature = normalized["webhook-signature"]
|
|
87
|
+
raise WebhookVerificationError, "Missing required headers" if blank?(id) || blank?(timestamp) || blank?(signature)
|
|
88
|
+
|
|
89
|
+
check_tolerance(parse_timestamp(timestamp))
|
|
90
|
+
|
|
91
|
+
expected = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}".b)
|
|
92
|
+
return if signature.to_s.split(" ").any? { |candidate| match?(candidate, expected) }
|
|
93
|
+
|
|
94
|
+
raise WebhookVerificationError, "No matching signature found"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Derive the HMAC key from a Standard Webhooks secret: strip the `whsec_`
|
|
98
|
+
# prefix and base64 decode the rest.
|
|
99
|
+
#
|
|
100
|
+
# @param secret [String] the endpoint secret.
|
|
101
|
+
# @raise [Pago::WebhookSecretError] when the secret is empty, unprefixed or not base64.
|
|
102
|
+
# @return [String] the raw signing key.
|
|
103
|
+
def derive_key(secret)
|
|
104
|
+
value = secret.to_s
|
|
105
|
+
raise WebhookSecretError, "the secret is empty" if value.empty?
|
|
106
|
+
raise WebhookSecretError, "missing the \"#{SECRET_PREFIX}\" prefix" unless value.start_with?(SECRET_PREFIX)
|
|
107
|
+
|
|
108
|
+
encoded = value[SECRET_PREFIX.length..]
|
|
109
|
+
raise WebhookSecretError, "nothing follows the prefix" if encoded.nil? || encoded.empty?
|
|
110
|
+
|
|
111
|
+
key = decode_base64(encoded)
|
|
112
|
+
raise WebhookSecretError, "the part after the prefix is not valid base64" if key.nil? || key.empty?
|
|
113
|
+
|
|
114
|
+
key
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_timestamp(value)
|
|
118
|
+
Integer(value.to_s, 10)
|
|
119
|
+
rescue ArgumentError, TypeError
|
|
120
|
+
raise WebhookVerificationError, "Invalid signature headers"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def check_tolerance(seconds)
|
|
124
|
+
now = Time.now.to_i
|
|
125
|
+
raise WebhookVerificationError, "Message timestamp too old" if seconds < now - TOLERANCE_SECONDS
|
|
126
|
+
raise WebhookVerificationError, "Message timestamp too new" if seconds > now + TOLERANCE_SECONDS
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def match?(candidate, expected)
|
|
130
|
+
version, separator, signature = candidate.partition(",")
|
|
131
|
+
return false unless separator == "," && version == SIGNATURE_VERSION
|
|
132
|
+
|
|
133
|
+
decoded = decode_base64(signature)
|
|
134
|
+
return false if decoded.nil?
|
|
135
|
+
|
|
136
|
+
secure_compare(expected, decoded)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Compare two strings without leaking their contents through timing.
|
|
140
|
+
def secure_compare(left, right)
|
|
141
|
+
if OpenSSL.respond_to?(:secure_compare)
|
|
142
|
+
OpenSSL.secure_compare(left, right)
|
|
143
|
+
elsif defined?(Rack::Utils) && Rack::Utils.respond_to?(:secure_compare)
|
|
144
|
+
Rack::Utils.secure_compare(left, right)
|
|
145
|
+
else
|
|
146
|
+
left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Strict base64 decoding: padding is required and stray characters are refused.
|
|
151
|
+
def decode_base64(value)
|
|
152
|
+
value.to_s.unpack1("m0")
|
|
153
|
+
rescue ArgumentError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def blank?(value) = value.nil? || value.to_s.empty?
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/pago.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "pago/version"
|
|
9
|
+
require_relative "pago/errors"
|
|
10
|
+
require_relative "pago/serde"
|
|
11
|
+
require_relative "pago/model"
|
|
12
|
+
require_relative "pago/http"
|
|
13
|
+
require_relative "pago/paginator"
|
|
14
|
+
require_relative "pago/service"
|
|
15
|
+
require_relative "pago/base_client"
|
|
16
|
+
require_relative "pago/webhooks"
|
|
17
|
+
require_relative "pago/v2026_04/enums"
|
|
18
|
+
require_relative "pago/v2026_04/models"
|
|
19
|
+
require_relative "pago/v2026_04/unions"
|
|
20
|
+
require_relative "pago/v2026_04/errors"
|
|
21
|
+
require_relative "pago/v2026_04/webhooks"
|
|
22
|
+
require_relative "pago/v2026_04/client"
|
|
23
|
+
|
|
24
|
+
# Pago — a billing platform for the intelligence era.
|
|
25
|
+
#
|
|
26
|
+
# The constants below alias the latest API version, so `Pago::Client` always
|
|
27
|
+
# points at the newest generated client. Pin an explicit version module
|
|
28
|
+
# (e.g. `Pago::V2026_04::Client`) to opt out of that.
|
|
29
|
+
module Pago
|
|
30
|
+
# @return [Class] the client of the latest API version (2026-04).
|
|
31
|
+
Client = V2026_04::Client
|
|
32
|
+
Models = V2026_04::Models
|
|
33
|
+
Enums = V2026_04::Enums
|
|
34
|
+
Unions = V2026_04::Unions
|
|
35
|
+
Errors = V2026_04::Errors
|
|
36
|
+
Webhooks = V2026_04::Webhooks
|
|
37
|
+
Services = V2026_04::Services
|
|
38
|
+
LATEST_API_VERSION = "2026-04"
|
|
39
|
+
end
|