payment_kit 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/CHANGELOG.md +102 -0
- data/LICENSE.txt +21 -0
- data/README.md +1008 -0
- data/Rakefile +41 -0
- data/app/controllers/payment_kit/webhook_controller.rb +45 -0
- data/config/routes.rb +5 -0
- data/lib/payment_kit/client.rb +449 -0
- data/lib/payment_kit/configuration.rb +117 -0
- data/lib/payment_kit/engine.rb +10 -0
- data/lib/payment_kit/errors.rb +103 -0
- data/lib/payment_kit/instrumentation.rb +109 -0
- data/lib/payment_kit/namespace.rb +27 -0
- data/lib/payment_kit/notification_adapter.rb +21 -0
- data/lib/payment_kit/resources/catalog.rb +36 -0
- data/lib/payment_kit/resources/customers.rb +62 -0
- data/lib/payment_kit/resources/invoices.rb +64 -0
- data/lib/payment_kit/resources/payments.rb +71 -0
- data/lib/payment_kit/resources/subscriptions.rb +129 -0
- data/lib/payment_kit/version.rb +6 -0
- data/lib/payment_kit/webhook.rb +64 -0
- data/lib/payment_kit.rb +204 -0
- metadata +90 -0
data/README.md
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
# PaymentKit
|
|
2
|
+
|
|
3
|
+
Ruby HTTP client for the [PaymentKit](https://docs.paymentkit.com) REST API.
|
|
4
|
+
|
|
5
|
+
This gem is a thin SDK: it authenticates requests, encodes JSON, follows redirects,
|
|
6
|
+
retries transient failures, maps API errors, and exposes resource methods that
|
|
7
|
+
return parsed JSON hashes.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby `>= 3.2`
|
|
12
|
+
- `activesupport >= 6.1` (powers the webhook event bus)
|
|
13
|
+
- Transport uses only stdlib (`net/http`, `json`, `openssl`); Rails is optional
|
|
14
|
+
and needed only for the mountable webhook engine
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add the gem to your Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "payment_kit"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then run:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install it directly:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gem install payment_kit
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "payment_kit"
|
|
40
|
+
|
|
41
|
+
PaymentKit.configure do |config|
|
|
42
|
+
config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY") # st_prod_...
|
|
43
|
+
config.account_id = ENV.fetch("PAYMENT_KIT_ACCOUNT_ID") # acc_prod_...
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
client = PaymentKit::Client.new
|
|
47
|
+
|
|
48
|
+
customer = client.create_customer(
|
|
49
|
+
email: "customer@example.com",
|
|
50
|
+
first_name: "Jane",
|
|
51
|
+
last_name: "Smith",
|
|
52
|
+
business_name: "Acme Inc"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
puts customer["id"] # => "cus_prod_..."
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
### Global configuration
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
PaymentKit.configure do |config|
|
|
64
|
+
config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY")
|
|
65
|
+
config.account_id = ENV.fetch("PAYMENT_KIT_ACCOUNT_ID")
|
|
66
|
+
config.signing_secret = ENV["PAYMENT_KIT_SIGNING_SECRET"] # optional, for webhooks
|
|
67
|
+
config.api_host = "https://app.paymentkit.com/api" # default
|
|
68
|
+
config.open_timeout = 10 # seconds, default
|
|
69
|
+
config.read_timeout = 30 # seconds, default
|
|
70
|
+
config.max_retries = 2 # default
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Option | Required | Default | Description |
|
|
75
|
+
|--------|----------|---------|-------------|
|
|
76
|
+
| `secret_key` | Yes* | — | Server secret token (`st_prod_...`). Never expose in browsers. |
|
|
77
|
+
| `account_id` | Yes* | — | Account external id (`acc_prod_...`) used in the API path. |
|
|
78
|
+
| `api_host` | No | `https://app.paymentkit.com/api` | API host root (without account id). |
|
|
79
|
+
| `base_url` | No | — | Full base URL override (`https://host/api/{account_id}`). Skips `account_id` when set. |
|
|
80
|
+
| `open_timeout` | No | `10` | TCP open timeout (seconds). |
|
|
81
|
+
| `read_timeout` | No | `30` | Response read timeout (seconds). |
|
|
82
|
+
| `max_retries` | No | `2` | Retries for transient HTTP statuses. |
|
|
83
|
+
| `signing_secret` | No | — | Webhook signing secret (`whsec_...`). |
|
|
84
|
+
|
|
85
|
+
\*Required unless you pass them (or `base_url`) when constructing `Client`.
|
|
86
|
+
|
|
87
|
+
Reset configuration in tests:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
PaymentKit.reset_configuration!
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Base URL resolution
|
|
94
|
+
|
|
95
|
+
Requests are sent to:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
{base_url}{path}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Where `base_url` is resolved as:
|
|
102
|
+
|
|
103
|
+
1. Explicit `base_url` if set (trailing slash removed), or
|
|
104
|
+
2. `{api_host}/{account_id}`
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# Production-style
|
|
110
|
+
PaymentKit.configure do |c|
|
|
111
|
+
c.secret_key = "st_prod_..."
|
|
112
|
+
c.account_id = "acc_prod_abc"
|
|
113
|
+
end
|
|
114
|
+
# => https://app.paymentkit.com/api/acc_prod_abc
|
|
115
|
+
|
|
116
|
+
# Custom host
|
|
117
|
+
PaymentKit.configure do |c|
|
|
118
|
+
c.secret_key = "st_prod_..."
|
|
119
|
+
c.account_id = "acc_prod_abc"
|
|
120
|
+
c.api_host = "https://app.paymentkit.com/api"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Full override (useful in tests)
|
|
124
|
+
client = PaymentKit::Client.new(
|
|
125
|
+
secret_key: "st_test",
|
|
126
|
+
base_url: "https://api.test/acc"
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Per-client configuration (recommended for multi-account)
|
|
131
|
+
|
|
132
|
+
Prefer explicit credentials on the client when serving multiple accounts:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
client = PaymentKit::Client.new(
|
|
136
|
+
secret_key: ENV.fetch("PAYMENT_KIT_SECRET_KEY"),
|
|
137
|
+
account_id: ENV.fetch("PAYMENT_KIT_ACCOUNT_ID"),
|
|
138
|
+
open_timeout: 5,
|
|
139
|
+
read_timeout: 20,
|
|
140
|
+
max_retries: 3
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
client.base_url
|
|
144
|
+
# => "https://app.paymentkit.com/api/acc_prod_..."
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Keyword arguments override the global `PaymentKit.configuration` for that instance only.
|
|
148
|
+
|
|
149
|
+
### Rails initializer example
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# config/initializers/payment_kit.rb
|
|
153
|
+
PaymentKit.configure do |config|
|
|
154
|
+
config.secret_key = Rails.application.credentials.dig(:payment_kit, :secret_key)
|
|
155
|
+
config.account_id = Rails.application.credentials.dig(:payment_kit, :account_id)
|
|
156
|
+
config.signing_secret = Rails.application.credentials.dig(:payment_kit, :signing_secret)
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Client overview
|
|
161
|
+
|
|
162
|
+
`PaymentKit::Client` is the only network entry point.
|
|
163
|
+
|
|
164
|
+
- Auth: `Authorization: Bearer {secret_key}`
|
|
165
|
+
- Bodies: JSON (`Content-Type: application/json`)
|
|
166
|
+
- Responses: parsed JSON `Hash` (or `Array` for auto-paginated lists)
|
|
167
|
+
- Writes: `POST`, `PUT` and `PATCH` send an automatic `Idempotency-Key` (UUID)
|
|
168
|
+
unless you pass one
|
|
169
|
+
- Redirects: follows HTTP `307` / `308` (PaymentKit path canonicalization)
|
|
170
|
+
- Retries: `408`, `429` and `5xx` with exponential backoff; `409` only when
|
|
171
|
+
PaymentKit marks it retryable
|
|
172
|
+
- Escape hatch: `raw_request` for endpoints the gem does not wrap
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
client = PaymentKit::Client.new
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Resource API
|
|
179
|
+
|
|
180
|
+
All methods return parsed JSON. Pass request bodies and query params as hashes
|
|
181
|
+
(symbol or string keys are fine). Every write method also accepts an
|
|
182
|
+
`idempotency_key:` — see [Idempotency](#idempotency).
|
|
183
|
+
|
|
184
|
+
Paths below are relative to the account base URL
|
|
185
|
+
(`{api_host}/{account_id}`), matching the
|
|
186
|
+
[PaymentKit API reference](https://docs.paymentkit.com/api-reference/introduction).
|
|
187
|
+
|
|
188
|
+
### Method index
|
|
189
|
+
|
|
190
|
+
| Method | HTTP | Path |
|
|
191
|
+
|--------|------|------|
|
|
192
|
+
| `create_customer` | `POST` | `/customers/` |
|
|
193
|
+
| `retrieve_customer` | `GET` | `/customers/{id}` |
|
|
194
|
+
| `update_customer` | `PUT` | `/customers/{id}` |
|
|
195
|
+
| `list_customers` | `GET` | `/customers/` |
|
|
196
|
+
| `set_credit_balance` | `PATCH` | `/customers/{id}/credit-balance` |
|
|
197
|
+
| `create_balance_transaction` *(deprecated)* | `PATCH` | `/customers/{id}/credit-balance` |
|
|
198
|
+
| `create_credit_note` | `POST` | `/customers/{id}/credit-notes` |
|
|
199
|
+
| `list_credit_notes` | `GET` | `/customers/{id}/credit-notes` |
|
|
200
|
+
| `create_subscription` | `POST` | `/subscriptions` |
|
|
201
|
+
| `retrieve_subscription` | `GET` | `/subscriptions/{id}` |
|
|
202
|
+
| `list_subscriptions` | `GET` | `/subscriptions` |
|
|
203
|
+
| `update_subscription` | `PATCH` | `/subscriptions/{id}` |
|
|
204
|
+
| `update_subscription_items` | `PATCH` | `/subscriptions/{id}/items` |
|
|
205
|
+
| `cancel_subscription` | `POST` | `/subscriptions/{id}/cancel` |
|
|
206
|
+
| `schedule_cancellation` | `POST` | `/subscriptions/{id}/schedule-cancellation` |
|
|
207
|
+
| `cancel_scheduled_cancellation` | `DELETE` | `/subscriptions/{id}/scheduled-cancellation` |
|
|
208
|
+
| `pause_subscription` | `POST` | `/subscriptions/{id}/pause` |
|
|
209
|
+
| `cancel_scheduled_pause` | `DELETE` | `/subscriptions/{id}/scheduled-pause` |
|
|
210
|
+
| `resume_subscription` | `POST` | `/subscriptions/{id}/resume` |
|
|
211
|
+
| `reschedule_billing` | `POST` | `/subscriptions/{id}/reschedule-billing` |
|
|
212
|
+
| `renew_subscription` | `POST` | `/subscriptions/{id}/renew` |
|
|
213
|
+
| `cancel_pending_change` | `DELETE` | `/subscriptions/{id}/pending-change` |
|
|
214
|
+
| `change_plan` *(deprecated)* | `POST` | `/subscriptions/{id}/change-plan` |
|
|
215
|
+
| `create_change_request` | `POST` | `/subscriptions/{id}/change-requests` |
|
|
216
|
+
| `retrieve_change_request` | `GET` | `/subscriptions/{id}/change-requests/{request_id}` |
|
|
217
|
+
| `active_change_request` | `GET` | `/subscriptions/{id}/change-requests/active` |
|
|
218
|
+
| `add_change_request_changes` | `PATCH` | `/subscriptions/{id}/change-requests/{request_id}` |
|
|
219
|
+
| `preview_change_request` | `POST` | `/subscriptions/{id}/change-requests/{request_id}/preview` |
|
|
220
|
+
| `apply_change_request` | `POST` | `/subscriptions/{id}/change-requests/{request_id}/apply` |
|
|
221
|
+
| `cancel_change_request` | `DELETE` | `/subscriptions/{id}/change-requests/{request_id}` |
|
|
222
|
+
| `apply_subscription_changes` | `POST` | `/subscriptions/{id}/change-requests/apply` |
|
|
223
|
+
| `create_invoice` | `POST` | `/invoices/` |
|
|
224
|
+
| `retrieve_invoice` | `GET` | `/invoices/{id}` |
|
|
225
|
+
| `list_invoices` | `GET` | `/invoices/` |
|
|
226
|
+
| `retrieve_invoice_pdf` | `GET` | `/invoices/{id}/pdf` |
|
|
227
|
+
| `finalize_invoice` | `POST` | `/invoices/{id}/finalize` |
|
|
228
|
+
| `pay_invoice` | `POST` | `/invoices/{id}/collect` |
|
|
229
|
+
| `void_invoice` | `POST` | `/invoices/{id}/void` |
|
|
230
|
+
| `mark_invoice_uncollectible` | `POST` | `/invoices/{id}/mark-uncollectible` |
|
|
231
|
+
| `bill_pending_items` | `POST` | `/invoices/bill-pending-items` |
|
|
232
|
+
| `create_invoice_item` | `POST` | `/invoice-items/` |
|
|
233
|
+
| `retrieve_invoice_item` | `GET` | `/invoice-items/{id}` |
|
|
234
|
+
| `list_invoice_items` | `GET` | `/invoice-items/` |
|
|
235
|
+
| `update_invoice_item` | `PATCH` | `/invoice-items/{id}` |
|
|
236
|
+
| `create_payment_intent` | `POST` | `/payments/intents/` |
|
|
237
|
+
| `retrieve_payment_intent` | `GET` | `/payments/intents/{id}` |
|
|
238
|
+
| `list_payment_intents` | `GET` | `/payments/intents/` |
|
|
239
|
+
| `list_refunds_by_intent` | `GET` | `/payments/refunds/by_intent/{id}` |
|
|
240
|
+
| `list_attempts_by_intent` | `GET` | `/payments/processor_attempts/by_intent/{id}` |
|
|
241
|
+
| `create_payment_method` | `POST` | `/payments/payment_methods/` |
|
|
242
|
+
| `retrieve_payment_method` | `GET` | `/payments/payment_methods/{id}` |
|
|
243
|
+
| `update_payment_method` | `PUT` | `/payments/payment_methods/{id}` |
|
|
244
|
+
| `deactivate_payment_method` | `PUT` | `/payments/payment_methods/{id}` |
|
|
245
|
+
| `detach_payment_method` | `DELETE` | `/payments/payment_methods/{id}` |
|
|
246
|
+
| `create_checkout_session` | `POST` | `/checkout-sessions` |
|
|
247
|
+
| `retrieve_checkout_session` | `GET` | `/checkout-sessions/{id}` |
|
|
248
|
+
| `list_products` | `GET` | `/products/` |
|
|
249
|
+
| `retrieve_product` | `GET` | `/products/{id}` |
|
|
250
|
+
| `create_product` | `POST` | `/products/` |
|
|
251
|
+
| `update_product` | `PATCH` | `/products/{id}` |
|
|
252
|
+
| `list_product_prices` | `GET` | `/products/{id}/prices` |
|
|
253
|
+
| `list_prices` | `GET` | `/prices/` |
|
|
254
|
+
| `retrieve_price` | `GET` | `/prices/{id}` |
|
|
255
|
+
| `create_price` | `POST` | `/prices/` |
|
|
256
|
+
|
|
257
|
+
### Customers
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
customer = client.create_customer(
|
|
261
|
+
email: "customer@example.com",
|
|
262
|
+
first_name: "Jane",
|
|
263
|
+
last_name: "Smith",
|
|
264
|
+
business_name: "Acme Inc",
|
|
265
|
+
phone: "+15550100",
|
|
266
|
+
billing_email: "ap@acme.test",
|
|
267
|
+
currency: "USD",
|
|
268
|
+
language: "en",
|
|
269
|
+
address: { line1: "1 Market St", city: "San Francisco", country: "US", postal_code: "94105" },
|
|
270
|
+
tax_ids: ["EU372009832"],
|
|
271
|
+
metadata: { plan_tier: "enterprise" }
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
customer = client.retrieve_customer("cus_123")
|
|
275
|
+
|
|
276
|
+
# PUT — only the fields you send are changed
|
|
277
|
+
customer = client.update_customer("cus_123",
|
|
278
|
+
email: "new@example.com",
|
|
279
|
+
metadata: { plan_tier: "pro" }
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
customers = client.list_customers(limit: 50) # auto-paginated Array
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Customer credit
|
|
286
|
+
|
|
287
|
+
`set_credit_balance` sets an **absolute target** balance for one currency —
|
|
288
|
+
PaymentKit issues or voids credit to reach it. It is not a delta. Balances are
|
|
289
|
+
tracked independently per currency, and `amount_atom` is in the smallest
|
|
290
|
+
currency unit.
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
# Make the customer's USD balance exactly 5000 atoms ($50.00)
|
|
294
|
+
balance = client.set_credit_balance("cus_123", amount_atom: 5000, currency: "USD")
|
|
295
|
+
# => { "amount_atom" => 5000, "currency" => "USD" }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
To *add* credit incrementally, append a credit note. `reason` is required and
|
|
299
|
+
must be one of `proration_excess`, `manual_adjustment`, `auto_apply` or
|
|
300
|
+
`debit_settlement`. Always pass a stable idempotency key: a double submit issues
|
|
301
|
+
two notes.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
note = client.create_credit_note(
|
|
305
|
+
"cus_123",
|
|
306
|
+
{
|
|
307
|
+
amount_atom: 2500,
|
|
308
|
+
currency: "USD",
|
|
309
|
+
reason: "manual_adjustment",
|
|
310
|
+
memo: "Goodwill credit for billing error",
|
|
311
|
+
invoice_id: "inv_123" # optional; otherwise a paid companion invoice is created
|
|
312
|
+
},
|
|
313
|
+
idempotency_key: "credit-overcharge-918"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
notes = client.list_credit_notes("cus_123", currency: "USD")
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
PaymentKit draws credit down automatically when an invoice is collected; you do
|
|
320
|
+
not apply it manually.
|
|
321
|
+
|
|
322
|
+
`create_balance_transaction` is deprecated. It hits the same endpoint, forwards
|
|
323
|
+
to `set_credit_balance` and warns — the old name implied delta semantics, but the
|
|
324
|
+
endpoint sets an absolute target balance:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
# Deprecated; identical to set_credit_balance("cus_123", ...)
|
|
328
|
+
client.create_balance_transaction("cus_123", amount_atom: 5000, currency: "USD")
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Subscriptions
|
|
332
|
+
|
|
333
|
+
`customer_id`, `currency`, `billing_interval`, `billing_interval_count`,
|
|
334
|
+
`period_start` and `collection_method` are required. Each entry in `items` is a
|
|
335
|
+
`price_id` plus `quantity`.
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
subscription = client.create_subscription(
|
|
339
|
+
customer_id: "cus_123",
|
|
340
|
+
currency: "USD",
|
|
341
|
+
billing_interval: "month", # day | week | month | year
|
|
342
|
+
billing_interval_count: 1,
|
|
343
|
+
period_start: "2026-02-01T00:00:00Z",
|
|
344
|
+
collection_method: "charge_automatically", # or send_invoice
|
|
345
|
+
processor_id: "proc_live_abc", # falls back to the account default
|
|
346
|
+
items: [
|
|
347
|
+
{ price_id: "price_monthly_pro", quantity: 1 },
|
|
348
|
+
{ price_id: "price_addon_seats", quantity: 5 }
|
|
349
|
+
],
|
|
350
|
+
trial_start: "2026-02-01T00:00:00Z",
|
|
351
|
+
trial_end: "2026-02-15T00:00:00Z",
|
|
352
|
+
net_d: 30, # payment terms in days
|
|
353
|
+
coupon_id: "coup_welcome20",
|
|
354
|
+
total_billing_cycles: 12 # auto-cancel after N cycles
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
subscription = client.retrieve_subscription("sub_123")
|
|
358
|
+
subscriptions = client.list_subscriptions(customer_id: "cus_123")
|
|
359
|
+
|
|
360
|
+
# Cancel at period end (and undo it) via the base update endpoint
|
|
361
|
+
client.update_subscription("sub_123", cancel_at_period_end: true)
|
|
362
|
+
client.update_subscription("sub_123", cancel_at_period_end: false)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Line-item changes go through the dedicated items endpoint, where
|
|
366
|
+
`proration_behavior` is **required** (`always_invoice`, `create_prorations` or
|
|
367
|
+
`none`). This is charge-first, so pass a stable idempotency key.
|
|
368
|
+
|
|
369
|
+
```ruby
|
|
370
|
+
client.update_subscription_items("sub_123",
|
|
371
|
+
{
|
|
372
|
+
proration_behavior: "always_invoice",
|
|
373
|
+
items: [
|
|
374
|
+
{ id: "si_plan", price_id: "price_monthly_pro", quantity: 2 }
|
|
375
|
+
]
|
|
376
|
+
},
|
|
377
|
+
idempotency_key: "seat-change-42-v3"
|
|
378
|
+
)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
#### Cancelling, pausing and rescheduling
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
# Immediate cancel. refund_option: none | full | prorated | cancel_unpaid
|
|
385
|
+
client.cancel_subscription("sub_123", refund_option: "prorated")
|
|
386
|
+
|
|
387
|
+
# Calculate the refund without executing
|
|
388
|
+
preview = client.cancel_subscription("sub_123", refund_option: "prorated", is_preview: true)
|
|
389
|
+
|
|
390
|
+
# Cancel on a specific date instead, then undo it
|
|
391
|
+
client.schedule_cancellation("sub_123", cancel_at: "2026-06-01T00:00:00Z", refund_option: "none")
|
|
392
|
+
client.cancel_scheduled_cancellation("sub_123")
|
|
393
|
+
|
|
394
|
+
# Pause now, or at period end. pause_for_cycles and resumption_date are
|
|
395
|
+
# mutually exclusive ways to schedule the auto-resume.
|
|
396
|
+
client.pause_subscription("sub_123", pause_behavior: "pause_immediately", pause_for_cycles: 2)
|
|
397
|
+
client.pause_subscription("sub_123", pause_behavior: "pause_at_end")
|
|
398
|
+
client.cancel_scheduled_pause("sub_123") # removes a pending pause_at_end
|
|
399
|
+
|
|
400
|
+
client.resume_subscription("sub_123")
|
|
401
|
+
client.renew_subscription("sub_123")
|
|
402
|
+
|
|
403
|
+
client.reschedule_billing("sub_123",
|
|
404
|
+
next_billing_date: "2026-03-15T00:00:00Z",
|
|
405
|
+
create_proration: true,
|
|
406
|
+
is_preview: false
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Cancels a scheduled (period-end) plan change before it executes
|
|
410
|
+
client.cancel_pending_change("sub_123")
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
#### Change requests (recommended for plan changes)
|
|
414
|
+
|
|
415
|
+
A create → add changes → preview → apply workflow that shows exact proration
|
|
416
|
+
before committing, and collects payment before modifying the subscription. Only
|
|
417
|
+
one active (`draft`/`ready`) request may exist per subscription; creating a
|
|
418
|
+
second raises `PaymentKit::ConflictError`.
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
request = client.create_change_request("sub_123",
|
|
422
|
+
reason: "Upgrade to annual plan",
|
|
423
|
+
expires_in_hours: 24
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Append item, coupon and balance changes. Callable repeatedly; each call
|
|
427
|
+
# appends. Doing this on a `ready` request reverts it to `draft`.
|
|
428
|
+
client.add_change_request_changes("sub_123", request["id"],
|
|
429
|
+
item_changes: [
|
|
430
|
+
{ action: "update", item_id: "si_monthly_plan", price_id: "price_annual_plan" },
|
|
431
|
+
{ action: "add", price_id: "price_addon_support", quantity: 1, apply_at_end: false },
|
|
432
|
+
{ action: "drop", item_id: "si_legacy_addon" }
|
|
433
|
+
],
|
|
434
|
+
coupon_changes: [
|
|
435
|
+
{ action: "add", coupon_id: "coup_welcome20" }
|
|
436
|
+
],
|
|
437
|
+
trial_behavior: "preserve" # or end_now
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Pure computation: returns proration amounts and the execution plan, → ready
|
|
441
|
+
preview = client.preview_change_request("sub_123", request["id"])
|
|
442
|
+
preview["preview"]["invoice_total_atom"]
|
|
443
|
+
|
|
444
|
+
# Charge-first execution. A decline raises PaymentKit::CardError (402).
|
|
445
|
+
client.apply_change_request("sub_123", request["id"],
|
|
446
|
+
idempotency_key: "cr-#{request["id"]}"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
request = client.retrieve_change_request("sub_123", request["id"])
|
|
450
|
+
active = client.active_change_request("sub_123") # nil when none is pending
|
|
451
|
+
client.cancel_change_request("sub_123", request["id"])
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
One-step shortcut — create, add changes, preview and apply in a single call:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
client.apply_subscription_changes("sub_123",
|
|
458
|
+
{
|
|
459
|
+
item_changes: [
|
|
460
|
+
{ action: "update", item_id: "si_monthly_plan", price_id: "price_annual_plan" }
|
|
461
|
+
],
|
|
462
|
+
reason: "Upgrade to annual plan",
|
|
463
|
+
payment_method_id: "pm_123"
|
|
464
|
+
},
|
|
465
|
+
idempotency_key: "upgrade-#{user_id}-v1"
|
|
466
|
+
)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
#### Legacy plan change (deprecated)
|
|
470
|
+
|
|
471
|
+
PaymentKit documents `change-plan` as the legacy single-call endpoint, planned
|
|
472
|
+
for deprecation. Prefer the change-request workflow above, or
|
|
473
|
+
`apply_subscription_changes`. Kept for existing integrations:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
client.change_plan("sub_123",
|
|
477
|
+
reason: "Billing interval change to year",
|
|
478
|
+
proration_behavior: "always_invoice",
|
|
479
|
+
effective_at: "immediate",
|
|
480
|
+
items: [
|
|
481
|
+
{ action: "update", subscription_item_id: "si_plan",
|
|
482
|
+
new_price_id: "price_annual_plan", quantity: 1 }
|
|
483
|
+
]
|
|
484
|
+
)
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### Invoices
|
|
488
|
+
|
|
489
|
+
Invoices are created in `draft`. Each line item takes either an `amount`, a
|
|
490
|
+
`unit_amount` × `quantity`, or a catalog `price_id`.
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
invoice = client.create_invoice(
|
|
494
|
+
customer_id: "cus_123",
|
|
495
|
+
currency: "USD",
|
|
496
|
+
issued_at: "2026-02-01T00:00:00Z",
|
|
497
|
+
description: "Setup fee — Enterprise onboarding",
|
|
498
|
+
collection_method: "charge_automatically",
|
|
499
|
+
items: [
|
|
500
|
+
{ description: "Enterprise onboarding", quantity: 1, amount: 500.00 },
|
|
501
|
+
{ description: "Custom integration", quantity: 4, unit_amount: 150.00 },
|
|
502
|
+
{ price_id: "price_support_plan", quantity: 1 }
|
|
503
|
+
]
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Draft → Open: locks amounts, optionally generates the PDF and emails it
|
|
507
|
+
invoice = client.finalize_invoice(invoice["id"])
|
|
508
|
+
|
|
509
|
+
# Attempts collection; finalizes the invoice first when still draft.
|
|
510
|
+
# A decline raises PaymentKit::CardError and leaves the invoice payable.
|
|
511
|
+
result = client.pay_invoice(invoice["id"], payment_method_id: "pm_123")
|
|
512
|
+
result["invoice_status"] # => "paid"
|
|
513
|
+
|
|
514
|
+
invoice = client.retrieve_invoice("inv_123", expand: "custom_fields")
|
|
515
|
+
invoices = client.list_invoices(customer_id: "cus_123", status: "open")
|
|
516
|
+
|
|
517
|
+
# Poll while status is "generating"
|
|
518
|
+
pdf = client.retrieve_invoice_pdf("inv_123")
|
|
519
|
+
pdf["pdf_url"] if pdf["status"] == "available"
|
|
520
|
+
|
|
521
|
+
client.void_invoice("inv_123")
|
|
522
|
+
|
|
523
|
+
# Only permitted from OPEN or PAST_DUE; other states return 422
|
|
524
|
+
client.mark_invoice_uncollectible("inv_123")
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Sweep floating (pending) items into standalone invoices — one per currency —
|
|
528
|
+
finalize them and attempt collection immediately, without waiting for renewal.
|
|
529
|
+
Duplicate requests create duplicate invoices, so a stable key is essential.
|
|
530
|
+
|
|
531
|
+
```ruby
|
|
532
|
+
result = client.bill_pending_items(
|
|
533
|
+
{
|
|
534
|
+
customer_id: "cus_123", # required
|
|
535
|
+
subscription_id: "sub_123", # optional narrowing
|
|
536
|
+
currency: "USD",
|
|
537
|
+
collection_method: "charge_automatically",
|
|
538
|
+
description: "July usage",
|
|
539
|
+
tax_amount_atom: 0
|
|
540
|
+
},
|
|
541
|
+
idempotency_key: "bill-cus_123-2026-07"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
result["invoices"].each { |inv| puts "#{inv["currency"]}: #{inv["items_swept"]} items" }
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
A renewal running concurrently raises `PaymentKit::ConflictError` (409).
|
|
548
|
+
|
|
549
|
+
### Invoice items
|
|
550
|
+
|
|
551
|
+
Floating items attach to a subscription and are collected at the next renewal
|
|
552
|
+
(or on demand via `bill_pending_items`). The subscription must be `active` or
|
|
553
|
+
`trialing`, and the `customer_id` must match it.
|
|
554
|
+
|
|
555
|
+
```ruby
|
|
556
|
+
# With a catalog price
|
|
557
|
+
item = client.create_invoice_item(
|
|
558
|
+
customer_id: "cus_123",
|
|
559
|
+
subscription_id: "sub_123",
|
|
560
|
+
price_id: "price_sms_usage",
|
|
561
|
+
quantity: 150,
|
|
562
|
+
description: "SMS charges — July 2026 (150 messages)"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# With a custom amount
|
|
566
|
+
item = client.create_invoice_item(
|
|
567
|
+
customer_id: "cus_123",
|
|
568
|
+
subscription_id: "sub_123",
|
|
569
|
+
amount: 75.00,
|
|
570
|
+
description: "Custom setup fee"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
item = client.retrieve_invoice_item(item["id"])
|
|
574
|
+
item = client.update_invoice_item(item["id"], quantity: 200, description: "SMS charges — revised")
|
|
575
|
+
items = client.list_invoice_items(subscription_id: "sub_123", status: "floating")
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### Payment intents
|
|
579
|
+
|
|
580
|
+
Low-level charge control. Amounts are in atomic units.
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
intent = client.create_payment_intent(
|
|
584
|
+
customer_id: "cus_123",
|
|
585
|
+
amount_atom: 2500,
|
|
586
|
+
currency: "USD",
|
|
587
|
+
payment_method_id: "pm_123",
|
|
588
|
+
processor_id: "proc_live_abc",
|
|
589
|
+
metadata: { order_id: "ord_9" }
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
intent = client.retrieve_payment_intent("pi_123", expand: "checkout_attempt")
|
|
593
|
+
intents = client.list_payment_intents(customer_id: "cus_123", limit: 50)
|
|
594
|
+
|
|
595
|
+
refunds = client.list_refunds_by_intent("pi_123")
|
|
596
|
+
attempts = client.list_attempts_by_intent("pi_123")
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Payment methods
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
pm = client.create_payment_method(customer_id: "cus_123", provider_type: "card")
|
|
603
|
+
pm = client.retrieve_payment_method("pm_123")
|
|
604
|
+
pm = client.update_payment_method("pm_123", metadata: { label: "primary" })
|
|
605
|
+
|
|
606
|
+
# Documented way to take a card out of use (PUT is_active: false)
|
|
607
|
+
client.deactivate_payment_method("pm_123")
|
|
608
|
+
|
|
609
|
+
# Outright delete. PaymentKit documents deletion only on the customer-portal
|
|
610
|
+
# surface, so if your account returns 404/405 use deactivate_payment_method.
|
|
611
|
+
client.detach_payment_method("pm_123")
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### Checkout sessions
|
|
615
|
+
|
|
616
|
+
Hosted collection. The response carries the `secure_token` used to initialise
|
|
617
|
+
PaymentKit.js or redirect to the hosted page.
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
session = client.create_checkout_session(
|
|
621
|
+
customer_id: "cus_123", # optional; pre-fills the customer
|
|
622
|
+
line_items: [{ price_id: "price_123", quantity: 1 }],
|
|
623
|
+
success_url: "https://example.com/success",
|
|
624
|
+
return_url: "https://example.com/cancel",
|
|
625
|
+
expires_in_hours: 24,
|
|
626
|
+
promotion_code: "LAUNCH20",
|
|
627
|
+
custom_fields: { internal_ref: "ord_9" }
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
session["secure_token"]
|
|
631
|
+
|
|
632
|
+
session = client.retrieve_checkout_session(session["id"])
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### Catalog
|
|
636
|
+
|
|
637
|
+
```ruby
|
|
638
|
+
product = client.create_product(
|
|
639
|
+
name: "Pro plan",
|
|
640
|
+
description: "Everything in Starter, plus priority support",
|
|
641
|
+
is_active: true,
|
|
642
|
+
metadata: { tier: "pro" }
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
product = client.retrieve_product("prod_123")
|
|
646
|
+
product = client.update_product("prod_123", is_active: false, default_price_id: "price_123")
|
|
647
|
+
products = client.list_products(limit: 50)
|
|
648
|
+
prices = client.list_product_prices("prod_123")
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
price = client.create_price(
|
|
653
|
+
product_id: "prod_123",
|
|
654
|
+
currency: "USD",
|
|
655
|
+
unit_amount_atom: 2500,
|
|
656
|
+
pricing_type: "recurring",
|
|
657
|
+
billing_scheme: "per_unit",
|
|
658
|
+
recurring_interval: "month",
|
|
659
|
+
recurring_interval_count: 1,
|
|
660
|
+
trial_days: 14
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
price = client.retrieve_price("price_123")
|
|
664
|
+
prices = client.list_prices(limit: 50)
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
## List pagination
|
|
668
|
+
|
|
669
|
+
Every `list_*` helper auto-paginates PaymentKit’s offset/limit envelope:
|
|
670
|
+
|
|
671
|
+
```json
|
|
672
|
+
{ "items": [...], "total": 150, "has_more": true }
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
and return a flat Ruby `Array` of item hashes. PaymentKit defaults `limit` to 50
|
|
676
|
+
and caps it at 100; the helpers keep requesting pages until `has_more` is false.
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
products = client.list_products(limit: 50)
|
|
680
|
+
products.each { |product| puts product["id"] }
|
|
681
|
+
|
|
682
|
+
# Filters are forwarded as query params
|
|
683
|
+
invoices = client.list_invoices(customer_id: "cus_123", status: "open")
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Use `raw_request` when you need a single page rather than the whole collection.
|
|
687
|
+
|
|
688
|
+
## Webhooks and event bus
|
|
689
|
+
|
|
690
|
+
Inbound webhooks are verified, then fanned out synchronously via
|
|
691
|
+
`ActiveSupport::Notifications`. The gem does **not** persist events, enqueue jobs,
|
|
692
|
+
or retry deliveries — those remain host responsibilities. Deduplication is a host
|
|
693
|
+
responsibility too, but the gem provides the `event_retriever` hook for it
|
|
694
|
+
(see [Deduplicating redeliveries](#deduplicating-redeliveries)).
|
|
695
|
+
|
|
696
|
+
Multiple signing secrets are supported and tried in order, which is what
|
|
697
|
+
PaymentKit's `roll-secret` grace period requires: during rotation both the old
|
|
698
|
+
and new secret are live.
|
|
699
|
+
|
|
700
|
+
### Configure signing secrets
|
|
701
|
+
|
|
702
|
+
```ruby
|
|
703
|
+
PaymentKit.configure do |config|
|
|
704
|
+
config.signing_secret = ENV.fetch("PAYMENT_KIT_SIGNING_SECRET") # whsec_...
|
|
705
|
+
# or multiple secrets (tried in order):
|
|
706
|
+
# config.signing_secrets = [ENV["PAYMENT_KIT_SIGNING_SECRET"], ENV["PAYMENT_KIT_SIGNING_SECRET_OLD"]]
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Module-level accessors also work:
|
|
710
|
+
PaymentKit.signing_secret = ENV.fetch("PAYMENT_KIT_SIGNING_SECRET")
|
|
711
|
+
PaymentKit.signing_secrets = [ENV["PAYMENT_KIT_SIGNING_SECRET"], ENV["PAYMENT_KIT_SIGNING_SECRET_OLD"]]
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### Subscribe to events
|
|
715
|
+
|
|
716
|
+
```ruby
|
|
717
|
+
PaymentKit.subscribe "invoice.paid" do |event|
|
|
718
|
+
# event is a Hash, e.g. { "id" => "evt_...", "type" => "invoice.paid", ... }
|
|
719
|
+
# Prefer enqueueing work here; slow handlers delay the webhook HTTP response.
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
PaymentKit.subscribe "invoice.", InvoiceHandler.new # prefix match; #call(event)
|
|
723
|
+
PaymentKit.all { |event| Rails.logger.info(event["type"]) }
|
|
724
|
+
|
|
725
|
+
PaymentKit.event_filter = lambda do |event|
|
|
726
|
+
# return event to dispatch, or nil to ignore (still a successful verify path)
|
|
727
|
+
event["type"] == "ping" ? nil : event
|
|
728
|
+
end
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
`configure` accepts two block shapes. A block that takes an argument receives the
|
|
732
|
+
configuration object; a block that takes none is evaluated against the module,
|
|
733
|
+
which reads better for registering subscribers in an initializer:
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
PaymentKit.configure do |config| # settings
|
|
737
|
+
config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY")
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
PaymentKit.configure do # subscriber DSL
|
|
741
|
+
subscribe("invoice.paid") { |event| InvoicePaidJob.perform_later(event["id"]) }
|
|
742
|
+
all { |event| Rails.logger.info(event["type"]) }
|
|
743
|
+
end
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### Process a webhook (non-Rails or custom controller)
|
|
747
|
+
|
|
748
|
+
```ruby
|
|
749
|
+
payload = request.body.read
|
|
750
|
+
signature = request.headers["X-Webhook-Signature"]
|
|
751
|
+
|
|
752
|
+
begin
|
|
753
|
+
event = PaymentKit.process_webhook(payload, signature)
|
|
754
|
+
# verified + instrumented; subscribers already ran
|
|
755
|
+
rescue PaymentKit::SignatureVerificationError
|
|
756
|
+
head :unauthorized # signature missing or invalid
|
|
757
|
+
rescue PaymentKit::InvalidRequestError
|
|
758
|
+
head :bad_request # verified, but the body is not JSON
|
|
759
|
+
end
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
Low-level verify without dispatch (existing Client API, preserved):
|
|
763
|
+
|
|
764
|
+
```ruby
|
|
765
|
+
event = client.verify_webhook(payload, signature)
|
|
766
|
+
# or:
|
|
767
|
+
event = PaymentKit::Webhook.construct_event(payload, signature, PaymentKit.signing_secrets)
|
|
768
|
+
PaymentKit.instrument(event)
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
### Rails Engine (optional)
|
|
772
|
+
|
|
773
|
+
When Rails is loaded, mount the engine to get `POST /` → verify → instrument → `200`:
|
|
774
|
+
|
|
775
|
+
```ruby
|
|
776
|
+
# config/routes.rb
|
|
777
|
+
mount PaymentKit::Engine, at: "/payment_kit"
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
Point PaymentKit’s webhook URL at `https://your.app/payment_kit`.
|
|
781
|
+
|
|
782
|
+
PaymentKit treats `4xx` as a permanent failure and retries `5xx`/timeouts five
|
|
783
|
+
times over roughly 27 hours, so the controller maps failures deliberately:
|
|
784
|
+
|
|
785
|
+
| Outcome | Status | Retried by PaymentKit |
|
|
786
|
+
|---------|--------|-----------------------|
|
|
787
|
+
| Bad or missing signature | `401` | No |
|
|
788
|
+
| Verified but unparseable body | `400` | No |
|
|
789
|
+
| Subscriber raised, no `error_handler` | `500` | Yes |
|
|
790
|
+
| Subscriber raised, `error_handler` set | `200` | No |
|
|
791
|
+
|
|
792
|
+
Endpoints must respond within 30 seconds, so subscribers should enqueue work
|
|
793
|
+
rather than perform it inline.
|
|
794
|
+
|
|
795
|
+
### Deduplicating redeliveries
|
|
796
|
+
|
|
797
|
+
`event_retriever` runs after verification and before dispatch. Return the event
|
|
798
|
+
to continue, or `nil` to drop it. PaymentKit redelivers on retry, so dedupe here
|
|
799
|
+
on the event id — the same value it sends in the `X-Webhook-Event-Id` header:
|
|
800
|
+
|
|
801
|
+
```ruby
|
|
802
|
+
PaymentKit.event_retriever = lambda do |event|
|
|
803
|
+
key = "payment_kit:webhook:#{event["id"]}"
|
|
804
|
+
Sidekiq.redis { |r| r.set(key, "1", nx: true, ex: 3.days.to_i) } ? event : nil
|
|
805
|
+
end
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
`process_webhook` returns `nil` when the retriever drops a delivery.
|
|
809
|
+
|
|
810
|
+
### Reporting subscriber failures
|
|
811
|
+
|
|
812
|
+
Without an `error_handler`, a raising subscriber returns `500` and PaymentKit
|
|
813
|
+
retries. Set one to report the exception and answer `200` instead, which is the
|
|
814
|
+
right choice when subscribers only enqueue background work:
|
|
815
|
+
|
|
816
|
+
```ruby
|
|
817
|
+
PaymentKit.error_handler = ->(exception, _request) { Sentry.capture_exception(exception) }
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
`error_handler` is request-scoped: it fires once, after the fan-out has already
|
|
821
|
+
failed. For per-subscriber isolation use `subscriber_error_handler`, which wraps
|
|
822
|
+
each subscriber individually:
|
|
823
|
+
|
|
824
|
+
```ruby
|
|
825
|
+
PaymentKit.subscriber_error_handler = lambda do |exception, event|
|
|
826
|
+
Sentry.capture_exception(exception, extra: { event_id: event["id"] })
|
|
827
|
+
end
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
This matters because `ActiveSupport::Notifications` runs the remaining
|
|
831
|
+
subscribers when one raises but still re-raises afterwards (aggregating into
|
|
832
|
+
`ActiveSupport::Notifications::InstrumentationSubscriberError` when several
|
|
833
|
+
fail). Without a handler the delivery fails and PaymentKit redelivers the whole
|
|
834
|
+
event, re-running the subscribers that already succeeded. With one set, the
|
|
835
|
+
failure is reported and the delivery is acknowledged.
|
|
836
|
+
|
|
837
|
+
## Error handling
|
|
838
|
+
|
|
839
|
+
All errors inherit from `PaymentKit::Error` and may expose:
|
|
840
|
+
|
|
841
|
+
- `status` — HTTP status
|
|
842
|
+
- `body` — raw response body
|
|
843
|
+
- `request_id` — from the RFC 7807 payload, falling back to the `Request-Id` header
|
|
844
|
+
- `error_code` / `retryable?` — set on transient failures such as `invoice_locked`
|
|
845
|
+
- `problem` and `error["..."]` — any RFC 7807 extension member (`invoice_id`,
|
|
846
|
+
`subscription_id`, …)
|
|
847
|
+
|
|
848
|
+
| Exception | Typical cause |
|
|
849
|
+
|-----------|----------------|
|
|
850
|
+
| `PaymentKit::AuthenticationError` | Missing/invalid key, HTTP 401 |
|
|
851
|
+
| `PaymentKit::PermissionError` | HTTP 403 — valid key, but not allowed on this resource |
|
|
852
|
+
| `PaymentKit::SignatureVerificationError` | Webhook signature missing or invalid |
|
|
853
|
+
| `PaymentKit::InvalidRequestError` | Missing `account_id`, HTTP 400/404/422, unparseable webhook body |
|
|
854
|
+
| `PaymentKit::CardError` | HTTP 402 — payment declined; the invoice stays payable |
|
|
855
|
+
| `PaymentKit::ConflictError` | HTTP 409 — clashes with current resource state |
|
|
856
|
+
| `PaymentKit::RateLimitError` | HTTP 429 |
|
|
857
|
+
| `PaymentKit::APIError` | Other HTTP errors (including exhausted 5xx retries) |
|
|
858
|
+
| `PaymentKit::APIConnectionError` | Timeouts, connection refused/reset, DNS failures |
|
|
859
|
+
|
|
860
|
+
```ruby
|
|
861
|
+
begin
|
|
862
|
+
client.create_subscription(params)
|
|
863
|
+
rescue PaymentKit::ConflictError => e
|
|
864
|
+
retry if e.retryable? # e.g. error_code == "invoice_locked"
|
|
865
|
+
warn "Conflict on invoice #{e.invoice_id}: #{e.message}"
|
|
866
|
+
rescue PaymentKit::CardError => e
|
|
867
|
+
warn "Declined: #{e.message}"
|
|
868
|
+
rescue PaymentKit::InvalidRequestError => e
|
|
869
|
+
warn "Bad request (#{e.status}): #{e.message} request_id=#{e.request_id}"
|
|
870
|
+
rescue PaymentKit::APIConnectionError => e
|
|
871
|
+
warn "Network problem: #{e.message}"
|
|
872
|
+
rescue PaymentKit::Error => e
|
|
873
|
+
warn "PaymentKit error: #{e.message}"
|
|
874
|
+
end
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
`PermissionError` and `SignatureVerificationError` both subclass
|
|
878
|
+
`AuthenticationError`, so existing `rescue PaymentKit::AuthenticationError`
|
|
879
|
+
blocks keep catching them.
|
|
880
|
+
|
|
881
|
+
API errors follow [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) problem
|
|
882
|
+
details (`title`, `detail`, `request_id`). Validation `detail` arrays are flattened
|
|
883
|
+
into readable field messages.
|
|
884
|
+
|
|
885
|
+
For compatibility with applications written against a nested error namespace,
|
|
886
|
+
`PaymentKit::Client::AuthenticationError` and friends resolve to the same classes.
|
|
887
|
+
|
|
888
|
+
## Idempotency
|
|
889
|
+
|
|
890
|
+
- `POST`, `PUT` and `PATCH` automatically send an `Idempotency-Key` header
|
|
891
|
+
(random UUID), and the same key is reused across the gem's internal retries so
|
|
892
|
+
a retried write cannot double-charge.
|
|
893
|
+
- A random key only protects a single call. For operations that must not
|
|
894
|
+
double-bill across *process-level* retries — `bill_pending_items`,
|
|
895
|
+
`create_credit_note`, charge-first `update_subscription_items` and
|
|
896
|
+
`apply_change_request` — pass your own stable key:
|
|
897
|
+
|
|
898
|
+
```ruby
|
|
899
|
+
client.bill_pending_items(params, idempotency_key: "dispatch-#{user_id}-#{timestamp}")
|
|
900
|
+
client.update_subscription_items("sub_1", { items: [...], proration_behavior: "always_invoice" },
|
|
901
|
+
idempotency_key: "plan-change-#{user_id}-#{version}")
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
Every write method accepts the body positionally or as keywords, with an optional
|
|
905
|
+
`idempotency_key:` alongside:
|
|
906
|
+
|
|
907
|
+
```ruby
|
|
908
|
+
client.create_customer(email: "a@b.com")
|
|
909
|
+
client.create_customer({ email: "a@b.com" }, idempotency_key: "signup-42")
|
|
910
|
+
client.create_customer(email: "a@b.com", idempotency_key: "signup-42")
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
### Retries
|
|
914
|
+
|
|
915
|
+
`408`, `429` and `5xx` are always retried with exponential backoff. `409` is
|
|
916
|
+
retried **only** when PaymentKit flags it (`retryable: true`, or a known
|
|
917
|
+
side-effect-free `error_code` such as `invoice_locked`) — other conflicts, like
|
|
918
|
+
creating a second active change request, fail immediately.
|
|
919
|
+
|
|
920
|
+
A numeric `Retry-After` response header takes precedence over the backoff curve,
|
|
921
|
+
capped at `PaymentKit::Client::MAX_RETRY_DELAY` (32s) so a bad header cannot park
|
|
922
|
+
a request indefinitely.
|
|
923
|
+
|
|
924
|
+
## Instrumentation
|
|
925
|
+
|
|
926
|
+
`request_begin` and `request_end` hooks wrap every outbound API call. They are
|
|
927
|
+
deliberately separate from the webhook event bus, so API traffic is never
|
|
928
|
+
delivered to `PaymentKit.all` subscribers:
|
|
929
|
+
|
|
930
|
+
```ruby
|
|
931
|
+
PaymentKit::Instrumentation.subscribe(:request_end) do |event|
|
|
932
|
+
StatsD.timing(
|
|
933
|
+
"payment_kit.request",
|
|
934
|
+
event.duration * 1000,
|
|
935
|
+
tags: ["method:#{event.method}", "path:#{event.path}", "status:#{event.status}"]
|
|
936
|
+
)
|
|
937
|
+
Rails.logger.warn("PaymentKit retried #{event.path} #{event.num_retries}x") if event.num_retries.positive?
|
|
938
|
+
end
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
`request_end` fires once per logical call — after retries, on both the success
|
|
942
|
+
and failure paths — and carries `method`, `path`, `status`, `duration`,
|
|
943
|
+
`num_retries` and `request_id`. A raising subscriber warns on stderr and never
|
|
944
|
+
breaks the API call. `subscribe` returns a name you can pass to `unsubscribe`.
|
|
945
|
+
|
|
946
|
+
## Calling unwrapped endpoints
|
|
947
|
+
|
|
948
|
+
`raw_request` reaches any PaymentKit endpoint this gem does not wrap yet, reusing
|
|
949
|
+
the same auth, retry, idempotency and error mapping:
|
|
950
|
+
|
|
951
|
+
```ruby
|
|
952
|
+
client.raw_request(:get, "/payment-links", params: { limit: 10 })
|
|
953
|
+
client.raw_request(:post, "/webhook-endpoints/we_1/roll-secret",
|
|
954
|
+
params: { ttl_seconds: 3600 }, idempotency_key: "roll-1")
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Paths are account-scoped by default. Surfaces that sit outside the account
|
|
958
|
+
prefix — such as the customer portal — opt out:
|
|
959
|
+
|
|
960
|
+
```ruby
|
|
961
|
+
client.raw_request(:get, "/billing-portal/token/#{token}/payment-methods",
|
|
962
|
+
account_scoped: false)
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
It returns the parsed JSON body as a `Hash`; unlike `list_*` helpers it does not
|
|
966
|
+
auto-paginate.
|
|
967
|
+
|
|
968
|
+
## Testing
|
|
969
|
+
|
|
970
|
+
Point the client at a stub base URL and stub `transport` (or use WebMock against
|
|
971
|
+
the resolved host):
|
|
972
|
+
|
|
973
|
+
```ruby
|
|
974
|
+
client = PaymentKit::Client.new(
|
|
975
|
+
secret_key: "st_test",
|
|
976
|
+
base_url: "https://api.test/acc"
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
# Example: inject responses by stubbing the private transport seam in unit tests
|
|
980
|
+
allow(client).to receive(:transport).and_return(fake_response)
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
In RSpec suites, call `PaymentKit.reset_configuration!` between examples that
|
|
984
|
+
mutate global config.
|
|
985
|
+
|
|
986
|
+
## Development
|
|
987
|
+
|
|
988
|
+
```bash
|
|
989
|
+
bin/setup
|
|
990
|
+
bundle exec rspec
|
|
991
|
+
bundle exec rubocop
|
|
992
|
+
bundle exec rake # spec + rubocop
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
Interactive console:
|
|
996
|
+
|
|
997
|
+
```bash
|
|
998
|
+
bin/console
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
## Documentation
|
|
1002
|
+
|
|
1003
|
+
- API & guides: https://docs.paymentkit.com
|
|
1004
|
+
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
|
1005
|
+
|
|
1006
|
+
## License
|
|
1007
|
+
|
|
1008
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|