paystack_sdk 0.1.0 → 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.
data/README.md CHANGED
@@ -8,12 +8,21 @@ The `paystack_sdk` gem provides a simple and intuitive interface for interacting
8
8
  - [Quick Start](#quick-start)
9
9
  - [Usage](#usage)
10
10
  - [Client Initialization](#client-initialization)
11
+ - [Test and Live Keys](#test-and-live-keys)
11
12
  - [Transactions](#transactions)
12
13
  - [Initialize a Transaction](#initialize-a-transaction)
13
14
  - [Verify a Transaction](#verify-a-transaction)
14
15
  - [List Transactions](#list-transactions)
15
16
  - [Fetch a Transaction](#fetch-a-transaction)
16
17
  - [Get Transaction Totals](#get-transaction-totals)
18
+ - [Checking a Payment](#checking-a-payment)
19
+ - [Charging a Saved Card](#charging-a-saved-card)
20
+ - [Charges](#charges)
21
+ - [Mobile Money in Ghana: Which Networks](#mobile-money-in-ghana-which-networks)
22
+ - [Create a Mobile Money Charge](#create-a-mobile-money-charge)
23
+ - [Create a Charge on Another Channel](#create-a-charge-on-another-channel)
24
+ - [Complete a Charge](#complete-a-charge)
25
+ - [Check a Pending Charge](#check-a-pending-charge)
17
26
  - [Customers](#customers)
18
27
  - [Create a Customer](#create-a-customer)
19
28
  - [List Customers](#list-customers)
@@ -22,14 +31,27 @@ The `paystack_sdk` gem provides a simple and intuitive interface for interacting
22
31
  - [Validate a Customer](#validate-a-customer)
23
32
  - [Set Risk Action](#set-risk-action)
24
33
  - [Deactivate Authorization](#deactivate-authorization)
34
+ - [Banks](#banks)
35
+ - [Miscellaneous](#miscellaneous)
36
+ - [Transfer Recipients](#transfer-recipients)
37
+ - [Transfers](#transfers)
38
+ - [Initiate a Transfer](#initiate-a-transfer)
39
+ - [Verify, Fetch and List Transfers](#verify-fetch-and-list-transfers)
40
+ - [Bulk Transfers and the OTP Requirement](#bulk-transfers-and-the-otp-requirement)
41
+ - [Refunds](#refunds)
42
+ - [Create a Refund](#create-a-refund)
43
+ - [Fetch, List and Retry Refunds](#fetch-list-and-retry-refunds)
25
44
  - [Response Handling](#response-handling)
26
45
  - [Working with Response Objects](#working-with-response-objects)
27
46
  - [Accessing the Original Response](#accessing-the-original-response)
28
47
  - [Error Handling](#error-handling)
29
48
  - [Advanced Usage](#advanced-usage)
49
+ - [Timeouts and Retries](#timeouts-and-retries)
50
+ - [Webhooks](#webhooks)
30
51
  - [Environment Variables](#environment-variables)
31
52
  - [Direct Resource Instantiation](#direct-resource-instantiation)
32
53
  - [Development](#development)
54
+ - [Style and Linting](#style-and-linting)
33
55
  - [Contributing](#contributing)
34
56
  - [License](#license)
35
57
  - [Code of Conduct](#code-of-conduct)
@@ -63,14 +85,12 @@ require 'paystack_sdk'
63
85
  paystack = PaystackSdk::Client.new(secret_key: "sk_test_xxx")
64
86
 
65
87
  # Initialize a transaction
66
- params = {
67
- email: "customer@email.com",
68
- amount: 2300, # Amount in the smallest currency unit (kobo for NGN)
69
- currency: "NGN"
70
- }
71
-
72
88
  begin
73
- response = paystack.transactions.initiate(params)
89
+ response = paystack.transactions.initiate(
90
+ email: "customer@email.com",
91
+ amount: 2300, # Amount in the smallest currency unit (kobo for NGN)
92
+ currency: "NGN"
93
+ )
74
94
 
75
95
  if response.success?
76
96
  puts "Visit this URL to complete payment: #{response.authorization_url}"
@@ -78,21 +98,19 @@ begin
78
98
  else
79
99
  puts "Error: #{response.error_message}"
80
100
  end
81
- rescue PaystackSdk::MissingParamError => e
101
+ rescue ArgumentError => e
82
102
  puts "Missing required data: #{e.message}"
83
103
  rescue PaystackSdk::InvalidFormatError => e
84
104
  puts "Invalid data format: #{e.message}"
85
105
  end
86
106
 
87
107
  # Create a customer
88
- customer_params = {
89
- email: "customer@email.com",
90
- first_name: "John",
91
- last_name: "Doe"
92
- }
93
-
94
108
  begin
95
- customer_response = paystack.customers.create(customer_params)
109
+ customer_response = paystack.customers.create(
110
+ email: "customer@email.com",
111
+ first_name: "John",
112
+ last_name: "Doe"
113
+ )
96
114
 
97
115
  if customer_response.success?
98
116
  puts "Customer created: #{customer_response.data.customer_code}"
@@ -121,10 +139,10 @@ The SDK validates your parameters **before** making API calls and throws excepti
121
139
 
122
140
  ```ruby
123
141
  begin
124
- # This will throw an exception before making any API call
125
- response = paystack.transactions.initiate({amount: 1000}) # Missing required email
126
- rescue PaystackSdk::MissingParamError => e
127
- puts "Fix your data: #{e.message}"
142
+ # Ruby raises ArgumentError for a missing required keyword, before any API call
143
+ response = paystack.transactions.initiate(amount: 1000) # Missing required email
144
+ rescue ArgumentError => e
145
+ puts "Fix your data: #{e.message}" # => "missing keyword: :email"
128
146
  end
129
147
  ```
130
148
 
@@ -133,7 +151,7 @@ end
133
151
  All successful API calls return a `Response` object that you can check for success:
134
152
 
135
153
  ```ruby
136
- response = paystack.transactions.initiate(valid_params)
154
+ response = paystack.transactions.initiate(**valid_params)
137
155
 
138
156
  if response.success?
139
157
  puts "Transaction created: #{response.authorization_url}"
@@ -151,9 +169,9 @@ end
151
169
 
152
170
  - **Validation errors** - when required parameters are missing or have invalid formats
153
171
  - **Authentication errors** (401) - usually configuration issues
154
- - **Rate limiting** (429) - requires retry logic
172
+ - **Rate limiting** (429) - raised after automatic retries are exhausted, or immediately if Paystack asks for a long wait (see [Timeouts and Retries](#timeouts-and-retries))
155
173
  - **Server errors** (5xx) - Paystack infrastructure issues
156
- - **Network errors** - connection failures
174
+ - **Network errors** - timeouts and connection failures, raised as `PaystackSdk::TimeoutError` / `PaystackSdk::ConnectionError`
157
175
 
158
176
  All other API errors (resource not found, business logic errors, etc.) are returned as unsuccessful Response objects.
159
177
 
@@ -172,6 +190,19 @@ paystack = PaystackSdk::Client.new # => This will dynamically fetch the secret k
172
190
  connection = paystack.connection
173
191
  ```
174
192
 
193
+ ### Test and Live Keys
194
+
195
+ A live key (`sk_live_...`) moves real money. `client.live?` tells you which kind a client holds, and `sandbox_only: true` makes the client refuse anything but a test key (`sk_test_...`) at construction, before any request is sent. Set it in staging and CI:
196
+
197
+ ```ruby
198
+ client = PaystackSdk::Client.new(secret_key: ENV["PAYSTACK_SECRET_KEY"], sandbox_only: true)
199
+ # => ArgumentError if the key is a live key, or anything not recognisable as a test key
200
+
201
+ client.live? # => false
202
+ ```
203
+
204
+ The error never includes the key. A pre-built Faraday connection is checked too (the key is read from its `Authorization` header).
205
+
175
206
  ### Transactions
176
207
 
177
208
  The SDK provides comprehensive support for Paystack's Transaction API.
@@ -179,16 +210,13 @@ The SDK provides comprehensive support for Paystack's Transaction API.
179
210
  #### Initialize a Transaction
180
211
 
181
212
  ```ruby
182
- # Prepare transaction parameters
183
- params = {
213
+ # Amount is in the smallest currency unit (e.g., kobo, pesewas, cents)
214
+ response = paystack.transactions.initiate(
184
215
  email: "customer@example.com",
185
- amount: 10000, # Amount in the smallest currency unit (e.g., kobo, pesewas, cents)
216
+ amount: 10000,
186
217
  currency: "GHS",
187
218
  callback_url: "https://example.com/callback"
188
- }
189
-
190
- # Initialize the transaction
191
- response = paystack.transactions.initiate(params)
219
+ )
192
220
 
193
221
  if response.success?
194
222
  puts "Transaction initialized successfully!"
@@ -200,6 +228,113 @@ else
200
228
  end
201
229
  ```
202
230
 
231
+ ### Charges
232
+
233
+ The Charge API lets you pick the payment channel yourself instead of sending the customer to Checkout: a saved card authorization, a bank account, USSD, mobile money, QR, EFT, Pay with Transfer or Capitec Pay. Many charges need one more step from the customer (a PIN, OTP, phone number, birthday or address) before they complete. See the Paystack docs: [Charge API](https://paystack.com/docs/api/charge/) and [Payment Channels](https://paystack.com/docs/payments/payment-channels/).
234
+
235
+ #### Mobile Money in Ghana: Which Networks
236
+
237
+ Paystack lists three Ghana mobile-money providers (`banks.list(country: "ghana", type: "mobile_money")`): `MTN`, `VOD` (shown as Vodafone, now Telecel) and `ATL` (AirtelTigo). The bank list uses uppercase codes; `charges.mobile_money` takes the provider in any case and sends it in lowercase (`mtn`, `vod`, `atl`).
238
+
239
+ What was checked, against Paystack's test API only: a merchant-started charge of 100 pesewas (GHS) for each of the three providers on Paystack's test number was accepted, came back with `status: "success"` and `gateway_response: "Approved"` immediately, and `check_pending(reference:)` returned the same. **Test mode does not model the payer approving the prompt on their phone, so this does not show which live networks accept a charge you start yourself.** Treat each network as unverified in live mode: start the charge, read `response.status` (`status?(:pay_offline)`, `status?(:send_otp)`, `status?(:pending)` and so on, as Paystack returns them), show `response.display_text` to the payer when there is one, and poll `charges.check_pending(reference:)` or wait for the `charge.success` webhook. Confirm with `transactions.verify(reference:)` before giving value. If a network refuses a merchant-started prompt, fall back to a payment link for that network.
240
+
241
+ #### Create a Mobile Money Charge
242
+
243
+ Mobile money is available to businesses in Ghana, Kenya and Côte d'Ivoire. `mobile_money` checks the `mobile_money` object (phone or till account, and a known provider) and sends it with `create`.
244
+
245
+ Supported providers (case-insensitive): `mtn`, `atl` (ATMoney/Airtel Money), `vod` (Telecel, formerly Vodafone), `mpesa`, `mpesa_offline`, `mptill` (M-PESA Till: send `account:`, the till number, instead of `phone:`), `orange`, `wave`.
246
+
247
+ ```ruby
248
+ paystack = PaystackSdk::Client.new(secret_key: "sk_test_xxx")
249
+
250
+ response = paystack.charges.mobile_money(
251
+ email: "customer@email.com",
252
+ amount: 100, # smallest unit (pesewas/cent)
253
+ currency: "GHS", # optional; Paystack uses your integration's currency without it
254
+ mobile_money: {
255
+ phone: "0551234987",
256
+ provider: "mtn" # mtn | atl | vod | mpesa | mpesa_offline | mptill | orange | wave
257
+ }
258
+ )
259
+
260
+ if response.success?
261
+ case response.status
262
+ when "pay_offline"
263
+ # Show instruction text and wait for webhook or verify later
264
+ puts response.display_text
265
+ when "send_otp"
266
+ # For Vodafone, collect voucher/OTP and submit below
267
+ puts response.display_text
268
+ when "success"
269
+ puts "Charge completed: #{response.reference}"
270
+ else
271
+ puts "Status: #{response.status}"
272
+ end
273
+ else
274
+ puts "Charge failed: #{response.error_message}"
275
+ end
276
+ ```
277
+
278
+ #### Create a Charge on Another Channel
279
+
280
+ `create` takes one channel object (or an `authorization_code`) as a keyword hash and sends it as Paystack documents it.
281
+
282
+ ```ruby
283
+ # A returning customer's saved card
284
+ paystack.charges.create(email: "customer@email.com", amount: 10000, authorization_code: "AUTH_xxxx")
285
+
286
+ # A bank account (Paystack may then ask for the customer's birthday or an OTP)
287
+ paystack.charges.create(
288
+ email: "customer@email.com",
289
+ amount: 10000,
290
+ bank: {code: "057", account_number: "0000000000"},
291
+ birthday: Date.new(1995, 12, 23) # or "1995-12-23"
292
+ )
293
+
294
+ # USSD (Nigeria), Pay with Transfer, and QR or EFT (South Africa)
295
+ paystack.charges.create(email: "customer@email.com", amount: 10000, ussd: {type: "737"})
296
+ paystack.charges.create(email: "customer@email.com", amount: 10000, bank_transfer: {account_expires_at: "2026-10-10T12:00:00Z"})
297
+ paystack.charges.create(email: "customer@email.com", amount: 10000, currency: "ZAR", qr: {provider: "scan-to-pay"})
298
+ paystack.charges.create(email: "customer@email.com", amount: 10000, currency: "ZAR", eft: {provider: "ozow"})
299
+
300
+ # Send the payment through a split or to a subaccount
301
+ paystack.charges.create(email: "customer@email.com", amount: 10000, authorization_code: "AUTH_xxxx", split_code: "SPL_xxxx")
302
+ ```
303
+
304
+ #### Complete a Charge
305
+
306
+ When the response asks for more from the customer (read `response.status` and `response.display_text`), send it with the charge's `reference`:
307
+
308
+ ```ruby
309
+ paystack.charges.submit_pin(pin: "1234", reference: "5bwib5v6anhe9xa")
310
+ paystack.charges.submit_otp(otp: "123456", reference: "5bwib5v6anhe9xa") # e.g. a Vodafone voucher
311
+ paystack.charges.submit_phone(phone: "08012345678", reference: "5bwib5v6anhe9xa")
312
+ paystack.charges.submit_birthday(birthday: "1961-09-21", reference: "5bwib5v6anhe9xa")
313
+ paystack.charges.submit_address(
314
+ address: "140 N 2ND ST",
315
+ city: "Stroudsburg",
316
+ state: "PA",
317
+ zip_code: "18360",
318
+ reference: "7c7rpkqpc0tijs8"
319
+ )
320
+ ```
321
+
322
+ #### Check a Pending Charge
323
+
324
+ If a charge comes back `pending`, or a `/charge` call failed with an exception, wait at least 10 seconds, then check it (Paystack warns that checking too early returns more `pending` results):
325
+
326
+ ```ruby
327
+ response = paystack.charges.check_pending(reference: "5bwib5v6anhe9xa")
328
+ puts response.status
329
+ ```
330
+
331
+ For offline flows such as mobile money, listen for the `charge.success` webhook, and confirm it with `transactions.verify(reference:)` before giving value:
332
+
333
+ ```ruby
334
+ verify = paystack.transactions.verify(reference: "r13havfcdt7btcm")
335
+ puts verify.status # "success", "failed", or current state
336
+ ```
337
+
203
338
  #### Verify a Transaction
204
339
 
205
340
  ```ruby
@@ -231,7 +366,7 @@ end
231
366
  #### List Transactions
232
367
 
233
368
  ```ruby
234
- # Get all transactions (default pagination: 50 per page)
369
+ # Get all transactions (Paystack's default pagination: 50 per page)
235
370
  response = paystack.transactions.list
236
371
 
237
372
  # With custom pagination
@@ -246,6 +381,9 @@ response = paystack.transactions.list(
246
381
  status: "success"
247
382
  )
248
383
 
384
+ # Filter by customer (the numeric customer ID, not the CUS_ code)
385
+ response = paystack.transactions.list(customer_id: 12345)
386
+
249
387
  if response.success?
250
388
  puts "Total transactions: #{response.count}" # response.size is another way
251
389
 
@@ -272,8 +410,7 @@ end
272
410
 
273
411
  ```ruby
274
412
  # Fetch a specific transaction by ID
275
- transaction_id = "12345"
276
- response = paystack.transactions.fetch(transaction_id)
413
+ response = paystack.transactions.fetch(id: 12345)
277
414
 
278
415
  if response.success?
279
416
  transaction = response.data
@@ -297,6 +434,9 @@ end
297
434
  # Get transaction volume and success metrics
298
435
  response = paystack.transactions.totals
299
436
 
437
+ # Within a date range
438
+ response = paystack.transactions.totals(from: "2025-01-01", to: "2025-04-30")
439
+
300
440
  if response.success?
301
441
  puts "Total Transactions: #{response.data.total_transactions}"
302
442
  puts "Total Volume: #{response.data.total_volume}"
@@ -306,23 +446,79 @@ else
306
446
  end
307
447
  ```
308
448
 
449
+ #### Transaction Timeline, Export and Charging
450
+
451
+ ```ruby
452
+ # Timeline of a transaction, by ID or reference
453
+ paystack.transactions.timeline(id: "transaction_reference")
454
+
455
+ # Export transactions (Paystack returns a download link)
456
+ paystack.transactions.export(from: "2025-01-01", to: "2025-04-30", status: "success", settled: true)
457
+
458
+ # Charge a returning customer's saved authorization
459
+ paystack.transactions.charge_authorization(
460
+ email: "customer@example.com",
461
+ amount: 10000,
462
+ authorization_code: "AUTH_xxxx"
463
+ )
464
+
465
+ # Debit part of an amount from a saved authorization
466
+ paystack.transactions.partial_debit(
467
+ email: "customer@example.com",
468
+ amount: 5000,
469
+ authorization_code: "AUTH_xxxx",
470
+ currency: "GHS"
471
+ )
472
+ ```
473
+
474
+ #### Checking a Payment
475
+
476
+ Confirm a payment by verifying it, then check the status, amount and currency. `paid?` does all three: it is true only when the call succeeded and the transaction's `status` is `"success"`, and, for the `amount` and `currency` you pass, they match what Paystack reports.
477
+
478
+ ```ruby
479
+ response = paystack.transactions.verify(reference: reference)
480
+
481
+ if response.paid?(amount: 5000, currency: "GHS") # amount in the smallest unit, e.g. pesewas
482
+ authorization = response.authorization
483
+ authorization.authorization_code # keep this to charge the card again
484
+ authorization.reusable # true if it can be charged again
485
+ end
486
+ ```
487
+
488
+ Fields keep Paystack's own names: `response.reference`, `amount`, `currency`, `status`, `paid_at`, `gateway_response`, `fees`, `customer.email`, `customer.customer_code`, and on `authorization`: `authorization_code`, `reusable`, `channel`, `last4`, `card_type`, `signature`, `exp_month`, `exp_year`, `bin`, `bank`, `account_name`, `country_code` and `brand`. `response.status?(:pending)` compares the `status` field with any value, which is how you read a charge that is waiting on the payer (`status?(:send_pin)`, `status?(:pay_offline)`); `response.display_text` is the prompt Paystack wants shown to them.
489
+
490
+ #### Charging a Saved Card
491
+
492
+ After a successful card payment, the `authorization_code` can be charged again with no checkout, for example for a renewal. Only an authorization with `reusable: true` can be charged again.
493
+
494
+ ```ruby
495
+ charge = paystack.transactions.charge_authorization(
496
+ email: "customer@example.com", # the customer the authorization belongs to
497
+ amount: 5000,
498
+ currency: "GHS",
499
+ authorization_code: "AUTH_xxxx",
500
+ reference: "renewal-2025-06-ama" # unique per attempt; reuse it if you retry so you cannot charge twice
501
+ )
502
+
503
+ paystack.transactions.verify(reference: "renewal-2025-06-ama").paid?(amount: 5000, currency: "GHS")
504
+ ```
505
+
506
+ This was checked against Paystack's test API: a reusable card authorization charged successfully (`status: "success"`, `gateway_response: "Approved"`), and an unknown `authorization_code` came back as an unsuccessful response with `"Authorization code is invalid"`. Writes are never retried after a timeout, so if a `charge_authorization` call times out, verify the `reference` before charging again. Test mode does not prove live behaviour.
507
+
309
508
  ### Customers
310
509
 
311
- The SDK provides comprehensive support for Paystack's Customer API, allowing you to manage customer records and their associated data.
510
+ The SDK provides comprehensive support for Paystack's Customer API, allowing you to manage customer records, their identity validation, risk actions and authorizations (including Direct Debit mandates).
312
511
 
313
512
  #### Create a Customer
314
513
 
315
514
  ```ruby
316
- # Prepare customer parameters
317
- params = {
515
+ response = paystack.customers.create(
318
516
  email: "customer@example.com",
319
517
  first_name: "John",
320
518
  last_name: "Doe",
321
- phone: "+2348123456789"
322
- }
323
-
324
- # Create the customer
325
- response = paystack.customers.create(params)
519
+ phone: "+2348123456789",
520
+ metadata: {plan: "gold"} # a Hash; Paystack rejects a JSON string here
521
+ )
326
522
 
327
523
  if response.success?
328
524
  puts "Customer created successfully!"
@@ -334,10 +530,12 @@ else
334
530
  end
335
531
  ```
336
532
 
533
+ `first_name`, `last_name` and `phone` are optional, except for customers you will assign a Dedicated Virtual Account to in some business categories (see Paystack's docs).
534
+
337
535
  #### List Customers
338
536
 
339
537
  ```ruby
340
- # Get all customers (default pagination: 50 per page)
538
+ # Get all customers (Paystack's default pagination: 50 per page)
341
539
  response = paystack.customers.list
342
540
 
343
541
  # With custom pagination
@@ -351,8 +549,12 @@ response = paystack.customers.list(
351
549
  to: "2025-06-10"
352
550
  )
353
551
 
552
+ # Cursor pagination: the cursors come back in response.meta
553
+ response = paystack.customers.list(use_cursor: true, per_page: 20)
554
+ response = paystack.customers.list(use_cursor: true, per_page: 20, next_cursor: response.meta.next)
555
+
354
556
  if response.success?
355
- puts "Total customers: #{response.data.size}"
557
+ puts "Customers on this page: #{response.data.size}"
356
558
 
357
559
  response.data.each do |customer|
358
560
  puts "Code: #{customer.customer_code}"
@@ -369,11 +571,10 @@ end
369
571
 
370
572
  ```ruby
371
573
  # Fetch by customer code
372
- customer_code = "CUS_xr58yrr2ujlft9k"
373
- response = paystack.customers.fetch(customer_code)
574
+ response = paystack.customers.fetch(email_or_code: "CUS_xr58yrr2ujlft9k")
374
575
 
375
- # Or fetch by email
376
- response = paystack.customers.fetch("customer@example.com")
576
+ # Or fetch by email (Paystack accepts either in the same place)
577
+ response = paystack.customers.fetch(email_or_code: "customer@example.com")
377
578
 
378
579
  if response.success?
379
580
  customer = response.data
@@ -390,14 +591,12 @@ end
390
591
  #### Update a Customer
391
592
 
392
593
  ```ruby
393
- customer_code = "CUS_xr58yrr2ujlft9k"
394
- update_params = {
594
+ response = paystack.customers.update(
595
+ code: "CUS_xr58yrr2ujlft9k",
395
596
  first_name: "Jane",
396
597
  last_name: "Smith",
397
598
  phone: "+2348987654321"
398
- }
399
-
400
- response = paystack.customers.update(customer_code, update_params)
599
+ )
401
600
 
402
601
  if response.success?
403
602
  puts "Customer updated successfully!"
@@ -409,19 +608,19 @@ end
409
608
 
410
609
  #### Validate a Customer
411
610
 
611
+ Paystack only supports `type: "bank_account"` for now, and requires every keyword below; `middle_name` and `value` are optional. Paystack answers `202` and completes the validation asynchronously.
612
+
412
613
  ```ruby
413
- customer_code = "CUS_xr58yrr2ujlft9k"
414
- validation_params = {
415
- country: "NG",
614
+ response = paystack.customers.validate(
615
+ code: "CUS_xr58yrr2ujlft9k",
616
+ first_name: "John",
617
+ last_name: "Doe",
416
618
  type: "bank_account",
417
- account_number: "0123456789",
619
+ country: "NG",
418
620
  bvn: "20012345677",
419
621
  bank_code: "007",
420
- first_name: "John",
421
- last_name: "Doe"
422
- }
423
-
424
- response = paystack.customers.validate(customer_code, validation_params)
622
+ account_number: "0123456789"
623
+ )
425
624
 
426
625
  if response.success?
427
626
  puts "Customer validation initiated: #{response.message}"
@@ -430,15 +629,14 @@ else
430
629
  end
431
630
  ```
432
631
 
433
- #### Set Risk Action
632
+ #### Set Risk Action (Whitelist/Blacklist)
434
633
 
435
634
  ```ruby
436
- params = {
635
+ # customer: the customer code or email address
636
+ response = paystack.customers.set_risk_action(
437
637
  customer: "CUS_xr58yrr2ujlft9k",
438
- risk_action: "allow" # Options: "default", "allow", "deny"
439
- }
440
-
441
- response = paystack.customers.set_risk_action(params)
638
+ risk_action: "allow" # "allow" to whitelist, "deny" to blacklist, "default" to reset
639
+ )
442
640
 
443
641
  if response.success?
444
642
  puts "Risk action set successfully!"
@@ -449,14 +647,35 @@ else
449
647
  end
450
648
  ```
451
649
 
452
- #### Deactivate Authorization
650
+ #### Authorizations and Direct Debit
453
651
 
454
652
  ```ruby
455
- params = {
456
- authorization_code: "AUTH_72btv547"
457
- }
653
+ # Start creating a reusable authorization (direct_debit is the only channel for now)
654
+ response = paystack.customers.initialize_authorization(
655
+ email: "customer@example.com",
656
+ channel: "direct_debit",
657
+ callback_url: "https://example.com/callback"
658
+ )
659
+ reference = response.data.reference # send the customer to response.data.redirect_url
458
660
 
459
- response = paystack.customers.deactivate_authorization(params)
661
+ # Check the authorization's status
662
+ paystack.customers.verify_authorization(reference: reference)
663
+
664
+ # Link a bank account to an existing customer for Direct Debit (id is the numeric customer ID)
665
+ paystack.customers.initialize_direct_debit(
666
+ id: 12345,
667
+ account: {number: "0123456789", bank_code: "058"},
668
+ address: {street: "Some Where", city: "Ikeja", state: "Lagos"}
669
+ )
670
+
671
+ # The customer's Direct Debit mandates
672
+ paystack.customers.fetch_mandate_authorizations(id: 12345)
673
+
674
+ # Trigger an activation charge on an inactive mandate
675
+ paystack.customers.direct_debit_activation_charge(id: 12345, authorization_id: 1069309917)
676
+
677
+ # Deactivate an authorization (any channel)
678
+ response = paystack.customers.deactivate_authorization(authorization_code: "AUTH_72btv547")
460
679
 
461
680
  if response.success?
462
681
  puts "Authorization deactivated: #{response.message}"
@@ -465,6 +684,217 @@ else
465
684
  end
466
685
  ```
467
686
 
687
+ ### Banks
688
+
689
+ Everything takes keyword arguments. Account numbers and bank codes are strings, so leading zeros survive.
690
+
691
+ ```ruby
692
+ # List banks. Filters are optional: country (ghana, kenya, nigeria, "south africa"), currency,
693
+ # type, gateway, per_page, page, use_cursor, next_cursor, previous, ...
694
+ response = paystack.banks.list(country: "nigeria", per_page: 50)
695
+ response.data.each { |bank| puts "#{bank["name"]}: #{bank["code"]}" }
696
+
697
+ # Resolve an account number to the name on the account
698
+ response = paystack.banks.resolve_account_number(account_number: "0022728151", bank_code: "063")
699
+ puts response.data["account_name"] if response.success?
700
+
701
+ # Validate a South African bank account before sending money
702
+ response = paystack.banks.validate_account(
703
+ account_name: "Ama Mensah",
704
+ account_number: "0123456789",
705
+ account_type: "personal",
706
+ bank_code: "632005",
707
+ country_code: "ZA",
708
+ document_type: "identityNumber",
709
+ document_number: "1234567890123" # optional
710
+ )
711
+ ```
712
+
713
+ ### Miscellaneous
714
+
715
+ ```ruby
716
+ # Details of a card BIN (6 or 8 digits, as a string)
717
+ response = paystack.miscellaneous.resolve_card_bin(bin: "539983")
718
+ puts response.data["bank"] if response.success?
719
+
720
+ # Supported countries
721
+ paystack.miscellaneous.list_countries
722
+
723
+ # States for address verification (country is required)
724
+ paystack.miscellaneous.list_states(country: "CA")
725
+ ```
726
+
727
+ ### Transfer Recipients
728
+
729
+ Every method takes keyword arguments. A recipient is who you send a transfer to.
730
+
731
+ #### Create a Transfer Recipient
732
+
733
+ ```ruby
734
+ response = paystack.transfer_recipients.create(
735
+ type: "nuban", # nuban, ghipss, mobile_money, basa or authorization
736
+ name: "Ama Mensah",
737
+ account_number: "0123456789",
738
+ bank_code: "058",
739
+ currency: "NGN",
740
+ metadata: {job: "Baker"}
741
+ )
742
+
743
+ puts response.data.recipient_code if response.success?
744
+ ```
745
+
746
+ A duplicate account number returns the existing recipient. To create many at once, pass a list of recipient hashes:
747
+
748
+ ```ruby
749
+ response = paystack.transfer_recipients.bulk_create(
750
+ batch: [
751
+ {type: "nuban", name: "Ama Mensah", account_number: "0123456789", bank_code: "058"},
752
+ {type: "nuban", name: "Kofi Boateng", account_number: "0987654321", bank_code: "058"}
753
+ ]
754
+ )
755
+
756
+ response.data.success # recipients that were created
757
+ response.data.errors # records Paystack rejected, with the reason
758
+ ```
759
+
760
+ #### List, Fetch, Update and Delete
761
+
762
+ ```ruby
763
+ paystack.transfer_recipients.list(per_page: 20, page: 2)
764
+
765
+ # fetch, update and delete take the recipient code or the numeric ID
766
+ paystack.transfer_recipients.fetch(id_or_code: "RCP_2x5j67tnnw1t98k")
767
+ paystack.transfer_recipients.update(id_or_code: "RCP_2x5j67tnnw1t98k", name: "Ama K. Mensah", email: "ama@example.com")
768
+ paystack.transfer_recipients.delete(id_or_code: "RCP_2x5j67tnnw1t98k") # Paystack sets the recipient to inactive
769
+ ```
770
+
771
+ Paystack's docs list `from` and `to` filters on the list endpoint, but the API ignores them (checked against the test API), so the SDK does not offer them. Cursor pagination is available with `use_cursor: true`, `next_cursor:` and `previous:`.
772
+
773
+ ### Transfers
774
+
775
+ Transfers send money from your Paystack balance to a transfer recipient (`RCP_...`). They move real money in live mode, so the SDK never retries them except on `429` (see [Timeouts and Retries](#timeouts-and-retries)).
776
+
777
+ #### Initiate a Transfer
778
+
779
+ ```ruby
780
+ # Amount is in the smallest currency unit (kobo, pesewas, cents).
781
+ # reference is required: generate it once and reuse it if you retry, so a retry cannot pay twice.
782
+ # Paystack documents 16 to 50 characters of lowercase letters, digits, - and _.
783
+ response = paystack.transfers.create(
784
+ source: "balance",
785
+ amount: 100_000,
786
+ recipient: "RCP_gd9vgag7n5lr5ix",
787
+ reference: "acv_9ee55786-2323-4760-98e2-6380c9cb3f68",
788
+ reason: "Bonus for the week"
789
+ )
790
+
791
+ if response.success?
792
+ case response.data.status
793
+ when "otp"
794
+ # Your integration requires an OTP: Paystack sent one to the business phone
795
+ paystack.transfers.finalize(transfer_code: response.data.transfer_code, otp: "928783")
796
+ else
797
+ puts "Transfer #{response.data.transfer_code} is #{response.data.status}"
798
+ end
799
+ else
800
+ puts "Error: #{response.error_message}"
801
+ end
802
+ ```
803
+
804
+ #### Verify, Fetch and List Transfers
805
+
806
+ ```ruby
807
+ # Verify by your reference
808
+ paystack.transfers.verify(reference: "acv_9ee55786-2323-4760-98e2-6380c9cb3f68")
809
+
810
+ # Fetch by transfer ID or code
811
+ paystack.transfers.fetch(id_or_code: "TRF_v5tip3zx8nna9o78")
812
+
813
+ # List, with Paystack's page pagination (default 50 per page)...
814
+ paystack.transfers.list(per_page: 20, page: 2, from: "2025-01-01", to: "2025-04-30")
815
+
816
+ # ...filtered by the numeric recipient ID, or by status
817
+ paystack.transfers.list(recipient: 56824902, status: "success")
818
+
819
+ # ...or with cursor pagination: pass response.meta.next back as next_cursor
820
+ paystack.transfers.list(use_cursor: true, per_page: 20)
821
+
822
+ # Export transfers (in Paystack's OpenAPI spec, not on its docs page)
823
+ paystack.transfers.export(from: "2025-01-01", to: "2025-04-30", status: "success")
824
+ ```
825
+
826
+ #### Bulk Transfers and the OTP Requirement
827
+
828
+ ```ruby
829
+ # Bulk transfers need the OTP requirement disabled. Each transfer uses Paystack's field names.
830
+ paystack.transfers.bulk_create(
831
+ source: "balance",
832
+ currency: "NGN",
833
+ transfers: [
834
+ {amount: 20_000, recipient: "RCP_gd9vgag7n5lr5ix", reference: "acv_2627bbfe-1a2a-4a1a-8d0e-9d2ee6c31496", reason: "Bonus"},
835
+ {amount: 35_000, recipient: "RCP_zpk2tgagu6lgb4g", reference: "acv_1bd0c1f8-78c2-463b-8bd4-ed9eeb36be50", reason: "Bonus"}
836
+ ]
837
+ )
838
+
839
+ # Turn the OTP requirement off (Paystack sends an OTP to the business phone), then confirm it
840
+ paystack.transfers.disable_otp
841
+ paystack.transfers.finalize_disable_otp(otp: "928783")
842
+
843
+ # Turn it back on
844
+ paystack.transfers.enable_otp
845
+
846
+ # Resend the OTP for a transfer awaiting one
847
+ paystack.transfers.resend_otp(transfer_code: "TRF_vsyqdmlzble3uii", reason: "resend_otp")
848
+ ```
849
+
850
+ ### Refunds
851
+
852
+ Refunds return money from a successful transaction to the customer. They move real money in live mode, so the SDK never retries them except on `429` (see [Timeouts and Retries](#timeouts-and-retries)).
853
+
854
+ #### Create a Refund
855
+
856
+ ```ruby
857
+ # transaction is the transaction's reference or its numeric ID.
858
+ # amount is in the smallest currency unit (kobo, pesewas, cents) and cannot exceed the transaction amount.
859
+ # Leave amount out to refund the whole transaction; pass less for a partial refund.
860
+ response = paystack.refunds.create(
861
+ transaction: "T685312322670591",
862
+ amount: 2_500,
863
+ currency: "GHS", # optional; Paystack refuses one that differs from the transaction's
864
+ customer_note: "Duplicate payment",
865
+ merchant_note: "Refunded by the finance team"
866
+ )
867
+
868
+ if response.success?
869
+ puts "Refund #{response.data.id} is #{response.data.status}" # "pending" until Paystack processes it
870
+ else
871
+ puts "Error: #{response.error_message}" # e.g. "Transaction not found"
872
+ end
873
+
874
+ # A full refund by transaction ID
875
+ paystack.refunds.create(transaction: 1_004_723_697)
876
+ ```
877
+
878
+ #### Fetch, List and Retry Refunds
879
+
880
+ ```ruby
881
+ paystack.refunds.fetch(id: 18_625_648)
882
+
883
+ # List, with page pagination (default 50 per page) and a date range...
884
+ paystack.refunds.list(per_page: 20, page: 1, from: "2025-01-01", to: "2025-04-30")
885
+
886
+ # ...or the refunds of one transaction, by its numeric ID (a reference returns none)
887
+ paystack.refunds.list(transaction_id: 1_004_723_697)
888
+
889
+ # Retry a refund with the needs-attention status by giving the customer's bank account
890
+ paystack.refunds.retry_with_customer_details(
891
+ id: 18_625_648,
892
+ refund_account_details: {currency: "GHS", account_number: "0123456789", bank_id: "9"}
893
+ )
894
+ ```
895
+
896
+ Paystack's docs also list a `currency` filter on the list endpoint, but the test API returned the same refunds for every currency, so the SDK does not offer it.
897
+
468
898
  ### Response Handling
469
899
 
470
900
  #### Working with Response Objects
@@ -472,7 +902,7 @@ end
472
902
  All API requests return a `PaystackSdk::Response` object that provides easy access to the response data.
473
903
 
474
904
  ```ruby
475
- response = paystack.transactions.initiate(params)
905
+ response = paystack.transactions.initiate(**params)
476
906
 
477
907
  # Check if the request was successful
478
908
  response.success? # => true or false
@@ -490,6 +920,12 @@ response.authorization_url # Same as response.data.authorization_url
490
920
  # Access nested data
491
921
  response.data.customer.email
492
922
 
923
+ # Hash-style access works with strings or symbols
924
+ response[:reference]
925
+ response["reference"]
926
+ response[:customer][:email]
927
+ response.key?(:reference) # => true
928
+
493
929
  # For arrays, use array methods
494
930
  response.data.first # First item in an array
495
931
  response.data.last # Last item in an array
@@ -501,6 +937,21 @@ response.data.each do |item|
501
937
  end
502
938
  ```
503
939
 
940
+ #### Pagination Metadata
941
+
942
+ List responses carry Paystack's pagination details in `meta`:
943
+
944
+ ```ruby
945
+ response = paystack.transactions.list(per_page: 20)
946
+
947
+ response.meta.total # => 40
948
+ response.meta.page # => 1
949
+ response.meta.pageCount # => 2
950
+ response.meta.perPage # => 20
951
+ ```
952
+
953
+ `response.meta` is `nil` when the response has no `meta`.
954
+
504
955
  #### Accessing the Original Response
505
956
 
506
957
  Sometimes you may need access to the original API response:
@@ -511,9 +962,8 @@ response = paystack.transactions.list
511
962
  # Access the original response body
512
963
  original = response.original_response
513
964
 
514
- # Access metadata from the original response
515
- total_count = original.dig("meta", "total")
516
- current_page = original.dig("meta", "page")
965
+ # Anything else in the body is still reachable
966
+ original.dig("meta", "total")
517
967
  ```
518
968
 
519
969
  #### Exception Handling
@@ -526,9 +976,14 @@ begin
526
976
  rescue PaystackSdk::AuthenticationError => e
527
977
  puts "Authentication failed: #{e.message}"
528
978
  rescue PaystackSdk::RateLimitError => e
529
- puts "Rate limit exceeded. Retry after: #{e.retry_after} seconds"
979
+ # retry_after is nil when Paystack did not send x-ratelimit-reset
980
+ puts "Rate limit exceeded. Retry after: #{e.retry_after || "unknown"} seconds"
530
981
  rescue PaystackSdk::ServerError => e
531
982
  puts "Server error: #{e.message}"
983
+ rescue PaystackSdk::TimeoutError, PaystackSdk::ConnectionError => e
984
+ # For writes (e.g. creating a transfer) the request may still have been processed:
985
+ # verify by your own reference before retrying
986
+ puts "Could not complete the request: #{e.message}"
532
987
  rescue PaystackSdk::APIError => e
533
988
  puts "API error: #{e.message}"
534
989
  rescue PaystackSdk::Error => e
@@ -566,36 +1021,39 @@ The SDK includes several specific error classes:
566
1021
  - **`PaystackSdk::RateLimitError`** - Rate limiting encountered
567
1022
  - **`PaystackSdk::ServerError`** - Server errors (5xx responses)
568
1023
 
1024
+ - **`PaystackSdk::ConnectionError`** - Could not reach Paystack (DNS, refused connection, TLS) after retries
1025
+ - **`PaystackSdk::TimeoutError`** - The request timed out after retries
1026
+
569
1027
  ##### Validation Error Examples
570
1028
 
571
1029
  The SDK validates your input data **before** making API calls and will throw exceptions immediately if required data is missing or incorrectly formatted:
572
1030
 
573
1031
  ```ruby
574
- # Missing required parameter
1032
+ # Missing required keyword
575
1033
  begin
576
- paystack.transactions.initiate({amount: 1000}) # Missing email
577
- rescue PaystackSdk::MissingParamError => e
578
- puts e.message # => "Missing required parameter: email"
1034
+ paystack.transactions.initiate(amount: 1000) # Missing email
1035
+ rescue ArgumentError => e
1036
+ puts e.message # => "missing keyword: :email"
579
1037
  end
580
1038
 
581
1039
  # Invalid format
582
1040
  begin
583
- paystack.transactions.initiate({
1041
+ paystack.transactions.initiate(
584
1042
  email: "invalid-email", # Not a valid email format
585
1043
  amount: 1000
586
- })
1044
+ )
587
1045
  rescue PaystackSdk::InvalidFormatError => e
588
1046
  puts e.message # => "Invalid format for Email. Expected format: valid email address"
589
1047
  end
590
1048
 
591
1049
  # Invalid value
592
1050
  begin
593
- paystack.customers.set_risk_action({
1051
+ paystack.customers.set_risk_action(
594
1052
  customer: "CUS_123",
595
1053
  risk_action: "invalid_action" # Not in allowed values
596
- })
1054
+ )
597
1055
  rescue PaystackSdk::InvalidValueError => e
598
- puts e.message # => "Invalid value for risk_action: must be one of [default, allow, deny]"
1056
+ puts e.message # => "Invalid value for risk_action: must be one of: allow, deny, default"
599
1057
  end
600
1058
  ```
601
1059
 
@@ -603,6 +1061,68 @@ These validation errors are thrown immediately and prevent the API call from bei
603
1061
 
604
1062
  ## Advanced Usage
605
1063
 
1064
+ ### Timeouts and Retries
1065
+
1066
+ Connections built by the SDK time out and retry transient failures by default:
1067
+
1068
+ ```ruby
1069
+ client = PaystackSdk::Client.new(
1070
+ secret_key: "sk_test_xxx",
1071
+ timeout: 30, # seconds to wait for a response
1072
+ open_timeout: 5, # seconds to wait for the connection to open
1073
+ max_retries: 2, # retries after the first attempt (0 disables retrying)
1074
+ retry_interval: 0.5 # base backoff in seconds, doubled on each retry with jitter
1075
+ )
1076
+ ```
1077
+
1078
+ - Read-only requests (`GET`) are retried on network failures and `429`/`502`/`503`/`504` responses.
1079
+ - Anything that writes (`POST`, `PUT`, `DELETE`) is retried **only on `429`**, where Paystack rejected the
1080
+ request for exceeding the rate limit. A timeout or `5xx` on a write may mean Paystack processed it, so it is
1081
+ not retried. Pass `retry_non_idempotent: true` only if you deduplicate yourself (e.g. verify by reference first).
1082
+ - On a `429` the SDK waits for Paystack's `x-ratelimit-reset` header (seconds) before retrying. If the wait
1083
+ exceeds 10 seconds it stops retrying and raises `PaystackSdk::RateLimitError` (`#retry_after` holds the value).
1084
+ - Errors that survive all retries are raised as `PaystackSdk::RateLimitError`, `PaystackSdk::ServerError`,
1085
+ `PaystackSdk::TimeoutError` or `PaystackSdk::ConnectionError`, all of which inherit from `PaystackSdk::Error`.
1086
+ - These options only apply to connections the SDK creates. Passing them together with your own `Faraday::Connection` raises `ArgumentError`; configure your connection yourself. Invalid values (e.g. a negative `max_retries`) also raise `ArgumentError`.
1087
+
1088
+ ### Webhooks
1089
+
1090
+ Paystack tells you about changes (a charge succeeded, a refund was processed, a transfer failed) by POSTing events to your webhook URL. `PaystackSdk::Webhook` verifies and parses them, and works with any framework.
1091
+
1092
+ Paystack signs each event: the `x-paystack-signature` header is a lowercase hex HMAC SHA512 of the **raw request body**, using your secret key. Verify it before using the event.
1093
+
1094
+ ```ruby
1095
+ # Rails controller (skip CSRF protection for this action)
1096
+ def create
1097
+ event = PaystackSdk::Webhook.construct_event(
1098
+ payload: request.raw_post, # raw body, not params
1099
+ signature: request.headers["X-Paystack-Signature"],
1100
+ secret: ENV.fetch("PAYSTACK_SECRET_KEY")
1101
+ )
1102
+
1103
+ HandlePaystackEventJob.perform_later(event.payload) # do the work in the background
1104
+ head :ok # acknowledge quickly
1105
+ rescue PaystackSdk::WebhookError
1106
+ head :bad_request
1107
+ end
1108
+ ```
1109
+
1110
+ ```ruby
1111
+ event.event # => "charge.success"
1112
+ event.data.reference # data is wrapped like any Response
1113
+ event.data[:amount]
1114
+ event.known? # => true if Paystack documents this event name
1115
+ event.payload # the parsed JSON Hash (string keys), handy for queuing a job
1116
+ ```
1117
+
1118
+ - `Webhook.valid_signature?(payload:, signature:, secret:)` returns a boolean, and `Webhook.verify!` raises `PaystackSdk::InvalidSignatureError`. The comparison is constant-time.
1119
+ - The payload must be the exact bytes received. A parsed or re-serialised body will not match, and a non-String payload raises `ArgumentError`. A blank secret also raises, rather than checking against nothing.
1120
+ - `Webhook.construct_event` verifies first and parses only afterwards. A signed body that is not a JSON event raises `PaystackSdk::InvalidPayloadError`. Events Paystack adds later still come back, with `known?` false.
1121
+ - `Webhook.trusted_ip?(ip)` checks the three addresses Paystack documents (`Webhook::IP_ADDRESSES`). Use it as an extra check, not instead of the signature.
1122
+ - `Webhook.sign(payload, secret)` produces a valid signature, for testing your own endpoint.
1123
+ - Paystack retries events your server does not acknowledge with a `200 OK`: in live mode every 3 minutes for 4 tries, then hourly for 72 hours. Return `200` fast, process in a background job, and make handlers safe to run more than once.
1124
+ - Before giving value for a `charge.success`, confirm it with `transactions.verify(reference:)`, as Paystack recommends.
1125
+
606
1126
  ### Environment Variables
607
1127
 
608
1128
  You can use environment variables to configure the SDK:
@@ -641,11 +1161,29 @@ For more detailed documentation on specific resources, please refer to the follo
641
1161
  - [Customers](https://paystack.com/docs/api/customer/)
642
1162
  - [Plans](https://paystack.com/docs/api/plan/)
643
1163
  - [Subscriptions](https://paystack.com/docs/api/subscription/)
1164
+ - [Payment Channels: Mobile Money](https://paystack.com/docs/payments/payment-channels/#mobile-money)
644
1165
 
645
1166
  ## Development
646
1167
 
647
1168
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
648
1169
 
1170
+ ### Style and Linting
1171
+
1172
+ This project uses [StandardRB](https://github.com/standardrb/standard) for code style and linting.
1173
+
1174
+ Add to your Gemfile (if not already present):
1175
+
1176
+ ```ruby
1177
+ gem "standard"
1178
+ ```
1179
+
1180
+ - Lint: `bundle exec standardrb`
1181
+ - Auto-fix: `bundle exec standardrb --fix`
1182
+ - Via Rake: `bundle exec rake standard`
1183
+ - Default task (runs specs + standard): `bundle exec rake`
1184
+
1185
+ If you encounter cache permission issues locally, you can disable caching: `bundle exec standardrb --no-cache`.
1186
+
649
1187
  ### Testing
650
1188
 
651
1189
  The SDK includes comprehensive test coverage with consistent response format handling. All test specifications use string keys with hashrocket notation (`=>`) to match the actual format returned by the Paystack API:
@@ -667,7 +1205,7 @@ Tests also validate specific error types to ensure proper exception handling:
667
1205
 
668
1206
  ```ruby
669
1207
  # Testing specific error types
670
- expect { customers.set_risk_action(invalid_params) }
1208
+ expect { customers.set_risk_action(customer: "CUS_123", risk_action: "block") }
671
1209
  .to raise_error(PaystackSdk::InvalidValueError, /risk_action/i)
672
1210
  ```
673
1211