# Paystack Ruby SDK (paystack_sdk 0.5.0): full documentation --- # Skill: paystack-sdk-overview # paystack_sdk: overview and conventions `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. ## Set up the client ```ruby client = PaystackSdk::Client.new(secret_key: ENV.fetch("PAYSTACK_SECRET_KEY")) # Staging and CI: refuse anything but a test key, at construction, before any request client = PaystackSdk::Client.new(secret_key: ENV.fetch("PAYSTACK_SECRET_KEY"), sandbox_only: true) client.live? # => true only for a live key (sk_live_...) ``` - With no `secret_key:` the client reads `PAYSTACK_SECRET_KEY`. - `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). - 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". ## Pick the resource 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. ## Conventions - **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)`. - **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. - **Amounts are integers in the currency's smallest unit** (pesewas, kobo, cents). `5000` is GHS 50.00. Never send a Float. - **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. - **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. ## Read a Response Every call returns a `PaystackSdk::Response`. ```ruby response = client.transactions.verify(reference: "order-1042") response.success? # the call succeeded response.status # a field of Paystack's data, by dot access (raises NoMethodError if the key is absent) response.customer.email # nested fields too response[:amount] # hash-style works with strings or symbols response.paid?(amount: 5000, currency: "GHS") # success + status "success" + amount and currency match response.status?(:send_pin) # compare the "status" field with any value response.meta # pagination: total, page, pageCount, perPage response.dig(:authorization, :authorization_code) # nil if any key is missing: use it for optional fields response.original_response # the raw body, for anything not wrapped response.error_message # Paystack's message when the call did not succeed ``` 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"]`. ## What raises and what returns | Situation | What you get | |---|---| | A required keyword is missing | `ArgumentError` | | A value fails validation before sending | a `PaystackSdk::ValidationError` (`MissingParamError`, `InvalidFormatError`, `InvalidValueError`) | | 401, bad or missing key | raises `PaystackSdk::AuthenticationError` | | 429, rate limited | raises `PaystackSdk::RateLimitError` (`retry_after` when Paystack gives it) | | 5xx | raises `PaystackSdk::ServerError` | | Any other 4xx (400, 404, 422...) | **returns an unsuccessful `Response`; nothing is raised.** Check `success?` and read `error_message` | | Timeout or no connection | raises `PaystackSdk::TimeoutError` or `PaystackSdk::ConnectionError` | 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?`. ## Retries 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:)`). ## Rules that prevent most bugs 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. 2. **Create and store your own `reference` before you call Paystack**, so you can verify the outcome after a timeout or a crash. 3. **Branch on `success?` for every call** (see the table above). 4. **Keep secret keys out of code and logs**, and use `sandbox_only: true` outside production. ## Topic skills: load the one that matches the task | You are... | Load | |---|---| | Taking a payment through Paystack's hosted checkout (initiate, redirect, verify) | `paystack-sdk-payments` | | Reacting to what a Charge API call returns (`send_pin`, `send_otp`, `pay_offline`, `failed`...) | `paystack-sdk-charge-statuses` | | Charging a mobile money wallet (Ghana: MTN, Telecel, AT Money) | `paystack-sdk-mobile-money` | | Charging a customer's saved card again (renewals, recurring giving) | `paystack-sdk-saved-card-renewals` | | Receiving Paystack webhooks | `paystack-sdk-webhooks` | | Refunding a payment | `paystack-sdk-refunds` | | Writing tests for code that calls Paystack | `paystack-sdk-testing` | | Reviewing or writing anything that handles money or keys (apply it before merging payment code) | `paystack-sdk-money-safety` | ## What the gem's maintainers have not verified 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). --- # Skill: paystack-sdk-charge-statuses # Charge statuses: what to show, what to call next 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)`. ## The one trap: `success?` is not "the charge worked" `response.success?` means Paystack accepted the *call*. It says nothing about whether the *charge* succeeded. Always look at `data.status`: | What you did | `success?` | `data.status` | |---|---|---| | `charges.create`/`mobile_money` with a phone number that is too short | `true` (HTTP 200, message "Charge attempted") | `failed` | | `charges.check_pending` on a charge that failed | `true` (HTTP 200) | `failed` | | `charges.submit_pin` / `submit_otp` with a wrong PIN or OTP | `false` (HTTP 400) | `failed` | So decide with the status, and give value only with `transactions.verify(reference:).paid?(amount:, currency:)` (see the rules in `paystack-sdk-overview`). ## Status table | `data.status` | Meaning | Show the payer | Next call | |---|---|---|---| | `success` | The charge went through | Confirmation | `transactions.verify(reference:)` then `paid?(amount:, currency:)` before giving value | | `send_pin` | The card needs its PIN | A PIN field | `charges.submit_pin(pin:, reference:)` | | `send_otp` | An OTP is needed | An OTP field, with `data.display_text` | `charges.submit_otp(otp:, reference:)` | | `send_phone` | A mobile number is needed | A phone field, with `data.display_text` | `charges.submit_phone(phone:, reference:)` | | `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:)` | | `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:)` | | `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 | | `pending` (name not confirmed) | Paystack is still processing | "Processing" | `charges.check_pending(reference:)` until it is not `pending`, or wait for the webhook | | `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 | | `failed` | The charge is dead | The reason, `data.message` | Start a **new** charge with a **new** `reference` | Always pass the same `reference` you started the charge with to each follow-up call. ## What was observed on Paystack's test API (2026-10-09, test cards from Paystack's test-payments page) These flows ran for real with `reference`s of our own, and each ended with `transactions.verify(...).paid?` true and a reusable authorization: - Card `4084 0840 8408 4081`, expiry `09/27`, CVV `408` (no validation): `success` at once. - Card `5078 5078 5078 5078 12`, CVV `081`, PIN `1111`: `send_pin` -> `submit_pin` -> `success`. - 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`. - 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`. - 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. - `submit_otp` with a reference Paystack does not know: HTTP 400, message "Transaction reference is invalid", no `data.status`. 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. ## A loop that handles every status ```ruby # `response` is the result of charges.create / charges.mobile_money, and `reference` is the one you chose. response = client.charges.mobile_money(email: email, amount: 5000, currency: "GHS", reference: reference, mobile_money: {phone: "0551234987", provider: "mtn"}) case response.status when "success" # still verify by reference before giving value when "pay_offline", "pending" # show response.display_text (when present) and wait for the charge.success webhook when "send_pin" response = client.charges.submit_pin(pin: pin_from_payer, reference: reference) when "send_otp" response = client.charges.submit_otp(otp: otp_from_payer, reference: reference) when "send_phone" response = client.charges.submit_phone(phone: phone_from_payer, reference: reference) when "failed" reason = response.original_response.dig("data", "message") # never retry the same reference else raise "unhandled charge status: #{response.status.inspect}" # fail loudly, do not guess end ``` 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. ## Rules - **Branch on `data.status`, not on `success?`.** - **One reference per charge attempt.** A `failed` charge cannot be resumed; the retry is a new charge with a new reference. - **Never retry a charge call after a timeout** (the SDK does not either). First look it up: `client.transactions.verify(reference: reference)`. - **Store nothing the payer typed.** PINs and OTPs go straight to Paystack and are never logged or saved. - **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. --- # Skill: paystack-sdk-mobile-money # Mobile money charges (Ghana first) 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. ## Start a charge ```ruby response = client.charges.mobile_money( email: "ama@example.com", amount: 5000, # pesewas: GHS 50.00 currency: "GHS", reference: reference, # create and store it first mobile_money: {phone: "0551234987", provider: "mtn"} ) ``` `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. ## Provider codes | Network (Ghana) | `provider` | Code in `banks.list(country: "ghana", type: "mobile_money")` | |---|---|---| | MTN | `mtn` | `MTN` | | Telecel (formerly Vodafone) | `vod` | `VOD` (Paystack's list still names it "Vodafone") | | AT Money / Airtel Money (AirtelTigo) | `atl` | `ATL` | 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). ## The phone number Paystack's Ghana example uses the **local ten-digit form**, `0551234987`. If you store numbers in E.164 (`+233551234987`), convert before calling: ```ruby def local_ghana_number(phone) digits = phone.to_s.gsub(/\D/, "") # drop +, spaces and dashes digits = digits.delete_prefix("233") if digits.size == 12 # +233 551234987 -> 551234987 digits = "0#{digits}" if digits.size == 9 # 551234987 -> 0551234987 raise ArgumentError, "not a Ghana mobile number: #{phone.inspect}" unless digits.match?(/\A0\d{9}\z/) digits end local_ghana_number("+233551234987") # => "0551234987" local_ghana_number("0551234987") # => "0551234987" ``` 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. ## What happens next (live mode, per Paystack's docs) 1. The response has `data.status` `pay_offline` and `data.display_text`. Show the text to the payer. 2. The payer approves on their phone. **They have 180 seconds**; after that the charge fails (a network-provider limit). 3. Paystack sends the `charge.success` webhook when it succeeds. Verify by reference before giving value. 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`. ```ruby case response.status when "pay_offline", "pending" # show response.display_text, then wait for the webhook (or poll check_pending) when "success" # test mode answers like this at once; still verify when "failed" # response.original_response.dig("data", "message") says why; start a new charge, new reference end ``` ## What test mode does not prove 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. ## Rules - **Never give value on `pay_offline` or on the response alone.** Wait for the webhook and verify by reference with `paid?(amount:, currency:)`. - **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). - **A new attempt is a new reference.** A `failed` charge is finished. - **Check `data.status`, not `success?`** (see `paystack-sdk-charge-statuses`). --- # Skill: paystack-sdk-money-safety # Money safety checklist 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`. ## 1. Amounts are integers in the minor unit `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. What happens to a bad amount on `transactions.initiate`: | Amount | The SDK | Paystack, if it got it (observed on the test API 2026-10-09) | |---|---|---| | `100.5` | raises `PaystackSdk::InvalidValueError`, nothing sent | HTTP 400 `"amount" must be an integer` | | `100.0` | raises `InvalidValueError` | **accepted**, stored as 100 | | `"100"` | raises `InvalidValueError` | **accepted**, stored as 100 | | `"100.50"` | raises `InvalidValueError` | HTTP 400 `"amount" must be an integer` | | `0` or `-100` | raises `InvalidValueError` | HTTP 400 `Invalid Amount Sent` | 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. ## 2. Never grant value from a webhook or a return URL alone 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. ```ruby # `payment` is your record: reference, amount (minor units), currency and email, saved before calling Paystack. def payment_outcome(client, payment) response = client.transactions.verify(reference: payment.reference) return :not_found unless response.success? return :not_paid unless response.status?(:success) return :mismatch unless response.paid?(amount: payment.amount, currency: payment.currency) && response.customer.email.to_s.casecmp?(payment.email) :paid end ``` - `:paid`: grant value, once (see rule 8). - `:mismatch`: Paystack took a payment, but not the one you asked for (amount, currency or email differ). Do not grant; alert a person. - `: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. - `:not_found`: the reference is unknown. Observed: HTTP 400, `success?` false, message "Transaction reference not found."; the SDK returns it, it does not raise. `paid?` with no arguments only checks the status: always pass `amount:` and `currency:`. ## 3. Never retry a write after a timeout 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. Do this instead: treat the outcome as unknown and verify the reference. ```ruby def charge_saved_card(client, payment, authorization_code) client.transactions.charge_authorization(email: payment.email, amount: payment.amount, currency: payment.currency, authorization_code: authorization_code, reference: payment.reference) payment_outcome(client, payment) rescue PaystackSdk::ConnectionError, PaystackSdk::ServerError :unknown # Paystack may have charged. Do not send it again: verify payment.reference later (a job). end ``` 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. ## 4. Reference discipline - Create the reference and save it on your record **before** the call, so a crash or timeout leaves something to verify. - One reference per payment attempt. A failed charge cannot be resumed; the next attempt gets a new reference. - 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. ## 5. Store the authorization code, never card data - 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. - 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. - Never store or log a card number, CVV, PIN or OTP. They go straight to Paystack. ## 6. Keep secret keys out of logs and error reports - The SDK puts nothing of the key in its error messages (checked for timeouts, connection failures, 401, 429 and 5xx), nor in `Response#inspect`. - **`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. - Read the key from the environment or encrypted credentials; never commit it. ## 7. Key hygiene - Use `PaystackSdk::Client.new(secret_key: ..., sandbox_only: true)` everywhere except production. It raises `ArgumentError` unless the key starts with `sk_test_`. - `client.live?` is true only for an `sk_live_` key. Use it to guard anything that must never run live, and to label logs. - `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. ## 8. Webhooks 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: - Verify the signature on the raw body before anything else. The IP check is optional, a second check, never a replacement. - 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. - 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. - 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. ## 9. Reconciliation 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): | Call | Use it for | |---|---| | `transactions.list(status:, from:, to:, per_page:, page:)` | Transactions Paystack has, to find ones you never recorded or never granted | | `settlements.list(from:, to:)`, `settlements.transactions(id:)` | Payouts to your bank account and the transactions in each | | `balances.fetch`, `balances.ledger(from:, to:)` | Your Paystack balance per currency and the entries that moved it | ## Pre-merge checklist | Check | Pass when | |---|---| | Amounts | Integers in minor units, converted from a decimal; no Float; no raw `client.connection` call that skips the SDK's check | | Reference | Created and saved before the call; one per attempt | | Granting value | Only after `transactions.verify` + `paid?(amount:, currency:)` with your stored amount and currency, and the customer email checked | | Grant once | The paid transition is idempotent across webhook, return URL and jobs | | Writes after timeout | `ConnectionError` / `ServerError` on a write leads to verify by reference, never to a resend | | Retries | `retry_non_idempotent` is not set | | Webhooks | Signature checked on the raw body; 200 fast; deduped; no value from the event body | | Stored data | Only `authorization_code` (encrypted) and display fields; no card number, CVV, PIN or OTP anywhere, including logs | | 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 | | Environments | `sandbox_only: true` outside production; nothing that reaches live mode (such as `storefronts.publish`) in tests or seeds | | Reconciliation | A scheduled job compares your records with `transactions.list` | ## Not verified 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. --- # Skill: paystack-sdk-payments # Accepting a payment with the hosted checkout The payer pays on Paystack's checkout page, not on yours. Your server starts the transaction, sends the payer to Paystack, and later asks Paystack what happened. Nothing the browser brings back counts as proof. Read `paystack-sdk-overview` first for the client, `Response` and error conventions. ## The flow 1. Create your own payment row with a new `reference`, an `amount` in pesewas and a `currency`, status `pending`. Save it **before** calling Paystack. 2. `client.transactions.initiate(...)` with that reference. Redirect the payer to `response.authorization_url`. 3. The payer comes back to your `callback_url`. Treat that request as "please check", nothing more. 4. `client.transactions.verify(reference:)` and give value only if `paid?(amount: payment.amount, currency: payment.currency)` is true. 5. Do the same in the `charge.success` webhook handler. Steps 4 and 5 run the same idempotent code (see "Confirming twice is normal"). ## Start the payment: `transactions.initiate` | Keyword | What to pass | |---|---| | `email:` (required) | The payer's email. The SDK refuses a malformed one before sending | | `amount:` (required) | A positive Integer in the subunit: `5000` is GHS 50.00. `0`, `50.0` and `"5000"` raise `PaystackSdk::InvalidValueError` before any request | | `currency:` | Always pass it (`"GHS"`). The SDK accepts `GHS`, `KES`, `NGN`, `ZAR`, `USD`. Left out, Paystack uses the integration currency (docs); ours came back `GHS` | | `reference:` | Yours, unique, stored first. The SDK accepts letters, digits and `-` `.` `=` `_`. Use letters, digits and `-` only: Paystack's docs list `-`, `.`, `=` and alphanumerics (an `_` was accepted on the test API, but it is not documented) | | `callback_url:` | Where Paystack sends the payer after paying. It overrides the dashboard setting; with neither set, the payer is not sent back (docs) | | `channels:` | An Array limiting the checkout, e.g. `["card", "mobile_money"]`. The docs list `card`, `bank`, `apple_pay`, `ussd`, `qr`, `mobile_money`, `bank_transfer`, `eft`, `capitec_pay`, `payattitude`. The SDK sends it unchecked | | `metadata:` | A Hash (or a JSON string). The SDK sends it stringified, as Paystack documents it, so numbers keep their type (`{order_id: 42}` comes back from `verify` as `42`; sent as a plain object Paystack would store `"42"`: observed on the test API 2026-10-09). Look payments up by your reference, not by metadata | The method also takes `plan:`, `invoice_limit:`, `split_code:`, `split:`, `subaccount:`, `transaction_charge:`, `bearer:` and `label:`. It has no `first_name:`, `last_name:` or `phone:` keyword. A successful call returns `authorization_url`, `access_code` and your `reference`. `transactions.verify` does not return `authorization_url`, so if you lose it, you cannot get it back from verify. ```ruby # app/controllers/payments_controller.rb def create payment = Payment.create!(member: current_member, amount: 5000, currency: "GHS", reference: "pay-#{SecureRandom.uuid}", status: "pending") response = PaystackSdk::Client.new.transactions.initiate( email: current_member.email, amount: payment.amount, currency: payment.currency, reference: payment.reference, callback_url: payments_callback_url, channels: ["card", "mobile_money"], metadata: {payment_id: payment.id} ) if response.success? redirect_to response.authorization_url, allow_other_host: true # else OpenRedirectError when raise_on_open_redirects is on else payment.update!(status: "failed", gateway_response: response.error_message) redirect_to new_payment_path, alert: "We could not start the payment. Please try again." end end ``` **One reference, one transaction, ever.** Paystack answered HTTP 400 "Duplicate Transaction Reference" (an unsuccessful `Response`, not an exception) to every reuse we tried: `initiate` again with the same reference (same or different amount), a card charge with a reference that `initiate` had already used, and `initiate` with a reference that was already paid. So a new attempt always needs a new reference and a new payment row. **If `initiate` raises `PaystackSdk::TimeoutError` or `ServerError`**, the SDK does not retry it, and it may have reached Paystack. Call `verify(reference:)`: "Transaction reference not found." means Paystack never created it; a found transaction means it exists but you have no `authorization_url` for it. Either way, start again with a new reference and mark the old row `failed`. ## When the payer comes back Paystack's docs (Accept Payments, Redirect) say it appends the reference to your `callback_url` as `?reference=...`, and that a visit to the callback URL "doesn't prove that transaction was successful". So the callback only runs the confirmation: ```ruby # GET /payments/callback?reference=... def callback payment = ConfirmPayment.call(params[:reference].to_s) return head(:not_found) if payment.nil? redirect_to payment_path(payment) # shows paid, pending or failed from your own row end ``` ## What `verify` tells you `verify` succeeds (`success?` true, HTTP 200) for any reference Paystack knows, paid or not. The payment outcome is in `data.status`, never in `success?`. | `data.status` | What it means | What to do | |---|---|---| | `success` | Paid. `paid?(amount:, currency:)` also checks the amount and currency are the ones you asked for | Mark paid and give value once, if `paid?` is true. If the status is `success` but `paid?` is false, do not give value: flag it for a person | | `abandoned` | Not paid (yet). Seen right after `initiate`, while a charge waited for a PIN, and after a card was declined at the first step (with the reason in `gateway_response`, e.g. "Transaction declined. Please use the test card.") | Keep the row `pending`. Do not call it failed: the docs define it as "the customer hasn't completed the transaction" | | `failed` | The attempt failed. Seen after a wrong PIN, with `gateway_response` "Incorrect PIN" | Mark failed; a retry is a new reference | | `ongoing`, `pending`, `processing`, `queued` | In progress (docs only, not seen here) | Keep `pending`, wait for the webhook or verify later | | `reversed` | Refunded or charged back (docs only, not seen here) | Do not give value; if you already did, handle it as a reversal | | anything else | Unknown | Keep `pending`, log the status and reference, do not guess | A reference Paystack does not know: `verify` returns HTTP 400, `success?` false, `error_message` "Transaction reference not found." and no `data`. It does not raise. Never treat that as paid or as failed; it is not a Paystack transaction (or not one yet). On a paid transaction, `verify` returned (test API): `id`, `status` `success`, `reference`, `amount` `100`, `currency` `GHS`, `paid_at`, `channel` `card`, `gateway_response` "Successful", your `metadata`, `customer` (`id`, `email`, `customer_code`) and `authorization` (`authorization_code`, `reusable` true, `channel`, plus card details: `bin`, `last4`, `exp_month`, `exp_year`, `brand`, `bank`, `signature`). On an unpaid one, `paid_at` was `null` and `authorization` was `{}`. Dot access (`response.paid_at`) raises `NoMethodError` when Paystack leaves a key out. Read a field that may be missing with `response.dig(:paid_at)` (or `response.dig(:authorization, :authorization_code)`): it returns `nil` as soon as a key is missing. ## Confirming twice is normal (design advice) The callback and the `charge.success` webhook confirm the same payment, possibly at the same moment. Paystack's Verify Payments guide warns to "confirm that you haven't already delivered value for that transaction to avoid double fulfillments". This is **our design advice, not Paystack behaviour**: a unique `reference` column, one service both paths call, a row lock and a state check inside it. ```ruby # db/migrate/..._create_payments.rb create_table :payments do |t| t.references :member, null: false t.string :reference, null: false t.integer :amount, null: false # pesewas t.string :currency, null: false t.string :status, null: false # yours: pending, paid, failed, needs_review t.string :paystack_status # the last data.status verify returned t.bigint :paystack_id # data.id; the docs say treat it as an unsigned 64-bit integer t.datetime :paid_at t.string :gateway_response t.string :authorization_code # only when reusable; `encrypts :authorization_code` in the model t.timestamps end add_index :payments, :reference, unique: true ``` ```ruby # app/services/confirm_payment.rb: the callback and the webhook both call this class ConfirmPayment def self.call(reference, client: PaystackSdk::Client.new) payment = Payment.find_by(reference: reference) return nil if payment.nil? # not ours: never act on it return payment if payment.status == "paid" # already done response = client.transactions.verify(reference: reference) return payment unless response.success? # unknown to Paystack: leave it pending data = response.original_response["data"] # a plain Hash: a missing key is nil, not an error payment.with_lock do # locks the row and reloads it if payment.status == "paid" # the other confirmation finished first: do nothing elsif response.paid?(amount: payment.amount, currency: payment.currency) authorization = data["authorization"] || {} payment.update!(status: "paid", paystack_status: data["status"], paystack_id: data["id"], paid_at: data["paid_at"], gateway_response: data["gateway_response"], authorization_code: (authorization["authorization_code"] if authorization["reusable"])) # record what the payment buys here, inside the lock, so it happens once elsif response.status?(:success) payment.update!(status: "needs_review", paystack_status: "success", gateway_response: data["gateway_response"]) else payment.update!(status: response.status?(:failed) ? "failed" : "pending", paystack_status: data["status"], gateway_response: data["gateway_response"]) end end payment end end ``` The lock only serialises the two callers on a database with row locks (PostgreSQL, MySQL). The `status == "paid"` check inside the lock is what stops the second caller. Send emails and other side effects after the commit (for example from a job), not from inside the lock. In the webhook, check the signature first, then call the same service with the event's reference: ```ruby event = PaystackSdk::Webhook.construct_event(payload: request.raw_post, signature: request.headers["X-Paystack-Signature"], secret: ENV.fetch("PAYSTACK_SECRET_KEY")) ConfirmPayment.call(event.data.reference) if event.event == "charge.success" head :ok ``` ## What to store | Store | Why | |---|---| | `reference`, `amount`, `currency` | Yours, written before `initiate`; what `paid?` compares against | | `status` (yours) and `paystack_status` | Your state machine, and what Paystack last said | | `id` (as `paystack_id`, `bigint`) | `transactions.fetch(id:)` takes the numeric ID, not the reference | | `paid_at`, `gateway_response` | Receipts and support ("Incorrect PIN", "Successful") | | `authorization.authorization_code` | **Only when `authorization.reusable` is true**, encrypted, for later charges. Never overwrite a stored code with an empty one | Never store card data: not `bin`, `last4`, `exp_month`, `exp_year`, `signature`, and never anything the payer typed. Keep the secret key out of logs. ## Looking transactions up ```ruby client.transactions.fetch(id: payment.paystack_id) # unknown ID: HTTP 404, success? false, "Transaction not found" page = client.transactions.list(per_page: 50, page: 1, status: "success", from: Date.new(2026, 10, 1), to: Date.new(2026, 10, 31), customer_id: 407293981) page.each { |transaction| puts transaction.reference } page.meta.total # also page.meta.pageCount, page.meta.perPage ``` - `per_page:` is sent as `perPage`; `customer_id:` is sent as `customer` and is Paystack's **numeric** customer ID (`data.customer.id` from verify). Paystack answers a `CUS_` code with `success?` true and no rows, which looks like "no payments", so the SDK refuses anything but a numeric ID (an Integer, or a string of digits) with `InvalidValueError` before sending. - `status:` is checked by the SDK: `success`, `failed`, `abandoned` or `reversed`. - `from:` and `to:` take a `Date`, a `Time` or an ISO 8601 String. ## Rules - **Your reference, stored first, used once.** A new attempt is a new reference. - **The callback URL proves nothing.** Verify by reference, then `paid?(amount:, currency:)` with *your* stored amount and currency. - **`success?` on verify is not "paid".** Read `data.status`; only `success` with `paid?` true is paid. - **`abandoned` is not failed.** Keep the row pending. - **Confirmation is idempotent**: one service, called by the callback and the webhook, with a lock and a state check. - **Never retry `initiate` after a timeout with the same reference.** Verify it, then start again with a new one. - **Store the authorization code only when reusable, encrypted. Never card data.** ## Observed, and not verified Observed on Paystack's test API on 2026-10-09 with `sandbox_only: true`: `initiate` with every keyword in the table (a 1-pesewa `initiate` and a 40-character `pay-` reference were accepted too), `verify` of an unpaid, a paid, a declined, a wrong-PIN and an unknown reference, the duplicate-reference answers, `fetch` (known and unknown ID) and `list` with `per_page`, `status`, `from`, `to` and `customer_id`. The paid transaction was made with Paystack's no-validation test card through the Charge API (`4084 0840 8408 4081`, expiry `09/27`, CVV `408`) with our own reference, because the hosted checkout page cannot be driven from code. Not verified: anything on the checkout page itself, including what the payer sees for each `channels` value, the exact query string Paystack adds to the `callback_url` (the docs show `reference`), and whether an `abandoned` transaction can still become `success` later. The statuses `ongoing`, `pending`, `processing`, `queued` and `reversed` come from Paystack's Verify Payments guide and were not produced here. The Rails code (the migration, the controller and `ConfirmPayment`) is design advice. The gem's specs run `ConfirmPayment` against a stand-in model; it was also run once with ActiveRecord 8.1 on SQLite against the test API (an abandoned payment stayed `pending`, a paid one confirmed from two threads ended `paid` once with its `AUTH_` code, an unknown reference returned `nil`). That run used SQLite, so the locking under real concurrent load on PostgreSQL or MySQL is not verified. Nothing here was run with a live key, and test mode does not model everything live mode does. --- # Skill: paystack-sdk-refunds # Refunds (`client.refunds`) A refund returns money from a successful transaction to the payer. It is **asynchronous**: `refunds.create` queues it, and the refund is `pending` until Paystack and the payer's bank or wallet provider finish. Treat "created" as "requested", never as "money returned". Basics (client, `Response`, errors) are in `paystack-sdk-overview`. Each claim below is marked **observed** (Paystack test API, 2026-10-09, `sk_test_` key), **documented** (Paystack docs, page named), or **design advice**. ## The calls ```ruby # Full refund: leave amount out. `transaction:` takes the transaction reference or its numeric id. client.refunds.create(transaction: "order-1042") # Partial refund: amount in pesewas (integer), currency optional client.refunds.create( transaction: "order-1042", amount: 200, currency: "GHS", customer_note: "Event cancelled", merchant_note: "Refund approved by treasurer, ticket 77" ) client.refunds.fetch(id: 18_633_077) # one refund, by the refund id client.refunds.list(transaction_id: 6_641_908_848) # the refunds of one transaction: NUMERIC id client.refunds.list(per_page: 20, page: 1, from: "2026-10-01", to: "2026-10-31") ``` Keywords: `create(transaction:, amount:, currency:, customer_note:, merchant_note:)`, `fetch(id:)`, `list(per_page:, page:, from:, to:, transaction_id:)`, `retry_with_customer_details(id:, refund_account_details:)`. `amount` must be a positive integer (the SDK checks before sending); `currency` must be one of GHS, KES, NGN, USD, ZAR. ## What was observed on the test API | What I did | What came back | |---|---| | Refund by reference; refund by numeric id | Both accepted ("Refund has been queued for processing"). | | `create` with a result | `data.status` is `pending`; `data.id` is the refund id; `data.amount`, `data.currency`; `refunded_by` was a short account name, not the email, so record your own requester. `customer_note`/`merchant_note` are echoed back; if you omit them Paystack fills them with "Refund for transaction " (and the account email in the merchant note). | | Refund 50 pesewas of a 100-pesewa charge | **Rejected, HTTP 400: "The minimum amount you may send in a single refund at this time is: GHS1.00".** Over-refund of 60 gave the same minimum-amount message. After a full refund, 1 pesewa gave "Cannot refund less than GHS0.5". So a single refund below GHS 1.00 is refused in test mode (a limit "at this time"; check it for live). | | 300-pesewa charge, refund 100 | Accepted, `pending`. | | Then refund 250 (200 left), and `create` with `amount` omitted | **Both rejected: "Total refund amount cannot exceed original transaction amount".** With `amount` omitted Paystack asks for the whole original amount, not the remainder. After a partial refund, always pass the amount. | | Then refund 100 (exactly part of the rest) | Accepted, `pending`. Two refunds on one transaction both appeared in `list(transaction_id:)`. | | `currency: "USD"` on a GHS transaction | Rejected: "Transaction currency must match refund currency". | | `list(transaction_id: )` | The transaction's refunds. Paystack answers a reference passed here with an empty list, not an error (observed on the test API), so the SDK refuses anything but a numeric ID (an Integer, or a string of digits) with `InvalidValueError` before sending. Find a refund by its transaction's numeric `data.id` from `verify`, not its reference. | | `transactions.verify(reference:)` after a refund (full or partial) | `data.status` was `reversal-pending`, not `success`, so `paid?` returned `false`. A refunded payment no longer verifies as paid; do not re-verify an old payment and "un-grant" it because of that. | | Refund status over the next minutes | Every refund stayed `pending` (`refunded_at` null). I did not see `processing`, `processed` or `failed`, nor `reversal-pending` change. | Rejections are HTTP 400 and arrive as an unsuccessful `Response` (nothing raised): branch on `success?` and show `error_message` to staff. ## Statuses (documented, not observed beyond `pending`) From Paystack's Refunds guide (docs.../payments/refunds) and the webhooks page: | Refund `status` | Meaning (Paystack's words, shortened) | Webhook event | |---|---|---| | `pending` | Initiated, waiting for the processor | `refund.pending` | | `processing` | Received by the processor | `refund.processing` | | `processed` | Successfully processed. The guide adds that customers may still wait up to 10 business days for the funds | `refund.processed` | | `failed` | Could not be processed; the guide says your account reflects the credited amount again | `refund.failed` | | `needs-attention` | You must provide the customer's bank details | the guide names `refund.needs-attention`, which is in `PaystackSdk::Webhook::EVENTS` (it is named in the Refunds guide but not on Paystack's Webhooks page) | ## What to record and how to treat a refund (design advice) Store a `Refund` row per request before calling Paystack: your own id, the payment's `reference` and Paystack transaction id, `amount` and `currency` (pesewas, integer), who requested and who approved it, `customer_note`/`merchant_note`, then after the call the Paystack refund `id` and `status`. Mark it `pending` until Paystack says `processed`. Reasons: a created refund can still fail (then the money stays with you), "processed" can still take days to reach the payer, and you can only tell the payer "refunded" once. Update the row from `refund.*` webhooks (verified with `Webhook.construct_event`; dedupe on event plus the refund id and status, there is no documented event id) and from `refunds.fetch(id:)`. Do not mark the member's payment as refunded in your ledger as final until `processed`. Keep the original payment record; a refund is its own record. ## Never retry a create blindly A timed-out `refunds.create` may have queued a refund (the SDK does not retry writes after a timeout). A second blind call could refund twice. First look: ```ruby # Returns the refunds Paystack already has for this payment (nil if the payment cannot be verified). def refunds_for_payment(client, reference) txn = client.transactions.verify(reference: reference) return nil unless txn.success? client.refunds.list(transaction_id: txn.data.id).original_response["data"] end # Pesewas already refunded or on their way (failed refunds do not count). def refunded_amount(rows) rows.reject { |row| row["status"] == "failed" }.sum { |row| row["amount"] } end # Safe to ask for `wanted` more pesewas? def can_refund?(rows, payment_amount, wanted) wanted >= 100 && refunded_amount(rows) + wanted <= payment_amount end ``` If a refund for that amount is listed, adopt its id; if the list is empty (with the numeric id), then create it. Persist your own refund row first so a crash leaves a trace. `wanted >= 100` is the GHS 1.00 minimum seen on the test API, not a documented number. ## Webhooks `refund.pending`, `refund.processing`, `refund.processed` and `refund.failed` are in `PaystackSdk::Webhook::EVENTS` (listed on Paystack's webhooks page). The refund webhook payload shape is **not** in the docs I had, and I did not receive one: read the fields you need from the event, and when it matters call `refunds.fetch(id:)` instead of trusting the payload. Return 200 quickly; Paystack retries unacknowledged events (documented). ## Mobile money and bank transfer payments Paystack's refunds guide says nothing about mobile money, minimum amounts, or how a refund reaches a wallet; its only account rule is the `needs-attention` flow where you supply a customer bank account. The channels page says a transfer that arrives with the wrong amount is refunded automatically. So for a mobile money or bank transfer payment, **not verified**: whether `refunds.create` works, how long it takes, and whether you will land in `needs-attention`. I only refunded card payments. Plan for a manual fallback (pay the member back yourself and record it). ## `retry_with_customer_details` (documented only; never called) ```ruby client.refunds.retry_with_customer_details( id: refund_id, refund_account_details: {currency: "GHS", account_number: "0123456789", bank_id: "9"} ) ``` Per the docs, use it only for a refund in `needs-attention` (after the `refund.needs-attention` event), with any valid bank account of the customer; `bank_id` comes from `banks.list`, `account_number` is a string, and `currency` must be the payment's. I did not call it. ## Rules - **Amounts are integer pesewas.** After any earlier refund on the transaction, pass `amount:` explicitly; omitting it asks for the full original amount and fails (observed). - **Check `success?` on every refund call**; 400s do not raise. - **A refund is pending until `processed`.** Do not tell the payer or finalise the books earlier. - **After a timeout, list by the numeric transaction id before creating again.** - **Only authorised staff may refund**; log who asked. Each refund moves real money in live mode. ## Not verified Any status beyond `pending`; how long a test refund stays pending; refund webhook payloads; the live minimum refund; mobile money and bank transfer refunds; `retry_with_customer_details`; whether a failed or reversed refund returns the transaction to `success`. --- # Skill: paystack-sdk-saved-card-renewals # Saved-card renewals (your app schedules the charge) The payer pays once through checkout (or the Charge API). Paystack returns an `authorization`. If it is reusable, you store its code and later charge the card yourself with `transactions.charge_authorization`, without the payer present. Your job owns the schedule. Read `paystack-sdk-overview` first; it covers `success?`, `paid?` and retries. Paystack Plans and Subscriptions (`client.plans`, `client.subscriptions`) are a different feature, in which Paystack does the scheduling. They have their own method docs in the gem and are not covered or verified here. ## 1. Get a reusable authorization from the first payment After the first payment, verify it and read `data.authorization`. Save it only if `reusable` is true. Paystack's Recurring Charges guide says: "You should only attempt to use the authorization_code if this flag returns as true." ```ruby def reusable_card_authorization(verify_response, amount:, currency:) return nil unless verify_response.paid?(amount: amount, currency: currency) auth = verify_response.original_response.dig("data", "authorization") || {} return nil unless auth["reusable"] == true && auth["channel"] == "card" { email: verify_response.original_response.dig("data", "customer", "email"), # the code belongs to THIS email authorization_code: auth["authorization_code"], # secret: store it encrypted signature: auth["signature"], # the same card gives the same signature last4: auth["last4"], exp_month: auth["exp_month"], exp_year: auth["exp_year"], bank: auth["bank"], card_type: auth["card_type"].to_s.strip # Paystack sends "visa " with a space } end ``` | Field | What it is | Store it? | |---|---|---| | `authorization_code` | What you charge (`AUTH_...`) | Yes, **encrypted** (for example with Active Record encryption). Never log it | | `customer.email` of that payment | The only email that can charge this code | Yes, next to the code. Do not take it from the user record later | | `signature` | Identifies the card, not the customer | Yes: dedupe saved cards on email + signature | | `last4`, `exp_month`, `exp_year`, `bank`, `card_type` | For "Visa ending 4081, expires 09/27" | Yes, plain | | `reusable` | Whether you may charge it later | Check it. Do not save a code that is not `true` | | Card number, CVV, PIN, OTP | You never receive them from Paystack | Never store or log what a payer typed | Observed on the test API 2026-10-09: the customers `kwes@email.com` and `jdoe@email.com` hold different codes (`AUTH_50w0d0f5xo`, `AUTH_f6sm3o0m7w`) with the **same** signature, because they used the same test card. So a signature on its own is not a customer key. Paystack's guide also says new authorization codes are created each time a card is used, while the signature stays the same: dedupe on email + signature and keep the newest code. ## 2. Charge a renewal ```ruby response = client.transactions.charge_authorization( email: card.email, # the email the code was saved with amount: 5000, # pesewas, an Integer (100.0 raises InvalidValueError before sending) currency: "GHS", # always pass it authorization_code: card.authorization_code, reference: renewal_reference(membership.id, "2026-10", 1), metadata: {membership_id: membership.id, period: "2026-10"} # a Hash is sent as a JSON string; verify returns it as a Hash ) ``` Other keywords: `queue:` (Paystack's docs: for scheduled charges, so its processing system is not overloaded; with `queue: true` a test charge still answered `success` at once), `split_code:`, `split:`, `subaccount:`, `transaction_charge:`, `bearer:` (`"account"` or `"subaccount"`). Only `email`, `amount` and `authorization_code` are required by the method. ## 3. What comes back (observed on the test API 2026-10-09) | Situation | HTTP | `success?` | Message | Transaction created? | |---|---|---|---|---| | Good code, its own email | 200 | true | "Charge attempted", `data.status` `success` | Yes | | Code that does not exist | 400 | false | "Authorization code is invalid" | No | | Another customer's code (email mismatch) | 400 | false | "Email does not match Authorization code. Authorization may be inactive or belong to a different email. Please confirm." | No | | A code you deactivated (`customers.deactivate_authorization`) | 400 | false | Same "Email does not match..." message | No | | A mobile money code (`reusable: false`), its own email | 400 | false | Same "Email does not match..." message | No | | Currency the account does not take (USD) | 400 | false | "Currency not supported by merchant" | No | | A reference already used | 400 | false | "Duplicate Transaction Reference" | No new one | | `verify` of a reference Paystack never saw | 400 | false | "Transaction reference not found." | n/a | None of these raise: read `response.error_message`. A 400 on `charge_authorization` did not create a transaction, so the same reference stayed free: a valid charge on it afterwards succeeded. Three different causes share one message, so do not try to tell "wrong email", "deactivated" and "not reusable" apart from the text. **Not observed: a declined card.** No test card here declines a saved-card charge, so a 200 with `data.status` `failed` (insufficient funds, expired card) was not seen. Handle every status that is not `success` as not paid. Paystack's guide also documents a response with `data.paused` true and `data.authorization_url` when a card is challenged for two-factor authentication (Nigerian integrations, on request): the payer must open that URL, and you verify the reference afterwards. ## 4. References, lookup-first jobs and the timeout rule One reference per **attempt**, built from your own record id and the billing period, and stored before the call. A retry of the same job (a crash, a timeout, a redeploy) reuses that reference and looks it up first. A deliberate new attempt after a decline gets the next attempt number. ```ruby def renewal_reference(record_id, period, attempt) reference = "renewal-#{record_id}-#{period}-a#{attempt}" raise ArgumentError, "unusable reference #{reference.inspect}" unless reference.match?(/\A[a-zA-Z0-9._=-]+\z/) reference end # :paid, :failed, :pending (look again later), :check_amount (paid, but not what you asked for) def renewal_outcome(response, amount:, currency:) return :paid if response.paid?(amount: amount, currency: currency) return :check_amount if response.status?(:success) return :failed if response.status?(:failed) :pending end # Returns [outcome, detail]. Outcomes: :paid, :failed, :pending, :check_amount, # :rejected (400, nothing charged: detail is Paystack's message) and :unknown (verify this reference later). def charge_renewal(client, card:, amount:, currency:, reference:) existing = client.transactions.verify(reference: reference) return [renewal_outcome(existing, amount: amount, currency: currency), nil] if existing.success? response = client.transactions.charge_authorization(email: card.email, amount: amount, currency: currency, authorization_code: card.authorization_code, reference: reference) return [:unknown, response.error_message] if response.error_message.to_s.include?("Duplicate") return [:rejected, response.error_message] unless response.success? [renewal_outcome(client.transactions.verify(reference: reference), amount: amount, currency: currency), nil] rescue PaystackSdk::TimeoutError, PaystackSdk::ConnectionError, PaystackSdk::ServerError => e [:unknown, e.class.name] # the charge may have happened: never resend now, verify this reference on the next run end ``` - **Never retry a charge after a timeout, connection error or 5xx.** The SDK does not (writes retry only on 429). The charge may have gone through: verify the reference first. `charge_renewal` returns `:unknown` and the next run starts with `verify`. - If you do send a reference twice, Paystack refuses the second with "Duplicate Transaction Reference" (observed), so the lookup-first job cannot double charge one attempt. - Grant the period only on `:paid`, which is `paid?(amount:, currency:)` on a `verify` by reference. - Run on the test API 2026-10-09: the first `charge_renewal` charged and returned `[:paid, nil]`; a second call with the same reference returned `[:paid, nil]` from the lookup without charging; another customer's code returned `[:rejected, "Email does not match..."]`. ## 5. Partial debit `transactions.partial_debit(email:, amount:, authorization_code:, currency:, at_least: nil, reference: nil)` charges a saved card for **whatever it can**, up to `amount`, when the full amount would fail for insufficient funds. Per Paystack's Partial Debits guide it is available only on request, only for Mastercard and Verve, and `verify` then returns `amount` (what was charged) and `requested_amount` (what you asked for). So `paid?(amount: requested, ...)` is false for a partial charge, and `renewal_outcome` returns `:check_amount`: record what was actually paid and bill the rest. Observed on the test API 2026-10-09: `partial_debit` in GHS answered HTTP 400 "Currency GHS is not supported for Partial Debit", although the API reference lists NGN or GHS. **For a Ghana integration, treat partial debit as unavailable** unless Paystack enables it for you. No partial charge was observed. ## 6. Two confirmations racing (reported by a consumer, not reproduced) An app reported that a webhook and a return-URL confirmation for the same first card payment ran at the same time and the reusable authorization was lost; they now re-verify by reference whenever the token is missing. What was observed (test API 2026-10-09): `transactions.verify` on a paid reference returned the same authorization (code, `reusable: true`, signature) on two calls in a row, for a saved-card charge and for a first card charge. So re-verifying is a sound recovery. Design advice: save the authorization in one place, idempotently (a unique index on email + signature, then insert-or-update), and have both confirmation paths call it. Also observed: after `customers.deactivate_authorization`, `verify` of the old reference still said `reusable: true`, while `customers.fetch` listed no authorizations. `reusable` is a snapshot of the payment; it does not tell you the code still works. ## 7. Mobile money is not a saved card A paid test mobile money charge (`0551234987`, `mtn`, observed 2026-10-09) returned an authorization with `reusable: false`, `signature: nil` and `channel: "mobile_money"`, and charging its code was refused (table above). There is no token to renew with: each mobile money renewal is a new payer-approved charge (see the `paystack-sdk-mobile-money` skill), or a payment link sent to the payer. ## 8. Dunning (design advice, not Paystack behaviour) - `:failed`: retry a few times on later days with a new attempt number each time, then stop and ask the payer to pay through checkout (which can save a new card). - `:rejected`: the code is unusable (invalid, inactive, another email, or not reusable). Do not retry it. Mark the card unusable and ask the payer to pay and save a card again. - `:unknown` and `:pending`: verify the same reference later; do not charge again. - `:check_amount`: record the partial payment and do not grant the full period. - Warn payers before `exp_month`/`exp_year` passes. What Paystack returns for an expired saved card was not observed. ## Rules - **Charge only codes saved with `reusable: true`, with the exact email they were saved with.** - **Store the code encrypted and keyed to that email; never store card data or anything the payer typed.** Keep codes and keys out of logs. - **One reference per attempt, stored before the call; a job retry looks it up first.** - **Never resend after a timeout or 5xx; verify the reference.** - **Grant the period only on `verify(reference:).paid?(amount:, currency:)`**, never on the charge response alone. - **Amounts are Integers in pesewas, and always pass `currency: "GHS"`.** ## Not verified A declined saved-card charge, an expired card, the `paused` two-factor flow, `queue: true` in live mode, a successful partial debit, and anything in live mode. Test mode is lenient. The consumer-reported race was not reproduced. --- # Skill: paystack-sdk-testing # Testing an app that uses paystack_sdk Three layers, cheapest first: (1) **HTTP stubs** in your normal suite (fast, offline, cover every error path); (2) an **opt-in test-API suite** that talks to Paystack with an `sk_test_` key; (3) a **manual live check** for what test mode cannot prove (last section). Conventions the other skills assume are in `paystack-sdk-overview`. ## Keys | Where | Key | Client | |---|---|---| | Unit tests | none needed; a dummy `sk_test_dummy` | `Client.new(secret_key: "sk_test_dummy")`, requests stubbed | | Test-API suite, CI, staging, development | your Paystack **test** secret key (`sk_test_...`) | `Client.new(secret_key: key, sandbox_only: true)` | | Production | the live key (`sk_live_...`) | no `sandbox_only` | ```ruby client = PaystackSdk::Client.new(secret_key: ENV.fetch("PAYSTACK_SECRET_KEY"), sandbox_only: true) client.live? # => false for sk_test_ keys; true only for sk_live_ keys ``` - `sandbox_only: true` raises `ArgumentError` at construction unless the key starts with `sk_test_`. It is a **prefix check**. It stops a live key from being used by mistake. It cannot stop a test-key call that Paystack itself routes to live mode (publishing a storefront copies it to the live integration even with a test key), so keep tests off calls that publish, register, activate, notify or pay out. - `client.live?` is true only for a key starting `sk_live_`. Assert `expect(client).not_to be_live` in the test-API suite as a tripwire. - Test and live are separate worlds: customers, transactions, saved cards (authorization codes), plans and references made with a test key do not exist in live, and the reverse. Never copy a test authorization code into a live flow. - Never commit a key or put it in a log. Read it from the environment (CI secret). ## Stub Paystack at the HTTP edge (WebMock) Stub the request to `https://api.paystack.co/...` and let `PaystackSdk::Client` run for real. Do not replace `client.transactions` or `Client` with a double: the SDK validates keywords before sending, builds the request, and wraps the body in `Response` (`success?`, `paid?`, `error_message`, dot access). A double skips all of that, so the test passes on code the SDK would reject, with a response shape the SDK never returns. Copy this into `spec/support/paystack_stubs.rb` (it needs `webmock/rspec`): ```ruby module PaystackStubs PAYSTACK_API = "https://api.paystack.co" # Paystack's success envelope: {status: true, message:, data: {...}} def paystack_ok(data = {}, message: "OK", meta: nil) body = {status: true, message: message, data: data} body[:meta] = meta if meta body end # Paystack's error envelope: {status: false, message:, meta: {nextStep:}, type:, code:} def paystack_error(message, code: nil, type: "validation_error", next_step: nil) body = {status: false, message: message} body[:meta] = {nextStep: next_step} if next_step body[:type] = type body[:code] = code if code body end # A transaction as `transactions.verify` returns it. Override any field. def paystack_transaction(overrides = {}) { id: 1, reference: "order-1042", status: "success", amount: 5000, currency: "GHS", channel: "card", paid_at: "2026-01-01T10:00:00.000Z", customer: {email: "ama@example.com"}, authorization: {authorization_code: "AUTH_abc123", reusable: true} }.merge(overrides) end # A stubbed response. `path` includes the query string if the request has one. def stub_paystack(verb, path, body, status: 200, headers: {}) stub_request(verb, "#{PAYSTACK_API}#{path}").to_return( status: status, body: body.to_json, headers: {"Content-Type" => "application/json"}.merge(headers) ) end def stub_paystack_timeout(verb, path) stub_request(verb, "#{PAYSTACK_API}#{path}").to_timeout end end ``` Add `config.include PaystackStubs` in `spec/spec_helper.rb` (or `rails_helper.rb`) and `WebMock.disable_net_connect!` (the default with `webmock/rspec`). ### Each case, as the SDK sees it ```ruby let(:client) { PaystackSdk::Client.new(secret_key: "sk_test_dummy", retry_interval: 0) } # Success: initialize stub_paystack(:post, "/transaction/initialize", paystack_ok( {authorization_url: "https://checkout.paystack.com/abc", access_code: "abc", reference: "order-1042"}, message: "Authorization URL created" )) response = client.transactions.initiate(email: "ama@example.com", amount: 5000, currency: "GHS", reference: "order-1042") response.success? # => true response.authorization_url # => "https://checkout.paystack.com/abc" # Success: verify, then the check your app must make stub_paystack(:get, "/transaction/verify/order-1042", paystack_ok(paystack_transaction, message: "Verification successful")) client.transactions.verify(reference: "order-1042").paid?(amount: 5000, currency: "GHS") # => true # 400 (and 404, 422): NOT raised, an unsuccessful Response stub_paystack(:post, "/transaction/initialize", paystack_error("Duplicate Transaction Reference", code: "duplicate_reference"), status: 400) response = client.transactions.initiate(email: "ama@example.com", amount: 5000, reference: "order-1042") response.success? # => false response.error_message # => "Duplicate Transaction Reference" # 401: raises PaystackSdk::AuthenticationError ("Invalid key") stub_paystack(:get, "/transaction/verify/x", paystack_error("Invalid key", code: "invalid_Key"), status: 401) # 429: raises PaystackSdk::RateLimitError; retry_after comes from the x-ratelimit-reset header stub_paystack(:get, "/transaction/verify/x", paystack_error("Rate limit exceeded"), status: 429, headers: {"x-ratelimit-reset" => "30"}) # 500: raises PaystackSdk::ServerError (status_code 500) stub_paystack(:post, "/transaction/initialize", paystack_error("Internal server error"), status: 500) # Timeout: raises PaystackSdk::TimeoutError stub_paystack_timeout(:post, "/transaction/initialize") ``` Assert what you send, not only what you get back: ```ruby stub = stub_paystack(:post, "/transaction/initialize", paystack_ok({reference: "order-1042"})) client.transactions.initiate(email: "ama@example.com", amount: 5000, currency: "GHS", reference: "order-1042") expect(stub.with(body: hash_including("amount" => 5000, "currency" => "GHS"))).to have_been_requested ``` Shapes, and how they were established: - **Observed on the test API 2026-10-09**: the 401, 400 and 404 bodies. 401 (bad key): `{"status":false,"message":"Invalid key","meta":{"nextStep":"Ensure that you provide the correct authorization key for the request"},"type":"validation_error","code":"invalid_Key"}`. 400 (`transaction/initialize` with email `not-an-email`): message `"Invalid Email Address Passed"`, `type` `"validation_error"`, `code` `"invalid_email_address"`. 404 (`transaction/verify` of an unknown reference): message `"Transaction reference not found."`, `code` `"transaction_not_found"`. The success shapes of initialize and verify were also observed; `paystack_transaction` is a trimmed version of the real verify `data`, not a full copy. - **Not observed**: the real bodies of a 429 and a 500 (stubbed with the same envelope as the others, which is an assumption), and a real timeout. What the SDK does with them is checked by the gem's own specs; the status codes and the header are what matters. - The SDK retries reads on network errors and 429/502/503/504, and writes only on 429. In tests pass `retry_interval: 0` (as above) so a stubbed 429 or timeout does not sleep. A 429 whose `x-ratelimit-reset` is more than 10 seconds is raised at once. The SDK validates input before sending, so a stub cannot make it send a bad email or a missing field: `initiate(email: "x", ...)` raises `PaystackSdk::InvalidFormatError` and never reaches your stub. Stub a 400 for something only Paystack can know (a duplicate reference, an unknown customer). ### What to test in your own code For each Paystack call your app makes, one spec per branch the app must handle: success; unsuccessful (`success?` false, 400); `AuthenticationError` (alert, do not retry); `RateLimitError`; `ServerError` and `TimeoutError` **on a write** (the app must verify by reference before retrying, never re-send); and "verify says not paid" (amount or currency mismatch: build it with `paystack_transaction(amount: 4000)`). ## An opt-in test-API suite The gem's own `spec/sandbox/transactions_spec.rb` runs against Paystack's real test API only when `PAYSTACK_TEST_SECRET_KEY` is set, and is skipped otherwise. Do the same: ```ruby RSpec.describe "Paystack test API", :paystack_sandbox do key = ENV["PAYSTACK_TEST_SECRET_KEY"].to_s before do skip "set PAYSTACK_TEST_SECRET_KEY to an sk_test_ key" unless key.start_with?("sk_test_") WebMock.allow_net_connect! end after { WebMock.disable_net_connect! } let(:client) { PaystackSdk::Client.new(secret_key: key, sandbox_only: true) } it "initializes and verifies a 100 pesewa payment" do reference = "myapp-test-#{Time.now.to_i}-#{rand(100_000)}" init = client.transactions.initiate(email: "test@example.com", amount: 100, currency: "GHS", reference: reference) expect(init).to be_success expect(init.reference).to eq(reference) verified = client.transactions.verify(reference: reference) expect(verified.status).to eq("abandoned") # nobody paid it expect(verified.paid?(amount: 100, currency: "GHS")).to be(false) end end ``` - Skip when the key is unset or is not `sk_test_`; never fall back to `PAYSTACK_SECRET_KEY`, which on a developer machine may be live. - Amounts of 100 pesewas (GHS 1.00). A **unique reference** per run: observed on the test API 2026-10-09, initializing a reference twice returns an unsuccessful Response, `Duplicate Transaction Reference` (`duplicate_reference`), the second time. - Keep it out of the default run (tag it and exclude the tag, or run only `rspec spec/paystack_sandbox`). Each run leaves a test-mode transaction on the account (an abandoned one, in the example above). - Do not call endpoints that publish, register, activate, notify or pay out from the suite (see Keys). ## Paystack's published test data Documented on Paystack's Test Payments page (https://paystack.com/docs/payments/test-payments/), not observed by this gem except where marked. Expiry can be any future date (the page shows 09/27). | Card | Number | CVV | Extra steps | |---|---|---|---| | No validation, reusable | 4084 0840 8408 4081 | 408 | none | | PIN | 5078 5078 5078 5078 12 | 081 | PIN 1111 | | PIN + OTP | 5060 6666 6666 6666 666 | 123 | PIN 1234, OTP 123456 | | PIN + phone + OTP | 5078 5078 5078 5078 04 | 884 | PIN 0000, OTP 123456 | The page also lists a declined card (4084 0800 0000 5408, CVV 001), a card where no token is generated (5078 5078 5078 5078 53, CVV 082, PIN 0000), and cards that decide how a **refund** ends (failed 4084 0800 0067 1803 CVV 180; needs attention 4084 0800 0067 1902 CVV 190). Not exercised by this gem. - Cards are entered on Paystack's checkout page (from `initiate`'s `authorization_url`). Through the API a card goes through the raw connection (`client.connection.post("/charge", ...)` with a `card` object) because `charges.create` has no card keyword. A real person's card number never belongs in your app's servers or logs; test numbers are the only card numbers in tests. - **Mobile money**: MTN test number `0551234987` (documented as "No PIN/OTP"). Observed on the test API 2026-10-09: `charges.mobile_money(... phone: "0551234987", provider: "mtn", currency: "GHS", amount: 100)` answered `Charge attempted` with status `success` at once, and `verify` then showed `success` and `paid?` true. This proves your code handles the happy path. **It proves nothing about live mobile money**, where the payer gets a prompt and the status is `pay_offline`/`send_pin`/`send_otp` for a while. See the `paystack-sdk-mobile-money` and `paystack-sdk-charge-statuses` skills, and drive those statuses with stubs. - The page's bank accounts (Zenith, Kuda), EFT accounts, M-PESA and Orange numbers are for Nigeria, Kenya or Cote d'Ivoire; skip them for Ghana. ## Testing a webhook endpoint Paystack signs the raw body with HMAC SHA512 of your secret key in `x-paystack-signature`. In a test, build a body, sign it with `PaystackSdk::Webhook.sign`, and post it to your endpoint with that header: ```ruby secret = "sk_test_dummy" body = {event: "charge.success", data: {id: 1, reference: "order-1042", status: "success", amount: 5000, currency: "GHS"}}.to_json signature = PaystackSdk::Webhook.sign(body, secret) event = PaystackSdk::Webhook.construct_event(payload: body, signature: signature, secret: secret) event.event # => "charge.success" event.data.reference # => "order-1042" PaystackSdk::Webhook.valid_signature?(payload: body + " ", signature: signature, secret: secret) # => false: one changed byte ``` Send exactly the string you signed (do not post a hash that the test framework re-serialises), assert a wrong or missing signature is rejected, and assert that the same event posted twice grants value once (Paystack retries events your server does not acknowledge, so the same event can arrive again; Paystack documents no event id, so dedupe on the event name plus `data.id` or `data.reference`). The endpoint should still **verify by reference** with the API rather than trust the body (stub `verify` with `stub_paystack`). ## What test mode cannot prove | Not proven by test mode | What to do instead | |---|---| | Live mobile money: the prompt, the wait, the payer declining or timing out | Stub every non-final status; then one supervised live payment of the smallest real amount, with a refund | | Webhooks actually arriving at your server (public URL, TLS, firewall, the three Paystack IPs, your response time) | Set the test webhook URL on the dashboard and make a test payment; or POST a signed body to your own endpoint | | Settlements and payouts to a bank or wallet | Check the first live settlement by hand | | Real declines, insufficient funds, fraud checks, bank 3DS | Stub the failure bodies; the docs list a declined test card, which is only a simulation | | Live-only configuration (live key, live webhook URL, live callback URL, account activation) | Review by hand before launch; `sandbox_only: true` cannot catch a wrong key in production | Not verified by this gem: any behaviour with a live key (it has never been exercised with one), the real 429 and 500 bodies, and the test-card flows through checkout. Say so rather than asserting them. ## Rules 1. Test and CI use `sk_test_` keys and `sandbox_only: true`; a live key never goes in a test environment. 2. Stub at HTTP with WebMock; do not mock `PaystackSdk::Client` or its resources. 3. Test the unhappy branches of every call: unsuccessful `Response`, 401, 429, 5xx, timeout, and "verify says not paid". 4. After a write times out or returns 5xx, the test expects a verify by reference, not a second write. 5. Test-API runs: opt in by environment variable, `sk_test_` only, 100 pesewas, unique references, nothing that publishes or pays out. 6. Passing test mode is not a launch check for mobile money, webhooks or settlements. --- # Skill: paystack-sdk-webhooks # Receiving Paystack webhooks A webhook is Paystack POSTing a JSON event to a URL you register on the Paystack dashboard. `PaystackSdk::Webhook` checks the signature and parses the event. It does not know your framework, your database or your jobs. What is executed and what is not: every Ruby block marked "runs in the spec" is run by `spec/skills_webhooks_spec.rb`. The Rails blocks are not run (the gem has no Rails dependency); they use ordinary Rails methods and are marked. Facts from Paystack's Webhooks page (payments/webhooks) are labelled "documented". The payload shapes below were observed in Paystack's own event log (the Webhook Events API) on a test integration: they are what Paystack recorded as sent. No webhook was received by code written for this skill: the test API cannot be made to send one to a URL we control. ## The signature rule (documented) Paystack sends the header `x-paystack-signature`: the HMAC SHA512, as lowercase hex, of the event payload signed with your secret key. Verify it **before** doing anything with the event, over the **raw request body, byte for byte**. A body that was parsed and re-serialised does not match. | Call | Returns | Raises | |---|---|---| | `Webhook.valid_signature?(payload:, signature:, secret:)` | `true` / `false` (constant-time compare). A `nil`, empty, truncated or upper-case signature is `false` | `ArgumentError` if `payload` is not a String (a parsed Hash, for example) or `secret` is blank | | `Webhook.verify!(payload:, signature:, secret:)` | `true` | `PaystackSdk::InvalidSignatureError`; also the `ArgumentError`s above | | `Webhook.construct_event(payload:, signature:, secret:)` | a `Webhook::Event` (`event`, `payload`, `data`, `known?`) | `InvalidSignatureError` (nothing is parsed first), `InvalidPayloadError` if the signed body is not a JSON object with a non-empty string `"event"`; also the `ArgumentError`s | | `Webhook.sign(payload, secret)` | the lowercase hex signature, for your own tests | | `InvalidSignatureError` and `InvalidPayloadError` are both `PaystackSdk::WebhookError`, which is a `PaystackSdk::Error`. `event.data` is the event's `data` wrapped for dot access (`event.data.reference`, `event.data[:amount]`), or `nil` if the event carries none, or an array-like when `data` is an array (`subscription.expiring_cards`). `event.payload` is the string-keyed Hash. Use `construct_event` unless you only need a yes/no. Rescue `PaystackSdk::WebhookError`, not `Error`, so a real SDK problem is not swallowed. The `ArgumentError`s are programming errors (blank secret): let them raise. ## Rails controller (not run in the spec; standard Rails methods) ```ruby class PaystackWebhooksController < ApplicationController # The request comes from Paystack's servers: there is no session and no CSRF token. skip_before_action :verify_authenticity_token # Also skip any login filter your ApplicationController adds (authenticate_user! and the like). def create event = PaystackSdk::Webhook.construct_event( payload: request.raw_post, # the raw body, never params or request.body.read after parsing signature: request.headers["X-Paystack-Signature"], secret: Rails.application.credentials.paystack_secret_key ) # Insert-or-ignore, then enqueue only the first time (see "Duplicates" below). if PaystackEvent.claim(event) HandlePaystackEventJob.perform_later(event.payload) end head :ok rescue PaystackSdk::WebhookError head :bad_request end end ``` ```ruby # config/routes.rb post "/paystack/webhook", to: "paystack_webhooks#create" ``` Rules for this action: read `request.raw_post`; never pass `params` or a re-generated JSON string; keep the secret key in credentials or ENV and out of logs; do not log the whole payload at info level (it holds customer emails and card metadata). Use the secret key of the same Paystack environment that is calling: a test key verifies test events, a live key live events (the signature is made with the key of the account and mode that sent it; documented as "signed using your secret key"; which of your keys signs which mode was not tested here). ## A framework-free receiver (runs in the spec) This is the same logic with no Rails: a receiver returning an HTTP status, and a Rack app around it. `claim` is your idempotency store (next section); here it is any object whose `claim(key)` returns true the first time it sees a key. ```ruby class PaystackReceiver def initialize(secret:, store:, enqueue:, allowed_ips: nil) @secret, @store, @enqueue, @allowed_ips = secret, store, enqueue, allowed_ips end def call(raw_body:, signature:, ip: nil) return 403 if @allowed_ips && !@allowed_ips.include?(ip) event = PaystackSdk::Webhook.construct_event(payload: raw_body, signature: signature, secret: @secret) @enqueue.call(event.payload) if @store.claim(self.class.key(event)) 200 rescue PaystackSdk::WebhookError 400 end # event name + data.id, else event name + data.reference def self.key(event) data = event.payload["data"] data = data.first if data.is_a?(Array) id = data.is_a?(Hash) ? (data["id"] || data["reference"]) : nil [event.event, id].join(":") end end class PaystackWebhookApp def initialize(receiver) @receiver = receiver end def call(env) input = env["rack.input"] body = input.read input.rewind status = @receiver.call(raw_body: body, signature: env["HTTP_X_PAYSTACK_SIGNATURE"], ip: env["REMOTE_ADDR"]) [status, {"content-type" => "text/plain"}, [""]] end end ``` Pass `allowed_ips: PaystackSdk::Webhook::IP_ADDRESSES` to turn the IP check on. A bad signature gets 400 here (and in the gem's own example); 401 or 403 would also be reasonable, Paystack only needs a non-200 to know it was not accepted. Never answer 200 to an event you rejected. ## Answer fast, work later (documented) Paystack: return `200 OK` immediately; long-running work in the webhook function leads to a timeout and an automatic error response, and without a 200 the event is retried. Documented retry policy: **live mode**, every 3 minutes for the first 4 tries, then hourly for the next 72 hours; **test mode**, hourly for 10 hours, with a 30 second timeout per attempt. A response that is not 200 (including a timeout) is "a failed attempt". So: verify the signature, store or enqueue, answer 200, and do the real work in a job (`HandlePaystackEventJob` above). Not observed here: the schedule was not seen in action. Paystack also documents a Webhook Events API (list, look up and resend events to your webhook URL). `paystack_sdk` has no resource for it as of this version; call it through `client.connection` only after reading its docs page. ## Duplicates and out-of-order delivery (design advice, not Paystack behaviour) Retries mean the same event can reach you more than once, and a retry of an old event can land after a newer one. The Webhooks page documents no event id. So: - **Dedupe key**: event name + `data.id`. Observed (event log, test integration): every payload is `{"event": ..., "data": {...}}` with **no event id at the top level**, and `data.id` was present in every payload seen (`charge.success`, `refund.pending`, `refund.processed`, `product.create`, `product.update`, `product.delete`, `subscription.create`, `subscription.not_renew`). `data.reference` exists only on transaction events (`charge.success`); refund events carry `data.transaction_reference` and `data.refund_reference` instead. So a refund that goes `refund.pending` then `refund.processed` has the same `data.id` and two different event names: the name has to be in the key. Fall back to `data.reference` only for an event type you have checked, and an event with neither collapses to its name alone, so check the payload of each event type you handle (the Webhook Events API below shows exactly what Paystack sent). - **Store the key with a unique index** and insert before you act: ```ruby # Rails migration and model: design advice, not run in the spec class CreatePaystackEvents < ActiveRecord::Migration[7.1] def change create_table :paystack_events do |t| t.string :key, null: false t.string :event, null: false t.datetime :processed_at t.timestamps end add_index :paystack_events, :key, unique: true end end class PaystackEvent < ApplicationRecord # true only for the delivery that created the row def self.claim(event) create!(key: PaystackReceiver.key(event), event: event.event) true rescue ActiveRecord::RecordNotUnique false end end ``` - **The job must be safe to run twice** anyway: it re-reads the current state, does its change in a transaction, and sets `processed_at`. A crash between claim and enqueue loses nothing if the claim and the enqueue share a transaction (a database-backed queue) or the job also checks `processed_at`; with a Redis queue, choose which failure you prefer (a lost event, or a duplicate job) and make the job idempotent. - **Out of order**: do not apply an event as a state change from the payload alone ("set status to what the event says"). Fetch the current state from Paystack and apply that (below), so an old event cannot overwrite a newer one. ## Never grant value from the payload alone A valid signature proves the event came from Paystack. It does not prove the amount, the currency or that the payment is still good. For anything that moves value (marking an invoice paid, giving a membership, crediting a wallet), the job does what a return URL would do: verify by reference and compare to what you expected. ```ruby # inside HandlePaystackEventJob, for "charge.success" reference = payload.dig("data", "reference") response = client.transactions.verify(reference: reference) if response.paid?(amount: expected_amount_in_pesewas, currency: "GHS") # grant value once; look the order up by reference, not by anything in the payload end ``` `expected_amount_in_pesewas` comes from your own record of the order, not from the event. See `paystack-sdk-payments` (accepting a payment and verifying it), `paystack-sdk-overview` and `paystack-sdk-charge-statuses`. Unknown reference, `paid?` false, or a mismatched amount: log it and do not grant. ## Which events (documented list = `Webhook::EVENTS`) `event.known?` is true for these 29. Paystack says it adds events over time, so an unknown name is still returned by `construct_event`: log it and answer 200. | Group | Events | |---|---| | Payments | `charge.success` (a successful charge was made) | | Refunds | `refund.pending` (initiated, waiting for the processor), `refund.processing` (received by the processor), `refund.processed` (done), `refund.failed` (cannot be processed; your account is credited with the refund amount), `refund.needs-attention` (named in Paystack's Refunds guide, not on the Webhooks page; the retry endpoint is for this state) | | Transfers | `transfer.success`, `transfer.failed`, `transfer.reversed` | | Subscriptions | `subscription.create`, `subscription.disable`, `subscription.not_renew` (status changed to non-renewing; will not be charged on the next payment date), `subscription.expiring_cards` (all subscriptions with cards expiring that month; sent at the start of the month) | | Invoices | `invoice.create` (usually 3 days before the subscription is due), `invoice.update` (usually means the customer was charged; inspect the invoice object), `invoice.payment_failed` | | Disputes | `charge.dispute.create`, `charge.dispute.remind`, `charge.dispute.resolve` | | Payment requests | `paymentrequest.pending`, `paymentrequest.success`, `paymentrequest.draft` (not on the Webhooks page; seen in an event log) | | Products | `product.create`, `product.update`, `product.delete` (not on the Webhooks page; seen in an event log) | | Customer identification | `customeridentification.success`, `customeridentification.failed` | | Dedicated accounts | `dedicatedaccount.assign.success`, `dedicatedaccount.assign.failed` | Payload shapes (observed in the event log of a test integration; `data` keys listed by event type): | Event | `data` carries | |---|---| | `charge.success` | `id`, `reference`, `status`, `amount`, `currency`, `paid_at`, `channel`, `gateway_response`, `metadata`, `fees`, `authorization` (the reusable `authorization_code` and card details), `customer`, `plan`, `subaccount`, `split` and more | | `refund.pending`, `refund.processed` | `id` (the refund's), `status`, `transaction_reference`, `refund_reference`, `amount`, `currency`, `customer`, `integration`, `domain`, `customer_note`, `merchant_note` (no `reference`) | | `subscription.create`, `subscription.not_renew` | `id`, `status`, `subscription_code`, `email_token`, `amount`, `next_payment_date`, `plan`, `authorization`, `customer` | | `product.create`, `product.update`, `product.delete` | `id`, `product_code`, `name`, `price`, `currency`, `quantity`, `active` | Everything above is a record of what Paystack sent to a test integration, not what your endpoint will see in live mode. Not observed: the payloads of the other events, and live payloads. The documented sample for `customeridentification.failed` has `customer_id`, `customer_code`, `email`, `identification` and `reason`. Still verify by reference instead of trusting any of these fields for money (below). ## See what Paystack sent, and replay it: `client.webhook_events` The Webhook Events API (documented by Paystack; not in its OpenAPI spec) is the log of every webhook Paystack tried to send you. It answers "did my endpoint get it?" without guessing. ```ruby client.webhook_events.list(status: "Failed", limit: 20) # Delivered, Pending or Failed client.webhook_events.list(category: "transactions", event_type: "charge.success") client.webhook_events.lookup(id: 6_641_907_106) # by a transaction id, or by an event _id event = client.webhook_events.fetch(id: "6ac94b47fdad55440ef2066a") event.status_detail # "retrying" event.response_code # what your endpoint answered, e.g. 404 event.merchant_response_body # the body your endpoint answered with event.event_payload[:data] # exactly what Paystack sent (brackets: Response#data is a method) ``` Observed on the test API: a list page holds 50 events (the docs say 20), newest first; `meta.next` is a cursor you pass back as `next_cursor:` (not together with `previous:`); an event is `Pending` with `status_detail` `retrying` while Paystack still retries, and `Failed` once it gave up; the event's `_id` is a MongoDB-style id, so it is the one id Paystack gives an event, although it is not in the payload you receive. A `Failed` list on a test integration whose URL returned 404 showed `response_code` 404 for every event, which is how you find a broken endpoint. `resend(ids:)` and `resend_matching(preview:, filters:)` make Paystack deliver events again. **They were never called against the test API** (they would hit your endpoint), the docs say resending is not idempotent, and `resend_matching` has no default for `preview:` on purpose: call it with `preview: true` first, and make sure your endpoint dedupes before you resend anything. ## Test your endpoint locally Paystack cannot reach localhost (documented: "localhost URLs can't receive events"), and this skill's authors could not make the Paystack test API send a real webhook. So test the endpoint yourself by signing a body with the same secret and posting it. This is what the spec runs, against the Rack app above: ```ruby secret = "sk_test_example" # a placeholder, never a real key body = {event: "charge.success", data: {id: 302961, reference: "ref-1"}}.to_json signature = PaystackSdk::Webhook.sign(body, secret) # then POST `body` (exactly this string) with the header X-Paystack-Signature: signature ``` In a Rails request spec (not run here) post the string, not a Hash, so the bytes are what you signed: ```ruby post "/paystack/webhook", params: body, headers: {"CONTENT_TYPE" => "application/json", "X-Paystack-Signature" => signature} expect(response).to have_http_status(:ok) ``` Cases to cover: valid signature (200 and a job enqueued), body changed after signing (400, no job), missing header (400), signature made with another key (400), the same delivery twice (200 both times, one job). To try a real delivery you need a public HTTPS URL (a tunnel, or a staging host) registered as the webhook URL on the dashboard, and an action that fires an event. That was not done for this skill. ## Behind a proxy: the IP allow-list (design advice) `Webhook.trusted_ip?(ip)` is true only for the three addresses Paystack documents for both test and live (`52.31.139.75`, `52.49.173.169`, `52.214.14.220`). Paystack lists signature validation and IP allow-listing as the two ways to verify origin. Treat the IP check as a second layer next to the signature, never instead of it. Behind a load balancer or CDN the socket address (`REMOTE_ADDR`) is the proxy, not Paystack. In Rails `request.remote_ip` resolves the client address through the proxies Rails is configured to trust; if the trusted-proxy setting is wrong, either everything is rejected or a spoofed `X-Forwarded-For` is believed. Not tested here. If you cannot get the client IP right, skip the IP check and rely on the signature, or enforce the allow-list at the firewall/load balancer instead of in Ruby. ## Rules - **Verify the signature on the raw body first.** `request.raw_post` in Rails, `rack.input` in Rack. Never `params`. - **Skip CSRF and login filters on this one action**, and nowhere else. - **Respond 200 quickly**; do the work in a background job. - **Reject with a non-2xx** (400) when the signature or payload is bad. - **Dedupe** on event name + `data.id` / `data.reference` with a unique index, and make the job idempotent. - **Verify by reference and check `paid?(amount:, currency:)`** against your own record before granting value. - **Handle unknown events** by logging and answering 200. - **Keep the secret key out of code, logs and the repo.** ## Not verified No webhook was received by code written for this skill. Unverified: that the signature is made with the key of the mode that sent the event, the payloads of every event not listed in the table above, live payloads, the live retry schedule in practice, how your proxy reports the client IP, and `resend` and `resend_matching` (never called). The retry schedule, header name, signature algorithm and IP list are as stated on Paystack's Webhooks documentation page. --- # Introduction The `paystack_sdk` gem provides a simple and intuitive interface for interacting with Paystack's payment gateway API. It allows developers to easily integrate Paystack's payment processing features into their Ruby applications. With support for various endpoints, this SDK simplifies tasks such as initiating transactions, verifying payments, managing customers, and more. --- # AI Skills The gem ships skills for AI coding agents (Claude Code `SKILL.md` folders) that teach an agent how to use `paystack_sdk` correctly: the conventions, what raises and what returns, and the checks that keep money safe. They are installed from the gem you have, so they match its version. ```sh # Any Ruby project: installs into ./.claude/skills bundle exec paystack_sdk skills install # A Rails app bin/rails generate paystack_sdk:skills ``` - **Safe to repeat.** A second run reports `identical`. After a gem upgrade, run it again: it updates only the folders it installed. - **Never overwrites your skills.** Every folder it writes is named `paystack-sdk-` and carries a `.paystack_sdk.json` marker. A folder with the same name that it did not install is left alone and reported as a `conflict` (`--force` replaces it). It removes only its own folders that a newer gem no longer ships. - **Version-stamped.** Each installed `SKILL.md` records the gem version in its frontmatter (`metadata.gem_version`). - Options: `--dir DIR` (another directory), `--global` (`~/.claude/skills`), `--dry-run` (change nothing; `rails generate ... --pretend` in Rails). Other commands: `paystack_sdk skills list`, `skills path` (where the skills live inside the gem, for agents that read files directly), `skills uninstall`, `paystack_sdk version`. Run `paystack_sdk skills list` for the skills in your installed version and when each one applies. Each is a `paystack-sdk-` folder; an agent picks them from their descriptions. --- # Installation Add this line to your application's Gemfile: ```ruby gem 'paystack_sdk' ``` And then execute: ```bash bundle install ``` Or install it yourself as: ```bash gem install paystack_sdk ``` --- # Quick Start ```ruby require 'paystack_sdk' # Initialize the client with your secret key paystack = PaystackSdk::Client.new(secret_key: "sk_test_xxx") # Initialize a transaction begin response = paystack.transactions.initiate( email: "customer@email.com", amount: 2300, # Amount in the smallest currency unit (kobo for NGN) currency: "NGN" ) if response.success? puts "Visit this URL to complete payment: #{response.authorization_url}" puts "Transaction reference: #{response.reference}" else puts "Error: #{response.error_message}" end rescue ArgumentError => e puts "Missing required data: #{e.message}" rescue PaystackSdk::InvalidFormatError => e puts "Invalid data format: #{e.message}" end # Create a customer begin customer_response = paystack.customers.create( email: "customer@email.com", first_name: "John", last_name: "Doe" ) if customer_response.success? puts "Customer created: #{customer_response.data.customer_code}" else puts "Error: #{customer_response.error_message}" end rescue PaystackSdk::ValidationError => e puts "Validation error: #{e.message}" end ``` ## Response Format The SDK handles API responses that use string keys (as returned by Paystack) and provides seamless access through both string and symbol notation. All response data maintains the original string key format from the API while offering convenient dot notation access. ## Error Handling The SDK uses a two-tier error handling approach: 1. **Validation Errors** (thrown as exceptions) - for missing or invalid input data 2. **API Response Errors** (returned as unsuccessful Response objects) - for API-level issues #### Input Validation The SDK validates your parameters **before** making API calls and throws exceptions immediately for data issues: ```ruby begin # Ruby raises ArgumentError for a missing required keyword, before any API call response = paystack.transactions.initiate(amount: 1000) # Missing required email rescue ArgumentError => e puts "Fix your data: #{e.message}" # => "missing keyword: :email" end ``` #### API Response Handling All successful API calls return a `Response` object that you can check for success: ```ruby response = paystack.transactions.initiate(**valid_params) if response.success? puts "Transaction created: #{response.authorization_url}" else puts "Transaction failed: #{response.error_message}" # Get detailed error information error_details = response.error_details puts "Status code: #{error_details[:status_code]}" puts "Error message: #{error_details[:message]}" end ``` **Note**: The SDK raises exceptions for: - **Validation errors** - when required parameters are missing or have invalid formats - **Authentication errors** (401) - usually configuration issues - **Rate limiting** (429) - raised after automatic retries are exhausted, or immediately if Paystack asks for a long wait (see [Timeouts and Retries](https://nanafox.github.io/paystack_sdk/latest/md/guide/timeouts-and-retries.md)) - **Server errors** (5xx) - Paystack infrastructure issues - **Network errors** - timeouts and connection failures, raised as `PaystackSdk::TimeoutError` / `PaystackSdk::ConnectionError` All other API errors (resource not found, business logic errors, etc.) are returned as unsuccessful Response objects. --- # Client Initialization ```ruby # Initialize with your Paystack secret key paystack = PaystackSdk::Client.new(secret_key: "sk_test_xxx") # Or set the PAYSTACK_SECRET_KEY in your environment and do this instead paystack = PaystackSdk::Client.new # => This will dynamically fetch the secret key # You can access the connection directly if needed connection = paystack.connection ``` --- # Test and Live Keys 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: ```ruby client = PaystackSdk::Client.new(secret_key: ENV["PAYSTACK_SECRET_KEY"], sandbox_only: true) # => ArgumentError if the key is a live key, or anything not recognisable as a test key client.live? # => false ``` The error never includes the key. A pre-built Faraday connection is checked too (the key is read from its `Authorization` header). --- # Transactions The SDK provides comprehensive support for Paystack's Transaction API. ## Initialize a Transaction ```ruby # Amount is in the smallest currency unit (e.g., kobo, pesewas, cents) response = paystack.transactions.initiate( email: "customer@example.com", amount: 10000, currency: "GHS", callback_url: "https://example.com/callback" ) if response.success? puts "Transaction initialized successfully!" puts "Authorization URL: #{response.authorization_url}" puts "Access Code: #{response.access_code}" puts "Reference: #{response.reference}" else puts "Error: #{response.error_message}" end ``` --- # Charges 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/). ## Mobile Money in Ghana: Which Networks 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`). 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. ## Create a Mobile Money Charge 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`. 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`. ```ruby paystack = PaystackSdk::Client.new(secret_key: "sk_test_xxx") response = paystack.charges.mobile_money( email: "customer@email.com", amount: 100, # smallest unit (pesewas/cent) currency: "GHS", # optional; Paystack uses your integration's currency without it mobile_money: { phone: "0551234987", provider: "mtn" # mtn | atl | vod | mpesa | mpesa_offline | mptill | orange | wave } ) if response.success? case response.status when "pay_offline" # Show instruction text and wait for webhook or verify later puts response.display_text when "send_otp" # For Vodafone, collect voucher/OTP and submit below puts response.display_text when "success" puts "Charge completed: #{response.reference}" else puts "Status: #{response.status}" end else puts "Charge failed: #{response.error_message}" end ``` ## Create a Charge on Another Channel `create` takes one channel object (or an `authorization_code`) as a keyword hash and sends it as Paystack documents it. ```ruby # A returning customer's saved card paystack.charges.create(email: "customer@email.com", amount: 10000, authorization_code: "AUTH_xxxx") # A bank account (Paystack may then ask for the customer's birthday or an OTP) paystack.charges.create( email: "customer@email.com", amount: 10000, bank: {code: "057", account_number: "0000000000"}, birthday: Date.new(1995, 12, 23) # or "1995-12-23" ) # USSD (Nigeria), Pay with Transfer, and QR or EFT (South Africa) paystack.charges.create(email: "customer@email.com", amount: 10000, ussd: {type: "737"}) paystack.charges.create(email: "customer@email.com", amount: 10000, bank_transfer: {account_expires_at: "2026-10-10T12:00:00Z"}) paystack.charges.create(email: "customer@email.com", amount: 10000, currency: "ZAR", qr: {provider: "scan-to-pay"}) paystack.charges.create(email: "customer@email.com", amount: 10000, currency: "ZAR", eft: {provider: "ozow"}) # Send the payment through a split or to a subaccount paystack.charges.create(email: "customer@email.com", amount: 10000, authorization_code: "AUTH_xxxx", split_code: "SPL_xxxx") ``` ## Complete a Charge When the response asks for more from the customer (read `response.status` and `response.display_text`), send it with the charge's `reference`: ```ruby paystack.charges.submit_pin(pin: "1234", reference: "5bwib5v6anhe9xa") paystack.charges.submit_otp(otp: "123456", reference: "5bwib5v6anhe9xa") # e.g. a Vodafone voucher paystack.charges.submit_phone(phone: "08012345678", reference: "5bwib5v6anhe9xa") paystack.charges.submit_birthday(birthday: "1961-09-21", reference: "5bwib5v6anhe9xa") paystack.charges.submit_address( address: "140 N 2ND ST", city: "Stroudsburg", state: "PA", zip_code: "18360", reference: "7c7rpkqpc0tijs8" ) ``` ## Check a Pending Charge 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): ```ruby response = paystack.charges.check_pending(reference: "5bwib5v6anhe9xa") puts response.status ``` For offline flows such as mobile money, listen for the `charge.success` webhook, and confirm it with `transactions.verify(reference:)` before giving value: ```ruby verify = paystack.transactions.verify(reference: "r13havfcdt7btcm") puts verify.status # "success", "failed", or current state ``` ## Verify a Transaction ```ruby # Verify using transaction reference response = paystack.transactions.verify(reference: "transaction_reference") if response.success? transaction = response.data puts "Transaction verified successfully!" puts "Status: #{transaction.status}" puts "Amount: #{transaction.amount}" puts "Currency: #{transaction.currency}" puts "Customer Email: #{transaction.customer.email}" # Check specific transaction status case transaction.status when "success" puts "Payment successful!" when "pending" puts "Payment is pending." else puts "Current status: #{transaction.status}" end else puts "Verification failed: #{response.error_message}" end ``` ## List Transactions ```ruby # Get all transactions (Paystack's default pagination: 50 per page) response = paystack.transactions.list # With custom pagination response = paystack.transactions.list(per_page: 20, page: 2) # With additional filters response = paystack.transactions.list( per_page: 10, page: 1, from: "2025-01-01", to: "2025-04-30", status: "success" ) # Filter by customer (the numeric customer ID, not the CUS_ code) response = paystack.transactions.list(customer_id: 12345) if response.success? puts "Total transactions: #{response.count}" # response.size is another way response.data.each do |transaction| puts "ID: #{transaction.id}" puts "Reference: #{transaction.reference}" puts "Amount: #{transaction.amount}" puts "----------------" end # Get the first transaction first_transaction = response.data.first puts "First transaction reference: #{first_transaction.reference}" # Get the last transaction last_transaction = response.data.last puts "Last transaction amount: #{last_transaction.amount}" else puts "Error: #{response.error_message}" end ``` ## Fetch a Transaction ```ruby # Fetch a specific transaction by ID response = paystack.transactions.fetch(id: 12345) if response.success? transaction = response.data puts "Transaction details:" puts "ID: #{transaction.id}" puts "Reference: #{transaction.reference}" puts "Amount: #{transaction.amount}" puts "Status: #{transaction.status}" # Access customer information puts "Customer Email: #{transaction.customer.email}" puts "Customer Name: #{transaction.customer.name}" else puts "Error: #{response.error_message}" end ``` ## Get Transaction Totals ```ruby # Get transaction volume and success metrics response = paystack.transactions.totals # Within a date range response = paystack.transactions.totals(from: "2025-01-01", to: "2025-04-30") if response.success? puts "Total Transactions: #{response.data.total_transactions}" puts "Total Volume: #{response.data.total_volume}" puts "Pending Transfers: #{response.data.pending_transfers}" else puts "Error: #{response.error_message}" end ``` ## Transaction Timeline, Export and Charging ```ruby # Timeline of a transaction, by ID or reference paystack.transactions.timeline(id: "transaction_reference") # Export transactions (Paystack returns a download link) paystack.transactions.export(from: "2025-01-01", to: "2025-04-30", status: "success", settled: true) # Charge a returning customer's saved authorization paystack.transactions.charge_authorization( email: "customer@example.com", amount: 10000, authorization_code: "AUTH_xxxx" ) # Debit part of an amount from a saved authorization paystack.transactions.partial_debit( email: "customer@example.com", amount: 5000, authorization_code: "AUTH_xxxx", currency: "GHS" ) ``` ## Checking a Payment 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. ```ruby response = paystack.transactions.verify(reference: reference) if response.paid?(amount: 5000, currency: "GHS") # amount in the smallest unit, e.g. pesewas authorization = response.authorization authorization.authorization_code # keep this to charge the card again authorization.reusable # true if it can be charged again end ``` 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. ## Charging a Saved Card 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. ```ruby charge = paystack.transactions.charge_authorization( email: "customer@example.com", # the customer the authorization belongs to amount: 5000, currency: "GHS", authorization_code: "AUTH_xxxx", reference: "renewal-2025-06-ama" # unique per attempt; reuse it if you retry so you cannot charge twice ) paystack.transactions.verify(reference: "renewal-2025-06-ama").paid?(amount: 5000, currency: "GHS") ``` 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. --- # Customers 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). ## Create a Customer ```ruby response = paystack.customers.create( email: "customer@example.com", first_name: "John", last_name: "Doe", phone: "+2348123456789", metadata: {plan: "gold"} # a Hash; Paystack rejects a JSON string here ) if response.success? puts "Customer created successfully!" puts "Customer Code: #{response.data.customer_code}" puts "Email: #{response.data.email}" puts "Name: #{response.data.first_name} #{response.data.last_name}" else puts "Error: #{response.error_message}" end ``` `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). ## List Customers ```ruby # Get all customers (Paystack's default pagination: 50 per page) response = paystack.customers.list # With custom pagination response = paystack.customers.list(per_page: 20, page: 2) # With date filters response = paystack.customers.list( per_page: 10, page: 1, from: "2025-01-01", to: "2025-06-10" ) # Cursor pagination: the cursors come back in response.meta response = paystack.customers.list(use_cursor: true, per_page: 20) response = paystack.customers.list(use_cursor: true, per_page: 20, next_cursor: response.meta.next) if response.success? puts "Customers on this page: #{response.data.size}" response.data.each do |customer| puts "Code: #{customer.customer_code}" puts "Email: #{customer.email}" puts "Name: #{customer.first_name} #{customer.last_name}" puts "----------------" end else puts "Error: #{response.error_message}" end ``` ## Fetch a Customer ```ruby # Fetch by customer code response = paystack.customers.fetch(email_or_code: "CUS_xr58yrr2ujlft9k") # Or fetch by email (Paystack accepts either in the same place) response = paystack.customers.fetch(email_or_code: "customer@example.com") if response.success? customer = response.data puts "Customer details:" puts "Code: #{customer.customer_code}" puts "Email: #{customer.email}" puts "Name: #{customer.first_name} #{customer.last_name}" puts "Phone: #{customer.phone}" else puts "Error: #{response.error_message}" end ``` ## Update a Customer ```ruby response = paystack.customers.update( code: "CUS_xr58yrr2ujlft9k", first_name: "Jane", last_name: "Smith", phone: "+2348987654321" ) if response.success? puts "Customer updated successfully!" puts "Updated Name: #{response.data.first_name} #{response.data.last_name}" else puts "Error: #{response.error_message}" end ``` ## Validate a Customer 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. ```ruby response = paystack.customers.validate( code: "CUS_xr58yrr2ujlft9k", first_name: "John", last_name: "Doe", type: "bank_account", country: "NG", bvn: "20012345677", bank_code: "007", account_number: "0123456789" ) if response.success? puts "Customer validation initiated: #{response.message}" else puts "Error: #{response.error_message}" end ``` ## Set Risk Action (Whitelist/Blacklist) ```ruby # customer: the customer code or email address response = paystack.customers.set_risk_action( customer: "CUS_xr58yrr2ujlft9k", risk_action: "allow" # "allow" to whitelist, "deny" to blacklist, "default" to reset ) if response.success? puts "Risk action set successfully!" puts "Customer: #{response.data.customer_code}" puts "Risk Action: #{response.data.risk_action}" else puts "Error: #{response.error_message}" end ``` ## Authorizations and Direct Debit ```ruby # Start creating a reusable authorization (direct_debit is the only channel for now) response = paystack.customers.initialize_authorization( email: "customer@example.com", channel: "direct_debit", callback_url: "https://example.com/callback" ) reference = response.data.reference # send the customer to response.data.redirect_url # Check the authorization's status paystack.customers.verify_authorization(reference: reference) # Link a bank account to an existing customer for Direct Debit (id is the numeric customer ID) paystack.customers.initialize_direct_debit( id: 12345, account: {number: "0123456789", bank_code: "058"}, address: {street: "Some Where", city: "Ikeja", state: "Lagos"} ) # The customer's Direct Debit mandates (every customer's: paystack.direct_debits.list_mandate_authorizations) paystack.customers.fetch_mandate_authorizations(id: 12345) # Trigger an activation charge on an inactive mandate (pending mandates of several customers: # paystack.direct_debits.trigger_activation_charge, see Direct Debit below) paystack.customers.direct_debit_activation_charge(id: 12345, authorization_id: 1069309917) # Deactivate an authorization (any channel) response = paystack.customers.deactivate_authorization(authorization_code: "AUTH_72btv547") if response.success? puts "Authorization deactivated: #{response.message}" else puts "Error: #{response.error_message}" end ``` --- # Banks Everything takes keyword arguments. Account numbers and bank codes are strings, so leading zeros survive. ```ruby # List banks. Filters are optional: country (ghana, kenya, nigeria, "south africa"), currency, # type, gateway, per_page, page, use_cursor, next_cursor, previous, ... response = paystack.banks.list(country: "nigeria", per_page: 50) response.data.each { |bank| puts "#{bank["name"]}: #{bank["code"]}" } # Resolve an account number to the name on the account response = paystack.banks.resolve_account_number(account_number: "0022728151", bank_code: "063") puts response.data["account_name"] if response.success? # Validate a South African bank account before sending money response = paystack.banks.validate_account( account_name: "Ama Mensah", account_number: "0123456789", account_type: "personal", bank_code: "632005", country_code: "ZA", document_type: "identityNumber", document_number: "1234567890123" # optional ) ``` --- # Miscellaneous ```ruby # Details of a card BIN (6 or 8 digits, as a string) response = paystack.miscellaneous.resolve_card_bin(bin: "539983") puts response.data["bank"] if response.success? # Supported countries paystack.miscellaneous.list_countries # States for address verification (country is required) paystack.miscellaneous.list_states(country: "CA") ``` --- # Transfer Recipients Every method takes keyword arguments. A recipient is who you send a transfer to. ## Create a Transfer Recipient ```ruby response = paystack.transfer_recipients.create( type: "nuban", # nuban, ghipss, mobile_money, basa or authorization name: "Ama Mensah", account_number: "0123456789", bank_code: "058", currency: "NGN", metadata: {job: "Baker"} ) puts response.data.recipient_code if response.success? ``` A duplicate account number returns the existing recipient. To create many at once, pass a list of recipient hashes: ```ruby response = paystack.transfer_recipients.bulk_create( batch: [ {type: "nuban", name: "Ama Mensah", account_number: "0123456789", bank_code: "058"}, {type: "nuban", name: "Kofi Boateng", account_number: "0987654321", bank_code: "058"} ] ) response.data.success # recipients that were created response.data.errors # records Paystack rejected, with the reason ``` ## List, Fetch, Update and Delete ```ruby paystack.transfer_recipients.list(per_page: 20, page: 2) # fetch, update and delete take the recipient code or the numeric ID paystack.transfer_recipients.fetch(id_or_code: "RCP_2x5j67tnnw1t98k") paystack.transfer_recipients.update(id_or_code: "RCP_2x5j67tnnw1t98k", name: "Ama K. Mensah", email: "ama@example.com") paystack.transfer_recipients.delete(id_or_code: "RCP_2x5j67tnnw1t98k") # Paystack sets the recipient to inactive ``` 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:`. --- # Transfers 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](https://nanafox.github.io/paystack_sdk/latest/md/guide/timeouts-and-retries.md)). ## Initiate a Transfer ```ruby # Amount is in the smallest currency unit (kobo, pesewas, cents). # reference is required: generate it once and reuse it if you retry, so a retry cannot pay twice. # Paystack documents 16 to 50 characters of lowercase letters, digits, - and _. response = paystack.transfers.create( source: "balance", amount: 100_000, recipient: "RCP_gd9vgag7n5lr5ix", reference: "acv_9ee55786-2323-4760-98e2-6380c9cb3f68", reason: "Bonus for the week" ) if response.success? case response.data.status when "otp" # Your integration requires an OTP: Paystack sent one to the business phone paystack.transfers.finalize(transfer_code: response.data.transfer_code, otp: "928783") else puts "Transfer #{response.data.transfer_code} is #{response.data.status}" end else puts "Error: #{response.error_message}" end ``` ## Verify, Fetch and List Transfers ```ruby # Verify by your reference paystack.transfers.verify(reference: "acv_9ee55786-2323-4760-98e2-6380c9cb3f68") # Fetch by transfer ID or code paystack.transfers.fetch(id_or_code: "TRF_v5tip3zx8nna9o78") # List, with Paystack's page pagination (default 50 per page)... paystack.transfers.list(per_page: 20, page: 2, from: "2025-01-01", to: "2025-04-30") # ...filtered by the numeric recipient ID, or by status paystack.transfers.list(recipient: 56824902, status: "success") # ...or with cursor pagination: pass response.meta.next back as next_cursor paystack.transfers.list(use_cursor: true, per_page: 20) # Export transfers (in Paystack's OpenAPI spec, not on its docs page) paystack.transfers.export(from: "2025-01-01", to: "2025-04-30", status: "success") ``` ## Bulk Transfers and the OTP Requirement ```ruby # Bulk transfers need the OTP requirement disabled. Each transfer uses Paystack's field names. paystack.transfers.bulk_create( source: "balance", currency: "NGN", transfers: [ {amount: 20_000, recipient: "RCP_gd9vgag7n5lr5ix", reference: "acv_2627bbfe-1a2a-4a1a-8d0e-9d2ee6c31496", reason: "Bonus"}, {amount: 35_000, recipient: "RCP_zpk2tgagu6lgb4g", reference: "acv_1bd0c1f8-78c2-463b-8bd4-ed9eeb36be50", reason: "Bonus"} ] ) # Turn the OTP requirement off (Paystack sends an OTP to the business phone), then confirm it paystack.transfers.disable_otp paystack.transfers.finalize_disable_otp(otp: "928783") # Turn it back on paystack.transfers.enable_otp # Resend the OTP for a transfer awaiting one paystack.transfers.resend_otp(transfer_code: "TRF_vsyqdmlzble3uii", reason: "resend_otp") ``` --- # Refunds 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](https://nanafox.github.io/paystack_sdk/latest/md/guide/timeouts-and-retries.md)). ## Create a Refund ```ruby # transaction is the transaction's reference or its numeric ID. # amount is in the smallest currency unit (kobo, pesewas, cents) and cannot exceed the transaction amount. # Leave amount out to refund the whole transaction; pass less for a partial refund. response = paystack.refunds.create( transaction: "T685312322670591", amount: 2_500, currency: "GHS", # optional; Paystack refuses one that differs from the transaction's customer_note: "Duplicate payment", merchant_note: "Refunded by the finance team" ) if response.success? puts "Refund #{response.data.id} is #{response.data.status}" # "pending" until Paystack processes it else puts "Error: #{response.error_message}" # e.g. "Transaction not found" end # A full refund by transaction ID paystack.refunds.create(transaction: 1_004_723_697) ``` ## Fetch, List and Retry Refunds ```ruby paystack.refunds.fetch(id: 18_625_648) # List, with page pagination (default 50 per page) and a date range... paystack.refunds.list(per_page: 20, page: 1, from: "2025-01-01", to: "2025-04-30") # ...or the refunds of one transaction, by its numeric ID (a reference returns none) paystack.refunds.list(transaction_id: 1_004_723_697) # Retry a refund with the needs-attention status by giving the customer's bank account paystack.refunds.retry_with_customer_details( id: 18_625_648, refund_account_details: {currency: "GHS", account_number: "0123456789", bank_id: "9"} ) ``` 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. --- # Settlements Settlements are the payouts Paystack makes to your bank account. Use them to reconcile what was paid out against the transactions behind each payout. ```ruby # List settlements (Paystack's defaults apply: 50 per page, page 1) paystack.settlements.list paystack.settlements.list(per_page: 20, page: 2) # Only settlements within a date range (a date or an ISO 8601 timestamp) paystack.settlements.list(from: "2025-01-01", to: "2025-01-31T23:59:59Z") # The transactions that make up one settlement paystack.settlements.transactions(id: 3090024) ``` Paystack's docs also list `status` and `subaccount` filters on the list endpoint, but they are not in its OpenAPI spec and the SDK does not offer them yet: the test account has no settlements, so whether they filter could not be checked. `transactions` takes no filters or pagination for the same reason. --- # Splits A transaction split shares the settlement for a payment between your payout account and one or more subaccounts (`ACCT_...`). Create the split once, then pass its `split_code` when you initialize a transaction or create a charge. ## Create a Split ```ruby # type is "percentage" (each share is a percentage) or "flat" (each share is an amount in the # currency's subunit: kobo, pesewas, cents). currency must be enabled on your integration. response = paystack.splits.create( name: "Halfsies", type: "percentage", currency: "GHS", subaccounts: [{subaccount: "ACCT_6uujpqtzmnufzkw", share: 50}], # Optional: who pays Paystack's fee. One of "account", "subaccount", "all-proportional" or "all" # (Paystack's default). "subaccount" also needs bearer_subaccount, a subaccount in the split. bearer_type: "all-proportional" ) split_code = response.data.split_code if response.success? # e.g. "SPL_RcScyW5jp2" ``` ## List, Fetch and Update Splits ```ruby # List, with Paystack's page pagination (default 50 per page) and filters paystack.splits.list(per_page: 20, page: 1, active: true) paystack.splits.list(name: "Halfsies", subaccount_code: "ACCT_6uujpqtzmnufzkw", from: "2025-01-01", to: "2025-04-30") # Fetch by numeric ID or by split code paystack.splits.fetch(id: 2703655) paystack.splits.fetch(id: "SPL_RcScyW5jp2") # Update (by numeric ID only: Paystack refuses a split code here) paystack.splits.update(id: 2703655, active: false) paystack.splits.update(id: 2703655, bearer_type: "subaccount", bearer_subaccount: "ACCT_6uujpqtzmnufzkw") ``` ## Add, Update or Remove a Subaccount ```ruby # Adds the subaccount, or changes its share if it is already in the split (numeric split ID) paystack.splits.add_subaccount(id: 2703655, subaccount: "ACCT_eg4sob4590pq9vb", share: 20) paystack.splits.remove_subaccount(id: 2703655, subaccount: "ACCT_eg4sob4590pq9vb") ``` --- # Disputes Disputes (chargebacks) are filed against your transactions by customers or their banks. Listing and fetching are read-only. `update`, `add_evidence` and `resolve` change a real dispute, so call them only when you mean to answer it. ## List and Fetch Disputes ```ruby # List, with Paystack's page pagination; filter by status, transaction ID or date range. # status is one of awaiting-merchant-feedback, awaiting-bank-feedback, pending, resolved response = paystack.disputes.list(status: "awaiting-merchant-feedback", per_page: 20, from: "2025-01-01", to: "2025-04-30") response.data.each { |dispute| puts "#{dispute.id}: #{dispute.status}" } if response.success? # Fetch one dispute, or every dispute filed for a transaction paystack.disputes.fetch(id: 2867) paystack.disputes.list_transaction(id: 5991760) # Export disputes. Paystack answers 404 "No disputes found" when there is nothing to export. paystack.disputes.export(from: "2025-01-01", to: "2025-04-30") ``` ## Answer a Dispute ```ruby # 1. Get a signed URL, upload the evidence file to it, and keep the returned file name upload = paystack.disputes.fetch_upload_url(id: 2867, upload_filename: "receipt.pdf") upload.data.signedUrl # upload your file to this URL upload.data.fileName # pass this as uploaded_filename # 2. Add evidence paystack.disputes.add_evidence( id: 2867, customer_email: "ama@example.com", customer_name: "Ama Mensah", customer_phone: "0802345167", service_details: "Tithe payment for March", delivery_date: "2025-03-31" ) # 3. Accept (with a refund) or decline. refund_amount is in the smallest currency unit. # It is `resolve` because `resolve` would shadow a method Ruby already has. paystack.disputes.resolve( id: 2867, resolution: "merchant-accepted", # or "declined" message: "Refunded at the customer's request", refund_amount: 50_000, uploaded_filename: upload.data.fileName ) # Or change the refund amount and attachment without resolving paystack.disputes.update(id: 2867, refund_amount: 50_000, uploaded_filename: upload.data.fileName) ``` --- # Subaccounts A subaccount is a settlement account that receives its share of the payments made to it, such as one per branch or partner. Pass its code (`ACCT_...`) as `subaccount` when you charge or initialize a transaction. ## Create a Subaccount ```ruby # bank_code comes from paystack.banks.list (country: "ghana" lists GHS banks and the # MTN, VOD and ATL mobile money codes). account_number is a string: leading zeros matter. # percentage_charge is the percentage the main account keeps from each payment (0 to 100). response = paystack.subaccounts.create( business_name: "Grace Chapel Accra", bank_code: "MTN", account_number: "0551234987", percentage_charge: 2.5, description: "Giving for the Accra branch", primary_contact_name: "Ama Mensah", primary_contact_email: "finance@gracechapel.example", primary_contact_phone: "0551234987" ) if response.success? puts "Created #{response.data.subaccount_code} for #{response.data.account_name}" else puts "Error: #{response.error_message}" # e.g. "Account details are invalid" end ``` Paystack's docs name the bank field `bank_code`; its OpenAPI spec calls it `settlement_bank`. The API reads both and prefers `bank_code`, so the SDK sends `bank_code`. ## List and Fetch Subaccounts ```ruby # Paystack's page pagination (default 50 per page) paystack.subaccounts.list(per_page: 20, page: 2) # Without active, the test API lists only active subaccounts. active: 0 lists the inactive # ones. Paystack reads any value other than 1, true included, as inactive, so pass 1 or 0. paystack.subaccounts.list(active: 0) # Fetch by subaccount code or numeric ID paystack.subaccounts.fetch(id_or_code: "ACCT_6uujpqtzmnufzkw") ``` ## Update a Subaccount ```ruby # Send only what changes paystack.subaccounts.update(id_or_code: "ACCT_6uujpqtzmnufzkw", percentage_charge: 3) # Deactivate (or reactivate with active: true) paystack.subaccounts.update(id_or_code: "ACCT_6uujpqtzmnufzkw", active: false) # A new settlement account: Paystack needs bank_code and account_number together paystack.subaccounts.update(id_or_code: "ACCT_6uujpqtzmnufzkw", bank_code: "040100", account_number: "1234567890123") ``` --- # Orders Orders record a customer's purchase of your products. `create` places an order for a customer, so it may notify the buyer: use test mode and a test email while you build. ```ruby response = paystack.orders.create( email: "ama@example.com", first_name: "Ama", last_name: "Mensah", phone: "+233200000000", currency: "GHS", items: [{item: 2782725, type: "product", quantity: 2, amount: 20_000}], # product ID; amount in pesewas shipping: {street_line: "1 Road", city: "Accra", state: "Greater Accra", country: "Ghana", shipping_fee: 0} ) order = paystack.orders.fetch(id: 12_345) # numeric order ID paystack.orders.list(per_page: 20, from: "2026-01-01") # filters: per_page, page, from, to paystack.orders.fetch_product_orders(id: 2782725) # orders for one product (the product ID) paystack.orders.validate(code: "ORD_abc123def456") # GET, for a "pay for me" order ``` Paystack's docs page shows `create` taking `customer` and `line_items`, but the API follows the OpenAPI spec: the test API answers the docs' shape with "Customer email is required". The order total must be at least 2 units of the currency (GHS 2), and a new order has status `created` until it is paid. `pay_for_me: true` needs receiver details that neither the spec nor the docs describe ("Receiver data is required"), so it is not covered here. --- # Balances Read-only. Amounts are integers in the currency's subunit (pesewas for GHS, kobo for NGN), so `48400` is GHS 484.00. Paystack documents both operations on its Transfers Control page. ```ruby # One entry per currency your integration holds response = paystack.balances.fetch response.data.first.currency # => "GHS" response.data.first.balance # => 48400 # Iterating (each, map) yields plain hashes with string keys response.data.each { |balance| puts "#{balance["currency"]}: #{balance["balance"]}" } # Every pay-in and pay-out, newest first. `difference` is the signed change in the subunit # (-2500 for a refund), `balance` the running balance after it, and `model_responsible` # says what caused it ("Transaction", "Refund", "Transfer", ...). ledger = paystack.balances.ledger(per_page: 20, page: 1) ledger.data.each { |entry| puts "#{entry["createdAt"]} #{entry["model_responsible"]} #{entry["difference"]}" } ledger.meta # => total, skipped, perPage, page, pageCount (50 per page by default) # Date filters work by calendar day (the time part is ignored). `to` includes that day; # `from` does not, so from: Date.new(2026, 10, 8) returns entries after 8 October. paystack.balances.ledger(from: Date.new(2026, 10, 1), to: Date.new(2026, 10, 31)) ``` --- # Plans A plan is a recurring charge definition: an amount, an interval and a currency. Customers are subscribed to a plan to be charged on that schedule. Paystack does not let you delete a plan. ## Create a Plan ```ruby # amount is in the subunit of the currency (pesewas for GHS). Paystack's minimum is 2 GHS. # interval is one of hourly, daily, weekly, monthly, quarterly, biannually (every 6 months) # or annually, in lowercase. currency defaults to the integration's currency. response = paystack.plans.create( name: "Monthly tithe", amount: 20_000, interval: "monthly", currency: "GHS", description: "Standing tithe", send_invoices: false, send_sms: false, invoice_limit: 12 ) if response.success? puts "Created #{response.data.plan_code}" else puts "Error: #{response.error_message}" # e.g. "Invalid interval selected" end ``` `send_invoices` and `send_sms` are booleans (Paystack's docs call `send_sms` a string; the API refuses anything that is not a boolean). ## List and Fetch Plans ```ruby paystack.plans.list(per_page: 20, page: 1) # Filter by interval, amount (in the subunit) or a date range paystack.plans.list(interval: "monthly", amount: 20_000) paystack.plans.list(from: "2026-01-01", to: "2026-12-31") # Fetch by plan code or numeric ID paystack.plans.fetch(id_or_code: "PLN_gx2wn530m0i3w3m") ``` ## Update a Plan ```ruby # Send only what changes paystack.plans.update(id_or_code: "PLN_gx2wn530m0i3w3m", name: "Monthly tithe (renamed)") # By default Paystack applies the change to the plan's existing subscriptions too. Pass # update_existing_subscriptions: false so that only new subscriptions use the new values. paystack.plans.update(id_or_code: "PLN_gx2wn530m0i3w3m", amount: 25_000, update_existing_subscriptions: false) ``` The response carries a message only, for example `"Plan updated. 1 subscription(s) affected"`. --- # Subscriptions A subscription charges a customer's saved card on a plan's schedule. The customer needs a reusable card authorization (from an earlier successful card payment), and the plan is created in your Paystack dashboard or through the Plans API. ## Create a Subscription ```ruby # customer: the customer's email or customer code. plan: the plan code (PLN_...). # authorization: which of the customer's saved cards to charge (AUTH_...); without it Paystack # uses the customer's most recent authorization. It must belong to this customer. # start_date: the date of the first debit, a Time, Date or ISO 8601 string. On the test API it # becomes next_payment_date (2027-01-15T10:00:00Z here), and the schedule follows the plan's interval. response = paystack.subscriptions.create( customer: "CUS_xnxdt6s1zg1f4nx", plan: "PLN_gx2wn530m0i3w3m", authorization: "AUTH_6tmt288t0o", start_date: Time.utc(2027, 1, 15, 10) ) if response.success? # Keep both: enable and disable need the code and the email token puts "#{response.data.subscription_code} #{response.data.email_token}" else puts "Error: #{response.error_message}" # e.g. "This subscription is already in place." end ``` A customer can hold one active subscription per plan. The SDK checks that `start_date` is ISO 8601 before sending it: on the test API, an invalid `start_date` is refused with a 400 but still leaves an active subscription with no payment date behind. ## List and Fetch Subscriptions ```ruby # Filters take numeric IDs: the plan and customer codes match nothing paystack.subscriptions.list(plan_id: 4298026, customer_id: 406976740, per_page: 20, page: 1) # Created within a window (Time, Date or ISO 8601 string) paystack.subscriptions.list(from: Date.new(2026, 10, 1), to: Time.now) # Fetch by subscription code or numeric ID paystack.subscriptions.fetch(id_or_code: "SUB_vsyqdmlzble3uii") ``` ## Enable or Disable a Subscription ```ruby # token is the subscription's email_token, returned by create, list and fetch paystack.subscriptions.disable(code: "SUB_vsyqdmlzble3uii", token: "d7gofp6yppn3qz7") # => data.status "non-renewing" paystack.subscriptions.enable(code: "SUB_vsyqdmlzble3uii", token: "d7gofp6yppn3qz7") ``` Disabling answers with `data.status` `non-renewing`, and fetch shows the same status. On the test API a disabled subscription that had never been charged could not be enabled again ("Subscription has been cancelled, and cannot be reactivated"). ## Let the Customer Update Their Card ```ruby # A link to a Paystack page where the customer changes the card on the subscription paystack.subscriptions.generate_update_link(code: "SUB_vsyqdmlzble3uii").data.link # Or have Paystack email the customer that link paystack.subscriptions.send_update_link(code: "SUB_vsyqdmlzble3uii") ``` Both take the subscription code; the numeric ID is not found. --- # Dedicated Virtual Accounts A dedicated virtual account is a bank account number Paystack issues to one of your customers, so they can pay you by bank transfer. Paystack's docs offer it to Nigerian and Ghanaian businesses (`NGN` and `GHS`), and it has to be enabled for your business first: until it is, every call returns an unsuccessful response with status 403 and the message "Dedicated NUBAN is not available for your business". ## Create or Assign an Account ```ruby # Which banks can issue an account for your integration (e.g. "wema-bank", "titan-paystack") paystack.dedicated_virtual_accounts.fetch_bank_providers # For a customer you already created (their code or ID) response = paystack.dedicated_virtual_accounts.create(customer: "CUS_xnxdt6s1zg1f4nx", preferred_bank: "wema-bank") if response.success? puts "#{response.data.account_number} at #{response.data.bank.name}" else puts "Error: #{response.error_message}" end # Or create the customer, validate them and assign an account in one call. Paystack's docs # show the reply "Assign dedicated account in progress", with no account in it. paystack.dedicated_virtual_accounts.assign( email: "ama@example.com", first_name: "Ama", last_name: "Mensah", phone: "+2348100000000", preferred_bank: "wema-bank", country: "NG" ) ``` ## List, Fetch, Requery and Deactivate ```ruby paystack.dedicated_virtual_accounts.list(active: true, currency: "NGN", provider_slug: "wema-bank") # By the account's numeric ID paystack.dedicated_virtual_accounts.fetch(dedicated_account_id: 1234553) # Ask Paystack to check the account for transfers it has not recorded yet paystack.dedicated_virtual_accounts.requery(account_number: "1234567890", provider_slug: "wema-bank", date: Date.new(2026, 10, 9)) paystack.dedicated_virtual_accounts.deactivate(dedicated_account_id: 1234553) ``` ## Split Payments on an Account ```ruby # Send payments into the account through a split (or a single subaccount:) paystack.dedicated_virtual_accounts.add_split(account_number: "0033322211", split_code: "SPL_e7jnRLtzla") # And stop splitting them paystack.dedicated_virtual_accounts.remove_split(account_number: "0033322211") ``` `create` and `assign` also take `split_code:` or `subaccount:` to set a split when the account is made. --- # Payment Requests A payment request is an invoice Paystack sends to a customer, who pays it online. Amounts are in the subunit (pesewas, kobo, cents). ## Create a Payment Request ```ruby # customer is a customer code (CUS_...) or ID. Give line_items and tax, each an Array of Hashes, # and Paystack adds them up (2 x 2000 + 300 = 4300 here); give amount instead when you have none. response = paystack.payment_requests.create( customer: "CUS_p6i0reogc8ulu1n", currency: "GHS", description: "Harvest thanksgiving pledge", line_items: [{name: "Pledge", amount: 2000, quantity: 2}], tax: [{name: "Levy", amount: 300}], due_date: Date.new(2026, 12, 31), # a Date, a Time or an ISO 8601 string metadata: {branch: "Osu"}, # a Hash; Paystack refuses a JSON string here draft: true # a draft is not sent; finalize it later ) if response.success? puts "Created #{response.data.request_code} for #{response.data.amount}" else puts "Error: #{response.error_message}" end ``` Paystack's docs say it emails the customer when a request is created, unless you pass `draft: true` or `send_notification: false`. Without `amount`, `line_items` or `tax`, Paystack refuses anything but a draft ("Amount was not passed or could not be extrapolated from line items."). ## List, Fetch and Verify Payment Requests ```ruby # customer_id is the numeric customer ID (Paystack ignores a CUS_ code here) paystack.payment_requests.list(customer_id: 407172149, status: "pending", per_page: 20) # Archived requests are left out unless you pass include_archive: true. Paystack includes them # whenever the parameter is sent, even as false, so leave it out rather than passing false. paystack.payment_requests.list(include_archive: true) # Fetch by numeric ID or PRQ_ code paystack.payment_requests.fetch(id_or_code: "PRQ_7w7dpecncnebg7e") # Verify takes the PRQ_ code only, and only once the request is no longer a draft paystack.payment_requests.verify(code: "PRQ_7w7dpecncnebg7e") # Pending, successful and total amounts per currency paystack.payment_requests.totals ``` ## Update, Finalize, Notify and Archive ```ruby # Send only what changes; new line_items and tax recompute the amount paystack.payment_requests.update(id_or_code: "PRQ_7w7dpecncnebg7e", due_date: "2027-01-31") # Finalize a draft. Paystack emails the customer unless send_notification is false. paystack.payment_requests.finalize(id_or_code: "PRQ_7w7dpecncnebg7e", send_notification: false) # Email the customer a reminder paystack.payment_requests.notify(code: "PRQ_7w7dpecncnebg7e") # Archive: it no longer shows up in list or verify paystack.payment_requests.archive(id_or_code: "PRQ_7w7dpecncnebg7e") ``` --- # Pages A payment page is a public pay link at `https://paystack.com/pay/`, useful for donations or one-off payments. Amounts are integers in the currency's subunit (pesewas for GHS). ```ruby # Without an amount the customer chooses what to pay (a donation page). Pass fixed_amount: true # together with amount to fix it. response = paystack.pages.create( name: "Sunday Offering", description: "Give to the work of the church", slug: "sunday-offering", collect_phone: true, custom_fields: [{display_name: "Branch", variable_name: "branch"}] ) response.data.slug # => "sunday-offering" # type is payment (default), subscription (with plan:), product or plan paystack.pages.create(name: "Lite plan", type: "subscription", plan: "4288882") # Is a slug free? An unsuccessful response ("Slug already in use") means it is taken. paystack.pages.check_slug_availability(slug: "sunday-offering").success? ``` ## List, Fetch and Update Pages ```ruby paystack.pages.list(per_page: 20, page: 1) # Fetch and update take the numeric ID or the slug paystack.pages.fetch(id_or_slug: "sunday-offering") paystack.pages.update(id_or_slug: "sunday-offering", description: "New description") # Deactivate the page URL paystack.pages.update(id_or_slug: 2215275, active: false) # Product pages only: add products by numeric ID (the page needs type: "product") paystack.pages.add_products(id: 2215275, products: [473, 292]) ``` --- # Products A product is something you sell through Paystack (a good, with stock if you track it). Prices are integers in the subunit of the currency: pesewas for GHS, kobo for NGN, cents for ZAR or USD. Only the currencies enabled on your integration are accepted. ## Create a Product ```ruby response = paystack.products.create( name: "Church anniversary t-shirt", price: 5000, # GHS 50.00, in pesewas currency: "GHS", description: "Cotton, sizes S to XL", quantity: 100, # stock on hand; use unlimited: true instead if you do not track stock metadata: {branch: "Osu"} # a Hash; Paystack stores a JSON string as an object of its characters ) if response.success? puts "Created #{response.data.product_code} (ID #{response.data.id})" else puts "Error: #{response.error_message}" end ``` `name`, `price` and `currency` are required by Paystack. Its docs and spec also list `description` as required, but the test API creates a product without one. ## List, Fetch, Update and Delete Products ```ruby paystack.products.list(per_page: 20, page: 1, active: true) # Products are fetched, updated and deleted by their numeric ID (not the PROD_ code) paystack.products.fetch(id: 2782723) # Send only what changes paystack.products.update(id: 2782723, price: 6000, quantity: 80) paystack.products.delete(id: 2782723) ``` `delete` calls `DELETE /product/{id}`, which is in Paystack's OpenAPI spec but on no docs page; the test API deletes the product and answers `404 Product not found` afterwards. --- # Storefronts A storefront is a Paystack-hosted shop (`https://paystack.shop/`) that sells products you created with the Products API. Storefronts are identified by their numeric ID. ## Create a Storefront ```ruby # Paystack requires name, slug and currency (its docs mark slug optional; the API does not). # The slug must be 5 to 100 characters: lowercase letters, numbers, dashes or underscores. response = paystack.storefronts.create( name: "Harvest stall", slug: "harvest-stall", currency: "GHS", # one your integration accepts; Paystack refuses others ("Currency not supported or allowed") description: "Sunday harvest sale" ) puts response.data.id if response.success? ``` Check a slug first with `verify_slug`. Paystack answers with the storefront that holds it when it is taken (in test or live mode), and with a 404 "Storefront not found" when it is free: ```ruby paystack.storefronts.verify_slug(slug: "harvest-stall").success? # => false: the slug is free ``` ## List, Fetch, Update and Delete Storefronts ```ruby paystack.storefronts.list(status: "active", per_page: 20) # status is active or inactive paystack.storefronts.fetch(id: 1852308) # Send only what changes; update returns a message, not the storefront paystack.storefronts.update(id: 1852308, slug: "harvest-stall-2", description: "Harvest weekend") # Paystack keeps a deleted storefront with status "deleted"; it drops out of list paystack.storefronts.delete(id: 1852308) ``` ## Products, Orders, Duplicate and Publish ```ruby # products takes numeric product IDs (Paystack refuses PROD_ codes here) paystack.storefronts.add_products(id: 1852308, products: [2782728]) paystack.storefronts.list_products(id: 1852308) paystack.storefronts.fetch_orders(id: 1852308) # A copy with its products, named "Copy of ..." and given a new slug paystack.storefronts.duplicate(id: 1852308) # Publish copies the storefront and its products to your live integration, even with a test key paystack.storefronts.publish(id: 1852308) ``` `publish` makes a public, live storefront. On the test API it answered "Storefront published to live" and returned a new storefront with `domain: "live"` and a new ID, gave it the original slug, and renamed the test storefront (adding "-TEST" to the name and giving it a new slug). The live copy cannot be fetched or deleted with a test key: remove it from the dashboard in live mode. --- # Bulk Charges A bulk charge charges many saved cards (reusable authorizations) in one batch, which Paystack queues and processes in the background. Amounts are in the subunit (pesewas, kobo, cents). ## Initiate a Bulk Charge ```ruby # The body is a JSON array: one Hash per charge, each with a reusable authorization code and an # integer amount. Give each a reference you can find again: it becomes the charge's transaction # reference (lowercase letters, digits, - and _, per the spec). response = paystack.bulk_charges.initiate( charges: [ {authorization: "AUTH_50w0d0f5xo", amount: 5000, reference: "tithe-2026-10-kwesi"}, {authorization: "AUTH_xfuz7dy4b9", amount: 2000, reference: "tithe-2026-10-ama"} ] ) if response.success? puts "Queued #{response.data.batch_code} with #{response.data.total_charges} charges" else puts "Error: #{response.error_message}" end ``` The SDK refuses an empty list but sends each Hash as given: Paystack, not the SDK, checks what is inside. A successful response means the charges were queued ("Charges have been queued"), not that they succeeded; read each charge's outcome with `fetch_charges` or from your webhooks. Like every write, `initiate` is not retried after a timeout, so check `list_batches` before sending the same charges again. ## List and Fetch Batches and Their Charges ```ruby # status is active, paused or complete. Paystack's docs also list from and to, but the API ignores them. paystack.bulk_charges.list_batches(status: "active", per_page: 20, page: 1) # Fetch by numeric ID or BCH_ code; total_charges and pending_charges show its progress batch = paystack.bulk_charges.fetch_batch(id_or_code: "BCH_1uhxe0d181eu850") puts "#{batch.data.pending_charges} of #{batch.data.total_charges} still pending" # Each charge with its customer, authorization, reference and status # (pending, success, failed, error or inactive_authorization) paystack.bulk_charges.fetch_charges(id_or_code: "BCH_1uhxe0d181eu850", status: "failed") ``` ## Pause and Resume a Batch ```ruby # Both are GET requests, as Paystack documents them, and take the BCH_ batch code paystack.bulk_charges.pause_batch(batch_code: "BCH_1uhxe0d181eu850") # "Bulk charge batch has been paused" paystack.bulk_charges.resume_batch(batch_code: "BCH_1uhxe0d181eu850") # "Bulk charge batch has been resumed" ``` A paused batch keeps its pending charges until it is resumed. --- # Apple Pay Apple Pay on Paystack needs each domain (or subdomain) that shows the Apple Pay button to be registered on your integration. The accessor is `paystack.apple_pay`. ## List Registered Domains ```ruby response = paystack.apple_pay.list_domains # An account with no domains answers "Apple Pay registered domains retrieved" with an empty list puts response.data.domainNames.inspect # => [] ``` `list_domains` was checked against the Paystack test API. Paystack's docs mark `use_cursor` required, but the API answers without it (and with `use_cursor=true`), so the SDK keeps it optional. Cursor pagination (`next_cursor:`, sent as `next`, and `previous:`) could not be exercised, because the test account has no domains. ## Register and Unregister a Domain (unverified) ```ruby paystack.apple_pay.register_domain(domain_name: "pay.example.com") paystack.apple_pay.unregister_domain(domain_name: "pay.example.com") ``` **These two methods have not been called against the Paystack API.** They change the account's Apple Pay domain configuration, which is account-wide and public and may reach live mode even with a test key (`sk_test_`), so they were deliberately not tried. They follow Paystack's docs and OpenAPI spec: one domain per call, sent as `domainName`, and `unregister_domain` is a `DELETE` that carries the domain in a JSON body. How Paystack answers (and whether it reads a body on `DELETE`) is unconfirmed. Try them with a domain you own, and check the result in the dashboard. --- # Integration Settings of your Paystack integration (the account). Currently the payment session timeout. ## Payment Session Timeout ```ruby # Seconds before a transaction becomes invalid. The test API returned 0 for an account that never set one. response = paystack.integrations.fetch_payment_session_timeout puts response.data.payment_session_timeout paystack.integrations.update_payment_session_timeout(timeout: 30) ``` `update_payment_session_timeout` changes a setting of the whole integration, which may be shared with live mode even when you use a test key, so treat it as a live change. It is **unverified**: it was not called against the Paystack API while building the SDK for that reason. It is generated from the OpenAPI spec and the docs page (`PUT /integration/payment_session_timeout`, body `timeout`, an integer in seconds). Only `fetch_payment_session_timeout` was checked against the API. --- # Virtual Terminals A virtual terminal lets you accept in-person payments without a POS device: payments made to it notify its destinations, which are WhatsApp numbers. Check that the product is enabled for your business. ## Create, List, Fetch, Update and Deactivate Virtual Terminals ```ruby response = paystack.virtual_terminals.create( name: "Front desk", destinations: [{target: "+2341234567890", name: "Front desk phone"}] ) puts response.data.code # "VT_..." # status is active or inactive (the docs list it, the OpenAPI spec omits it) paystack.virtual_terminals.list(status: "active", per_page: 20) paystack.virtual_terminals.fetch(code: "VT_MCK5292Z") paystack.virtual_terminals.update(code: "VT_MCK5292Z", name: "New terminal name") paystack.virtual_terminals.deactivate(code: "VT_MCK5292Z") # "Terminal set to inactive" ``` On the Paystack test API, `list` and `fetch` work (an account with no terminals gets an empty list). `create` answers 400 `"destinations" is required` without destinations, `"destinations" must contain at least 1 items` with an empty list and `invalid phone number ...` for a target that is not a phone number; `fetch` of an unknown code answers 404 `Virtual Terminal not found`. `create` with a valid body was not run (it needs a real phone number), so the optional `split_code` and `metadata` and the docs' `currency` and `custom_fields` are unverified; the SDK sends what the spec lists. ## Destinations and Split Codes ```ruby paystack.virtual_terminals.assign_destination( code: "VT_MCK5292Z", destinations: [{target: "+2341234567890", name: "Another one"}] ) paystack.virtual_terminals.unassign_destination(code: "VT_MCK5292Z", targets: ["+2341234567890"]) paystack.virtual_terminals.add_split_code(code: "VT_MCK5292Z", split_code: "SPL_98WF13Zu8w5") # DELETE with a JSON body, as Paystack documents it paystack.virtual_terminals.remove_split_code(code: "VT_MCK5292Z", split_code: "SPL_98WF13Zu8w5") ``` Assign and unassign destination change who receives payment notifications on WhatsApp, so they were not called against the test API and are described from Paystack's docs only (unverified). The split code operations were only run against an unknown terminal: an unknown split code answers 400 `Invalid split code`, and removing one that is not assigned answers 400 `Virtual Terminal split code assignment does not exist`. --- # Direct Debit `client.direct_debits` works across your whole integration: every customer's Direct Debit mandates at once, and activation charges for several customers in one call. To link a bank account, or to work with one customer's mandates by their customer ID, use the methods on `client.customers` (see [Authorizations and Direct Debit](https://nanafox.github.io/paystack_sdk/latest/md/guide/customers.md#authorizations-and-direct-debit)): | You want to | Integration-wide (`direct_debits`) | One customer (`customers`) | |---|---|---| | See mandates | `list_mandate_authorizations` (`GET /directdebit/mandate-authorizations`), filtered by `status`, paged by cursor | `fetch_mandate_authorizations(id:)` (`GET /customer/{id}/directdebit-mandate-authorizations`) | | Trigger an activation charge | `trigger_activation_charge(customer_ids:)` (`PUT /directdebit/activation-charge`), for **pending** mandates | `direct_debit_activation_charge(id:, authorization_id:)` (`PUT /customer/{id}/directdebit-activation-charge`), for one **inactive** mandate | ## List Mandate Authorizations ```ruby # All parameters are optional. status is pending, active or revoked response = paystack.direct_debits.list_mandate_authorizations(status: "active", per_page: 10) response.each do |mandate| puts "#{mandate.authorization_code} #{mandate.bank_name} #{mandate.status}" end # Cursor pagination: pass meta.next back as cursor (it is null on the last page) next_cursor = response.meta.next paystack.direct_debits.list_mandate_authorizations(status: "active", per_page: 10, cursor: next_cursor) if next_cursor ``` Paystack answers a cursor it cannot read with a 500, which the SDK raises as `PaystackSdk::ServerError`. ## Trigger Activation Charges ```ruby # Numeric customer IDs of customers with pending mandates response = paystack.direct_debits.trigger_activation_charge(customer_ids: [28958104, 983697220]) response.message # => "Mandate is queued for retry" ``` This charges the customers' bank accounts. It has not been verified against the test API: the method follows Paystack's docs and OpenAPI spec. --- # Terminals A Paystack Terminal is a physical point-of-sale device linked to your integration. **None of this was verified against the Paystack API:** the test account used to build the SDK (Ghana) gets `403` "Sorry, this feature is not yet available in your country." from every Terminal GET (list, fetch, status, event status; the others were not called), which the SDK returns as an unsuccessful `Response`. The methods follow Paystack's OpenAPI spec and docs page. ## List and Fetch Terminals ```ruby # Cursor pagination: pass the meta's next or previous cursor back. per_page is sent as the spec names # it (the docs say perPage; which one the API reads is unverified). response = paystack.terminals.list(per_page: 20) paystack.terminals.list(next_cursor: response.meta.next) if response.success? && response.meta.next terminal = paystack.terminals.fetch(terminal_id: "2872S934") # Check a terminal is online and available before sending it an event status = paystack.terminals.fetch_status(terminal_id: "2872S934") puts "online: #{status.data.online}, available: #{status.data.available}" ``` ## Send an Event and Check It ```ruby # Pushes an event to the physical device. type is invoice or transaction; action is process or view # (invoice) or process or print (transaction). event = paystack.terminals.send_event( terminal_id: "2872S934", type: "invoice", action: "process", data: {id: 7895939, reference: 4634337895939} ) paystack.terminals.fetch_event_status(terminal_id: "2872S934", event_id: event.data.id) # data.delivered ``` The spec marks `type`, `action` and `data` optional and the SDK follows it; the docs list them without an "optional" badge. ## Update, Commission and Decommission ```ruby paystack.terminals.update(terminal_id: "2872S934", name: "Front desk", address: "Accra") # Link or unlink a debug device by its serial number paystack.terminals.commission(serial_number: "1111150412230003899") paystack.terminals.decommission(serial_number: "1111150412230003899") ``` --- # Webhook Events The Webhook Events API is the log of every webhook Paystack tried to send to your integration's webhook URL: whether it was delivered, what Paystack sent, and what your endpoint answered. It is documented by Paystack but is not in its OpenAPI spec, so `client.webhook_events` is written from the docs (the entry in `spec/fixtures/docs_only_operations.yml` records what was checked). Use it to find out why a webhook never arrived, to see the exact payload, and to replay events your endpoint missed. ```ruby # Events Paystack could not deliver (status is Delivered, Pending or Failed) response = paystack.webhook_events.list(status: "Failed", limit: 20) response.each do |event| puts "#{event.event_name} #{event.status} (#{event.status_detail}) answered #{event.response_code}" end # The next page: pass meta.next back as next_cursor (not together with previous) paystack.webhook_events.list(next_cursor: response.original_response.dig("meta", "next")) # Filters: category, event_type, status, category_row_id, from, to paystack.webhook_events.list(event_type: "charge.success", from: Date.new(2025, 6, 1)) # One event in full: the payload Paystack sent, and your endpoint's answer event = paystack.webhook_events.fetch(id: "6ac94b47fdad55440ef2066a") event.event_payload[:data] # brackets: a key named data is not reachable with dot access event.merchant_response_body # Find an event by its own id, or by the id of the resource it is about (a transaction id) paystack.webhook_events.lookup(id: 6_641_907_106) ``` Checked against Paystack's test API: `list`, `lookup` and `fetch` (read-only). A page holds 50 events by default (the docs say 20) and `limit` is capped at 50. Every event has a `_id`; the payloads Paystack records are `{"event": ..., "data": {...}}` with no event id inside, and `data.id` was present in every payload seen. **Resending is unverified and makes Paystack deliver webhooks to your endpoint.** `resend(ids:)` and `resend_matching(preview:, filters:)` were never called: the docs say resending is not idempotent, so your endpoint must dedupe. `resend_matching` makes you choose `preview:` (the docs make it optional) because with no filters it resends everything. Call it with `preview: true` first. --- # Response Handling ## Working with Response Objects All API requests return a `PaystackSdk::Response` object that provides easy access to the response data. ```ruby response = paystack.transactions.initiate(**params) # Check if the request was successful response.success? # => true or false # Access response message response.api_message # => "Authorization URL created" # Access data using dot notation response.data.authorization_url response.data.access_code # Access data directly from the response response.authorization_url # Same as response.data.authorization_url # Access nested data response.data.customer.email # Hash-style access works with strings or symbols response[:reference] response["reference"] response[:customer][:email] response.key?(:reference) # => true # For arrays, use array methods response.data.first # First item in an array response.data.last # Last item in an array response.data.size # Size of the array # Iterate through array data response.data.each do |item| puts item.id end ``` ## Pagination Metadata List responses carry Paystack's pagination details in `meta`: ```ruby response = paystack.transactions.list(per_page: 20) response.meta.total # => 40 response.meta.page # => 1 response.meta.pageCount # => 2 response.meta.perPage # => 20 ``` `response.meta` is `nil` when the response has no `meta`. ## Optional Fields: `dig` Dot access raises `NoMethodError` for a key that is not in the response. That is what you want for a typo, but not for a field Paystack only sometimes sends (`paid_at` on an unpaid transaction, `authorization` details on a payment without a card). Read those with `dig`: it returns `nil` as soon as a key along the path is missing, accepts strings or symbols (and an Integer to index an Array), and returns the value as it is, like `Hash#dig`. ```ruby response = paystack.transactions.verify(reference: "order-1042") response.dig(:paid_at) # => "2025-06-01T10:00:00.000Z" or nil response.dig(:authorization, :authorization_code) # => "AUTH_xxxx" or nil response.dig(:customer, :email) # => "ama@example.com" response.dig(:log, :history, 0, :message) # an Integer indexes an Array ``` `response[:key]` also gives `nil` for a missing key, but wraps what it finds in a `Response`; `dig` gives you the plain value. ## Accessing the Original Response Sometimes you may need access to the original API response: ```ruby response = paystack.transactions.list # Access the original response body original = response.original_response # Anything else in the body is still reachable original.dig("meta", "total") ``` ## Exception Handling The SDK provides specific error classes for different types of failures, making it easier to handle errors appropriately: ```ruby begin response = paystack.transactions.verify(reference: "invalid_reference") rescue PaystackSdk::AuthenticationError => e puts "Authentication failed: #{e.message}" rescue PaystackSdk::RateLimitError => e # retry_after is nil when Paystack did not send x-ratelimit-reset puts "Rate limit exceeded. Retry after: #{e.retry_after || "unknown"} seconds" rescue PaystackSdk::ServerError => e puts "Server error: #{e.message}" rescue PaystackSdk::TimeoutError, PaystackSdk::ConnectionError => e # For writes (e.g. creating a transfer) the request may still have been processed: # verify by your own reference before retrying puts "Could not complete the request: #{e.message}" rescue PaystackSdk::APIError => e puts "API error: #{e.message}" rescue PaystackSdk::Error => e puts "General error: #{e.message}" end # Alternatively, check response success without exceptions response = paystack.transactions.verify(reference: "invalid_reference") unless response.success? puts "Error: #{response.error_message}" # Take action based on the error if response.error_message.include?("not found") puts "The transaction reference was not found." elsif response.error_message.include?("Invalid key") puts "API authentication failed. Check your API key." end end ``` ### Error Types The SDK includes several specific error classes: - **`PaystackSdk::ValidationError`** - Base class for all validation errors - **`PaystackSdk::MissingParamError`** - Raised when required parameters are missing - **`PaystackSdk::InvalidFormatError`** - Raised when parameters have invalid format (e.g., invalid email) - **`PaystackSdk::InvalidValueError`** - Raised when parameters have invalid values (e.g., not in allowed list) - **`PaystackSdk::APIError`** - Base class for API-related errors - **`PaystackSdk::AuthenticationError`** - Authentication failures - **`PaystackSdk::ResourceNotFoundError`** - Resource not found (404 errors) - **`PaystackSdk::RateLimitError`** - Rate limiting encountered - **`PaystackSdk::ServerError`** - Server errors (5xx responses) - **`PaystackSdk::ConnectionError`** - Could not reach Paystack (DNS, refused connection, TLS) after retries - **`PaystackSdk::TimeoutError`** - The request timed out after retries ### Validation Error Examples The SDK validates your input data **before** making API calls and will throw exceptions immediately if required data is missing or incorrectly formatted: ```ruby # Missing required keyword begin paystack.transactions.initiate(amount: 1000) # Missing email rescue ArgumentError => e puts e.message # => "missing keyword: :email" end # Invalid format begin paystack.transactions.initiate( email: "invalid-email", # Not a valid email format amount: 1000 ) rescue PaystackSdk::InvalidFormatError => e puts e.message # => "Invalid format for Email. Expected format: valid email address" end # Invalid value begin paystack.customers.set_risk_action( customer: "CUS_123", risk_action: "invalid_action" # Not in allowed values ) rescue PaystackSdk::InvalidValueError => e puts e.message # => "Invalid value for risk_action: must be one of: allow, deny, default" end ``` These validation errors are thrown immediately and prevent the API call from being made, helping you catch data issues early in development. --- # Timeouts and Retries Connections built by the SDK time out and retry transient failures by default: ```ruby client = PaystackSdk::Client.new( secret_key: "sk_test_xxx", timeout: 30, # seconds to wait for a response open_timeout: 5, # seconds to wait for the connection to open max_retries: 2, # retries after the first attempt (0 disables retrying) retry_interval: 0.5 # base backoff in seconds, doubled on each retry with jitter ) ``` - Read-only requests (`GET`) are retried on network failures and `429`/`502`/`503`/`504` responses. - Anything that writes (`POST`, `PUT`, `DELETE`) is retried **only on `429`**, where Paystack rejected the request for exceeding the rate limit. A timeout or `5xx` on a write may mean Paystack processed it, so it is not retried. Pass `retry_non_idempotent: true` only if you deduplicate yourself (e.g. verify by reference first). - On a `429` the SDK waits for Paystack's `x-ratelimit-reset` header (seconds) before retrying. If the wait exceeds 10 seconds it stops retrying and raises `PaystackSdk::RateLimitError` (`#retry_after` holds the value). - Errors that survive all retries are raised as `PaystackSdk::RateLimitError`, `PaystackSdk::ServerError`, `PaystackSdk::TimeoutError` or `PaystackSdk::ConnectionError`, all of which inherit from `PaystackSdk::Error`. - 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`. --- # Webhooks 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. 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. ```ruby # Rails controller (skip CSRF protection for this action) def create event = PaystackSdk::Webhook.construct_event( payload: request.raw_post, # raw body, not params signature: request.headers["X-Paystack-Signature"], secret: ENV.fetch("PAYSTACK_SECRET_KEY") ) HandlePaystackEventJob.perform_later(event.payload) # do the work in the background head :ok # acknowledge quickly rescue PaystackSdk::WebhookError head :bad_request end ``` ```ruby event.event # => "charge.success" event.data.reference # data is wrapped like any Response event.data[:amount] event.known? # => true if Paystack documents this event name event.payload # the parsed JSON Hash (string keys), handy for queuing a job ``` - `Webhook.valid_signature?(payload:, signature:, secret:)` returns a boolean, and `Webhook.verify!` raises `PaystackSdk::InvalidSignatureError`. The comparison is constant-time. - 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. - `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. - `Webhook.trusted_ip?(ip)` checks the three addresses Paystack documents (`Webhook::IP_ADDRESSES`). Use it as an extra check, not instead of the signature. - `Webhook.sign(payload, secret)` produces a valid signature, for testing your own endpoint. - 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. - Before giving value for a `charge.success`, confirm it with `transactions.verify(reference:)`, as Paystack recommends. --- # Environment Variables You can use environment variables to configure the SDK: ```ruby # Set the PAYSTACK_SECRET_KEY environment variable ENV["PAYSTACK_SECRET_KEY"] = "sk_test_xxx" # Then initialize resources without specifying the key transactions = PaystackSdk::Resources::Transactions.new customers = PaystackSdk::Resources::Customers.new ``` --- # Direct Resource Instantiation For more advanced usage, you can instantiate resource classes directly: ```ruby # With a secret key transactions = PaystackSdk::Resources::Transactions.new(secret_key: "sk_test_xxx") customers = PaystackSdk::Resources::Customers.new(secret_key: "sk_test_xxx") # With an existing Faraday connection connection = Faraday.new(url: "https://api.paystack.co") do |conn| # Configure the connection end # The secret key can be omitted if set in an environment transactions = PaystackSdk::Resources::Transactions.new(connection, secret_key:) customers = PaystackSdk::Resources::Customers.new(connection, secret_key:) ``` For more detailed documentation on specific resources, please refer to the following guides: - [Transactions](https://paystack.com/docs/api/transaction/) - [Customers](https://paystack.com/docs/api/customer/) - [Plans](https://paystack.com/docs/api/plan/) - [Subscriptions](https://paystack.com/docs/api/subscription/) - [Payment Channels: Mobile Money](https://paystack.com/docs/payments/payment-channels/#mobile-money) --- # Development 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. ## Style and Linting This project uses [StandardRB](https://github.com/standardrb/standard) for code style and linting. Add to your Gemfile (if not already present): ```ruby gem "standard" ``` - Lint: `bundle exec standardrb` - Auto-fix: `bundle exec standardrb --fix` - Via Rake: `bundle exec rake standard` - Default task (runs specs + standard): `bundle exec rake` If you encounter cache permission issues locally, you can disable caching: `bundle exec standardrb --no-cache`. ## Testing 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: ```ruby # Example test response format .and_return(Faraday::Response.new(status: 200, body: { "status" => true, "message" => "Transaction initialized", "data" => { "authorization_url" => "https://checkout.paystack.com/abc123", "access_code" => "access_code_123", "reference" => "ref_123" } })) ``` Tests also validate specific error types to ensure proper exception handling: ```ruby # Testing specific error types expect { customers.set_risk_action(customer: "CUS_123", risk_action: "block") } .to raise_error(PaystackSdk::InvalidValueError, /risk_action/i) ``` ## Installation and Release To install this gem onto your local machine, run: ```bash bundle exec rake install ``` To release a new version, update the version number in `version.rb`, and then run: ```bash bundle exec rake release ``` This will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). --- # Contributing ## Contributing Bug reports and pull requests are welcome on GitHub at [https://github.com/nanafox/paystack_sdk](https://github.com/nanafox/paystack_sdk). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nanafox/paystack_sdk/blob/main/CODE_OF_CONDUCT.md). ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of Conduct Everyone interacting in the PaystackSdk project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/nanafox/paystack_sdk/blob/main/CODE_OF_CONDUCT.md). --- # How a payment works The payer pays on Paystack's checkout page, not on yours. Your server starts the transaction, sends the payer to Paystack, and later asks Paystack what happened. Nothing the browser brings back counts as proof. ## The five steps 1. **Record it first.** Create your own payment row with a new `reference`, an `amount` in the currency's smallest unit (`5000` is GHS 50.00), a `currency` and the status `pending`. Save it before you call Paystack. 2. **Start it.** `client.transactions.initiate(...)` with that reference. Redirect the payer to `response.authorization_url`. 3. **The payer comes back** to your `callback_url`. Treat that request as "please check", nothing more: Paystack's own docs say a visit to the callback URL does not prove the transaction succeeded. 4. **Ask Paystack.** `client.transactions.verify(reference:)`, and give value only if `paid?(amount:, currency:)` is true. 5. **Do the same from the webhook.** The `charge.success` handler runs the same confirmation code as the callback. ```ruby res = client.transactions.initiate(email: member.email, amount: 5000, currency: "GHS", reference: payment.reference) redirect_to res.authorization_url, allow_other_host: true # later, from the callback and from the webhook res = client.transactions.verify(reference: payment.reference) grant_access(payment) if res.paid?(amount: payment.amount, currency: payment.currency) ``` ## Why steps 4 and 5 are the same code The callback and the webhook can arrive at the same moment, so confirming twice is normal. Write one confirmation that is safe to run twice: a unique `reference` column, a row lock, and a check of your own status inside the lock. Record what the payment buys in that same locked block, so it happens once. This is design advice, not Paystack behaviour; the [payments skill](https://nanafox.github.io/paystack_sdk/latest/skills/paystack-sdk-payments.md) has a complete example. ## When something goes wrong - **A timeout or a server error on `initiate`.** The SDK does not retry a write, and the request may have reached Paystack. Call `verify(reference:)`: "Transaction reference not found." means Paystack never created it. Either way, start again with a new reference and mark the old row failed. - **A reference used twice.** Paystack answers 400 "Duplicate Transaction Reference". One reference is one transaction, ever. - **A status you do not recognise.** Keep the row `pending`, log the status and reference, and do not guess. Next: [what a result means](https://nanafox.github.io/paystack_sdk/latest/md/concepts/results-and-trust.md), and the [Transactions guide](https://nanafox.github.io/paystack_sdk/latest/md/guide/transactions.md). --- # What a result means Most mistakes with a payment API come from reading one flag as another. This page is the short version of what each signal in `paystack_sdk` does and does not tell you. ## `success?` is not "the payment worked" `response.success?` is true when Paystack accepted the call and answered with a success HTTP status. `transactions.verify` succeeds for any reference Paystack knows, paid or not. The outcome of the payment is in `data.status`. | You want to know | Look at | Not at | |---|---|---| | Was the call accepted? | `success?` | | | Was the customer charged the right amount? | `paid?(amount:, currency:)` | `success?` | | What state is the transaction in? | `status?(:success)`, or `data.status` | the HTTP status | | Why did a charge stop? | `data.status` and `gateway_response` | `success?` | ## Three kinds of failure, three behaviours - **A call that cannot be valid** (a malformed email, an amount of `50.0`) raises `PaystackSdk::InvalidValueError` before anything is sent. - **A 400 or 404 from Paystack** comes back as a `Response` with `success?` false and an `error_message`. It does not raise. - **A bad key, a rate limit or a server error** (401, 429, 5xx) raises, so it cannot be mistaken for a result. ## Trust your own record, not the browser Verify by `reference`, check the amount and currency with `paid?`, and compare the customer email before giving value. A redirect to your `callback_url` and a webhook body are both prompts to check, not proof. ## Retries are conservative on purpose Reads retry on network errors and on 429, 502, 503 and 504. Writes retry only on 429, and never after a timeout, because a write that timed out may have succeeded. Retrying it could charge a customer twice. See the [Response reference](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), [Errors](https://nanafox.github.io/paystack_sdk/latest/md/reference/errors.md) and the [money-safety skill](https://nanafox.github.io/paystack_sdk/latest/skills/paystack-sdk-money-safety.md). --- # Client The `Client` class serves as the main entry point for interacting with the Paystack API. It initializes a connection to the Paystack API and provides access to various resources. ## Creating a client ```ruby PaystackSdk::Client.new(connection = nil, secret_key: nil, sandbox_only: false, **options) ``` | Parameter | Type | Required | Description | |---|---|---|---| | `connection` | Faraday::Connection, nil | | The Faraday connection object used for API requests. If nil, a new connection will be created using the default API key. | | `secret_key` | String, nil | | Optional API key to use for creating a new connection. Only used if connection is nil. | | `sandbox_only` | Boolean | | Refuse to build a client unless the key is a test key (`sk_test_...`). Set this in staging and CI so they can never charge real money. | ## Methods ### `live?` ```ruby client.live? ``` Whether this client is using a live key (`sk_live_...`), so requests move real money. A test key, or a key in any other form, is not live. **Returns** `Boolean` ## Resources | Accessor | Page | |---|---| | `client.apple_pay` | [apple pay](https://nanafox.github.io/paystack_sdk/latest/md/reference/apple-pay.md) | | `client.balances` | [balances](https://nanafox.github.io/paystack_sdk/latest/md/reference/balances.md) | | `client.banks` | [banks](https://nanafox.github.io/paystack_sdk/latest/md/reference/banks.md) | | `client.bulk_charges` | [bulk charges](https://nanafox.github.io/paystack_sdk/latest/md/reference/bulk-charges.md) | | `client.charges` | [charges](https://nanafox.github.io/paystack_sdk/latest/md/reference/charges.md) | | `client.customers` | [customers](https://nanafox.github.io/paystack_sdk/latest/md/reference/customers.md) | | `client.dedicated_virtual_accounts` | [dedicated virtual accounts](https://nanafox.github.io/paystack_sdk/latest/md/reference/dedicated-virtual-accounts.md) | | `client.direct_debits` | [direct debits](https://nanafox.github.io/paystack_sdk/latest/md/reference/direct-debits.md) | | `client.disputes` | [disputes](https://nanafox.github.io/paystack_sdk/latest/md/reference/disputes.md) | | `client.integrations` | [integrations](https://nanafox.github.io/paystack_sdk/latest/md/reference/integrations.md) | | `client.miscellaneous` | [miscellaneous](https://nanafox.github.io/paystack_sdk/latest/md/reference/miscellaneous.md) | | `client.orders` | [orders](https://nanafox.github.io/paystack_sdk/latest/md/reference/orders.md) | | `client.pages` | [pages](https://nanafox.github.io/paystack_sdk/latest/md/reference/pages.md) | | `client.payment_requests` | [payment requests](https://nanafox.github.io/paystack_sdk/latest/md/reference/payment-requests.md) | | `client.plans` | [plans](https://nanafox.github.io/paystack_sdk/latest/md/reference/plans.md) | | `client.products` | [products](https://nanafox.github.io/paystack_sdk/latest/md/reference/products.md) | | `client.refunds` | [refunds](https://nanafox.github.io/paystack_sdk/latest/md/reference/refunds.md) | | `client.settlements` | [settlements](https://nanafox.github.io/paystack_sdk/latest/md/reference/settlements.md) | | `client.splits` | [splits](https://nanafox.github.io/paystack_sdk/latest/md/reference/splits.md) | | `client.storefronts` | [storefronts](https://nanafox.github.io/paystack_sdk/latest/md/reference/storefronts.md) | | `client.subaccounts` | [subaccounts](https://nanafox.github.io/paystack_sdk/latest/md/reference/subaccounts.md) | | `client.subscriptions` | [subscriptions](https://nanafox.github.io/paystack_sdk/latest/md/reference/subscriptions.md) | | `client.terminals` | [terminals](https://nanafox.github.io/paystack_sdk/latest/md/reference/terminals.md) | | `client.transactions` | [transactions](https://nanafox.github.io/paystack_sdk/latest/md/reference/transactions.md) | | `client.transfer_recipients` | [transfer recipients](https://nanafox.github.io/paystack_sdk/latest/md/reference/transfer-recipients.md) | | `client.transfers` | [transfers](https://nanafox.github.io/paystack_sdk/latest/md/reference/transfers.md) | | `client.virtual_terminals` | [virtual terminals](https://nanafox.github.io/paystack_sdk/latest/md/reference/virtual-terminals.md) | | `client.webhook_events` | [webhook events](https://nanafox.github.io/paystack_sdk/latest/md/reference/webhook-events.md) | --- # Response The Response class provides a wrapper around Paystack API responses. It offers convenient access to response data through dot notation and supports both direct attribute access and hash/array-like operations. The Response class handles API responses that use string keys (as returned by the Paystack API) and provides seamless access through both string and symbol notation. Features: - Dynamic attribute access via dot notation (response.data.attribute) - Hash-like access (response[:key] or response["key"]) - Array-like access for list responses (response[0]) - Iteration support (response.each) - Automatic handling of nested data structures - Consistent handling of string-keyed API responses ```ruby response = PaystackSdk::Response.new(api_response) # Check if request was successful if response.success? # Access data using dot notation puts response.data.authorization_url puts response.data.reference # Or directly from response puts response.authorization_url else puts "Error: #{response.error_message}" end ## Methods ### `error_message` ```ruby response.error_message ``` **Returns** `String, nil` Error message if the request failed ### `api_message` ```ruby response.api_message ``` **Returns** `String, nil` API message from the response ### `message` ```ruby response.message ``` **Returns** `String, nil` API message from the response, if available ### `raw_data` ```ruby response.raw_data ``` **Returns** `Hash, Array, Object` The underlying data ### `status_code` ```ruby response.status_code ``` **Returns** `Integer` The status code of the API response ### `meta` ```ruby response.meta ``` Pagination metadata that Paystack returns with list responses (e.g. `total`, `page`, `pageCount`, `perPage`). **Returns** `Response, nil` The wrapped `meta` object, or nil if the response has none **Example** ```ruby response = transactions.list response.meta.total # => 40 response.meta.pageCount # => 2 ``` ### `data` ```ruby response.data ``` Returns a Response object for the data This enables chained access like response.data.key **Returns** `Response` self, to enable chaining ### `success?` ```ruby response.success? ``` Check if the response was successful **Returns** `Boolean` true if the API request was successful ### `paid?` ```ruby response.paid?(amount: nil, currency: nil) ``` Whether this response is a successful payment: the call succeeded and the transaction's `status` is "success". Paystack's docs say to confirm the amount and currency as well, so pass the ones you expect and they are compared too. | Parameter | Type | Required | Description | |---|---|---|---| | `amount` | Integer, nil | | The amount (in the currency's subunit) you expect | | `currency` | String, nil | | The currency you expect, e.g. "GHS" | **Returns** `Boolean` **Example** ```ruby response = client.transactions.verify(reference: ref) response.paid?(amount: 5000, currency: "GHS") ``` ### `status?` ```ruby response.status?(value) ``` Whether the `status` field in the response data equals the given value, e.g. `response.status?(:send_pin)` on a charge. Paystack names the values; none are assumed here. | Parameter | Type | Required | Description | |---|---|---|---| | `value` | String, Symbol | | | **Returns** `Boolean` ### `failed?` ```ruby response.failed? ``` Check if the response failed **Returns** `Boolean` true if the API request failed ### `error_details` ```ruby response.error_details ``` Get error information if the request failed **Returns** `Hash` Hash containing error details, or empty hash if successful ### `original_response` ```ruby response.original_response ``` Returns the original response body This is useful for debugging or accessing raw data **Returns** `Hash, Array` The original response body ### `[]` ```ruby response.[](key) ``` Access data via hash/array notation. Hash keys can be given as strings or symbols, whichever way the data is keyed. | Parameter | Type | Required | Description | |---|---|---|---| | `key` | Object | | The key or index to access | **Returns** `Object, Response` The value for the given key or index ### `dig` ```ruby response.dig(*keys) ``` Reads a nested value from the data, or nil as soon as a key is missing. Use it for fields Paystack only sometimes sends (`paid_at`, `authorization.exp_month`...): dot access raises `NoMethodError` for a key that is not there, which is what you want for a typo and not for an optional field. Keys can be strings or symbols; an Integer indexes an Array. The value is returned as it is (a plain Hash, Array or scalar), like `Hash#dig`. | Parameter | Type | Required | Description | |---|---|---|---| | `keys` | Array<String, Symbol, Integer> | | The path to the value | **Returns** `Object, nil` The value, or nil if any key along the path is missing **Raises** `ArgumentError` **Example** ```ruby response.dig(:authorization, :authorization_code) # => "AUTH_..." or nil ``` ### `key?` ```ruby response.key?(key) ``` Check if key exists in hash (as a string or a symbol) | Parameter | Type | Required | Description | |---|---|---|---| | `key` | Symbol, String | | The key to check | **Returns** `Boolean` Whether the key exists ### `each` ```ruby response.each ``` Iterate through hash entries or array items **Returns** `Response, Enumerator` Self for chaining or Enumerator if no block given ### `size` ```ruby response.size ``` **Returns** `Integer` The number of items ### `length` ```ruby response.length ``` **Returns** `Integer` The number of items ### `count` ```ruby response.count ``` **Returns** `Integer` The number of items ### `empty?` ```ruby response.empty? ``` **Returns** `Boolean` Whether the collection is empty ### `first` ```ruby response.first ``` **Returns** `Object, Response` The first item, wrapped if necessary ### `last` ```ruby response.last ``` **Returns** `Object, Response` The last item, wrapped if necessary --- # Webhook Helpers for receiving Paystack webhooks, independent of any web framework. Paystack signs every event with the `x-paystack-signature` header: a lowercase hex HMAC SHA512 of the raw request body, using your secret key. Verify it before doing anything with the event, and return a `200 OK` quickly (do long work in a background job): events that are not acknowledged are retried for 72 hours in live mode. ```ruby def create event = PaystackSdk::Webhook.construct_event( payload: request.raw_post, signature: request.headers["X-Paystack-Signature"], secret: ENV.fetch("PAYSTACK_SECRET_KEY") ) HandlePaystackEventJob.perform_later(event.payload) head :ok rescue PaystackSdk::WebhookError head :bad_request end ``` ## Module methods ### `sign` ```ruby PaystackSdk::Webhook.sign(payload, secret) ``` Computes the signature Paystack would send for a payload. Mostly useful in your own tests. | Parameter | Type | Required | Description | |---|---|---|---| | `payload` | String | | The raw request body | | `secret` | String | | Your secret key | **Returns** `String` Lowercase hex HMAC SHA512 ### `valid_signature?` ```ruby PaystackSdk::Webhook.valid_signature?(payload:, signature:, secret:) ``` Checks a webhook's signature in constant time. | Parameter | Type | Required | Description | |---|---|---|---| | `payload` | String | yes | The raw request body, byte for byte. Not a parsed or re-serialised version of it, which would not match. | | `signature` | String, nil | yes | The `x-paystack-signature` header value | | `secret` | String | yes | Your secret key | **Returns** `Boolean` **Raises** `ArgumentError` If the payload is not a String or the secret is blank ### `verify!` ```ruby PaystackSdk::Webhook.verify!(payload:, signature:, secret:) ``` Like {valid_signature?} but raises when the signature is wrong. **Returns** `true` **Raises** `PaystackSdk::InvalidSignatureError` ### `construct_event` ```ruby PaystackSdk::Webhook.construct_event(payload:, signature:, secret:) ``` Verifies the signature, then parses the event. Nothing is parsed if the signature is wrong. **Returns** `PaystackSdk::Webhook::Event` **Raises** `PaystackSdk::InvalidSignatureError` If the signature does not match **Raises** `PaystackSdk::InvalidPayloadError` If the signed body is not a JSON event ### `trusted_ip?` ```ruby PaystackSdk::Webhook.trusted_ip?(ip) ``` Whether an address is one Paystack documents for webhooks. A second check alongside the signature, not a replacement for it. | Parameter | Type | Required | Description | |---|---|---|---| | `ip` | String, nil | | | **Returns** `Boolean` ## Webhook::Event A verified webhook event. ### `event` ```ruby event.event ``` **Returns** `String` The event name, e.g. "charge.success" ### `payload` ```ruby event.payload ``` **Returns** `Hash` The parsed JSON body, string-keyed ### `data` ```ruby event.data ``` **Returns** `PaystackSdk::Response, nil` The event's `data`, wrapped for dot and hash access ### `known?` ```ruby event.known? ``` **Returns** `Boolean` Whether this event name is in `EVENTS` (documented, or seen sent by Paystack) ## EVENTS Events Paystack documents, plus ones Paystack was seen sending that its Webhooks page does not list. Paystack adds events over time, so an event outside this list is still returned by {Webhook.construct_event}. Documented on the Webhooks page, except `refund.needs-attention`, which only the Refunds guide names. Seen in the event log of a test integration (Webhook Events API) and not on the Webhooks page: `paymentrequest.draft`, `product.create`, `product.update` and `product.delete`. - `charge.dispute.create` - `charge.dispute.remind` - `charge.dispute.resolve` - `charge.success` - `customeridentification.failed` - `customeridentification.success` - `dedicatedaccount.assign.failed` - `dedicatedaccount.assign.success` - `invoice.create` - `invoice.payment_failed` - `invoice.update` - `paymentrequest.draft` - `paymentrequest.pending` - `paymentrequest.success` - `product.create` - `product.delete` - `product.update` - `refund.failed` - `refund.needs-attention` - `refund.pending` - `refund.processed` - `refund.processing` - `subscription.create` - `subscription.disable` - `subscription.expiring_cards` - `subscription.not_renew` - `transfer.failed` - `transfer.reversed` - `transfer.success` --- # Errors Every error the SDK raises inherits from `PaystackSdk::Error`. | Class | Inherits from | When | |---|---|---| | `PaystackSdk::APIError` | `PaystackSdk::Error` | Base class for API errors. Raised when the Paystack API returns an error response. | | `PaystackSdk::AuthenticationError` | `PaystackSdk::APIError` | Raised when authentication fails | | `PaystackSdk::ConnectionError` | `PaystackSdk::Error` | Raised when a request could not reach Paystack (DNS, refused, reset, ...) after all retries were exhausted. | | `PaystackSdk::Error` | `StandardError` | Base error class for all Paystack SDK errors. All SDK-specific exceptions inherit from this class. | | `PaystackSdk::InvalidFormatError` | `PaystackSdk::ValidationError` | Raised when a parameter has an invalid format. Contains both the parameter name and expected format for detailed error handling. | | `PaystackSdk::InvalidPayloadError` | `PaystackSdk::WebhookError` | Raised when a correctly signed webhook body is not a JSON event. | | `PaystackSdk::InvalidSignatureError` | `PaystackSdk::WebhookError` | Raised when a webhook's signature does not match its payload. Treat the request as not coming from Paystack. | | `PaystackSdk::InvalidValueError` | `PaystackSdk::ValidationError` | Raised when a parameter has an invalid value. Contains both the parameter name and the reason for the invalid value. | | `PaystackSdk::MissingParamError` | `PaystackSdk::ValidationError` | Raised when a required parameter is missing. Contains the parameter name for detailed error handling. | | `PaystackSdk::RateLimitError` | `PaystackSdk::APIError` | Raised when rate limiting is encountered | | `PaystackSdk::ResourceNotFoundError` | `PaystackSdk::APIError` | Raised when a resource is not found | | `PaystackSdk::ServerError` | `PaystackSdk::APIError` | Raised when the server returns a 5xx error | | `PaystackSdk::TimeoutError` | `PaystackSdk::ConnectionError` | Raised when a request to Paystack timed out after all retries were exhausted. | | `PaystackSdk::ValidationError` | `PaystackSdk::Error` | Base class for all validation errors. Raised when input parameters fail validation before API calls. | | `PaystackSdk::WebhookError` | `PaystackSdk::Error` | Base class for webhook errors. | --- # Apple Pay Apple Pay operations. Use it as `client.apple_pay`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list_domains` ```ruby apple_pay.list_domains(use_cursor: nil, next_cursor: nil, previous: nil) ``` List Domains. Lists all registered domains on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `use_cursor` | Boolean | | A flag to indicate if cursor based pagination should be used | | `next_cursor` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the next set of data | | `previous` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the previous set of data | Paystack docs: ### `register_domain` ```ruby apple_pay.register_domain(domain_name:) ``` Register Domain. Register a top-level domain or subdomain for your Apple Pay integration. | Parameter | Type | Required | Description | |---|---|---|---| | `domain_name` | String | yes | The domain or subdomain for your application | ::: warning Note Registers a domain for Apple Pay on the whole account. This changes Apple Pay configuration that may also affect live mode, even with a test key (sk_test_). Not verified against the live API by this SDK: do not call it to try the API out. ::: Paystack docs: ### `unregister_domain` ```ruby apple_pay.unregister_domain(domain_name:) ``` Unregister Domain. Unregister a top-level domain or subdomain previously used for your Apple Pay integration. | Parameter | Type | Required | Description | |---|---|---|---| | `domain_name` | String | yes | The domain or subdomain for your application | ::: warning Note Unregisters a domain from Apple Pay on the whole account. This changes Apple Pay configuration that may also affect live mode, even with a test key (sk_test_). Not verified against the live API by this SDK, including how Paystack reads the request body of a DELETE. ::: Paystack docs: --- # Balances Balance operations. Use it as `client.balances`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `fetch` ```ruby balances.fetch ``` Fetch Balance. Fetch the available balance on your integration Paystack docs: ### `ledger` ```ruby balances.ledger(per_page: nil, page: nil, from: nil, to: nil) ``` Balance Ledger. Fetch all pay-ins and pay-outs that occured on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: --- # Banks Bank operations. Use it as `client.banks`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby banks.list( country: nil, currency: nil, use_cursor: nil, per_page: nil, page: nil, next_cursor: nil, previous: nil, pay_with_bank_transfer: nil, pay_with_bank: nil, enabled_for_verification: nil, gateway: nil, type: nil, include_nip_sort_code: nil ) ``` List Banks. List banks supported on Paystack | Parameter | Type | Required | Description | |---|---|---|---| | `country` | String | | The country from which to obtain the list of supported banks One of: ghana, kenya, nigeria, south africa. | | `currency` | String | | The currency of the banks to list. One of: GHS, KES, NGN, ZAR, USD. | | `use_cursor` | Boolean | | A flag to indicate if cursor based pagination should be used | | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `next_cursor` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the next set of data | | `previous` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the previous set of data | | `pay_with_bank_transfer` | Boolean | | A flag to filter for available banks a customer can make a transfer to complete a payment | | `pay_with_bank` | Boolean | | A flag to filter for banks a customer can pay directly from | | `enabled_for_verification` | Boolean | | A flag to filter the banks that are supported for account verification in South Africa. | | `gateway` | String | | The type of gateway for a Nigerian bank One of: emandate, digitalbankmandate. | | `type` | String | | Type of financial channel One of: ghipss, mobile_money, nuban, kepss, basa. | | `include_nip_sort_code` | Boolean | | A flag that returns Nigerian banks with their NIP institution code. | Paystack docs: ### `resolve_account_number` ```ruby banks.resolve_account_number(account_number:, bank_code:) ``` Resolve Account Number. Resolve an account number to confirm the name associated with it | Parameter | Type | Required | Description | |---|---|---|---| | `account_number` | String | yes | The account number of interest | | `bank_code` | String | yes | The bank code associated with the account number | Paystack docs: ### `validate_account` ```ruby banks.validate_account( account_name:, account_number:, account_type:, bank_code:, country_code:, document_type:, document_number: nil ) ``` Validate Bank Account. Confirm the authenticity of a customer's account number before sending money | Parameter | Type | Required | Description | |---|---|---|---| | `account_name` | String | yes | Customer's first and last name registered with their bank | | `account_number` | String | yes | Customer's account number | | `account_type` | String | yes | The type of the customer's account number One of: personal, business. | | `bank_code` | String | yes | The bank code of the customer’s bank. | | `country_code` | String | yes | The two digit ISO code of the customer’s bank | | `document_type` | String | yes | Customer’s mode of identity One of: identityNumber, passportNumber, businessRegistrationNumber. | | `document_number` | String | | Customer’s mode of identity number | Paystack docs: --- # Bulk Charges Bulk Charge operations. Use it as `client.bulk_charges`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list_batches` ```ruby bulk_charges.list_batches(per_page: nil, page: nil, status: nil) ``` List Bulk Charge Batches. List all bulk charge batches. | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The offset to retrieve data from | | `status` | String | | Filter by the status of the charges One of: active, paused, complete. | Paystack docs: ### `initiate` ```ruby bulk_charges.initiate(charges:) ``` Initiate Bulk Charge. Charge multiple customers in batches | Parameter | Type | Required | Description | |---|---|---|---| | `charges` | Array | yes | The request body: an array of hashes, each with authorization, amount, and optionally reference, attempt_partial_debit, at_least, metadata | Paystack docs: ### `fetch_batch` ```ruby bulk_charges.fetch_batch(id_or_code:) ``` Fetch Bulk Charge Batch. This endpoint retrieves a specific batch code. | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The code for the charge whose batches you want to retrieve | Paystack docs: ### `fetch_charges` ```ruby bulk_charges.fetch_charges(id_or_code:, per_page: nil, page: nil, status: nil) ``` List Charges in a Batch. This endpoint retrieves the charges associated with a specified batch code | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | An code for the batch whose charges you want to retrieve | | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The offset to retrieve data from | | `status` | String | | Filter by the status of the charges One of: success, failed, pending, error, inactive_authorization. | Paystack docs: ### `pause_batch` ```ruby bulk_charges.pause_batch(batch_code:) ``` Pause Bulk Charge Batch. Pause the processing of a charge batch | Parameter | Type | Required | Description | |---|---|---|---| | `batch_code` | String | yes | The batch code for the bulk charge you want to pause | Paystack docs: ### `resume_batch` ```ruby bulk_charges.resume_batch(batch_code:) ``` Resume Bulk Charge Batch. Resume the processing of a previously paused charge batch | Parameter | Type | Required | Description | |---|---|---|---| | `batch_code` | String | yes | The batch code for the bulk charge you want to pause | Paystack docs: --- # Charges Charge operations. Use it as `client.charges`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `create` ```ruby charges.create( email:, amount:, authorization_code: nil, pin: nil, reference: nil, birthday: nil, device_id: nil, metadata: nil, bank: nil, mobile_money: nil, ussd: nil, eft: nil, currency: nil, split_code: nil, subaccount: nil, bank_transfer: nil, qr: nil, capitec_pay: nil ) ``` Create Charge. Initiate a payment by integrating the payment channel of your choice. | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `amount` | Integer | yes | Amount should be in kobo if currency is NGN, pesewas, if currency is GHS, and cents, if currency is ZAR | | `authorization_code` | String | | An authorization code to charge. | | `pin` | String | | 4-digit PIN (send with a non-reusable authorization code) | | `reference` | String | | Unique transaction reference. | | `birthday` | String | | The customer's birthday in the format YYYY-MM-DD e.g 2017-05-16 | | `device_id` | String | | This is the unique identifier of the device a user uses in making payment. | | `metadata` | Hash | | JSON object of custom data | | `bank` | Hash | | The bank object if charging a bank account | | `mobile_money` | Hash | | Details of the mobile service provider | | `ussd` | Hash | | The USSD code for the provider to charge | | `eft` | Hash | | Details of the EFT provider | | `currency` | String | | The currency to charge in (GHS, KES and so on); Paystack uses your integration's currency when it is left out. | | `split_code` | String | | The split code (SPL_...) of a previously created split. | | `subaccount` | String | | The code (ACCT_...) of the subaccount that owns the payment. | | `bank_transfer` | Hash | | Settings for the Pay with Transfer and Pesalink channel (account_expires_at, the expiry time of the account). | | `qr` | Hash | | The QR provider details (provider, scan-to-pay being the only one); South Africa only. | | `capitec_pay` | Hash | | The Capitec Pay account holder (identifier_key, one of CELLPHONE, IDNUMBER or ACCOUNTNUMBER, and identifier_value); South Africa only. | Paystack docs: ### `submit_pin` ```ruby charges.submit_pin(pin:, reference:) ``` Submit PIN. Submit PIN to continue a charge | Parameter | Type | Required | Description | |---|---|---|---| | `pin` | String | yes | Customer's PIN for the ongoing transaction | | `reference` | String | yes | Transaction reference that requires the PIN | Paystack docs: ### `submit_otp` ```ruby charges.submit_otp(otp:, reference:) ``` Submit OTP. Submit OTP to complete a charge | Parameter | Type | Required | Description | |---|---|---|---| | `otp` | String | yes | Customer's OTP for ongoing transaction | | `reference` | String | yes | The reference of the ongoing transaction | Paystack docs: ### `submit_phone` ```ruby charges.submit_phone(phone:, reference:) ``` Submit Phone. Submit phone number when requested | Parameter | Type | Required | Description | |---|---|---|---| | `phone` | String | yes | Customer's mobile number | | `reference` | String | yes | The reference of the ongoing transaction | Paystack docs: ### `submit_birthday` ```ruby charges.submit_birthday(birthday:, reference:) ``` Submit Birthday. Submit the customer's birthday when requested | Parameter | Type | Required | Description | |---|---|---|---| | `birthday` | String | yes | Customer's birthday in the format YYYY-MM-DD e.g 2016-09-21 | | `reference` | String | yes | The reference of the ongoing transaction | Paystack docs: ### `submit_address` ```ruby charges.submit_address(address:, city:, state:, zip_code:, reference:) ``` Submit Address. Send the details of the customer's address for address verification | Parameter | Type | Required | Description | |---|---|---|---| | `address` | String | yes | Customer's address | | `city` | String | yes | Customer's city | | `state` | String | yes | Customer's state | | `zip_code` | String | yes | Customer's zipcode | | `reference` | String | yes | The reference of the ongoing transaction | Paystack docs: ### `check_pending` ```ruby charges.check_pending(reference:) ``` Check pending charge. When you get `pending` as a charge status or if there was an exception when calling any of the `/charge` endpoints, wait 10 seconds or more, then make a check to see if its status has changed. | Parameter | Type | Required | Description | |---|---|---|---| | `reference` | String | yes | The reference of the ongoing transaction | Paystack docs: ### `mobile_money` ```ruby charges.mobile_money(email:, amount:, mobile_money:, currency: nil, reference: nil, metadata: nil) ``` Charges a mobile money wallet: a `#create` call with a checked `mobile_money` object. Mobile money is available to businesses in Ghana, Kenya and Côte d'Ivoire. The charge usually comes back with status `pay_offline` (the customer approves it on their phone; show them `display_text` and wait for the `charge.success` webhook) or `send_otp` (collect the OTP and call {#submit_otp}). | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `amount` | Integer | yes | Amount in the subunit of the currency (pesewas, cents) | | `mobile_money` | Hash | yes | The wallet to charge, with symbol or string keys: `phone` (the customer's number) or, for M-PESA Till (`mptill`), `account` (the till number), and `provider`, one of {MOBILE_MONEY_PROVIDERS} in any case (sent in lowercase). | | `currency` | String, nil | | 3-letter currency code, e.g. GHS or KES; Paystack uses your integration's currency when it is left out. | | `reference` | String, nil | | Unique transaction reference | | `metadata` | Hash, nil | | Custom data for your post-payment processes | Paystack docs: ### `mobile_money` ```ruby charges.mobile_money(email:, amount:, mobile_money:, currency: nil, reference: nil, metadata: nil) ``` Charges a mobile money wallet: a `#create` call with a checked `mobile_money` object. Mobile money is available to businesses in Ghana, Kenya and Côte d'Ivoire. The charge usually comes back with status `pay_offline` (the customer approves it on their phone; show them `display_text` and wait for the `charge.success` webhook) or `send_otp` (collect the OTP and call {#submit_otp}). | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `amount` | Integer | yes | Amount in the subunit of the currency (pesewas, cents) | | `mobile_money` | Hash | yes | The wallet to charge, with symbol or string keys: `phone` (the customer's number) or, for M-PESA Till (`mptill`), `account` (the till number), and `provider`, one of {MOBILE_MONEY_PROVIDERS} in any case (sent in lowercase). | | `currency` | String, nil | | 3-letter currency code, e.g. GHS or KES; Paystack uses your integration's currency when it is left out. | | `reference` | String, nil | | Unique transaction reference | | `metadata` | Hash, nil | | Custom data for your post-payment processes | Paystack docs: --- # Customers Customer operations. Use it as `client.customers`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby customers.list( use_cursor: nil, next_cursor: nil, previous: nil, from: nil, to: nil, per_page: nil, page: nil ) ``` List Customers. List customers on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `use_cursor` | Boolean | | A flag to indicate if cursor based pagination should be used | | `next_cursor` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the next set of data | | `previous` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the previous set of data | | `from` | String | | The start date | | `to` | String | | The end date | | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | Paystack docs: ### `create` ```ruby customers.create(email:, first_name: nil, last_name: nil, phone: nil, metadata: nil) ``` Create Customer. Create a customer on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `first_name` | String | | Customer's first name | | `last_name` | String | | Customer's last name | | `phone` | String | | Customer's phone number | | `metadata` | Hash | | A set of key/value pairs that you can attach to the customer. | Paystack docs: ### `fetch` ```ruby customers.fetch(email_or_code:) ``` Fetch Customer. Get details of a customer on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `email_or_code` | String | yes | The code for the customer gotten from the response of the customer creation | Paystack docs: ### `update` ```ruby customers.update(code:, first_name: nil, last_name: nil, phone: nil, metadata: nil) ``` Update Customer. Update a customer's details on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | The code for the customer gotten from the response of the customer creation | | `first_name` | String | | Customer's first name | | `last_name` | String | | Customer's last name | | `phone` | String | | Customer's phone number | | `metadata` | Hash | | A set of key/value pairs that you can attach to the customer. | Paystack docs: ### `set_risk_action` ```ruby customers.set_risk_action(customer:, risk_action: nil) ``` Set Risk Action. Set customer's risk action by whitelisting or blacklisting the customer | Parameter | Type | Required | Description | |---|---|---|---| | `customer` | String | yes | The customer code from the response of the customer creation | | `risk_action` | String | | This determines the fraud rules that should be applied to the customer One of: allow, deny, default. | Paystack docs: ### `validate` ```ruby customers.validate( code:, first_name:, last_name:, type:, country:, bvn:, bank_code:, account_number:, middle_name: nil, value: nil ) ``` Validate Customer. Validate a customer's identity | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | The code for the customer gotten from the response of the customer creation | | `first_name` | String | yes | Customer's first name | | `last_name` | String | yes | Customer's last name | | `type` | String | yes | Predefined types of identification. | | `country` | String | yes | Two-letter country code of identification issuer | | `bvn` | String | yes | Customer's Bank Verification Number | | `bank_code` | String | yes | You can get the list of bank codes by calling the List Banks endpoint (https://api.paystack.co/bank). | | `account_number` | String | yes | Customer's bank account number. | | `middle_name` | String | | Customer's middle name | | `value` | String | | Customer's identification number. | Paystack docs: ### `initialize_authorization` ```ruby customers.initialize_authorization(email:, channel:, callback_url: nil, account: nil, address: nil) ``` Initialize Authorization. Initiate a request to create a reusable authorization code for recurring transactions | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `channel` | String | yes | direct_debit is the only supported option for now One of: direct_debit. | | `callback_url` | String | | Fully qualified url (e.g. | | `account` | Hash | | | | `address` | Hash | | | Paystack docs: ### `verify_authorization` ```ruby customers.verify_authorization(reference:) ``` Verify Authorization. Check the status of an authorization request | Parameter | Type | Required | Description | |---|---|---|---| | `reference` | String | yes | The reference returned in the initialization response | Paystack docs: ### `deactivate_authorization` ```ruby customers.deactivate_authorization(authorization_code:) ``` Deactivate Authorization. Deactivate an authorization for any payment channel. | Parameter | Type | Required | Description | |---|---|---|---| | `authorization_code` | String | yes | Authorization code to be deactivated | Paystack docs: ### `initialize_direct_debit` ```ruby customers.initialize_direct_debit(id:, account:, address:) ``` Initialize Direct Debit. Initialize the process of linking an account to a customer for Direct Debit transactions | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The ID of the customer to initialize the direct debit for | | `account` | Hash | yes | | | `address` | Hash | yes | | Paystack docs: ### `direct_debit_activation_charge` ```ruby customers.direct_debit_activation_charge(id:, authorization_id:) ``` Direct Debit Activation Charge. Trigger an activation charge on an inactive mandate on behalf of your customer | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The customer ID attached to the authorization | | `authorization_id` | Integer | yes | The authorization ID gotten from the initiation response | Paystack docs: ### `fetch_mandate_authorizations` ```ruby customers.fetch_mandate_authorizations(id:) ``` Fetch Mandate Authorizations. Get the list of direct debit mandates associated with a customer | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The customer ID for the authorizations to fetch | Paystack docs: --- # Dedicated Virtual Accounts Dedicated Virtual Account operations. Use it as `client.dedicated_virtual_accounts`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby dedicated_virtual_accounts.list( active: nil, customer: nil, currency: nil, provider_slug: nil, bank_id: nil, per_page: nil, page: nil ) ``` List Dedicated Accounts. List dedicated virtual accounts available on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `active` | Boolean | | Status of the dedicated virtual account | | `customer` | Integer | | The customer's ID | | `currency` | String | | The currency of the dedicated virtual account One of: NGN, GHS. | | `provider_slug` | String | | The bank's slug in lowercase, without spaces | | `bank_id` | String | | The bank's ID | | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | Paystack docs: ### `create` ```ruby dedicated_virtual_accounts.create(customer:, preferred_bank: nil, subaccount: nil, split_code: nil) ``` Create Dedicated Account. Create a dedicated virtual account for an existing customer | Parameter | Type | Required | Description | |---|---|---|---| | `customer` | String | yes | The code for the previously created customer | | `preferred_bank` | String | | The bank slug for preferred bank. | | `subaccount` | String | | Subaccount code of the account you want to split the transaction with | | `split_code` | String | | Split code consisting of the lists of accounts you want to split the transaction with | Paystack docs: ### `assign` ```ruby dedicated_virtual_accounts.assign( email:, first_name:, last_name:, phone:, preferred_bank:, country:, account_number: nil, bvn: nil, bank_code: nil, subaccount: nil, split_code: nil ) ``` Assign Dedicated Account. With this endpoint, you can create a customer, validate the customer, and assign a DVA to the customer. | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `first_name` | String | yes | Customer's first name | | `last_name` | String | yes | Customer's last name | | `phone` | String | yes | Customer's phone name | | `preferred_bank` | String | yes | The bank slug for preferred bank. | | `country` | String | yes | The two letter code country One of: NG, GH. | | `account_number` | String | | Customer's account number | | `bvn` | String | | Customer's Bank Verification Number | | `bank_code` | String | | Customer's bank code | | `subaccount` | String | | Subaccount code of the account you want to split the transaction with | | `split_code` | String | | Split code consisting of the lists of accounts you want to split the transaction with | Paystack docs: ### `fetch` ```ruby dedicated_virtual_accounts.fetch(dedicated_account_id:) ``` Fetch Dedicated Account. Get details of a dedicated virtual account on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `dedicated_account_id` | String | yes | ID of dedicated virtual account | Paystack docs: ### `deactivate` ```ruby dedicated_virtual_accounts.deactivate(dedicated_account_id:) ``` Deactivate Dedicated Account. Deactivate a dedicated virtual account on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `dedicated_account_id` | String | yes | ID of dedicated virtual account | Paystack docs: ### `requery` ```ruby dedicated_virtual_accounts.requery(account_number: nil, provider_slug: nil, date: nil) ``` Requery Dedicated Account. Requery Dedicated Virtual Account for new transactions | Parameter | Type | Required | Description | |---|---|---|---| | `account_number` | String | | Virtual account number to requery | | `provider_slug` | String | | The bank's slug in lowercase, without spaces. | | `date` | String | | The day the transfer was made | Paystack docs: ### `add_split` ```ruby dedicated_virtual_accounts.add_split(account_number:, subaccount: nil, split_code: nil) ``` Split Dedicated Account Transaction. Split a dedicated virtual account transaction with one or more accounts | Parameter | Type | Required | Description | |---|---|---|---| | `account_number` | String | yes | Valid Dedicated virtual account | | `subaccount` | String | | Subaccount code of the account you want to split the transaction with | | `split_code` | String | | Split code consisting of the lists of accounts you want to split the transaction with | Paystack docs: ### `remove_split` ```ruby dedicated_virtual_accounts.remove_split(account_number:) ``` Remove Split from Dedicated Account. If you've previously set up split payment for transactions on a dedicated virtual account, you can remove it with this endpoint | Parameter | Type | Required | Description | |---|---|---|---| | `account_number` | String | yes | Valid Dedicated virtual account | Paystack docs: ### `fetch_bank_providers` ```ruby dedicated_virtual_accounts.fetch_bank_providers ``` Fetch Bank Providers. Get available bank providers for a dedicated virtual account Paystack docs: --- # Direct Debits Direct Debit operations. Use it as `client.direct_debits`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `trigger_activation_charge` ```ruby direct_debits.trigger_activation_charge(customer_ids:) ``` Trigger Activation Charge. Trigger activation charge for specified customers | Parameter | Type | Required | Description | |---|---|---|---| | `customer_ids` | Array | yes | Array of customer IDs to trigger activation charge for | ::: warning Note Charges the bank accounts behind the given customers' pending mandates to activate them; in live mode this moves real money. It was not called against the test API while this method was built, so its behaviour is documented, not verified. For one customer's inactive mandate, `customers.direct_debit_activation_charge` takes an `authorization_id` instead. ::: Paystack docs: ### `list_mandate_authorizations` ```ruby direct_debits.list_mandate_authorizations(cursor: nil, status: nil, per_page: nil) ``` List Mandate Authorizations. Get a list of all the direct debit mandates on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `cursor` | String | | The cursor value of the next set of authorizations to fetch. | | `status` | String | | Filter by the authorization status One of: pending, active, revoked. | | `per_page` | Integer | | The number of authorizations to fetch per request | Paystack docs: --- # Disputes Dispute operations. Use it as `client.disputes`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby disputes.list(per_page: nil, page: nil, status: nil, transaction: nil, from: nil, to: nil) ``` List Disputes. List transaction disputes filed by customers | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `status` | String | | Dispute status One of: awaiting-merchant-feedback, awaiting-bank-feedback, pending, resolved. | | `transaction` | String | | Transaction ID | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `fetch` ```ruby disputes.fetch(id:) ``` Fetch Dispute. Fetch a transaction dispute | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the dispute | Paystack docs: ### `update` ```ruby disputes.update(id:, refund_amount:, uploaded_filename: nil) ``` Update Dispute. Update a transaction dispute | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the dispute | | `refund_amount` | Integer | yes | The amount to refund, in the subunit of your currency | | `uploaded_filename` | String | | Filename of attachment returned via response from the Dispute upload URL | Paystack docs: ### `fetch_upload_url` ```ruby disputes.fetch_upload_url(id:, upload_filename: nil) ``` Fetch Upload URL. Get the URL to upload a dispute evidence | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the dispute | | `upload_filename` | String | | The file name, with its extension, that you want to upload (for example filename.pdf). | Paystack docs: ### `export` ```ruby disputes.export(per_page: nil, page: nil, status: nil, from: nil, to: nil) ``` Export Disputes. Export the disputes available on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `status` | String | | One of: awaiting-merchant-feedback, awaiting-bank-feedback, pending, resolved. | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `list_transaction` ```ruby disputes.list_transaction(id:) ``` List Transaction Disputes. List all disputes filed for a transaction | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the transaction | Paystack docs: ### `resolve` ```ruby disputes.resolve(id:, resolution:, message:, refund_amount:, uploaded_filename:, evidence: nil) ``` Resolve Dispute. Resolve a transaction dispute | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the dispute | | `resolution` | String | yes | Dispute resolution. | | `message` | String | yes | Reason for resolving | | `refund_amount` | Integer | yes | The amount to refund, in the subunit of your integration currency | | `uploaded_filename` | String | yes | Filename of attachment returned via response from the Dispute upload URL | | `evidence` | Integer | | Evidence Id for fraud claims | Paystack docs: ### `add_evidence` ```ruby disputes.add_evidence( id:, customer_email:, customer_name:, customer_phone:, service_details:, delivery_address: nil, delivery_date: nil ) ``` Add Evidence. Provide evidence for a dispute | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the dispute | | `customer_email` | String | yes | Customer email | | `customer_name` | String | yes | Customer name | | `customer_phone` | String | yes | Customer mobile number | | `service_details` | String | yes | Details of service offered | | `delivery_address` | String | | Delivery address | | `delivery_date` | String | | ISO 8601 representation of delivery date (YYYY-MM-DD) | Paystack docs: --- # Integrations Integration operations. Use it as `client.integrations`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `fetch_payment_session_timeout` ```ruby integrations.fetch_payment_session_timeout ``` Fetch Payment Session Timeout. Fetch the session timeout of a transaction Paystack docs: ### `update_payment_session_timeout` ```ruby integrations.update_payment_session_timeout(timeout:) ``` Update Payment Session Timeout. Update the session timeout of a transaction | Parameter | Type | Required | Description | |---|---|---|---| | `timeout` | Integer | yes | Time in seconds before a transaction becomes invalid | ::: warning Note Changes the payment session timeout of the whole integration (account-wide), and the setting may be shared with LIVE mode even when you call it with a test key (sk_test_). It has not been verified against the live API. Read the current value with `fetch_payment_session_timeout` first, and do not call this to try the API out. ::: Paystack docs: --- # Miscellaneous Miscellaneous operations. Use it as `client.miscellaneous`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `resolve_card_bin` ```ruby miscellaneous.resolve_card_bin(bin:) ``` Resolve Card BIN. Get the details of a card BIN | Parameter | Type | Required | Description | |---|---|---|---| | `bin` | String | yes | The card bank identification number | Paystack docs: ### `list_countries` ```ruby miscellaneous.list_countries ``` List Countries. List all supported countries on Paystack Paystack docs: ### `list_states` ```ruby miscellaneous.list_states(country:) ``` List States (AVS). Get a list of states for a country for address verification | Parameter | Type | Required | Description | |---|---|---|---| | `country` | String | yes | The country code of the states to list. | Paystack docs: --- # Orders Order operations. Use it as `client.orders`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby orders.list(per_page: nil, page: nil, from: nil, to: nil) ``` List Orders. List the previously created orders | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `create` ```ruby orders.create( email:, first_name:, last_name:, phone:, currency:, items:, shipping:, is_gift: nil, pay_for_me: nil ) ``` Create Order. Create an order for selected items | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | The email of the customer placing the order | | `first_name` | String | yes | The customer's first name | | `last_name` | String | yes | The customer's last name | | `phone` | String | yes | The customer's mobile number | | `currency` | String | yes | Currency in which amount is set One of: GHS, KES, NGN, USD, ZAR. | | `items` | Array | yes | | | `shipping` | Hash | yes | The shipping details of the order | | `is_gift` | Boolean | | A flag to indicate if the order is for someone else | | `pay_for_me` | Boolean | | A flag to indicate if the someone else should pay for the order | Paystack docs: ### `fetch` ```ruby orders.fetch(id:) ``` Fetch Order. Fetch the details of a previously created order | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the order | Paystack docs: ### `fetch_product_orders` ```ruby orders.fetch_product_orders(id:) ``` Fetch Product Orders. Fetch all orders for a particular product | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the order | Paystack docs: ### `validate` ```ruby orders.validate(code:) ``` Validate Order. Validate a pay for me order | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | The unique code of a previously created order | Paystack docs: --- # Pages Page operations. Use it as `client.pages`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby pages.list(per_page: nil, page: nil, from: nil, to: nil) ``` List Pages. List all previously created payment pages | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `create` ```ruby pages.create( name:, description: nil, amount: nil, currency: nil, slug: nil, type: nil, plan: nil, fixed_amount: nil, split_code: nil, metadata: nil, redirect_url: nil, success_message: nil, notification_email: nil, collect_phone: nil, custom_fields: nil ) ``` Create Page. Create a webpage to receive payments | Parameter | Type | Required | Description | |---|---|---|---| | `name` | String | yes | Name of page | | `description` | String | | The description of the page | | `amount` | Integer | | Amount should be in kobo if currency is NGN, pesewas, if currency is GHS, and cents, if currency is ZAR | | `currency` | String | | The transaction currency. One of: NGN, GHS, ZAR, KES, USD. | | `slug` | String | | URL slug you would like to be associated with this page. | | `type` | String | | The type of payment page to create. One of: payment, subscription, product, plan. | | `plan` | String | | The ID of the plan to subscribe customers on this payment page to when `type` is set to `subscription`. | | `fixed_amount` | Boolean | | Specifies whether to collect a fixed amount on the payment page. | | `split_code` | String | | The split code of the transaction split. | | `metadata` | Hash | | JSON object of custom data | | `redirect_url` | String | | If you would like Paystack to redirect to a URL upon successful payment, specify the URL here. | | `success_message` | String | | A success message to display to the customer after a successful transaction | | `notification_email` | String | | An email address that will receive transaction notifications for this payment page | | `collect_phone` | Boolean | | Specify whether to collect phone numbers on the payment page | | `custom_fields` | Array | | If you would like to accept custom fields, specify them here. | Paystack docs: ### `fetch` ```ruby pages.fetch(id_or_slug:) ``` Fetch Page. Get a previously created payment page | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_slug` | String | yes | The unique identifier of a payment page | Paystack docs: ### `update` ```ruby pages.update(id_or_slug:, name: nil, description: nil, amount: nil, active: nil) ``` Update Page. Update a previously created payment page | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_slug` | String | yes | The unique identifier of a payment page | | `name` | String | | Name of page | | `description` | String | | The description of the page | | `amount` | Integer | | Amount should be in the subunit of the currency | | `active` | Boolean | | Set to false to deactivate page url | Paystack docs: ### `check_slug_availability` ```ruby pages.check_slug_availability(slug:) ``` Check Slug Availability. Check if a custom slug is available for use when creating a payment page | Parameter | Type | Required | Description | |---|---|---|---| | `slug` | String | yes | The custom slug to check | Paystack docs: ### `add_products` ```ruby pages.add_products(id:, products:) ``` Add Products. Add products to a previously created payment page. | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | | | `products` | Array | yes | A list of IDs of products to add to a page. | Paystack docs: --- # Payment Requests Payment Request operations. Use it as `client.payment_requests`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby payment_requests.list( per_page: nil, page: nil, customer_id: nil, status: nil, currency: nil, from: nil, to: nil, include_archive: nil ) ``` List Payment Request. List all previously created payment requests to your customers | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `customer_id` | Integer | | Customer ID | | `status` | String | | Invoice status to filter One of: draft, pending, success, failed. | | `currency` | String | | If your integration supports more than one currency, choose the one to filter | | `from` | String | | The start date | | `to` | String | | The end date | | `include_archive` | Boolean | | Pass true to include archived payment requests. | Paystack docs: ### `create` ```ruby payment_requests.create( customer:, amount: nil, currency: nil, due_date: nil, description: nil, line_items: nil, tax: nil, send_notification: nil, draft: nil, has_invoice: nil, invoice_number: nil, split_code: nil, metadata: nil, redirect_url: nil ) ``` Create Payment Request. Create a new payment request by issuing an invoice to a customer | Parameter | Type | Required | Description | |---|---|---|---| | `customer` | String | yes | Customer id or code | | `amount` | Integer | | The amount in the subunit. | | `currency` | String | | Specify the currency of the invoice. | | `due_date` | String | | ISO 8601 date or date-time the request is due, for example 2026-12-31 or 2026-12-31T23:00:00Z. | | `description` | String | | A short description of the payment request | | `line_items` | Array | | Line items, each a Hash such as {name: "item 1", amount: 2000, quantity: 1} (amount in the subunit). | | `tax` | Array | | Taxes, each a Hash such as {name: "VAT", amount: 2000} (amount in the subunit). | | `send_notification` | Boolean | | Indicates whether Paystack sends an email notification to customer. | | `draft` | Boolean | | Indicate if request should be saved as draft. | | `has_invoice` | Boolean | | Set to true to create a draft invoice (adds an auto incrementing invoice number if none is provided) even if there are no line_items or tax passed | | `invoice_number` | Integer | | Numeric value of invoice. | | `split_code` | String | | The split code of the transaction split. | | `metadata` | Hash | | A Hash of custom data, stored on the payment request. | | `redirect_url` | String | | A URL for Paystack to redirect to after a successful payment. | Paystack docs: ### `fetch` ```ruby payment_requests.fetch(id_or_code:) ``` Fetch Payment Request. Fetch a previously created payment request | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The unique identifier of a previously created payment request | Paystack docs: ### `update` ```ruby payment_requests.update( id_or_code:, customer: nil, amount: nil, currency: nil, due_date: nil, description: nil, line_items: nil, tax: nil, send_notification: nil, draft: nil, has_invoice: nil, invoice_number: nil, split_code: nil ) ``` Update Payment Request. Update a previously created payment request | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The unique identifier of a previously created payment request | | `customer` | String | | Customer id or code | | `amount` | Integer | | Payment request amount. | | `currency` | String | | Specify the currency of the invoice. | | `due_date` | String | | ISO 8601 date or date-time the request is due, for example 2026-12-31 or 2026-12-31T23:00:00Z. | | `description` | String | | A short description of the payment request | | `line_items` | Array | | Line items, each a Hash such as {name: "item 1", amount: 2000, quantity: 1} (amount in the subunit). | | `tax` | Array | | Taxes, each a Hash such as {name: "VAT", amount: 2000} (amount in the subunit). | | `send_notification` | Boolean | | Indicates whether Paystack sends an email notification to customer. | | `draft` | Boolean | | Indicate if request should be saved as draft. | | `has_invoice` | Boolean | | Set to true to create a draft invoice (adds an auto incrementing invoice number if none is provided) even if there are no line_items or tax passed | | `invoice_number` | Integer | | Numeric value of invoice. | | `split_code` | String | | The split code of the transaction split. | Paystack docs: ### `verify` ```ruby payment_requests.verify(code:) ``` Verify Payment Request. Verify the status of a previously created payment request | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | The unique identifier of a previously created payment request | Paystack docs: ### `notify` ```ruby payment_requests.notify(code:) ``` Send Notification. Trigger an email reminder to a customer for a previously created payment request | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | The unique identifier of a previously created payment request | Paystack docs: ### `totals` ```ruby payment_requests.totals ``` Payment Request Total. Get the metric of all pending and successful payment requests Paystack docs: ### `finalize` ```ruby payment_requests.finalize(id_or_code:, send_notification: nil) ``` Finalize Payment Request. Finalise the creation of a draft payment request for a customer | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The unique identifier of a draft payment request | | `send_notification` | Boolean | | Whether Paystack emails the customer; defaults to true. | Paystack docs: ### `archive` ```ruby payment_requests.archive(id_or_code:) ``` Archive Payment Request. Archive a payment request to clean up your records. | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The unique identifier of a previously created payment request | Paystack docs: --- # Plans Plan operations. Use it as `client.plans`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby plans.list(per_page: nil, page: nil, interval: nil, amount: nil, from: nil, to: nil, status: nil) ``` List Plans. List all recurring payment plans | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `interval` | String | | Specify interval of the plan One of: hourly, daily, weekly, monthly, quarterly, biannually, annually. | | `amount` | Integer | | The amount on the plans to retrieve | | `from` | String | | The start date | | `to` | String | | The end date | | `status` | String | | Filter list by plans with specified status. | Paystack docs: ### `create` ```ruby plans.create( name:, amount:, interval:, description: nil, send_invoices: nil, send_sms: nil, currency: nil, invoice_limit: nil ) ``` Create Plan. Create a plan for recurring payments | Parameter | Type | Required | Description | |---|---|---|---| | `name` | String | yes | Name of plan | | `amount` | Integer | yes | Amount should be in kobo if currency is NGN, pesewas, if currency is GHS, and cents, if currency is ZAR | | `interval` | String | yes | Payment interval One of: hourly, daily, weekly, monthly, quarterly, biannually, annually. | | `description` | String | | A description for this plan | | `send_invoices` | Boolean | | Set to false if you don't want invoices to be sent to your customers | | `send_sms` | Boolean | | Set to false if you don't want text messages to be sent to your customers | | `currency` | String | | Currency in which amount is set. | | `invoice_limit` | Integer | | Number of invoices to raise during subscription to this plan. | Paystack docs: ### `fetch` ```ruby plans.fetch(id_or_code:) ``` Fetch Plan. Get the details of a payment plan | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The plan code you want to fetch | Paystack docs: ### `update` ```ruby plans.update( id_or_code:, name: nil, amount: nil, interval: nil, description: nil, send_invoices: nil, send_sms: nil, currency: nil, invoice_limit: nil, update_existing_subscriptions: nil ) ``` Update Plan. Update a plan details on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The plan code you want to fetch | | `name` | String | | Name of plan | | `amount` | Integer | | Amount should be in kobo if currency is NGN, pesewas, if currency is GHS, and cents, if currency is ZAR | | `interval` | String | | Payment interval One of: hourly, daily, weekly, monthly, quarterly, biannually, annually. | | `description` | String | | A description for this plan | | `send_invoices` | Boolean | | Set to false if you don't want invoices to be sent to your customers | | `send_sms` | Boolean | | Set to false if you don't want text messages to be sent to your customers | | `currency` | String | | Currency in which amount is set. | | `invoice_limit` | Integer | | Number of invoices to raise during subscription to this plan. | | `update_existing_subscriptions` | Boolean | | Set to true if you want the existing subscriptions to use the new changes. | Paystack docs: --- # Products Product operations. Use it as `client.products`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby products.list(per_page: nil, page: nil, active: nil, from: nil, to: nil) ``` List Products. List all previously created products | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `active` | Boolean | | The state of the product | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `create` ```ruby products.create( name:, price:, currency:, description: nil, unlimited: nil, quantity: nil, split_code: nil, metadata: nil ) ``` Create Product. Create a new product on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `name` | String | yes | Name of product | | `price` | Integer | yes | Price should be in kobo if currency is NGN, pesewas, if currency is GHS, and cents, if currency is ZAR | | `currency` | String | yes | Currency in which price is set. | | `description` | String | | The description of the product. | | `unlimited` | Boolean | | Set to true if the product has unlimited stock. | | `quantity` | Integer | | Number of products in stock. | | `split_code` | String | | The split code if sharing the transaction with partners | | `metadata` | Hash | | A set of key/value pairs that you can attach to the product. | Paystack docs: ### `fetch` ```ruby products.fetch(id:) ``` Fetch Product. Fetch a previously created product | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the product | Paystack docs: ### `update` ```ruby products.update( id:, name: nil, description: nil, price: nil, currency: nil, unlimited: nil, quantity: nil, split_code: nil, metadata: nil ) ``` Update product. Update a previously created product | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the product | | `name` | String | | Name of product | | `description` | String | | The description of the product | | `price` | Integer | | Price should be in kobo if currency is NGN, pesewas, if currency is GHS, and cents, if currency is ZAR | | `currency` | String | | Currency in which price is set. | | `unlimited` | Boolean | | Set to true if the product has unlimited stock. | | `quantity` | Integer | | Number of products in stock. | | `split_code` | String | | The split code if sharing the transaction with partners | | `metadata` | Hash | | JSON object of custom data | Paystack docs: ### `delete` ```ruby products.delete(id:) ``` Delete Product. Delete a previously created product | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the product | Paystack docs: --- # Refunds Refund operations. Use it as `client.refunds`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby refunds.list(per_page: nil, page: nil, from: nil, to: nil, transaction_id: nil) ``` List Refunds. List previously created refunds | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `from` | String | | The start date | | `to` | String | | The end date | | `transaction_id` | Integer | | The ID of the refunded transaction. | Paystack docs: ### `create` ```ruby refunds.create(transaction:, amount: nil, currency: nil, customer_note: nil, merchant_note: nil) ``` Create Refund. Initiate a refund for a previously completed transaction | Parameter | Type | Required | Description | |---|---|---|---| | `transaction` | String | yes | The reference or ID of a previously completed transaction. | | `amount` | Integer | | Amount to be refunded to the customer. | | `currency` | String | | Three-letter ISO currency One of: GHS, KES, NGN, USD, ZAR. | | `customer_note` | String | | Customer reason | | `merchant_note` | String | | Merchant reason | Paystack docs: ### `retry_with_customer_details` ```ruby refunds.retry_with_customer_details(id:, refund_account_details:) ``` Retry Refund. Retry a refund with a `needs-attention` status by providing the bank account details of a customer. | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The identifier of the refund | | `refund_account_details` | Hash | yes | An object that contains the customer’s account details for refund | Paystack docs: ### `fetch` ```ruby refunds.fetch(id:) ``` Fetch Refund. Get a previously created refund | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The identifier of the refund | Paystack docs: --- # Settlements Settlement operations. Use it as `client.settlements`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby settlements.list(per_page: nil, page: nil, from: nil, to: nil) ``` List Settlements. List settlements made to your settlement accounts | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `from` | String | | A timestamp from which to start listing settlements, for example 2016-09-24T00:00:05.000Z or 2016-09-21. | | `to` | String | | A timestamp at which to stop listing settlements, for example 2016-09-24T00:00:05.000Z or 2016-09-21. | Paystack docs: ### `transactions` ```ruby settlements.transactions(id:) ``` Fetch Settlement Transactions. Get the transactions that make up a particular settlement | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The settlement ID in which you want to fetch its transactions | Paystack docs: --- # Splits Split operations. Use it as `client.splits`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby splits.list( subaccount_code: nil, name: nil, active: nil, per_page: nil, page: nil, from: nil, to: nil ) ``` List Splits. List the transaction splits available on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `subaccount_code` | String | | Filter by subaccount code | | `name` | String | | The name of the split | | `active` | Boolean | | The status of the split | | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `create` ```ruby splits.create(name:, type:, subaccounts:, currency:, bearer_type: nil, bearer_subaccount: nil) ``` Create Split. Create a split configuration for transactions | Parameter | Type | Required | Description | |---|---|---|---| | `name` | String | yes | Name of the transaction split | | `type` | String | yes | The type of transaction split you want to create. One of: percentage, flat. | | `subaccounts` | Array | yes | A list of object containing subaccount code and number of shares | | `currency` | String | yes | The transaction currency One of: NGN, GHS, ZAR, USD. | | `bearer_type` | String | | This allows you specify how the transaction charge should be processed One of: subaccount, account, all-proportional, all. | | `bearer_subaccount` | String | | This is the subaccount code of the customer or partner that would bear the transaction charge if you specified subaccount as the bearer type | Paystack docs: ### `fetch` ```ruby splits.fetch(id:) ``` Fetch Split. Get details of a split configuration for a transaction | Parameter | Type | Required | Description | |---|---|---|---| | `id` | String | yes | The ID of the split configuration to fetch | Paystack docs: ### `update` ```ruby splits.update(id:, name: nil, active: nil, bearer_type: nil, bearer_subaccount: nil) ``` Update Split. Update a split configuration for transactions | Parameter | Type | Required | Description | |---|---|---|---| | `id` | String | yes | | | `name` | String | | Name of the transaction split | | `active` | Boolean | | Toggle status of split. | | `bearer_type` | String | | This allows you specify how the transaction charge should be processed One of: subaccount, account, all-proportional, all. | | `bearer_subaccount` | String | | This is the subaccount code of the customer or partner that would bear the transaction charge if you specified subaccount as the bearer type | Paystack docs: ### `add_subaccount` ```ruby splits.add_subaccount(id:, subaccount:, share:) ``` Add Subaccount to Split. Add a subaccount to a split configuration, or update the share of an existing subaccount | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The ID of the split configuration to fetch | | `subaccount` | String | yes | Subaccount code of the customer or partner | | `share` | Integer | yes | The percentage or flat quota of the customer or partner | Paystack docs: ### `remove_subaccount` ```ruby splits.remove_subaccount(id:, subaccount:, share: nil) ``` Remove Subaccount from split. Remove a subaccount from a split configuration | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The ID of the split configuration to fetch | | `subaccount` | String | yes | Subaccount code of the customer or partner | | `share` | Integer | | Not used when removing a subaccount; the docs do not list it, and Paystack removes the subaccount whatever share is sent. | Paystack docs: --- # Storefronts Storefront operations. Use it as `client.storefronts`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby storefronts.list(per_page: nil, page: nil, status: nil) ``` List Storefronts. List the storefronts you previously created | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `status` | String | | One of: active, inactive. | Paystack docs: ### `create` ```ruby storefronts.create(name:, slug:, currency:, description: nil) ``` Create Storefront. Create a digital shop to manage and display your products | Parameter | Type | Required | Description | |---|---|---|---| | `name` | String | yes | Name of the storefront | | `slug` | String | yes | A unique identifier to access your store. | | `currency` | String | yes | Currency for prices of products in your storefront. One of: GHS, KES, NGN, USD, ZAR. | | `description` | String | | The description of the storefront | Paystack docs: ### `fetch` ```ruby storefronts.fetch(id:) ``` Fetch Storefront. Get the details of a previously created Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | Paystack docs: ### `update` ```ruby storefronts.update(id:, name: nil, slug: nil, description: nil) ``` Update Storefront. Update the details of a previously created Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | | `name` | String | | Name of the storefront | | `slug` | String | | A unique identifier to access your store. | | `description` | String | | The description of the storefront | Paystack docs: ### `delete` ```ruby storefronts.delete(id:) ``` Delete Storefront. Delete a previously created Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | Paystack docs: ### `verify_slug` ```ruby storefronts.verify_slug(slug:) ``` Verify Storefront Slug. Verify the availability of a slug before using it for your Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `slug` | String | yes | The custom slug to check | Paystack docs: ### `fetch_orders` ```ruby storefronts.fetch_orders(id:) ``` Fetch Storefront Orders. Fetch all orders in your Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | Paystack docs: ### `list_products` ```ruby storefronts.list_products(id:) ``` List Storefront Products. List the products in a Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | Paystack docs: ### `add_products` ```ruby storefronts.add_products(id:, products:) ``` Add Products to Storefront. Add previously created products to a Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | | `products` | Array | yes | An array of product IDs | Paystack docs: ### `publish` ```ruby storefronts.publish(id:) ``` Publish Storefront. Make your Storefront publicly available | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | ::: warning Note Publishing copies the storefront and its products into LIVE mode, even when you call it with a test key (sk_test_). The live copy is active, can be found by its slug, and cannot be read or deleted with a test key. Do not call this to try the API out; use `duplicate` instead. ::: Paystack docs: ### `duplicate` ```ruby storefronts.duplicate(id:) ``` Duplicate Storefront. Duplicate a previously created Storefront | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The unique identifier of the Storefront | Paystack docs: --- # Subaccounts Subaccount operations. Use it as `client.subaccounts`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby subaccounts.list(per_page: nil, page: nil, active: nil) ``` List Subaccounts. List subaccounts available on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `active` | Integer | | Filter by the state of the subaccounts | Paystack docs: ### `create` ```ruby subaccounts.create( business_name:, bank_code:, account_number:, percentage_charge:, description: nil, primary_contact_email: nil, primary_contact_name: nil, primary_contact_phone: nil, metadata: nil ) ``` Create Subaccount. Create a subacount for a partner | Parameter | Type | Required | Description | |---|---|---|---| | `business_name` | String | yes | Name of business for subaccount | | `bank_code` | String | yes | Bank code for the bank, from the List Banks endpoint. | | `account_number` | String | yes | Bank account number | | `percentage_charge` | Numeric | yes | The percentage the main account receives from each payment made to the subaccount. | | `description` | String | | A description for this subaccount | | `primary_contact_email` | String | | A contact email for the subaccount | | `primary_contact_name` | String | | The name of the contact person for this subaccount | | `primary_contact_phone` | String | | A phone number to call for this subaccount | | `metadata` | String | | Stringified JSON object of custom data | Paystack docs: ### `fetch` ```ruby subaccounts.fetch(id_or_code:) ``` Fetch Subaccount. Get details of a subaccount on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The subaccount code you want to fetch | Paystack docs: ### `update` ```ruby subaccounts.update( id_or_code:, business_name: nil, bank_code: nil, account_number: nil, active: nil, percentage_charge: nil, description: nil, primary_contact_email: nil, primary_contact_name: nil, primary_contact_phone: nil, metadata: nil ) ``` Update Subaccount. Update a subaccount details on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The subaccount code you want to fetch | | `business_name` | String | | Name of business for subaccount | | `bank_code` | String | | Bank code for the bank, from the List Banks endpoint. | | `account_number` | String | | Bank account number | | `active` | Boolean | | Activate or deactivate a subaccount | | `percentage_charge` | Numeric | | The default percentage charged when receiving on behalf of this subaccount. | | `description` | String | | A description for this subaccount | | `primary_contact_email` | String | | A contact email for the subaccount | | `primary_contact_name` | String | | The name of the contact person for this subaccount | | `primary_contact_phone` | String | | A phone number to call for this subaccount | | `metadata` | String | | Stringified JSON object of custom data | Paystack docs: --- # Subscriptions Subscription operations. Use it as `client.subscriptions`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby subscriptions.list(per_page: nil, page: nil, plan_id: nil, customer_id: nil, from: nil, to: nil) ``` List Subscriptions. List all subscriptions available on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | Number of records to fetch per page | | `page` | Integer | | The section to retrieve | | `plan_id` | Integer | | Plan ID | | `customer_id` | Integer | | Customer ID | | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `create` ```ruby subscriptions.create(customer:, plan:, authorization: nil, start_date: nil) ``` Create Subscription. Create a subscription a customer | Parameter | Type | Required | Description | |---|---|---|---| | `customer` | String | yes | Customer's email address or customer code | | `plan` | String | yes | Plan code | | `authorization` | String | | If customer has multiple authorizations, you can set the desired authorization you wish to use for this subscription here. | | `start_date` | String | | Set the date for the first debit. | Paystack docs: ### `fetch` ```ruby subscriptions.fetch(id_or_code:) ``` Fetch Subscription. Get details of a customer's subscription | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The subscription code for the subscription you want to fetch | Paystack docs: ### `disable` ```ruby subscriptions.disable(code:, token:) ``` Disable Subscription. Disable a subscription on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Subscription code | | `token` | String | yes | Email token | Paystack docs: ### `enable` ```ruby subscriptions.enable(code:, token:) ``` Enable Subscription. Enable a subscription on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Subscription code | | `token` | String | yes | Email token | Paystack docs: ### `generate_update_link` ```ruby subscriptions.generate_update_link(code:) ``` Generate Update Subscription Link. Generate a link for updating the card on a subscription | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Subscription code | Paystack docs: ### `send_update_link` ```ruby subscriptions.send_update_link(code:) ``` Send Update Subscription Link. Email a customer a link for updating the card on their subscription | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Subscription code | Paystack docs: --- # Terminals Terminal operations. Use it as `client.terminals`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `send_event` ```ruby terminals.send_event(terminal_id:, type: nil, action: nil, data: nil) ``` Send Event. Send an event from your application to the Paystack Terminal | Parameter | Type | Required | Description | |---|---|---|---| | `terminal_id` | String | yes | The ID of the Terminal the event should be sent to. | | `type` | String | | The type of event to push One of: invoice, transaction. | | `action` | String | | The action the Terminal needs to perform. One of: process, view, print. | | `data` | Hash | | The parameters needed to perform the specified action | ::: warning Note This pushes the event to a physical Paystack Terminal. Not verified against the Paystack API: the test account used to build this SDK has no Terminal access. ::: Paystack docs: ### `fetch_event_status` ```ruby terminals.fetch_event_status(terminal_id:, event_id:) ``` Fetch Event Status. Check the status of an event sent to the Terminal | Parameter | Type | Required | Description | |---|---|---|---| | `terminal_id` | String | yes | The ID of the Terminal the event should be sent to. | | `event_id` | String | yes | The ID of the event that was sent to the Terminal | Paystack docs: ### `fetch_status` ```ruby terminals.fetch_status(terminal_id:) ``` Fetch Terminal Status. Check the availiability of a Terminal before sending an event to it | Parameter | Type | Required | Description | |---|---|---|---| | `terminal_id` | String | yes | The ID of the Terminal the event should be sent to. | Paystack docs: ### `list` ```ruby terminals.list(next_cursor: nil, previous: nil, per_page: nil) ``` List Terminals. List the Terminals available on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `next_cursor` | String | | A cursor that indicates your place in the list. | | `previous` | String | | A cursor that indicates your place in the list. | | `per_page` | Integer | | Specify how many records you want to retrieve per page | Paystack docs: ### `fetch` ```ruby terminals.fetch(terminal_id:) ``` Fetch Terminal. Get the details of a Terminal | Parameter | Type | Required | Description | |---|---|---|---| | `terminal_id` | String | yes | The ID of the Terminal the event should be sent to. | Paystack docs: ### `update` ```ruby terminals.update(terminal_id:, name: nil, address: nil) ``` Update Terminal. Update the details of a Terminal | Parameter | Type | Required | Description | |---|---|---|---| | `terminal_id` | String | yes | The ID of the Terminal the event should be sent to. | | `name` | String | | The new name for the Terminal | | `address` | String | | The new address for the Terminal | Paystack docs: ### `commission` ```ruby terminals.commission(serial_number:) ``` Commission Terminal. Activate your debug device by linking it to your integration | Parameter | Type | Required | Description | |---|---|---|---| | `serial_number` | String | yes | Device Serial Number | ::: warning Note This links a physical device to your integration. Not verified against the Paystack API: the test account used to build this SDK has no Terminal access. ::: Paystack docs: ### `decommission` ```ruby terminals.decommission(serial_number:) ``` Decommission Terminal. Unlink your debug device from your integration | Parameter | Type | Required | Description | |---|---|---|---| | `serial_number` | String | yes | Device Serial Number | ::: warning Note This unlinks a physical device from your integration. Not verified against the Paystack API: the test account used to build this SDK has no Terminal access. ::: Paystack docs: --- # Transactions Transaction operations. Use it as `client.transactions`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `initiate` ```ruby transactions.initiate( email:, amount:, currency: nil, reference: nil, channels: nil, callback_url: nil, plan: nil, invoice_limit: nil, split_code: nil, split: nil, subaccount: nil, transaction_charge: nil, bearer: nil, label: nil, metadata: nil ) ``` Initialize Transaction. Create a new transaction | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `amount` | Integer | yes | Amount should be in smallest denomination of the currency. | | `currency` | String | | List of all support currencies One of: GHS, KES, NGN, ZAR, USD. | | `reference` | String | | Unique transaction reference. | | `channels` | Array | | An array of payment channels to control what channels you want to make available to the user to make a payment with | | `callback_url` | String | | Fully qualified url, e.g. | | `plan` | String | | If transaction is to create a subscription to a predefined plan, provide plan code here. | | `invoice_limit` | Integer | | Number of times to charge customer during subscription to plan | | `split_code` | String | | The split code of the transaction split | | `split` | Hash | | Split configuration for transactions | | `subaccount` | String | | The code for the subaccount that owns the payment | | `transaction_charge` | String | | A flat fee to charge the subaccount for a transaction. | | `bearer` | String | | The bearer of the transaction charge One of: account, subaccount. | | `label` | String | | Used to replace the email address shown on the Checkout | | `metadata` | String | | Custom data as a JSON object (a Hash, or a JSON string). | Paystack docs: ### `charge_authorization` ```ruby transactions.charge_authorization( email:, amount:, authorization_code:, reference: nil, currency: nil, split_code: nil, split: nil, subaccount: nil, transaction_charge: nil, bearer: nil, metadata: nil, queue: nil ) ``` Charge Authorization. Charge all authorizations marked as reusable with this endpoint whenever you need to receive payments | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `amount` | Integer | yes | Amount in the lower denomination of your currency | | `authorization_code` | String | yes | Valid authorization code to charge | | `reference` | String | | Unique transaction reference. | | `currency` | String | | List of all support currencies One of: GHS, KES, NGN, ZAR, USD. | | `split_code` | String | | The split code of the transaction split | | `split` | Hash | | Split configuration for transactions | | `subaccount` | String | | The code for the subaccount that owns the payment | | `transaction_charge` | String | | A flat fee to charge the subaccount for a transaction. | | `bearer` | String | | The bearer of the transaction charge One of: account, subaccount. | | `metadata` | String | | Stringified JSON object of custom data | | `queue` | Boolean | | If you are making a scheduled charge call, it is a good idea to queue them so the processing system does not get overloaded causing transaction processing errors. | Paystack docs: ### `partial_debit` ```ruby transactions.partial_debit( email:, amount:, authorization_code:, currency:, at_least: nil, reference: nil ) ``` Partial Debit. Retrieve part of a payment from a customer | Parameter | Type | Required | Description | |---|---|---|---| | `email` | String | yes | Customer's email address | | `amount` | Integer | yes | Specified in the lowest denomination of your currency | | `authorization_code` | String | yes | Valid authorization code to charge | | `currency` | String | yes | List of all support currencies One of: GHS, KES, NGN, ZAR, USD. | | `at_least` | String | | Minimum amount to charge | | `reference` | String | | Unique transaction reference. | Paystack docs: ### `verify` ```ruby transactions.verify(reference:) ``` Verify Transaction. Verify a previously initiated transaction using it's reference | Parameter | Type | Required | Description | |---|---|---|---| | `reference` | String | yes | The transaction reference to verify | Paystack docs: ### `list` ```ruby transactions.list( use_cursor: nil, next_cursor: nil, previous: nil, per_page: nil, page: nil, from: nil, to: nil, status: nil, source: nil, terminal_id: nil, virtual_account_number: nil, customer_id: nil, amount: nil, settlement: nil, channel: nil, subaccount_code: nil, split_code: nil ) ``` List Transactions. List transactions that has occurred on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `use_cursor` | Boolean | | A flag to indicate if cursor based pagination should be used | | `next_cursor` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the next set of data | | `previous` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the previous set of data | | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `from` | String | | The start date | | `to` | String | | The end date | | `status` | String | | Filter transaction by status One of: success, failed, abandoned, reversed. | | `source` | String | | The origin of the payment One of: merchantApi, checkout, pos, virtualTerminal. | | `terminal_id` | String | | Filter transactions by a terminal ID | | `virtual_account_number` | String | | Filter transactions by a virtual account number | | `customer_id` | Integer | | Filter transactions by a customer code | | `amount` | Integer | | Filter transactions by a specific amount | | `settlement` | Integer | | The settlement ID to filter for settled transactions | | `channel` | String | | The payment method the customer used to complete the transaction One of: card, pos, bank, dedicated_nuban, ussd, bank_transfer. | | `subaccount_code` | String | | Filter transaction by subaccount code | | `split_code` | String | | Filter transaction by split code | Paystack docs: ### `fetch` ```ruby transactions.fetch(id:) ``` Fetch Transaction. Fetch a transaction to get its details | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The ID of the transaction to fetch | Paystack docs: ### `timeline` ```ruby transactions.timeline(id:) ``` Fetch Transaction Timeline. Fetch the steps taken from the initiation to the completion of a transaction | Parameter | Type | Required | Description | |---|---|---|---| | `id` | Integer | yes | The ID of the transaction to fetch | Paystack docs: ### `totals` ```ruby transactions.totals(from: nil, to: nil) ``` Transaction Totals. Get the total amount of all transactions | Parameter | Type | Required | Description | |---|---|---|---| | `from` | String | | The start date | | `to` | String | | The end date | Paystack docs: ### `export` ```ruby transactions.export( from: nil, to: nil, status: nil, customer_id: nil, subaccount_code: nil, settlement: nil, currency: nil, amount: nil, settled: nil, payment_page: nil ) ``` Export Transactions. Download transactions that occurred on your integration for a specific timeframe | Parameter | Type | Required | Description | |---|---|---|---| | `from` | String | | The start date | | `to` | String | | The end date | | `status` | String | | Filter by the status of the transaction One of: success, failed, abandoned, reversed, all. | | `customer_id` | Integer | | Filter by customer ID | | `subaccount_code` | String | | Filter by subaccount code | | `settlement` | Integer | | Filter by the settlement ID | | `currency` | String | | Specify the transaction currency to export. | | `amount` | Integer | | Filter transactions by amount, using the supported currency subunit. | | `settled` | Boolean | | Set to true to export only settled transactions, false for pending transactions. | | `payment_page` | Integer | | Specify a payment page's id to export only transactions conducted on said page. | Paystack docs: --- # Transfer Recipients Transfer Recipient operations. Use it as `client.transfer_recipients`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby transfer_recipients.list(use_cursor: nil, next_cursor: nil, previous: nil, per_page: nil, page: nil) ``` List Transfer Recipients. List transfer recipients available on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `use_cursor` | Boolean | | A flag to indicate if cursor based pagination should be used | | `next_cursor` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the next set of data | | `previous` | String | | An alphanumeric value returned for every cursor based retrieval, used to retrieve the previous set of data | | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | Paystack docs: ### `create` ```ruby transfer_recipients.create( type:, name:, account_number:, bank_code:, description: nil, currency: nil, authorization_code: nil, metadata: nil ) ``` Create Transfer Recipient. Creates a new recipient. A duplicate account number will lead to the retrieval of the existing record. | Parameter | Type | Required | Description | |---|---|---|---| | `type` | String | yes | Recipient Type One of: nuban, ghipss, mobile_money, basa, authorization. | | `name` | String | yes | The recipient's name according to their account registration. | | `account_number` | String | yes | Recipient's bank account number | | `bank_code` | String | yes | Recipient's bank code, from the List Banks endpoint. | | `description` | String | | A description for this recipient | | `currency` | String | | Currency for the account receiving the transfer | | `authorization_code` | String | | An authorization code from a previous transaction | | `metadata` | Hash | | JSON object of custom data | Paystack docs: ### `bulk_create` ```ruby transfer_recipients.bulk_create(batch:) ``` Bulk Create Transfer Recipient. Create multiple transfer recipients in batches. A duplicate account number will lead to the retrieval of the existing record. | Parameter | Type | Required | Description | |---|---|---|---| | `batch` | Array<Hash> | yes | A list of transfer recipient objects, each with the fields accepted by `#create` | Paystack docs: ### `fetch` ```ruby transfer_recipients.fetch(id_or_code:) ``` Fetch Transfer recipient. Fetch the details of a transfer recipient | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The recipient code (RCP_...) or numeric ID | Paystack docs: ### `update` ```ruby transfer_recipients.update(id_or_code:, name: nil, email: nil) ``` Update Transfer Recipient. Update the details of a transfer recipient | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The recipient code (RCP_...) or numeric ID | | `name` | String | | Recipient's name | | `email` | String | | Recipient's email address | Paystack docs: ### `delete` ```ruby transfer_recipients.delete(id_or_code:) ``` Delete Transfer Recipient. Delete a transfer recipient (sets the transfer recipient to inactive) | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String | yes | The recipient code (RCP_...) or numeric ID | Paystack docs: --- # Transfers Transfer operations: send money from your balance to transfer recipients, and manage the OTP requirement that guards them. Use it as `client.transfers`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby transfers.list( use_cursor: nil, next_cursor: nil, previous: nil, per_page: nil, page: nil, from: nil, to: nil, recipient: nil, status: nil ) ``` List Transfers. List the transfers made on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `use_cursor` | Boolean | | Set to true to use cursor-based pagination (the response meta then carries `next` and `previous`) | | `next_cursor` | String | | The `next` cursor from a previous cursor-based response | | `previous` | String | | The `previous` cursor from a previous cursor-based response | | `per_page` | Integer | | How many records to retrieve per page (Paystack defaults to 50) | | `page` | Integer | | The page to retrieve (Paystack defaults to 1) | | `from` | String, Date, Time | | A timestamp from which to start listing transfers | | `to` | String, Date, Time | | A timestamp at which to stop listing transfers | | `recipient` | Integer | | Filter by the recipient ID | | `status` | String | | Filter by status. One of: pending, success, failed, otp, abandoned, reversed, blocked, rejected, received. | Paystack docs: ### `create` ```ruby transfers.create(amount:, recipient:, reference:, source:, reason: nil, currency: nil) ``` Initiate Transfer. Send money to your customers. The transfer's status is `pending` when the OTP requirement is disabled, and `otp` when an OTP is required (complete it with `#finalize`). | Parameter | Type | Required | Description | |---|---|---|---| | `amount` | Integer | yes | Amount to transfer in the currency's subunit (kobo for NGN, pesewas for GHS) | | `recipient` | String | yes | Code for the transfer recipient (RCP_...) | | `reference` | String | yes | A unique identifier for the transfer, so retrying it cannot create a second transfer. Paystack documents 16 to 50 characters of lowercase letters, digits, `-` and `_`. | | `source` | String | yes | Where to transfer from. Only "balance" for now. | | `reason` | String | | The reason for the transfer; it also shows up in the narration of the recipient's credit notification | | `currency` | String | | The currency of the transfer; Paystack defaults to NGN. One of: NGN, ZAR, KES, GHS. | Paystack docs: ### `finalize` ```ruby transfers.finalize(transfer_code:, otp:) ``` Finalize Transfer. Finalize an initiated transfer that is waiting for an OTP. | Parameter | Type | Required | Description | |---|---|---|---| | `transfer_code` | String | yes | The transfer code you want to finalize | | `otp` | String | yes | OTP sent to the business phone to verify the transfer | Paystack docs: ### `bulk_create` ```ruby transfers.bulk_create(source:, transfers:, currency: nil) ``` Initiate Bulk Transfer. Batch multiple transfers in a single request. You need to disable the Transfers OTP requirement to use this endpoint. | Parameter | Type | Required | Description | |---|---|---|---| | `source` | String | yes | Where to transfer from. Only "balance" for now. | | `transfers` | Array<Hash> | yes | The transfers, each with `amount`, `recipient`, `reference` and optionally `reason`, named as Paystack names them | | `currency` | String | | The currency of the transfers. One of: NGN, ZAR, KES, GHS. | Paystack docs: ### `fetch` ```ruby transfers.fetch(id_or_code:) ``` Fetch Transfer. Get details of a transfer on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `id_or_code` | String, Integer | yes | The transfer ID or code you want to fetch | Paystack docs: ### `verify` ```ruby transfers.verify(reference:) ``` Verify Transfer. Verify the status of a transfer on your integration. | Parameter | Type | Required | Description | |---|---|---|---| | `reference` | String | yes | Transfer reference | Paystack docs: ### `export` ```ruby transfers.export(recipient: nil, status: nil, from: nil, to: nil) ``` Export Transfers. Export a list of transfers carried out on your integration. This operation is in Paystack's OpenAPI spec but not on its docs page; the API answers it (404 "Transfers not found" when nothing matches). | Parameter | Type | Required | Description | |---|---|---|---| | `recipient` | String | | Export transfers by the recipient code | | `status` | String | | Export transfers by status. One of: pending, success, failed, otp, abandoned, reversed, blocked, rejected, received. | | `from` | String, Date, Time | | The start date | | `to` | String, Date, Time | | The end date | Paystack docs: ### `resend_otp` ```ruby transfers.resend_otp(transfer_code:, reason:) ``` Resend OTP for Transfer. Generates a new OTP and sends it to the business phone, for when it has trouble receiving one. | Parameter | Type | Required | Description | |---|---|---|---| | `transfer_code` | String | yes | The transfer code that requires an OTP validation | | `reason` | String | yes | The purpose of the OTP. The docs list "resend_otp" and "transfer"; the spec also allows "disable_otp". | Paystack docs: ### `disable_otp` ```ruby transfers.disable_otp ``` Disable OTP for Transfers. Start turning off the OTP requirement so transfers can be completed programmatically. Paystack sends an OTP to the business phone; confirm with {#finalize_disable_otp}. **Raises** `PaystackSdk::Error` If the API request fails. Paystack docs: ### `finalize_disable_otp` ```ruby transfers.finalize_disable_otp(otp:) ``` Finalize Disabling OTP for Transfers. Finalize the request to disable OTP on your transfers. | Parameter | Type | Required | Description | |---|---|---|---| | `otp` | String | yes | OTP sent to the business phone to verify disabling the OTP requirement | Paystack docs: ### `enable_otp` ```ruby transfers.enable_otp ``` Enable OTP requirement for Transfers. Turn the OTP requirement for transfers back on. **Raises** `PaystackSdk::Error` If the API request fails. Paystack docs: --- # Virtual Terminals Virtual Terminal operations. Use it as `client.virtual_terminals`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby virtual_terminals.list(per_page: nil, page: nil, status: nil) ``` List Virtual Terminals. List Virtual Terminals on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `per_page` | Integer | | The number of records to fetch per request | | `page` | Integer | | The offset to retrieve data from | | `status` | String | | Filter virtual terminals by status, active or inactive. | Paystack docs: ### `create` ```ruby virtual_terminals.create(name:, destinations:, split_code: nil, metadata: nil) ``` Create Virtual Terminal. Create a Virtual Terminal on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `name` | String | yes | The name of the virtual terminal | | `destinations` | Array | yes | Array of objects containing recipients for payment notifications for the Virtual Terminal. | | `split_code` | String | | Split code to associate with the virtual terminal | | `metadata` | Hash | | Additional custom data as key-value pairs | Paystack docs: ### `fetch` ```ruby virtual_terminals.fetch(code:) ``` Fetch Virtual Terminal. Fetch a Virtual Terminal on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | Paystack docs: ### `update` ```ruby virtual_terminals.update(code:, name:) ``` Update Virtual Terminal. Update a Virtual Terminal on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | | `name` | String | yes | Name of the virtual terminal | Paystack docs: ### `deactivate` ```ruby virtual_terminals.deactivate(code:) ``` Deactivate Virtual Terminal. Deactivate a Virtual Terminal on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | Paystack docs: ### `assign_destination` ```ruby virtual_terminals.assign_destination(code:, destinations:) ``` Assign Destination to Virtual Terminal. Add a destination (WhatsApp number) to a Virtual Terminal on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | | `destinations` | Array | yes | Array of objects containing recipients for payment notifications for the Virtual Terminal. | Paystack docs: ### `unassign_destination` ```ruby virtual_terminals.unassign_destination(code:, targets:) ``` Unassign Destination from Virtual Terminal. Unassign a destination (WhatsApp Number) from a Virtual Terminal on your integration | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | | `targets` | Array | yes | Array of destination targets to unassign | Paystack docs: ### `add_split_code` ```ruby virtual_terminals.add_split_code(code:, split_code:) ``` Add Split Code to Virtual Terminal. Add Split Code to Virtual Terminal | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | | `split_code` | String | yes | The split code to assign to the virtual terminal | Paystack docs: ### `remove_split_code` ```ruby virtual_terminals.remove_split_code(code:, split_code:) ``` Remove Split Code from Virtual Terminal. Remove Split Code from Virtual Terminal | Parameter | Type | Required | Description | |---|---|---|---| | `code` | String | yes | Code of the Virtual Terminal | | `split_code` | String | yes | The split code to assign to the virtual terminal | Paystack docs: --- # Webhook Events Webhook Events API: the log of webhooks Paystack has sent to your integration's webhook URL, with their delivery status, the payload that was sent and what your endpoint answered, and a way to send them again. Use it to find out whether a webhook was delivered, to see exactly what Paystack sent, and to recover events your endpoint missed. `list`, `lookup` and `fetch` only read. `resend` and `resend_matching` make Paystack deliver webhooks to your endpoint again, and have **not** been called against the Paystack test API. Use it as `client.webhook_events`. Every method takes keyword arguments, returns a [`PaystackSdk::Response`](https://nanafox.github.io/paystack_sdk/latest/md/reference/response.md), and raises a `PaystackSdk::Error` if a parameter is invalid before anything is sent. An unsuccessful result from Paystack (a 400 or 404) comes back as a Response with `success?` false. ## Methods ### `list` ```ruby webhook_events.list( category: nil, event_type: nil, status: nil, category_row_id: nil, from: nil, to: nil, limit: nil, next_cursor: nil, previous: nil ) ``` List Events. Fetches a page of the webhook events sent to your integration, newest first. The response `meta` holds `next` and `previous` cursors: pass `meta.next` back as `next_cursor:` for the next page. Observed on the Paystack test API (2026-10-09), where the docs differ: a page holds 50 events by default (the docs say 20), `limit` is capped at 50, and a `limit` that is not a number is ignored. | Parameter | Type | Required | Description | |---|---|---|---| | `category` | String | | Filter by category, for example "transactions" or "refund". | | `event_type` | String | | Filter by event name, for example "charge.success". | | `status` | String | | Filter by delivery status: Delivered, Pending or Failed. | | `category_row_id` | String, Integer | | Filter by the ID of the resource the event is about, such as a transaction ID. | | `from` | String, Time, Date | | Only events from this time on, for example 2016-09-24T00:00:05.000Z or 2016-09-21. | | `to` | String, Time, Date | | Only events up to this time. | | `limit` | Integer | | How many events to return per page. | | `next_cursor` | String | | The `meta.next` cursor of a previous response (sent as `next`). Not together with `previous`. | | `previous` | String | | The `meta.previous` cursor of a previous response. Not together with `next_cursor`. | Paystack docs: ### `lookup` ```ruby webhook_events.lookup(id:) ``` Look Up an Event. Finds a webhook event by its own ID, or by the ID of the resource it is about (a transaction ID, not the transaction's reference). Observed on the test API: an event's `_id` and a `category_row_id` both match; a value that matches nothing is a 404 "Webhook not found" (an unsuccessful Response). | Parameter | Type | Required | Description | |---|---|---|---| | `id` | String, Integer | yes | An event `_id`, or the ID of the resource it is about. Sent as `reference`, the name Paystack's docs give it, although it is not a reference. | Paystack docs: ### `fetch` ```ruby webhook_events.fetch(id:) ``` Fetch Event. Gets one event in full: `event_payload` (the body Paystack sent), `webhook_url`, `status`, `status_detail`, `response_code`, `merchant_response_body` (what your endpoint answered), `trial_count` and `trials`. Observed on the test API, where `event_payload` is a Hash with the keys `event` and `data`. Read the payload's own `data` with brackets, `response.event_payload[:data]`: dot access `event_payload.data` returns the Response itself, because `Response#data` is a method. | Parameter | Type | Required | Description | |---|---|---|---| | `id` | String | yes | The event's `_id`, from an item of `#list`. | Paystack docs: ### `resend` ```ruby webhook_events.resend(ids:) ``` Resend Events. Asks Paystack to deliver a specific set of events to your current webhook URL again. | Parameter | Type | Required | Description | |---|---|---|---| | `ids` | Array<String> | yes | The `_id`s of the events to resend: not empty, at most 100. | ::: warning Note This makes Paystack send webhooks to your endpoint. The docs say resending is not idempotent: sending the same event ID twice delivers it twice, so your endpoint must dedupe. It has not been called against the Paystack test API; the method follows the docs. ::: Paystack docs: ### `resend_matching` ```ruby webhook_events.resend_matching(preview:, filters: nil) ``` Resend Matching Events. Asks Paystack to deliver every event that matches the filters to your current webhook URL again. With `preview: true` it only counts the matching events. `preview:` has no default here, although the docs make it optional: with no filters this resends every event, so you have to say which you mean. Call it with `preview: true` first. | Parameter | Type | Required | Description | |---|---|---|---| | `preview` | Boolean | yes | true to return the matching count without resending anything; false to resend. | | `filters` | Hash, nil | | Any of the `#list` filters: category, event_type, status, category_row_id, from, to. | ::: warning Note This can make Paystack send a lot of webhooks to your endpoint, which must dedupe. It has not been called against the Paystack test API, including with `preview: true`; the method follows the docs. ::: Paystack docs: --- # Changelog ## [Unreleased] ## [0.5.0] - 2026-10-10 ### Upgrading from 0.4 Two behaviours changed in small ways. Check them if you use these calls: - `transactions.initiate` sends `metadata` stringified, as Paystack documents it, so numbers in it keep their type when you read them back (`{count: 42}` stays `42`; it used to come back as `"42"`). - Filters that take a numeric ID (`customer_id:`, `plan_id:`, `transaction_id:` and `authorization_id:`) raise `InvalidValueError` for anything but a numeric ID. Paystack used to answer a code such as `CUS_abc123` there with an empty list and no error, so if you passed one you were silently getting nothing. If you are on 0.4.0, take 0.4.1 first if you only need the security fix. ### Added - `Response#dig(*keys)` reads a nested value and returns `nil` as soon as a key is missing, for fields Paystack only sometimes sends (`response.dig(:authorization, :authorization_code)`). Dot access still raises `NoMethodError` for a key that is not there, which catches typos. - The `paystack-sdk-overview` skill now has a "Topic skills" table that routes an agent to the right skill for the task, and the skills link to each other with `[[name]]`. A spec fails if a shipped skill is not reachable from the overview. - `client.webhook_events` (Paystack's Webhook Events API: `list`, `lookup`, `fetch`, `resend`, `resend_matching`), the log of the webhooks Paystack sent you with their delivery status, the payload and what your endpoint answered. It is documented by Paystack but not in its OpenAPI spec, so it is hand-written and recorded in `spec/fixtures/docs_only_operations.yml`; `bin/paystack-spec audit` and the contract checker read that file. `list`, `lookup` and `fetch` were checked against the test API; `resend` and `resend_matching` were not (they deliver webhooks to your endpoint), and `resend_matching` requires `preview:`. - `Webhook::EVENTS` also lists `refund.needs-attention` (named in Paystack's Refunds guide) and `paymentrequest.draft`, `product.create`, `product.update` and `product.delete`, which Paystack was seen sending but its Webhooks page does not list. - Skills `paystack-sdk-charge-statuses` (what each charge `status` means, what to show the payer, which `charges.*` call comes next, and why `success?` is not "the charge worked"; the flows were reproduced on the test API with Paystack's test cards) and `paystack-sdk-mobile-money` (Ghana providers and codes, the phone number form, the 180-second approval window, how the result arrives, and what test mode does not prove). Specs run what they say. - Skill `paystack-sdk-webhooks`: receiving Paystack webhooks in Rails or Rack with `PaystackSdk::Webhook` (signature over the raw body, `verify!` vs `valid_signature?` vs `construct_event` and their errors, CSRF, the optional IP allow-list, answering 200 fast, the documented retry policy, duplicate and out-of-order deliveries, verifying by reference before granting value, the documented events, and testing with a signed request). Its Ruby is run by `spec/skills_webhooks_spec.rb`; payload fields beyond Paystack's one documented sample are marked unverified. - Skill `paystack-sdk-refunds`: full and partial refunds, the pending-until-processed lifecycle, what to record, refund webhooks, and what to do after a timeout. Includes what the test API showed (a GHS 1.00 minimum per refund, an omitted `amount` asks for the whole original amount and fails after a partial refund, a refunded transaction verifies as `reversal-pending`) and marks the rest unverified. - Skill `paystack-sdk-money-safety`: the checklist for any code that moves or records money (integer minor-unit amounts, verifying by reference with `paid?(amount:, currency:)` and the customer email before granting value, never resending a write after a timeout, reference discipline, storing only the encrypted authorization code, keeping keys out of logs, `sandbox_only` limits, webhook and reconciliation rules) with a pre-merge table. Its claims are run by `spec/skills_money_safety_spec.rb` or were observed on the Paystack test API. - Skill `paystack-sdk-testing`: keys for test, staging and CI (`sandbox_only`, `live?` and what they do not check), a copyable WebMock helper with success, 400, 401, 429, 500 and timeout stubs (each run through the SDK in the gem's specs), an opt-in test-API suite pattern, Paystack's published test cards and the MTN test number, testing a webhook endpoint with `Webhook.sign`, and what test mode cannot prove. - Skill `paystack-sdk-saved-card-renewals`: charging a returning customer's saved card on your own schedule with `transactions.charge_authorization`. Covers saving only `reusable` authorizations keyed to their email, the responses observed on the Paystack test API (invalid, inactive, other-email and mobile money codes, duplicate references), lookup-first renewal references and the never-retry-after-timeout rule, partial debit (refused for GHS on the test API), the reported confirmation race, mobile money having no reusable token, and dunning advice. Its helper code is run by `spec/skills_saved_card_renewals_spec.rb`. - Skill `paystack-sdk-payments`: the hosted checkout flow with `client.transactions` (`initiate` keywords, redirecting to `authorization_url`, why the callback proves nothing, `verify` and `paid?`, what each verify status means, idempotent confirmation from the callback and the webhook as labelled design advice, which fields to store, `fetch` and `list`). Its claims are run by `spec/skills_payments_spec.rb` or were observed on Paystack's test API; the checkout page itself could not be driven and is marked unverified. - AI skills for Claude Code, shipped in the gem and installed with `paystack_sdk skills install` (any Ruby project) or `rails generate paystack_sdk:skills` (Rails) into `.claude/skills/`. The first skill is `paystack-sdk-overview`. Installing is idempotent, writes only `paystack-sdk-` folders marked with `.paystack_sdk.json`, never overwrites a folder it did not install (`--force` to replace), removes only its own stale folders, stamps each `SKILL.md` with the gem version, and supports `--dir`, `--global` and `--dry-run`. Also `skills list`, `skills path` and `skills uninstall`. - The `paystack_sdk` executable (`exe/paystack_sdk`); it handles the skills and `version` and does not load Faraday. ### Fixed - `transactions.initiate` now sends `metadata` stringified, as `charge_authorization` already did and as Paystack documents it. Paystack accepts an object too, but stores it differently: a nested number comes back as a string (`{count: 42}` as `{"count" => "42"}`), whereas a stringified object keeps it (`42`). Checked against the test API. If you read numbers back out of `metadata`, they are now numbers. - Filters that take a numeric ID (`customer_id:` on `transactions.list`, `transactions.export`, `subscriptions.list` and `payment_requests.list`, `plan_id:` on `subscriptions.list`, `transaction_id:` on `refunds.list`, `authorization_id:` on `customers.direct_debit_activation_charge`) now refuse anything but a numeric ID (an Integer, or a string of digits) with `InvalidValueError`. Paystack answers a code such as `CUS_abc123` there with an empty list and no error, which looks like "no results". If you passed a code to one of these, you were silently getting nothing. - Passing a String date or date-time to a `from:` or `to:` filter (`transactions.list(from: "2026-10-01")`, and every other date filter) raised `NoMethodError: undefined method 'iso8601' for class Time` in a plain Ruby process, because the gem never loaded Ruby's `time` library. It only worked where something else had loaded it, such as Rails or RSpec. The gem now requires it. A Date or Time object was never affected. ## [0.4.1] - 2026-10-10 ### Security - The secret key no longer appears when a `Client`, a resource (`client.transactions`...) or the Faraday connection is inspected. Ruby's default `inspect` and `pp` printed every instance variable, and the connection holds the key in its `Authorization` header, so `puts client`, `Rails.logger.info(client)`, an error page, or an error tracker that serialises locals wrote the key out. `Client#inspect` and `Resources::Base#inspect` now return only the class name, and the connection built by the SDK (and its `headers`) inspect without the key. The key is still sent. Errors raised by the SDK, their causes and `Response` never contained the key (checked). Affects every version before 0.4.1 (0.0.x to 0.4.0): if you may have logged one of these objects, rotate the key. A connection you pass in yourself (`Client.new(connection)`) is yours, and its own `inspect` is unchanged. ## [0.4.0] - 2026-10-09 ### Added - The SDK now covers every operation in Paystack's OpenAPI spec (163 of 163). Resources that could not be exercised against the Paystack test API (Terminals, Dedicated Virtual Accounts, and the state-changing calls on Apple Pay, Integrations, Virtual Terminals and Direct Debits) follow the spec and docs, and their README sections and method docs say so. - `Pages` (`client.pages`), generated from Paystack's OpenAPI spec: `create`, `list`, `fetch(id_or_slug:)`, `update(id_or_slug:)`, `check_slug_availability(slug:)` and `add_products(id:, products:)`. Checked against the Paystack test API, `fetch` and `update` take the numeric ID or the slug, `update` needs no `name` or `description` (the docs mark them required), and `add_products` reads `products` (the docs say `product`) and takes the numeric ID only. - `Products` (`client.products`), generated from Paystack's OpenAPI spec: `create`, `list`, `fetch(id:)`, `update(id:, ...)` and `delete(id:)`. `delete` (`DELETE /product/{id}`) is in the spec but on no docs page; the test API deletes the product. Checked against the test API, `create` does not require `description` (the spec and docs say it does; `name`, `price` and `currency` are required) and takes `metadata` as a Hash (the spec says a JSON string, which Paystack stores as an object of its characters). Products are addressed by numeric ID. - `Orders` (`client.orders`): `create`, `list`, `fetch(id:)`, `fetch_product_orders(id:)` (the product's ID) and `validate(code:)` (a GET that takes the order code), generated from Paystack's OpenAPI spec. `create` follows the spec (`email`, `first_name`, `last_name`, `phone`, `currency`, `items`, `shipping`): the test API rejects the shape on Paystack's docs page (`customer`, `line_items`) with "Customer email is required". `items` are `{item: , type: "product", quantity:, amount:}` and `shipping` needs `street_line`, `city`, `state`, `country` and `shipping_fee`. `fetch(id:)` returned a generic API error for a just-created order in the test API. - `Storefronts` (`client.storefronts`), generated from Paystack's OpenAPI spec: `create`, `list`, `fetch(id:)`, `update(id:)`, `delete(id:)`, `verify_slug(slug:)`, `fetch_orders(id:)`, `add_products(id:, products:)`, `list_products(id:)`, `publish(id:)` and `duplicate(id:)`. Checked against the Paystack test API, `create` requires `slug` and `currency` (the docs mark slug optional and omit currency), `update` takes `slug`, `add_products` takes numeric product IDs, and `publish` copies the storefront and its products to the live integration even with a test key. - `bin/scaffold_names.yml` entries can carry a `note:`, rendered as a `@note` in the method's docs. `storefronts.publish` uses it to warn that publishing creates a live copy even with a test key. - `BulkCharges` (`client.bulk_charges`), generated from Paystack's OpenAPI spec: `initiate(charges:)`, `list_batches`, `fetch_batch(id_or_code:)`, `fetch_charges(id_or_code:)`, `pause_batch(batch_code:)` and `resume_batch(batch_code:)`. `initiate` sends `charges` (an Array of Hashes with `authorization`, `amount` and `reference`) as the JSON array body. Pause and resume are GET requests, as the docs and the test API have them. `from` and `to`, which the docs list, are left out: the test API ignores them. - `bin/paystack-scaffold` takes a `body:` keyword in `bin/scaffold_names.yml` for an operation whose body is a JSON array (default `items`), and names that keyword, not an empty string, in the missing-parameter error. - `ApplePay` (`client.apple_pay`), generated from Paystack's OpenAPI spec: `list_domains`, `register_domain(domain_name:)` and `unregister_domain(domain_name:)`. Only `list_domains` was checked against the Paystack test API (it answers without `use_cursor`, which the docs mark required). Registering and unregistering change account-wide Apple Pay configuration that may reach live mode with a test key, so they were not called and are unverified; their method docs carry a `@note` saying so. `bin/paystack-scaffold` can name a singular resource (`SINGULAR_TAGS`), used so the tag "Apple Pay" gives `ApplePay`, not `ApplePays`. - `Integrations` (`client.integrations`), generated from Paystack's OpenAPI spec: `fetch_payment_session_timeout` and `update_payment_session_timeout(timeout:)` (seconds). Only the fetch was checked against the Paystack test API. The update changes an account-wide setting that may be shared with live mode, so it was not called and is unverified; its docs carry a warning. - `VirtualTerminals` (`client.virtual_terminals`), generated from Paystack's OpenAPI spec: `create`, `list`, `fetch(code:)`, `update(code:, name:)`, `deactivate(code:)`, `assign_destination(code:, destinations:)`, `unassign_destination(code:, targets:)`, `add_split_code(code:, split_code:)` and `remove_split_code(code:, split_code:)` (a DELETE with a JSON body). `list` also takes `status` (`active` or `inactive`), which the docs list and the test API validates but the spec omits. Checked against the Paystack test API: `list`, `fetch`, validation errors and unknown-code errors only. Nothing was created, and assign and unassign destination were not called (a destination is a WhatsApp number), so those, `create` with a valid body and the split code operations on a real terminal are unverified. - `DirectDebits` (`client.direct_debits`), generated from Paystack's OpenAPI spec: `list_mandate_authorizations(cursor:, status:, per_page:)` and `trigger_activation_charge(customer_ids:)`. These are the integration-wide `/directdebit/...` endpoints; the per-customer ones stay on `client.customers`. `list_mandate_authorizations` was checked against the test API (all parameters optional, `status` limited to pending, active and revoked). `trigger_activation_charge` charges customers' bank accounts and follows the docs and spec without a live check. - `Terminals` (`client.terminals`), generated from Paystack's OpenAPI spec: `send_event(terminal_id:)`, `fetch_event_status(terminal_id:, event_id:)`, `fetch_status(terminal_id:)`, `list`, `fetch(terminal_id:)`, `update(terminal_id:)`, `commission(serial_number:)` and `decommission(serial_number:)`. Unverified against the Paystack API: the test account has no Terminal access (every GET returns 403 "not yet available in your country"), so the methods follow the spec, and the device operations were never called. ## [0.3.0] - 2026-10-09 ### Added - `client.splits` for Paystack's Transaction Splits API: `create`, `list`, `fetch`, `update`, `add_subaccount` (also updates an existing subaccount's share) and `remove_subaccount`, generated from the OpenAPI spec. `list` sends `perPage` (the API ignores the spec's `per_page`); `fetch` takes a split code or numeric ID; `add_subaccount` requires `subaccount` and `share`, and `remove_subaccount` requires `subaccount`, as the docs and test API do. - `disputes` (new, generated from Paystack's OpenAPI spec): `list`, `fetch`, `list_transaction`, `update`, `add_evidence`, `fetch_upload_url`, `resolve` and `export`. `fetch_upload_url` takes `upload_filename`, which Paystack documents and the test API honours although the OpenAPI spec omits it. - `Subaccounts` (`client.subaccounts`): `create`, `list`, `fetch(id_or_code:)` and `update(id_or_code:, ...)`, generated from Paystack's OpenAPI spec. The bank is sent as `bank_code`, as Paystack's docs name it (the spec says `settlement_bank`; the test API reads both and prefers `bank_code`). `list(active:)` takes `1` or `0`: Paystack reads `true` as inactive. - `Plans` (`client.plans`): `create`, `list`, `fetch(id_or_code:)` and `update(id_or_code:, ...)`, generated from Paystack's OpenAPI spec. `interval` accepts `hourly` and `quarterly` as well as the spec's five values (the test API accepts all seven; the spec omits two). `update` takes `update_existing_subscriptions`, which Paystack documents and the test API validates although the spec omits it; leave it out and Paystack applies the change to existing subscriptions. `list(status:)` is documented by Paystack but absent from the spec. - `settlements` (new): `list(per_page:, page:, from:, to:)` for `GET /settlement` and `transactions(id:)` for `GET /settlement/{id}/transactions`. `from` and `to` are in Paystack's docs but not the spec; the test API validates them. - `Balances` (`client.balances`): `fetch` and `ledger(per_page:, page:, from:, to:)`, generated from Paystack's OpenAPI spec and read-only (GET). `fetch` returns one entry per currency with the balance in the currency's subunit (pesewas for GHS). Paystack's docs have no Balance page; the two operations sit on the Transfers Control page and the behaviour was confirmed against the test API. - `Subscriptions` (`client.subscriptions`): `create`, `list`, `fetch(id_or_code:)`, `enable(code:, token:)`, `disable(code:, token:)`, `generate_update_link(code:)` and `send_update_link(code:)`, generated from Paystack's OpenAPI spec. `list` filters by `plan_id:` and `customer_id:` (sent as `plan` and `customer`): the test API matches numeric IDs only. `start_date` is checked as ISO 8601 before sending, since the test API refuses an invalid one but still creates the subscription. - `DedicatedVirtualAccounts` (`client.dedicated_virtual_accounts`), generated from Paystack's OpenAPI spec: `create`, `assign`, `list`, `fetch(dedicated_account_id:)`, `requery`, `deactivate(dedicated_account_id:)`, `add_split`, `remove_split` and `fetch_bank_providers`. - `PaymentRequests` (`client.payment_requests`), generated from Paystack's OpenAPI spec: `create`, `list`, `fetch(id_or_code:)`, `update(id_or_code:)`, `verify(code:)`, `notify(code:)`, `totals`, `finalize(id_or_code:, send_notification: nil)` and `archive(id_or_code:)`. Checked against the Paystack test API, `create` does not require `amount` when `line_items` or `tax` are given (the spec says it is required), takes `metadata` as a Hash and `redirect_url` (both docs-only), and accepts a date as `due_date`; `list` filters by the numeric `customer_id:` and takes `include_archive:`. - `bin/paystack-scaffold` applies `required: true` from body-field entries in `spec/support/paystack_contract_exceptions.yml`, as it already did for query parameters. - `bin/paystack-scaffold` applies `enum:` from body-field entries in `spec/support/paystack_contract_exceptions.yml`, as it already did for query parameters. ### Fixed - `bin/paystack-scaffold` now adds a resource's `Client` accessor above `Client`'s private helpers, where it is public, instead of at the end of the class. It also finds the docs anchor of an operation whose cURL sample on the docs page calls another path (Split Dedicated Account Transaction), and corrects the `@see` link of `customers.direct_debit_activation_charge` to `#directdebit-activation-charge`. ## [0.2.0] - 2026-10-09 ### Breaking - `Transactions` is now generated from Paystack's OpenAPI spec and takes keyword arguments instead of payload hashes. Method names are unchanged. - `initiate(email:, amount:, ...)`, `charge_authorization(email:, amount:, authorization_code:, ...)` and `partial_debit(email:, amount:, authorization_code:, currency:, ...)` no longer accept a hash; use `initiate(**params)` to migrate. - `fetch(id:)` replaces `fetch(transaction_id)`; `timeline(id:)` replaces `timeline(id_or_reference)`. - `list`, `totals` and `export` take named filters (`from`, `to`, `status`, `customer_id`, `settlement`, ...) instead of `**params`, so a misspelt filter now raises `ArgumentError`. `list` no longer defaults `per_page: 50, page: 1`; Paystack's own defaults apply. - The numeric customer filter on `list` and `export` is `customer_id:` (it sends Paystack's `customer`). - `Charges` is now generated from Paystack's OpenAPI spec and takes keyword arguments instead of payload hashes. - `mobile_money(email:, amount:, mobile_money:, currency: nil, reference: nil, metadata: nil)` no longer accepts a hash; use `mobile_money(**params)` to migrate. It no longer sends `callback_url`, which neither Paystack's docs nor its spec list for `POST /charge` (the API does not validate it either). - `submit_otp(otp:, reference:)` no longer accepts a hash. - `Banks` is regenerated from Paystack's OpenAPI spec, `Miscellaneous` is new, and `Verification` (`client.verification`) is removed. All take keyword arguments. - `verification.resolve_account(account_number:, bank_code:)` is now `banks.resolve_account_number(account_number:, bank_code:)`. - `verification.validate_account(hash)` is now `banks.validate_account(account_name:, account_number:, account_type:, bank_code:, country_code:, document_type:, document_number: nil)`. - `verification.resolve_card_bin(bin)` is now `miscellaneous.resolve_card_bin(bin:)`. New: `miscellaneous.list_countries` and `miscellaneous.list_states(country:)`. - `banks.list` takes named filters instead of a hash (`per_page:` is sent as `perPage`, `next_cursor:` as `next`) and now accepts every filter Paystack documents (`country`, `type`, `gateway`, `use_cursor`, ...). Its `type` enum uses `ghipss` (the spec's `ghipps` is a typo), and `currency` still accepts `USD`. - `TransferRecipients` is now generated from Paystack's OpenAPI spec and takes keyword arguments instead of payload hashes. Method names are unchanged. - `create(type:, name:, account_number:, bank_code:, ...)` no longer accepts a hash; use `create(**params)` to migrate. `type` is checked against Paystack's list (`nuban`, `ghipss`, `mobile_money`, `basa`, `authorization`). - `fetch(id_or_code:)`, `update(id_or_code:, name:, email:)` and `delete(id_or_code:)` replace `recipient_code:`. The value can be the recipient code or its numeric ID. `update` takes `name:` and `email:` instead of a `params:` hash; `name` is optional, as the API accepts an update with only `email` (docs say `name` is required; checked against the test API). - `list` takes `per_page:`, `page:`, `use_cursor:`, `next_cursor:` and `previous:` instead of a query hash. `perPage` is what Paystack's docs name the page size; the API honours it. `from` and `to` are in the docs but the API ignores them, so they are not offered. - `Customers` is now generated from Paystack's OpenAPI spec and takes keyword arguments instead of payload hashes. Method names are unchanged. - `create(email:, ...)`, `set_risk_action(customer:, risk_action: nil)` and `deactivate_authorization(authorization_code:)` no longer accept a hash; use `create(**params)` to migrate. - `fetch(email_or_code:)` replaces `fetch(email_or_code)` (the keyword is the path variable's name in Paystack's docs; it takes an email or a customer code); `update(code:, first_name: nil, ...)` replaces `update(code, payload)`; `validate(code:, ...)` replaces `validate(code, payload)`. - `validate` now requires `first_name`, `last_name`, `type`, `country`, `bvn`, `bank_code` and `account_number`, as Paystack does (the test API answers 400 without each). It previously did not require `bvn` or the names. - `list` takes named filters (`per_page`, `page`, `from`, `to`, `use_cursor`, `next_cursor`, `previous`) instead of `**params`, and no longer defaults `per_page: 50, page: 1`; Paystack's own defaults apply. - `metadata` on `create` and `update` must be a Hash; it is sent as a JSON object (Paystack rejects a JSON string). - `Transfers` is now generated from Paystack's OpenAPI spec and takes keyword arguments instead of payload hashes. Existing method names are unchanged. - `create(source:, amount:, recipient:, reference:, reason: nil, currency: nil)` no longer accepts a hash; use `create(**params)` to migrate. `reference` is now required, as Paystack's docs and spec both require it, and `currency` must be one of NGN, ZAR, KES, GHS. - `fetch(id_or_code:)` replaces `fetch(id:)`, using the docs' name for the path variable (it takes a transfer ID or a `TRF_` code). The request is unchanged. - `list` takes named filters (`per_page`, `page`, `from`, `to`, `recipient`, `status`, and cursor pagination with `use_cursor`, `next_cursor`, `previous`) instead of a query hash, so a misspelt filter now raises `ArgumentError`. `per_page` is sent as `perPage`. ### Fixed - Values placed in URL paths (references, codes, ids, card BINs) are now escaped, and `.` / `..` are refused with `PaystackSdk::InvalidValueError` before any request is sent. Previously a value such as `".."` was resolved by the URL builder into a different endpoint (`transactions.verify(reference: "..")` called `/transaction`), and `/`, `?` or `#` in a value changed the path or query. Affects `transactions` (`verify`, `fetch`, `timeline`), `transfers` (`fetch`, `verify`), `transfer_recipients` (`fetch`, `update`, `delete`), `customers` (`fetch`, `update`, `validate`) and `miscellaneous` (`resolve_card_bin`). - `Response#[]` and `Response#key?` returned `nil`/`false` for every key on real Paystack bodies (string keys). Both now accept strings or symbols. - `Customers#deactivate_authorization` called a non-existent endpoint (`customer/deactivate_authorization`). It now posts to Paystack's documented `POST /customer/authorization/deactivate`. - `429` responses now raise `RateLimitError` (previously swallowed as a client error because the `400..499` branch matched first). `retry_after` is read from Paystack's `x-ratelimit-reset` header (nil when absent) instead of the undocumented `Retry-After`. ### Added - `Client#live?` and `Client.new(..., sandbox_only: true)`: the client refuses to be built with anything but an `sk_test_` key (or a pre-built connection that sends one), raising `ArgumentError` before any request. - `Response#paid?(amount: nil, currency: nil)` (call succeeded, `status` is `"success"`, and the amount and currency match if given) and `Response#status?(value)`. - `spec/sandbox/`: specs against Paystack's real test API, skipped unless `PAYSTACK_TEST_SECRET_KEY` is set to an `sk_test_` key. They confirm that `charge_authorization` charges a saved reusable card. - `client.refunds`, generated from Paystack's OpenAPI spec: `create(transaction:, amount: nil, currency: nil, customer_note: nil, merchant_note: nil)`, `list`, `fetch(id:)` and `retry_with_customer_details(id:, refund_account_details:)`. `transaction` takes the transaction reference or its numeric ID; leave `amount` out for a full refund. `list` filters by `transaction_id:` (sent as `transaction`, documented by Paystack, absent from the spec). Both confirmed against the test API. - `transfer_recipients.bulk_create(batch:)` for `POST /transferrecipient/bulk`. - The rest of the Charge API: `charges.create`, `submit_pin`, `submit_phone`, `submit_birthday`, `submit_address` and `check_pending`. `create` takes the channel objects (`bank`, `mobile_money`, `ussd`, `eft`, `qr`, `bank_transfer`, `capitec_pay`) as keyword hashes, plus `currency`, `split_code` and `subaccount`, which Paystack documents and the test API honours although the OpenAPI spec omits them. - `charges.mobile_money` accepts the `mpesa_offline` and `mptill` providers Paystack documents, and an M-PESA Till `account` in place of `phone`. - `bin/paystack-scaffold` generates body parameters recorded as docs-only (`spec: null`) in `spec/support/paystack_contract_exceptions.yml`, as it already did for query parameters. - `customers` covers the rest of Paystack's Customer API: `initialize_authorization`, `verify_authorization`, `initialize_direct_debit`, `direct_debit_activation_charge` and `fetch_mandate_authorizations`, plus cursor pagination on `list` (`use_cursor`, `next_cursor`, `previous`). - `bin/scaffold_names.yml` entries can be a hash, `{name: ..., keywords: {path_variable: ruby_keyword}}`, so a path variable takes the Ruby keyword Paystack's docs use where the spec names it differently. - `bin/paystack-scaffold` applies body-field entries from `spec/support/paystack_contract_exceptions.yml` (wire name, type and description), as it already did for query parameters. - `transfers.bulk_create`, `export`, `resend_otp`, `disable_otp`, `finalize_disable_otp` and `enable_otp` (Paystack's Initiate Bulk Transfer, Export Transfers and Transfers Control OTP operations). - `transactions.export` accepts `currency`, `amount`, `settled` and `payment_page` (documented by Paystack, absent from the OpenAPI spec; confirmed to filter results against the test API) and `subaccount_code`. Paystack's docs also list `perPage` and `page` on Export, but the API ignores them, so the SDK does not offer them. - `PaystackSdk::Webhook` verifies Paystack webhook signatures (HMAC SHA512, constant-time) and parses events: `valid_signature?`, `verify!`, `construct_event`, `sign`, `trusted_ip?`, plus the documented `EVENTS` and `IP_ADDRESSES`. New errors: `WebhookError`, `InvalidSignatureError`, `InvalidPayloadError`. - `Response#meta` exposes the pagination metadata (`total`, `page`, `pageCount`, `perPage`) that list endpoints return. - Default request timeouts (`timeout`, `open_timeout`) and automatic retries with backoff (`max_retries`, `retry_interval`, `retry_non_idempotent`) on SDK-built connections. Writes are only retried on `429`; `GET`s are also retried on network failures and 502/503/504. - `PaystackSdk::TimeoutError` and `PaystackSdk::ConnectionError` wrap transport failures. - Connection options are validated and raise `ArgumentError` when invalid or when combined with a pre-built connection. ### Changed - Faraday constraint relaxed to `>= 2.13, < 3`; added `faraday-retry` dependency. ## [0.1.0] - 2025-06-26 ### Changed #### Error Handling Strategy - **BREAKING**: Clarified error handling approach - the SDK now consistently follows a two-tier error handling strategy: - **Validation errors** (missing/invalid input data) are thrown as exceptions immediately before API calls - **API response errors** (business logic, not found, etc.) are returned as unsuccessful Response objects - Updated documentation to clearly explain when exceptions are thrown vs. when to check `response.success?` #### Test Infrastructure - **BREAKING**: Standardized all test specifications to use connection doubles instead of Faraday-specific mocks - Updated test pattern across all resource specs to use `instance_double("PaystackSdk::Connection")` for better framework independence - All tests now follow consistent mocking pattern with `PaystackSdk::Response.new()` expectations #### Documentation - Enhanced README with comprehensive error handling examples - Added validation error examples showing `MissingParamError`, `InvalidFormatError`, and `InvalidValueError` - Updated Quick Start guide to demonstrate proper exception handling - Clarified the difference between input validation (exceptions) and API response handling (Response objects) ### Improved - Better separation of concerns between input validation and API error handling - More consistent test suite that's not tied to specific HTTP client implementation - Clearer developer experience with predictable error handling patterns ## [0.0.9] - 2025-06-26 ### Added - Verification resource with support for: - Resolving bank accounts - Resolving card BINs - Validating accounts (with required and optional fields) - Registered `verification` resource in the main client for easy access - Regression tests for all Verification resource methods, including required field validation and full parameter support ### Changed - Fix the link for listing banks API ## [0.0.8] - 2025-06-26 ### Added - Banks resource with support for listing banks and currency validation - Regression tests for Banks resource, including validation for allowed currencies - Registered `banks` resource in the main client for easy access ### Improved - Validation for allowed currency values in Banks resource for safer API usage ## [0.0.7] - 2025-06-25 ### Added - Transfer Recipients resource with full CRUD operations (create, list, fetch, update, delete) - Transfers resource with full API support (create/initiate, list, fetch, finalize, verify) - All resource methods now use `handle_response` for consistent response handling - Regression tests for Transfer Recipients and Transfers resources, ensuring response wrapping and validation ### Changed - Registered `transfer_recipients` and `transfers` resources in the main client for easy access - Updated specs to verify that all resource methods wrap responses using `PaystackSdk::Response` ### Improved - Enhanced test coverage for new resources and response handling - Improved code consistency by enforcing response wrapping and validation patterns across all new resources ## [0.0.6] - 2025-06-10 ### Added - Customers resource with full CRUD operations (create, list, fetch, update) - Customer validation and risk action management features - Customer authorization deactivation functionality - Comprehensive documentation for Customers API in README ### Changed - Improved test consistency by standardizing response body format using string keys with hashrocket notation (`=>`) across all test specifications - Enhanced error handling specificity in tests by using appropriate error classes (`InvalidValueError`, `InvalidFormatError`, `MissingParamError`) instead of generic `Error` class ### Improved - Better test coverage and reliability with consistent response format handling - More precise error validation ensuring proper exception types are raised for different validation failures - Enhanced documentation with comprehensive examples for all customer operations ## [0.0.5] - 2025-05-15 ### Added - Connection utilities module for improved API connection handling and management - Enhanced connection configuration and error handling capabilities ## [0.0.4] - 2025-05-15 ### Added - Enhanced transaction resource validations with comprehensive parameter checking - New transaction methods: `charge_authorization` and `partial_debit` - Flexible `list` method accepting additional parameters for advanced API requests ### Changed - Updated SDK authentication to use `secret_key` instead of `api_key` for consistency with Paystack documentation - Improved transaction method names for better clarity and consistency - Enhanced README documentation with comprehensive usage examples ### Improved - Better validation error messages and handling - More robust parameter validation across all transaction methods ## [0.0.3] - 2025-05-13 ### Changed - Renamed `#initialize_transaction` method to `#initiate` for better clarity and consistency with Paystack API - Updated .gitignore to exclude Gemfile.lock from version control ### Fixed - Corrected typos in README documentation regarding original API response handling ## [0.0.2] - 2025-05-11 ### Added - Comprehensive Response class for Paystack API response handling with dynamic attribute access - Enhanced response object capabilities with better data access patterns - Improved debugging support for development ### Changed - Refactored transaction specs to utilize PaystackSdk::Response for better consistency - Removed redundant success? method in favor of centralized Response handling - Enhanced documentation clarity on original API response handling ### Improved - Better response handling architecture across all SDK components - More intuitive API for accessing response data and metadata ## [0.0.1] - 2025-05-10 ### Added - Initial release of Paystack Ruby SDK - Basic transaction operations (initiate, verify) - Foundational SDK architecture with modular design - Comprehensive error handling framework - Basic client initialization and configuration - Initial documentation and usage examples