paystack_sdk 0.4.1 → 0.5.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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hand-written from Paystack's docs (https://paystack.com/docs/api/webhook-events/). These operations are not in
4
+ # Paystack's OpenAPI spec, so the scaffold cannot generate them; they are recorded, with what was and was not
5
+ # observed, in spec/fixtures/docs_only_operations.yml.
6
+
7
+ require_relative "base"
8
+
9
+ module PaystackSdk
10
+ module Resources
11
+ # Webhook Events API: the log of webhooks Paystack has sent to your integration's webhook URL, with their
12
+ # delivery status, the payload that was sent and what your endpoint answered, and a way to send them
13
+ # again. Use it to find out whether a webhook was delivered, to see exactly what Paystack sent, and to
14
+ # recover events your endpoint missed.
15
+ #
16
+ # `list`, `lookup` and `fetch` only read. `resend` and `resend_matching` make Paystack deliver webhooks
17
+ # to your endpoint again, and have **not** been called against the Paystack test API.
18
+ class WebhookEvents < PaystackSdk::Resources::Base
19
+ # Delivery statuses the API accepts for the `status` filter (anything else is a 400).
20
+ STATUSES = %w[Delivered Pending Failed].freeze
21
+
22
+ # The filters List Events takes, which Resend Matching Events accepts too.
23
+ FILTERS = %w[category event_type status category_row_id from to].freeze
24
+
25
+ # The most event ids Paystack accepts in one resend.
26
+ MAX_RESEND_IDS = 100
27
+
28
+ # Ruby keyword => the parameter name Paystack documents.
29
+ WIRE_NAMES = {next_cursor: "next", id: "reference"}.freeze
30
+
31
+ # List Events.
32
+ #
33
+ # Fetches a page of the webhook events sent to your integration, newest first. The response `meta` holds
34
+ # `next` and `previous` cursors: pass `meta.next` back as `next_cursor:` for the next page.
35
+ #
36
+ # Observed on the Paystack test API (2026-10-09), where the docs differ: a page holds 50 events by
37
+ # default (the docs say 20), `limit` is capped at 50, and a `limit` that is not a number is ignored.
38
+ #
39
+ # @param category [String] Filter by category, for example "transactions" or "refund".
40
+ # @param event_type [String] Filter by event name, for example "charge.success".
41
+ # @param status [String] Filter by delivery status: Delivered, Pending or Failed.
42
+ # @param category_row_id [String, Integer] Filter by the ID of the resource the event is about, such as a
43
+ # transaction ID.
44
+ # @param from [String, Time, Date] Only events from this time on, for example 2016-09-24T00:00:05.000Z or 2016-09-21.
45
+ # @param to [String, Time, Date] Only events up to this time.
46
+ # @param limit [Integer] How many events to return per page.
47
+ # @param next_cursor [String] The `meta.next` cursor of a previous response (sent as `next`). Not together with `previous`.
48
+ # @param previous [String] The `meta.previous` cursor of a previous response. Not together with `next_cursor`.
49
+ # @return [PaystackSdk::Response] The response from the Paystack API.
50
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
51
+ # @see https://paystack.com/docs/api/webhook-events/#list
52
+ def list(category: nil, event_type: nil, status: nil, category_row_id: nil, from: nil, to: nil, limit: nil,
53
+ next_cursor: nil, previous: nil)
54
+ validate_allowed_values!(value: status, allowed_values: STATUSES, name: "status")
55
+ validate_positive_integer!(value: limit, name: "limit")
56
+ reject_both_cursors!(next_cursor, previous)
57
+
58
+ wire_query = to_wire(
59
+ {
60
+ category:, event_type:, status:, category_row_id:,
61
+ from: format_datetime(from, name: "from"),
62
+ to: format_datetime(to, name: "to"),
63
+ limit:, next_cursor:, previous:
64
+ },
65
+ WIRE_NAMES
66
+ )
67
+
68
+ handle_response(@connection.get("/integration/webhooks/events", wire_query))
69
+ end
70
+
71
+ # Look Up an Event.
72
+ #
73
+ # Finds a webhook event by its own ID, or by the ID of the resource it is about (a transaction ID, not
74
+ # the transaction's reference). Observed on the test API: an event's `_id` and a `category_row_id`
75
+ # both match; a value that matches nothing is a 404 "Webhook not found" (an unsuccessful Response).
76
+ #
77
+ # @param id [String, Integer] An event `_id`, or the ID of the resource it is about. Sent as `reference`, the name
78
+ # Paystack's docs give it, although it is not a reference.
79
+ # @return [PaystackSdk::Response] The response from the Paystack API.
80
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
81
+ # @see https://paystack.com/docs/api/webhook-events/#lookup
82
+ def lookup(id:)
83
+ validate_presence!(value: id, name: "id")
84
+
85
+ handle_response(@connection.get("/integration/webhooks/events/lookup", to_wire({id:}, WIRE_NAMES)))
86
+ end
87
+
88
+ # Fetch Event.
89
+ #
90
+ # Gets one event in full: `event_payload` (the body Paystack sent), `webhook_url`, `status`,
91
+ # `status_detail`, `response_code`, `merchant_response_body` (what your endpoint answered), `trial_count`
92
+ # and `trials`. Observed on the test API, where `event_payload` is a Hash with the keys `event` and
93
+ # `data`. Read the payload's own `data` with brackets, `response.event_payload[:data]`: dot access
94
+ # `event_payload.data` returns the Response itself, because `Response#data` is a method.
95
+ #
96
+ # @param id [String] The event's `_id`, from an item of {#list}.
97
+ # @return [PaystackSdk::Response] The response from the Paystack API.
98
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
99
+ # @see https://paystack.com/docs/api/webhook-events/#fetch
100
+ def fetch(id:)
101
+ validate_presence!(value: id, name: "id")
102
+
103
+ handle_response(@connection.get("/integration/webhooks/events/#{escape_path(id, name: "id")}"))
104
+ end
105
+
106
+ # Resend Events.
107
+ #
108
+ # Asks Paystack to deliver a specific set of events to your current webhook URL again.
109
+ #
110
+ # @note This makes Paystack send webhooks to your endpoint. The docs say resending is not idempotent:
111
+ # sending the same event ID twice delivers it twice, so your endpoint must dedupe. It has not been
112
+ # called against the Paystack test API; the method follows the docs.
113
+ #
114
+ # @param ids [Array<String>] The `_id`s of the events to resend: not empty, at most 100.
115
+ # @return [PaystackSdk::Response] The response from the Paystack API.
116
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
117
+ # @see https://paystack.com/docs/api/webhook-events/#resend
118
+ def resend(ids:)
119
+ unless ids.is_a?(Array) && !ids.empty? && ids.size <= MAX_RESEND_IDS && ids.all? { |id| id.is_a?(String) && !id.strip.empty? }
120
+ raise PaystackSdk::InvalidValueError.new("ids", "must be a non-empty Array of event _id strings, at most #{MAX_RESEND_IDS}")
121
+ end
122
+
123
+ handle_response(@connection.post("/integration/webhooks/events/resend", {ids: ids}))
124
+ end
125
+
126
+ # Resend Matching Events.
127
+ #
128
+ # Asks Paystack to deliver every event that matches the filters to your current webhook URL again.
129
+ # With `preview: true` it only counts the matching events.
130
+ #
131
+ # `preview:` has no default here, although the docs make it optional: with no filters this resends
132
+ # every event, so you have to say which you mean. Call it with `preview: true` first.
133
+ #
134
+ # @note This can make Paystack send a lot of webhooks to your endpoint, which must dedupe. It has not
135
+ # been called against the Paystack test API, including with `preview: true`; the method follows
136
+ # the docs.
137
+ #
138
+ # @param preview [Boolean] true to return the matching count without resending anything; false to resend.
139
+ # @param filters [Hash, nil] Any of the {#list} filters: category, event_type, status, category_row_id, from, to.
140
+ # @return [PaystackSdk::Response] The response from the Paystack API.
141
+ # @raise [PaystackSdk::Error] If a parameter is invalid or the API request fails.
142
+ # @see https://paystack.com/docs/api/webhook-events/#resend-matching
143
+ def resend_matching(preview:, filters: nil)
144
+ unless [true, false].include?(preview)
145
+ raise PaystackSdk::InvalidValueError.new("preview", "must be true (count only) or false (resend)")
146
+ end
147
+
148
+ body = filters.nil? ? {} : {filters: checked_filters(filters)}
149
+ handle_response(@connection.post("/integration/webhooks/events/resend-matching?preview=#{preview}", body))
150
+ end
151
+
152
+ private
153
+
154
+ # `next` and `previous` together are a 400 from the API ("must be one of next or previous").
155
+ def reject_both_cursors!(next_cursor, previous)
156
+ return unless next_cursor && previous
157
+
158
+ raise PaystackSdk::InvalidValueError.new("next_cursor", "cannot be used together with previous")
159
+ end
160
+
161
+ def checked_filters(filters)
162
+ validate_hash!(input: filters, name: "filters")
163
+ stringified = filters.transform_keys(&:to_s)
164
+ unknown = stringified.keys - FILTERS
165
+ raise PaystackSdk::InvalidValueError.new("filters", "unknown filter(s) #{unknown.join(", ")}; allowed: #{FILTERS.join(", ")}") unless unknown.empty?
166
+
167
+ validate_allowed_values!(value: stringified["status"], allowed_values: STATUSES, name: "filters status")
168
+ stringified["from"] = format_datetime(stringified["from"], name: "filters from") if stringified.key?("from")
169
+ stringified["to"] = format_datetime(stringified["to"], name: "filters to") if stringified.key?("to")
170
+ compact_params(stringified)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -248,6 +248,31 @@ module PaystackSdk
248
248
  end
249
249
  end
250
250
 
251
+ # Reads a nested value from the data, or nil as soon as a key is missing. Use it for fields Paystack
252
+ # only sometimes sends (`paid_at`, `authorization.exp_month`...): dot access raises `NoMethodError`
253
+ # for a key that is not there, which is what you want for a typo and not for an optional field.
254
+ # Keys can be strings or symbols; an Integer indexes an Array. The value is returned as it is (a
255
+ # plain Hash, Array or scalar), like `Hash#dig`.
256
+ #
257
+ # @param keys [Array<String, Symbol, Integer>] The path to the value
258
+ # @return [Object, nil] The value, or nil if any key along the path is missing
259
+ #
260
+ # @example
261
+ # response.dig(:authorization, :authorization_code) # => "AUTH_..." or nil
262
+ def dig(*keys)
263
+ raise ArgumentError, "dig needs at least one key" if keys.empty?
264
+
265
+ keys.reduce(@raw_data) do |node, key|
266
+ value = case node
267
+ when Hash then [key, key.to_s, key.to_s.to_sym].find { |candidate| node.key?(candidate) }&.then { |found| node[found] }
268
+ when Array then key.is_a?(Integer) ? node[key] : nil
269
+ end
270
+ return nil if value.nil?
271
+
272
+ value
273
+ end
274
+ end
275
+
251
276
  # Check if key exists in hash (as a string or a symbol)
252
277
  #
253
278
  # @param key [Symbol, String] The key to check
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: paystack-sdk-charge-statuses
3
+ description: 'Use when code starts a payment with the Charge API (card or mobile money) and must react to what comes back: a status such as send_pin, send_otp, send_phone, pay_offline, pending, failed or success, deciding what to show the payer, and which paystack_sdk call to make next. Also use when a charge call reports success? but the payment did not go through.'
4
+ ---
5
+
6
+ # Charge statuses: what to show, what to call next
7
+
8
+ The Charge API (`client.charges`) does not finish in one call when the payer must prove something. Each response carries `data.status`, and that status tells you what the payer must do and which call comes next. Read it with `response.status` or `response.status?(:send_pin)`.
9
+
10
+ ## The one trap: `success?` is not "the charge worked"
11
+
12
+ `response.success?` means Paystack accepted the *call*. It says nothing about whether the *charge* succeeded. Always look at `data.status`:
13
+
14
+ | What you did | `success?` | `data.status` |
15
+ |---|---|---|
16
+ | `charges.create`/`mobile_money` with a phone number that is too short | `true` (HTTP 200, message "Charge attempted") | `failed` |
17
+ | `charges.check_pending` on a charge that failed | `true` (HTTP 200) | `failed` |
18
+ | `charges.submit_pin` / `submit_otp` with a wrong PIN or OTP | `false` (HTTP 400) | `failed` |
19
+
20
+ So decide with the status, and give value only with `transactions.verify(reference:).paid?(amount:, currency:)` (see the rules in [[paystack-sdk-overview]]).
21
+
22
+ ## Status table
23
+
24
+ | `data.status` | Meaning | Show the payer | Next call |
25
+ |---|---|---|---|
26
+ | `success` | The charge went through | Confirmation | `transactions.verify(reference:)` then `paid?(amount:, currency:)` before giving value |
27
+ | `send_pin` | The card needs its PIN | A PIN field | `charges.submit_pin(pin:, reference:)` |
28
+ | `send_otp` | An OTP is needed | An OTP field, with `data.display_text` | `charges.submit_otp(otp:, reference:)` |
29
+ | `send_phone` | A mobile number is needed | A phone field, with `data.display_text` | `charges.submit_phone(phone:, reference:)` |
30
+ | `send_birthday` | A date of birth is needed (the bank flow; display text "Please enter your birthday") | A date field | `charges.submit_birthday(birthday:, reference:)` |
31
+ | `send_address` (name not confirmed) | A billing address is needed | Address, city, state and zip code | `charges.submit_address(address:, city:, state:, zip_code:, reference:)` |
32
+ | `pay_offline` | The payer approves on their phone or by USSD | `data.display_text` (and any USSD code in the response) | Nothing to submit. Wait for the `charge.success` webhook, then verify. If nothing arrives, poll `charges.check_pending(reference:)` or verify |
33
+ | `pending` (name not confirmed) | Paystack is still processing | "Processing" | `charges.check_pending(reference:)` until it is not `pending`, or wait for the webhook |
34
+ | `open_url` (EFT, South Africa) | The payer must finish on the provider's page | Redirect to `data.url` | Nothing to submit. Wait for the webhook, then verify |
35
+ | `failed` | The charge is dead | The reason, `data.message` | Start a **new** charge with a **new** `reference` |
36
+
37
+ Always pass the same `reference` you started the charge with to each follow-up call.
38
+
39
+ ## What was observed on Paystack's test API (2026-10-09, test cards from Paystack's test-payments page)
40
+
41
+ These flows ran for real with `reference`s of our own, and each ended with `transactions.verify(...).paid?` true and a reusable authorization:
42
+
43
+ - Card `4084 0840 8408 4081`, expiry `09/27`, CVV `408` (no validation): `success` at once.
44
+ - Card `5078 5078 5078 5078 12`, CVV `081`, PIN `1111`: `send_pin` -> `submit_pin` -> `success`.
45
+ - Card `5060 6666 6666 6666 666`, CVV `123`, PIN `1234`, OTP `123456`: `send_pin` -> `submit_pin` -> `send_otp` (display text "Please enter OTP (none will be sent to your phone)") -> `submit_otp` -> `success`.
46
+ - Card `5078 5078 5078 5078 04`, CVV `884`, PIN `0000`, OTP `123456`: `send_pin` -> `send_phone` (display text "Kindly enter a mobile no (at least 10 digits)") -> `send_otp` -> `success`.
47
+ - A wrong PIN or OTP returned HTTP 400, `data.status` `failed`, `data.message` "Incorrect PIN" or "Token Authorization Not Successful...". **The charge is dead after one wrong entry**: entering the correct OTP afterwards still failed. Start over with a new reference.
48
+ - `submit_otp` with a reference Paystack does not know: HTTP 400, message "Transaction reference is invalid", no `data.status`.
49
+
50
+ Not reproduced here: `send_birthday`, `pay_offline` and `open_url` appear in Paystack's payment-channels guide (the bank flow, mobile money and EFT) but a test charge never produced them (a mobile money charge answers `success` at once in test mode). `send_address` and `pending` are not named in the docs we read: the SDK has `submit_address` and `check_pending`, so the statuses are expected, but their exact names are not confirmed. Treat all of these as unverified. The SDK's `charges.create` has no `card:` keyword; card details go through Paystack's card API, which is only for PCI-compliant businesses. The test cards above are for Paystack's test mode only.
51
+
52
+ ## A loop that handles every status
53
+
54
+ ```ruby
55
+ # `response` is the result of charges.create / charges.mobile_money, and `reference` is the one you chose.
56
+ response = client.charges.mobile_money(email: email, amount: 5000, currency: "GHS", reference: reference,
57
+ mobile_money: {phone: "0551234987", provider: "mtn"})
58
+
59
+ case response.status
60
+ when "success"
61
+ # still verify by reference before giving value
62
+ when "pay_offline", "pending"
63
+ # show response.display_text (when present) and wait for the charge.success webhook
64
+ when "send_pin"
65
+ response = client.charges.submit_pin(pin: pin_from_payer, reference: reference)
66
+ when "send_otp"
67
+ response = client.charges.submit_otp(otp: otp_from_payer, reference: reference)
68
+ when "send_phone"
69
+ response = client.charges.submit_phone(phone: phone_from_payer, reference: reference)
70
+ when "failed"
71
+ reason = response.original_response.dig("data", "message") # never retry the same reference
72
+ else
73
+ raise "unhandled charge status: #{response.status.inspect}" # fail loudly, do not guess
74
+ end
75
+ ```
76
+
77
+ Collect the payer's input between steps; do not loop without them. The `else` branch matters: Paystack can return statuses this skill does not list.
78
+
79
+ ## Rules
80
+
81
+ - **Branch on `data.status`, not on `success?`.**
82
+ - **One reference per charge attempt.** A `failed` charge cannot be resumed; the retry is a new charge with a new reference.
83
+ - **Never retry a charge call after a timeout** (the SDK does not either). First look it up: `client.transactions.verify(reference: reference)`.
84
+ - **Store nothing the payer typed.** PINs and OTPs go straight to Paystack and are never logged or saved.
85
+ - **Fail loudly on an unknown status**, and log the status and reference so it can be handled. The names `send_address` and `pending` are the likeliest to differ from the table.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: paystack-sdk-mobile-money
3
+ description: 'Use when charging a mobile money wallet in Ghana (MTN, Telecel, AT Money/AirtelTigo) with paystack_sdk, or any provider that uses charges.mobile_money: choosing the provider code and the phone number format, what the payer sees, how long the payer has, how to learn the result, and what test mode does not prove.'
4
+ ---
5
+
6
+ # Mobile money charges (Ghana first)
7
+
8
+ A mobile money charge is **merchant-started and payer-approved**: you ask Paystack to charge a wallet, the payer gets a prompt on their phone and approves it there, and the result arrives later. Your code must expect "not finished yet" as the normal answer. Statuses and next calls are in [[paystack-sdk-charge-statuses]]; this skill is the mobile money specifics.
9
+
10
+ ## Start a charge
11
+
12
+ ```ruby
13
+ response = client.charges.mobile_money(
14
+ email: "ama@example.com",
15
+ amount: 5000, # pesewas: GHS 50.00
16
+ currency: "GHS",
17
+ reference: reference, # create and store it first
18
+ mobile_money: {phone: "0551234987", provider: "mtn"}
19
+ )
20
+ ```
21
+
22
+ `charges.mobile_money` checks the `mobile_money` object (a phone, or an `account` for an M-PESA Till; a known provider in any case, sent in lowercase) and then calls `charges.create`. It does not change the hash you pass in.
23
+
24
+ ## Provider codes
25
+
26
+ | Network (Ghana) | `provider` | Code in `banks.list(country: "ghana", type: "mobile_money")` |
27
+ |---|---|---|
28
+ | MTN | `mtn` | `MTN` |
29
+ | Telecel (formerly Vodafone) | `vod` | `VOD` (Paystack's list still names it "Vodafone") |
30
+ | AT Money / Airtel Money (AirtelTigo) | `atl` | `ATL` |
31
+
32
+ The charge takes the lowercase provider code; the bank list returns uppercase codes. The SDK accepts either case for `mobile_money:`. Network brand names change (Telecel Cash, AT Money); the codes above are what Paystack uses. Other providers `charges.mobile_money` accepts: `mpesa`, `mpesa_offline`, `mptill` (Kenya), `orange`, `wave` (Côte d'Ivoire).
33
+
34
+ ## The phone number
35
+
36
+ Paystack's Ghana example uses the **local ten-digit form**, `0551234987`. If you store numbers in E.164 (`+233551234987`), convert before calling:
37
+
38
+ ```ruby
39
+ def local_ghana_number(phone)
40
+ digits = phone.to_s.gsub(/\D/, "") # drop +, spaces and dashes
41
+ digits = digits.delete_prefix("233") if digits.size == 12 # +233 551234987 -> 551234987
42
+ digits = "0#{digits}" if digits.size == 9 # 551234987 -> 0551234987
43
+ raise ArgumentError, "not a Ghana mobile number: #{phone.inspect}" unless digits.match?(/\A0\d{9}\z/)
44
+
45
+ digits
46
+ end
47
+
48
+ local_ghana_number("+233551234987") # => "0551234987"
49
+ local_ghana_number("0551234987") # => "0551234987"
50
+ ```
51
+
52
+ What was checked, and what it does not prove: on Paystack's **test** API, `0551234987`, `+233551234987`, `233551234987`, `551234987` and `055 123 4987` were all accepted and all answered `success`; `0551234` (too short) answered `failed`. Test mode is lenient, so it cannot tell you which form live mode requires. The docs' example for Kenya recommends the country code (`+254...`); for Ghana the docs show only the local form. Use the local form for Ghana and confirm the first live charge.
53
+
54
+ ## What happens next (live mode, per Paystack's docs)
55
+
56
+ 1. The response has `data.status` `pay_offline` and `data.display_text`. Show the text to the payer.
57
+ 2. The payer approves on their phone. **They have 180 seconds**; after that the charge fails (a network-provider limit).
58
+ 3. Paystack sends the `charge.success` webhook when it succeeds. Verify by reference before giving value.
59
+ 4. If no webhook arrives after 180 seconds, call `transactions.verify(reference:)` (or `charges.check_pending(reference:)`). The reason a charge failed is in `data.message`.
60
+
61
+ ```ruby
62
+ case response.status
63
+ when "pay_offline", "pending"
64
+ # show response.display_text, then wait for the webhook (or poll check_pending)
65
+ when "success"
66
+ # test mode answers like this at once; still verify
67
+ when "failed"
68
+ # response.original_response.dig("data", "message") says why; start a new charge, new reference
69
+ end
70
+ ```
71
+
72
+ ## What test mode does not prove
73
+
74
+ On the test API a mobile money charge for each of `mtn`, `vod` and `atl`, on Paystack's test number `0551234987`, returned `success` immediately and `check_pending` agreed. That means the call shapes work. It says **nothing** about live behaviour: whether a given network accepts a merchant-started prompt for a given wallet, whether `pay_offline` and its display text look as documented, or how long approval takes. Treat live mobile money as unverified until the first real charge, and keep a fallback (such as emailing a payment link) for a network that refuses.
75
+
76
+ ## Rules
77
+
78
+ - **Never give value on `pay_offline` or on the response alone.** Wait for the webhook and verify by reference with `paid?(amount:, currency:)`.
79
+ - **Create the `reference` before the call and store it.** After a timeout the charge may exist: verify the reference first, and never repeat the call blindly (the SDK does not retry writes after a timeout).
80
+ - **A new attempt is a new reference.** A `failed` charge is finished.
81
+ - **Check `data.status`, not `success?`** (see [[paystack-sdk-charge-statuses]]).
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: paystack-sdk-money-safety
3
+ description: 'Use before writing, reviewing or merging any code that moves or records money with the paystack_sdk gem: starting or confirming a payment, granting value after a webhook or return URL, charging a saved card, handling a timeout on a write, storing card or authorization data, logging or reporting errors that touch a Paystack client, choosing keys per environment, or reconciling payments. A checklist of rules, each checked against the gem and the Paystack test API, ending in a pre-merge table.'
4
+ ---
5
+
6
+ # Money safety checklist
7
+
8
+ Apply every rule below before you write payment code, and run the table at the end before you merge it. The gem's conventions (client, `Response`, errors) are in [[paystack-sdk-overview]].
9
+
10
+ ## 1. Amounts are integers in the minor unit
11
+
12
+ `5000` is GHS 50.00 (pesewas). Convert once, at the edge, from a decimal you trust: `(BigDecimal(price) * 100).to_i`. Never use a Float for money anywhere in your app.
13
+
14
+ What happens to a bad amount on `transactions.initiate`:
15
+
16
+ | Amount | The SDK | Paystack, if it got it (observed on the test API 2026-10-09) |
17
+ |---|---|---|
18
+ | `100.5` | raises `PaystackSdk::InvalidValueError`, nothing sent | HTTP 400 `"amount" must be an integer` |
19
+ | `100.0` | raises `InvalidValueError` | **accepted**, stored as 100 |
20
+ | `"100"` | raises `InvalidValueError` | **accepted**, stored as 100 |
21
+ | `"100.50"` | raises `InvalidValueError` | HTTP 400 `"amount" must be an integer` |
22
+ | `0` or `-100` | raises `InvalidValueError` | HTTP 400 `Invalid Amount Sent` |
23
+
24
+ So do not bypass the SDK's check (for example through `client.connection.post`): Paystack itself silently accepts a whole Float or a numeric String.
25
+
26
+ ## 2. Never grant value from a webhook or a return URL alone
27
+
28
+ A webhook, a callback or the payer landing on your return URL is a hint. Value is granted only after you verify **by your own reference** and compare with what **you** expected to charge, stored on your own record before the call. Never compare with the amount in the response, the webhook or the URL.
29
+
30
+ ```ruby
31
+ # `payment` is your record: reference, amount (minor units), currency and email, saved before calling Paystack.
32
+ def payment_outcome(client, payment)
33
+ response = client.transactions.verify(reference: payment.reference)
34
+ return :not_found unless response.success?
35
+ return :not_paid unless response.status?(:success)
36
+ return :mismatch unless response.paid?(amount: payment.amount, currency: payment.currency) &&
37
+ response.customer.email.to_s.casecmp?(payment.email)
38
+
39
+ :paid
40
+ end
41
+ ```
42
+
43
+ - `:paid`: grant value, once (see rule 8).
44
+ - `:mismatch`: Paystack took a payment, but not the one you asked for (amount, currency or email differ). Do not grant; alert a person.
45
+ - `:not_paid`: not (yet) paid. An initialized but unpaid transaction verified as `abandoned` on the test API. Check again later or wait for the webhook.
46
+ - `:not_found`: the reference is unknown. Observed: HTTP 400, `success?` false, message "Transaction reference not found."; the SDK returns it, it does not raise.
47
+
48
+ `paid?` with no arguments only checks the status: always pass `amount:` and `currency:`.
49
+
50
+ ## 3. Never retry a write after a timeout
51
+
52
+ The SDK sends a write (POST, PUT, DELETE) once: a timeout or 5xx raises `PaystackSdk::TimeoutError` / `ConnectionError` / `ServerError` and is **not** repeated, because Paystack may have processed it. Writes are retried only on 429. Reads (GET, such as `verify`) are retried on network errors and 429/502/503/504.
53
+
54
+ Do this instead: treat the outcome as unknown and verify the reference.
55
+
56
+ ```ruby
57
+ def charge_saved_card(client, payment, authorization_code)
58
+ client.transactions.charge_authorization(email: payment.email, amount: payment.amount,
59
+ currency: payment.currency, authorization_code: authorization_code, reference: payment.reference)
60
+ payment_outcome(client, payment)
61
+ rescue PaystackSdk::ConnectionError, PaystackSdk::ServerError
62
+ :unknown # Paystack may have charged. Do not send it again: verify payment.reference later (a job).
63
+ end
64
+ ```
65
+
66
+ Do not pass `retry_non_idempotent: true` to `PaystackSdk::Client.new`. It makes the SDK resend writes after a timeout or 5xx (default 2 more times), which can charge twice.
67
+
68
+ ## 4. Reference discipline
69
+
70
+ - Create the reference and save it on your record **before** the call, so a crash or timeout leaves something to verify.
71
+ - One reference per payment attempt. A failed charge cannot be resumed; the next attempt gets a new reference.
72
+ - Paystack rejects a reference it has already seen: `initiate` and `charge_authorization` with a used reference returned HTTP 400 "Duplicate Transaction Reference" (observed on the test API 2026-10-09). That is a safety net, not a plan: verify first.
73
+
74
+ ## 5. Store the authorization code, never card data
75
+
76
+ - Store `authorization.authorization_code` (encrypted at rest, for example Rails `encrypts`) and the customer email: `charge_authorization` needs both. Store it only when `authorization.reusable` is true.
77
+ - A verified card transaction returns `authorization_code`, `bin`, `last4`, `exp_month`, `exp_year`, `channel`, `card_type`, `bank`, `country_code`, `brand`, `reusable`, `signature` and `account_name` (observed). `last4`, `brand` and expiry are enough to show the payer which card is saved.
78
+ - Never store or log a card number, CVV, PIN or OTP. They go straight to Paystack.
79
+
80
+ ## 6. Keep secret keys out of logs and error reports
81
+
82
+ - The SDK puts nothing of the key in its error messages (checked for timeouts, connection failures, 401, 429 and 5xx), nor in `Response#inspect`.
83
+ - **`inspect` and `pp` of a `PaystackSdk::Client`, of any resource (`client.transactions`) and of the connection the SDK builds do not include the secret key** (they print the class name; the connection's `Authorization` header shows `[REDACTED]`). **Versions before 0.4.1 printed the key in full**: on one of those, never log, `pp`, or send to an error reporter a client, a resource or a connection, and rotate the key if you may have. A connection you build yourself and pass to `Client.new(connection)` is yours: its own `inspect` is unchanged. Whatever the version, log the reference, the `Response#error_message` and the HTTP `status_code` instead of whole objects.
84
+ - Read the key from the environment or encrypted credentials; never commit it.
85
+
86
+ ## 7. Key hygiene
87
+
88
+ - Use `PaystackSdk::Client.new(secret_key: ..., sandbox_only: true)` everywhere except production. It raises `ArgumentError` unless the key starts with `sk_test_`.
89
+ - `client.live?` is true only for an `sk_live_` key. Use it to guard anything that must never run live, and to label logs.
90
+ - `sandbox_only` is a prefix check, not a sandbox. A test key can still reach live mode: the gem documents that `storefronts.publish` copies the storefront and its products to the live integration even with a test key. Do not call such methods from tests or seeds.
91
+
92
+ ## 8. Webhooks
93
+
94
+ The gem's `PaystackSdk::Webhook` checks the signature (`construct_event`, `valid_signature?`, `verify!`) and the documented sender addresses (`trusted_ip?`). The [[paystack-sdk-webhooks]] skill covers the handler in full. For money safety:
95
+
96
+ - Verify the signature on the raw body before anything else. The IP check is optional, a second check, never a replacement.
97
+ - Respond `200 OK` fast and do the work in a job. Paystack's webhooks page says unacknowledged events are retried (every 3 minutes for 4 tries, then hourly for 72 hours in live mode; hourly for 10 hours in test mode; 30 second timeout). Documented, not observed.
98
+ - Expect repeats and out-of-order delivery. Paystack documents no event id: dedupe on the event name plus `data.id` or `data.reference`, and in the job call `payment_outcome` (rule 2) instead of trusting the event body.
99
+ - Grant value at most once: make the "paid" transition idempotent (a unique constraint or a row lock on your payment record), because the webhook, the return URL and a verify job can all arrive at the same time.
100
+
101
+ ## 9. Reconciliation
102
+
103
+ Run a daily job that compares your records with Paystack's. Calls the gem has for it (each answered on the test API; how to match rows is up to you):
104
+
105
+ | Call | Use it for |
106
+ |---|---|
107
+ | `transactions.list(status:, from:, to:, per_page:, page:)` | Transactions Paystack has, to find ones you never recorded or never granted |
108
+ | `settlements.list(from:, to:)`, `settlements.transactions(id:)` | Payouts to your bank account and the transactions in each |
109
+ | `balances.fetch`, `balances.ledger(from:, to:)` | Your Paystack balance per currency and the entries that moved it |
110
+
111
+ ## Pre-merge checklist
112
+
113
+ | Check | Pass when |
114
+ |---|---|
115
+ | Amounts | Integers in minor units, converted from a decimal; no Float; no raw `client.connection` call that skips the SDK's check |
116
+ | Reference | Created and saved before the call; one per attempt |
117
+ | Granting value | Only after `transactions.verify` + `paid?(amount:, currency:)` with your stored amount and currency, and the customer email checked |
118
+ | Grant once | The paid transition is idempotent across webhook, return URL and jobs |
119
+ | Writes after timeout | `ConnectionError` / `ServerError` on a write leads to verify by reference, never to a resend |
120
+ | Retries | `retry_non_idempotent` is not set |
121
+ | Webhooks | Signature checked on the raw body; 200 fast; deduped; no value from the event body |
122
+ | Stored data | Only `authorization_code` (encrypted) and display fields; no card number, CVV, PIN or OTP anywhere, including logs |
123
+ | Secrets | Key only from the environment or credentials; `paystack_sdk` >= 0.4.1 (earlier versions printed the key in `inspect`); nothing logs a whole client, resource or connection anyway |
124
+ | Environments | `sandbox_only: true` outside production; nothing that reaches live mode (such as `storefronts.publish`) in tests or seeds |
125
+ | Reconciliation | A scheduled job compares your records with `transactions.list` |
126
+
127
+ ## Not verified
128
+
129
+ Nothing here ran with a live key. Paystack's accepting `100.0` and `"100"` was seen in test mode only. Whether a resend with the same reference is rejected while the first request is still in flight was not tested. Webhook retry timing and ordering come from Paystack's webhooks page, not from observation. `settlements.list` returned no rows on the test account, so its row shape was not seen.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: paystack-sdk-overview
3
+ description: 'Use when writing or reviewing Ruby code that calls Paystack through the paystack_sdk gem: creating a client, picking a resource and method, reading a Response, handling errors, or deciding how far to trust a result. Load this first; it covers the conventions every other paystack-sdk skill assumes.'
4
+ ---
5
+
6
+ # paystack_sdk: overview and conventions
7
+
8
+ `paystack_sdk` is a Ruby client for Paystack's API. It mirrors Paystack's endpoints, does not reshape Paystack's field names, and validates input before sending. This skill was installed from a specific gem version (see `metadata.gem_version` above). If `Gemfile.lock` pins a different version, run `paystack_sdk skills install` (or `rails g paystack_sdk:skills`) again so the skills match the code.
9
+
10
+ ## Set up the client
11
+
12
+ ```ruby
13
+ client = PaystackSdk::Client.new(secret_key: ENV.fetch("PAYSTACK_SECRET_KEY"))
14
+
15
+ # Staging and CI: refuse anything but a test key, at construction, before any request
16
+ client = PaystackSdk::Client.new(secret_key: ENV.fetch("PAYSTACK_SECRET_KEY"), sandbox_only: true)
17
+
18
+ client.live? # => true only for a live key (sk_live_...)
19
+ ```
20
+
21
+ - With no `secret_key:` the client reads `PAYSTACK_SECRET_KEY`.
22
+ - `sandbox_only: true` raises `ArgumentError` unless the key starts with `sk_test_`. It checks the key's prefix only. It cannot stop a call that reaches live mode from a test key (publishing a storefront does).
23
+ - Connection options: `timeout` (default 30 s), `open_timeout` (5 s), `max_retries` (2), `retry_interval` (0.5 s). Leave `retry_non_idempotent` off; see "Retries".
24
+
25
+ ## Pick the resource
26
+
27
+ Each Paystack resource is a method on the client: `transactions`, `charges`, `customers`, `refunds`, `transfers`, `transfer_recipients`, `banks`, `miscellaneous`, `subaccounts`, `splits`, `settlements`, `disputes`, `balances`, `plans`, `subscriptions`, `payment_requests`, `pages`, `products`, `orders`, `storefronts`, `bulk_charges`, `dedicated_virtual_accounts`, `apple_pay`, `integrations`, `virtual_terminals`, `direct_debits`, `terminals`, `webhook_events` (the log of webhooks Paystack sent you; not in Paystack's OpenAPI spec). The signature and a `@see` link to Paystack's docs are in each method's YARD comment in the gem; read them rather than guessing keywords.
28
+
29
+ ## Conventions
30
+
31
+ - **Keyword arguments, never a payload hash.** `client.transactions.initiate(email: "ama@example.com", amount: 5000)`. To pass a hash you hold, splat it: `initiate(**params)`.
32
+ - **Ruby names are snake_case; Paystack's names go on the wire.** `per_page:` is sent as `perPage`, `next_cursor:` as `next`. Never write Paystack's camelCase names in Ruby calls.
33
+ - **Amounts are integers in the currency's smallest unit** (pesewas, kobo, cents). `5000` is GHS 50.00. Never send a Float.
34
+ - **Path values are escaped.** A blank value raises `PaystackSdk::MissingParamError`, and `.` or `..` raises `PaystackSdk::InvalidValueError`, so a caller cannot redirect a request to another endpoint.
35
+ - **Identifier keywords follow Paystack's docs**: `id_or_code:`, `email_or_code:`, `reference:`. The numeric ID and the string code are not always interchangeable; the method's docs and its topic skill say which.
36
+
37
+ ## Read a Response
38
+
39
+ Every call returns a `PaystackSdk::Response`.
40
+
41
+ ```ruby
42
+ response = client.transactions.verify(reference: "order-1042")
43
+
44
+ response.success? # the call succeeded
45
+ response.status # a field of Paystack's data, by dot access (raises NoMethodError if the key is absent)
46
+ response.customer.email # nested fields too
47
+ response[:amount] # hash-style works with strings or symbols
48
+ response.paid?(amount: 5000, currency: "GHS") # success + status "success" + amount and currency match
49
+ response.status?(:send_pin) # compare the "status" field with any value
50
+ response.meta # pagination: total, page, pageCount, perPage
51
+ response.dig(:authorization, :authorization_code) # nil if any key is missing: use it for optional fields
52
+ response.original_response # the raw body, for anything not wrapped
53
+ response.error_message # Paystack's message when the call did not succeed
54
+ ```
55
+
56
+ Lists: `response.each` and `first`/`last` give you wrapped rows with dot access (`row.reference`). Enumerable calls such as `map` and `select` pass your block plain **string-keyed Hashes** and return a wrapped result, not an Array. For a plain Array of rows, use `response.original_response["data"]`.
57
+
58
+ ## What raises and what returns
59
+
60
+ | Situation | What you get |
61
+ |---|---|
62
+ | A required keyword is missing | `ArgumentError` |
63
+ | A value fails validation before sending | a `PaystackSdk::ValidationError` (`MissingParamError`, `InvalidFormatError`, `InvalidValueError`) |
64
+ | 401, bad or missing key | raises `PaystackSdk::AuthenticationError` |
65
+ | 429, rate limited | raises `PaystackSdk::RateLimitError` (`retry_after` when Paystack gives it) |
66
+ | 5xx | raises `PaystackSdk::ServerError` |
67
+ | Any other 4xx (400, 404, 422...) | **returns an unsuccessful `Response`; nothing is raised.** Check `success?` and read `error_message` |
68
+ | Timeout or no connection | raises `PaystackSdk::TimeoutError` or `PaystackSdk::ConnectionError` |
69
+
70
+ All of these inherit from `PaystackSdk::Error`. The classic bug is calling `.data` on a 400 and treating the result as success. Always branch on `success?`.
71
+
72
+ ## Retries
73
+
74
+ Reads (GET) retry on network errors and on 429/502/503/504. **Writes retry only on 429**, because a timeout or 5xx on a write may mean Paystack processed it. So after a `TimeoutError` or `ServerError` on a write, do not call it again blindly: look the thing up first (for a payment, `transactions.verify(reference:)`).
75
+
76
+ ## Rules that prevent most bugs
77
+
78
+ 1. **Never give value because of a redirect, a callback or a webhook alone.** Verify by reference: `client.transactions.verify(reference: ref).paid?(amount: expected_amount, currency: "GHS")`. Compare against what you expected to charge, not against what the response says.
79
+ 2. **Create and store your own `reference` before you call Paystack**, so you can verify the outcome after a timeout or a crash.
80
+ 3. **Branch on `success?` for every call** (see the table above).
81
+ 4. **Keep secret keys out of code and logs**, and use `sandbox_only: true` outside production.
82
+
83
+ ## Topic skills: load the one that matches the task
84
+
85
+ | You are... | Load |
86
+ |---|---|
87
+ | Taking a payment through Paystack's hosted checkout (initiate, redirect, verify) | [[paystack-sdk-payments]] |
88
+ | Reacting to what a Charge API call returns (`send_pin`, `send_otp`, `pay_offline`, `failed`...) | [[paystack-sdk-charge-statuses]] |
89
+ | Charging a mobile money wallet (Ghana: MTN, Telecel, AT Money) | [[paystack-sdk-mobile-money]] |
90
+ | Charging a customer's saved card again (renewals, recurring giving) | [[paystack-sdk-saved-card-renewals]] |
91
+ | Receiving Paystack webhooks | [[paystack-sdk-webhooks]] |
92
+ | Refunding a payment | [[paystack-sdk-refunds]] |
93
+ | Writing tests for code that calls Paystack | [[paystack-sdk-testing]] |
94
+ | Reviewing or writing anything that handles money or keys (apply it before merging payment code) | [[paystack-sdk-money-safety]] |
95
+
96
+ ## What the gem's maintainers have not verified
97
+
98
+ Nothing has been exercised with a live key. Some operations could not be exercised against Paystack's test API either, and the README marks them "unverified": terminals, dedicated virtual accounts, and the state-changing calls on Apple Pay, integrations, virtual terminals and direct debits. Do not tell a user such a call works; say it follows Paystack's documentation and has not been confirmed. Test mode also does not model everything live mode does (for example, a mobile money charge answers `success` at once in test mode).