Skip to content

Paystack

Setup guide for the paystack payments provider. The shared API (initiate_payment, verify_payment, refund_payment, webhooks, hooks, saved methods) is documented in Payments.

The paystack provider supports one-time payments (initiate_payment, verify_payment), refunds (refund_payment), subscriptions on a Paystack plan, cancelling them, the billing portal (Paystack’s manage link), reading disputes, saved cards, off-session charges and webhooks, in NGN, GHS, KES, ZAR, USD, XOF, EGP and RWF (whatever your Paystack account is enabled for). GET /payments/providers reports billing_portal, dispute_read, off_session, payment_methods, subscription_cancel and subscriptions.

Feature Status
One-time payments, verify, refunds Supported
Subscriptions (type: "subscription") Supported (a Paystack plan code)
Cancel subscription Supported, immediately only (at_period_end: True is NOT_SUPPORTED, 501)
Billing portal (get_billing_portal) Supported (Paystack’s manage link)
get_dispute, dispute webhooks Supported
submit_dispute_evidence Not supported (NOT_SUPPORTED, 501): answer disputes in the Paystack dashboard
Saved cards (save_payment_method on a one-time payment), charge_off_session Supported (cards only)
save_payment_method on a subscription Not supported (NOT_SUPPORTED, 501)
Plan change, pause, saving a card without paying, attaching a card token Not offered by Paystack (NOT_SUPPORTED, 501)

It needs no extra install: it calls Paystack’s REST API (https://api.paystack.co, which has no version in its path) directly over HTTP. Paystack does not version its API, so Kirak follows the API reference as of September 2026 (the paystack.com/docs pages and Paystack’s OpenAPI spec, PaystackOSS/openapi, of June 2026); a later change on Paystack’s side shows up as an unconfirmed or failing webhook, not as a version mismatch.

Why plain HTTP (no Paystack SDK). Paystack’s own Python package, paystack-sdk, has had no release since 1.0.1 in September 2022, and the few endpoints Kirak calls are simple REST.

1. Get credentials. In the Paystack Dashboard, Settings -> API Keys & Webhooks, copy the secret key: sk_test_... in test mode, sk_live_... in live mode. Paystack uses the same API host for both; the key decides the mode.

2. Configure the instance and secrets. In kirak.json:

"paystack": {
"type": "paystack",
"environment": "test",
"success_url": "https://your-domain.com/payment/success"
}

environment is test (the default) or live and must match the key: a live instance needs an sk_live_ key and a test one an sk_test_ key, else the instance fails when first used. success_url is sent to Paystack as the callback_url the buyer returns to (Paystack appends ?reference=<reference>); only an absolute http(s):// URL is sent, so the module’s relative default /payment/success is not, and the callback URL set in the dashboard applies then. In the environment:

Terminal window
KIRAK_PAYMENT_PAYSTACK_SECRET_KEY=sk_test_...

For an instance with another name the variable is KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY (see docs/reference/configuration.md).

3. Configure the webhook. In Settings -> API Keys & Webhooks, set the webhook URL of the mode you use to https://your-domain.com/payments/webhook/paystack (/payments/webhook/<instance> for another instance name). Paystack sends every event to it (there is no per-event subscription); Kirak handles charge.success, refund.pending, refund.processing, refund.processed, refund.failed, refund.needs-attention, subscription.create, invoice.update, invoice.payment_failed, subscription.not_renew, subscription.disable, charge.dispute.create, charge.dispute.remind and charge.dispute.resolve, and acknowledges the rest (including subscription.expiring_cards and invoice.create, which are only logged) with event = None.

Each delivery’s x-paystack-signature is checked locally: the hex HMAC-SHA512 of the raw body keyed with the secret key, compared in constant time. A missing header is rejected with MISSING_WEBHOOK_SIGNATURE (400) and a mismatch (including a non-ASCII header) with INVALID_WEBHOOK_SIGNATURE (400). Paystack signs no timestamp, so there is no time window. Paystack events carry no id: a delivery whose raw body was already handled (same sha256) returns already_processed: true. A retry whose bytes differ is handled again; charge.success still completes the row only once, but a refund without refund_reference can be recorded twice (see Known limits). Paystack retries a delivery that does not get a 2xx for up to 72 hours. Optionally, allow only Paystack’s webhook IPs at your proxy: 52.31.139.75, 52.49.173.169 and 52.214.14.220.

4. Use it:

# One-time payment -- send the buyer to data["url"] (Paystack's checkout)
result = await kirak.payments.initiate_payment({
"provider": "paystack",
"user_id": "42", "amount": 250000, "currency": "NGN", # kobo: NGN 2,500.00
"name": "Pro Plan", "type": "one_time",
"payment_email": "buyer@example.com", # required by Paystack
})
reference = result["data"]["reference"] # kirak-<32 hex>
# On the callback page (?reference=...): read the payment now instead of
# waiting for the webhook
await kirak.payments.verify_payment({"provider": "paystack", "payment_id": reference})
# Refund (admin/system): payment_id is the reference; omit amount for a full refund
await kirak.payments.refund_payment({"provider": "paystack", "payment_id": reference, "amount": 50000})
# Subscription -- subscription_plan_id is a Paystack plan code (PLN_...), created
# in Paystack first; the plan sets the price. Send the buyer to data["url"].
result = await kirak.payments.initiate_payment({
"provider": "paystack",
"user_id": "42", "amount": 500000, "currency": "NGN",
"name": "Monthly", "type": "subscription",
"payment_email": "buyer@example.com",
"subscription_plan_id": "PLN_gx2wn530m0i3w3m",
})
# Cancel now (Paystack cannot cancel at the period end through its API)
await kirak.payments.cancel_subscription({"provider": "paystack", "subscription_id": "SUB_..."})
# Manage link: the customer changes the card or cancels on Paystack's page
result = await kirak.payments.get_billing_portal({"provider": "paystack", "user_id": "42"})
# Read a dispute (admin/system)
await kirak.payments.get_dispute({"provider": "paystack", "dispute_id": 358950})
# Save the card while paying (one-time only), then charge it later (admin/system)
await kirak.payments.initiate_payment({
"provider": "paystack", "user_id": "42", "amount": 250000, "currency": "NGN",
"name": "Pro Plan", "type": "one_time", "payment_email": "buyer@example.com",
"save_payment_method": True, "consent": {"text_version": "2026-09"},
})
await kirak.payments.charge_off_session({
"user_id": "42", "amount": 150000, "currency": "NGN", "name": "Usage - September",
"idempotency_key": "usage-42-2026-09",
})

How a payment flows. initiate_payment needs payment_email (else MISSING_PAYMENT_EMAIL, 400, nothing saved). It saves a PENDING transaction, stores a new reference kirak-<32 random hex> as its transaction_id, then calls POST /transaction/initialize with email, amount x quantity in subunits, currency, the reference, the callback_url (above) and metadata kirak_transaction_id (the transaction id) and kirak_instance (the instance name). It returns url (Paystack’s authorization_url), reference, access_code and transaction_id. Paystack takes no idempotency key and rejects a reused reference, so every call gets a fresh random reference (never the caller’s idempotency_key or the row id, which two Kirak databases on one Paystack account could repeat). If Paystack rejects the call (4xx) the row is marked FAILED; a timeout, 5xx or 429 (or a reply without authorization_url) leaves it PENDING and raises, since the transaction may exist.

Webhooks and verify_payment find the transaction by the reference stored on a row of this instance. The reference is stored before Paystack is called, so the metadata tag is never needed to find a row, and an event whose reference no row of this instance holds (another instance, another database or other software on the same account) is acknowledged, changes nothing and reports event = None.

  • charge.success is not trusted on its own: Kirak re-reads the transaction with GET /transaction/verify/{reference} and completes the row only when Paystack says success (or reversed: paid, then refunded, so the refund can be recorded next) for the same reference, in the row’s currency, for amount x quantity (or, when the account passes Paystack’s fees to the customer, a requested_amount of exactly that and an amount at least that). The row is then marked COMPLETED once (a redelivery reports event = None) and payment_completed is reported with amount (what Paystack charged, in minor units) and currency. A fee-inclusive XOF or RWF amount that is not a multiple of 100 subunits has no exact minor-unit value; amount is then the row’s amount x quantity. The Paystack transaction id and raw amount are kept in ipn_dump.paystack_transaction_id and ipn_dump.gateway_amount; transaction_id stays the reference.
  • If the amount, currency or reference differ, nothing is written and no event is reported (event_type charge.success.unconfirmed); the delivery is acknowledged.
  • The same applies when the verified metadata names another transaction (kirak_transaction_id) or instance (kirak_instance) than the row’s; a missing value is not checked, and extra keys are ignored. This holds for checkouts, subscription first charges and off-session charges, and for verify_payment. Renaming an instance and moving payment_service on old rows to the new name therefore leaves the charges still in flight unconfirmed (their metadata names the old instance): settle them before renaming.
  • If the verify endpoint says anything else (failed, abandoned, or still in progress), or the verify call times out, is rate limited or gets a 5xx, nothing is written and the webhook fails (500) so Paystack redelivers it and a later delivery verifies again.
  • payment_completed can therefore fire for a charge that was already refunded (verify says reversed); the refund is recorded when the redelivered refund.processed arrives. If Paystack stopped retrying refund.processed before the payment was completed (it retries for up to 72 hours), the row ends COMPLETED instead of REFUNDED: check the refund in the Paystack dashboard.

verify_payment (payment_id = the reference) reads the transaction and returns transaction_id, reference, paystack_status and status (the row’s status after the call). A confirmed success (same checks as above) sets the row PROCESSING; only charge.success completes it. success that does not match also returns outcome: "not_confirmed". failed marks the row FAILED (never over a settled row; a later confirmed charge.success still completes it). Anything else changes nothing, including abandoned, which Paystack most likely reports for a transaction the buyer has not paid yet. TRANSACTION_NOT_FOUND (404) for a reference this instance did not create; an unknown outcome raises and changes nothing.

Refunds. refund_payment (payment_id = the reference) calls POST /refund for the whole transaction, or amount (minor units of the transaction’s own currency; a currency passed with it is used only when the transaction has none), and returns refund_id (Paystack’s numeric refund id), status (pending) and transaction_id; nothing is recorded yet. Refund webhooks carry no refund id, so the refunds recorded on the row are keyed by Paystack’s refund_reference instead, not by this refund_id. Paystack takes no idempotency key for refunds: after a timeout, check the transaction’s refunds in Paystack before retrying.

  • refund.pending and refund.processing report refund_pending and change nothing.
  • refund.processed records the refund (amount converted from Paystack’s subunits; Paystack sends it as a JSON string or a number; an XOF or RWF amount off the x100 grid is rounded half up, the raw value kept in ipn_dump.refund_gateway_amount) and marks the row REFUNDED, or PARTIALLY_REFUNDED while the refunds recorded so far are below amount x quantity (a REFUNDED row never moves back), and reports refund_completed with amount and currency. One that arrives before the payment’s charge.success was handled fails (500) so Paystack redelivers it after completion. One without an amount fails (500).
  • refund.failed (the money went back to your balance) and refund.needs-attention (Paystack needs the customer’s bank details) change no status and report event = None; they are logged and noted in the row’s ipn_dump.refund_issue.

How a subscription flows. initiate_payment with type: "subscription" needs subscription_plan_id, a Paystack plan code (else MISSING_SUBSCRIPTION_PLAN_ID, 400, nothing saved). It works like a one-time payment (same reference, same url), with plan added to the initialize call; Paystack charges the plan’s price instead of amount. The plan code is kept in the row’s payment_meta.subscription_plan_id. Paystack creates the subscription when that first charge succeeds, but none of its events names both: charge.success has the reference but no subscription code, and subscription.create has the code but no reference. Kirak therefore links them by customer, plan and card:

  • charge.success for a subscription row is confirmed by verify like a one-time payment, except that the check is the plan (the verified transaction must be on the row’s plan code) instead of the amount. Kirak then lists the customer’s subscriptions to that plan (GET /subscription?customer=<id>&plan=<id>) and keeps those that are active, not yet held by a transaction of this instance (nor saved as a subscriptions row for another user by a checkout not completed yet), on the charge’s card (the same authorization.authorization_code, or the same card signature when the charge has no code) and not created before the charge’s transaction (the subscription’s createdAt against the verified transaction’s createdAt, both Paystack times; skipped when either is missing; the row’s created_at is never used, since the database writes it in its own time zone). This keeps an older subscription of the same customer (made outside Kirak, by another database on the account, or left unbound) from being taken for the new one. If exactly one is left, it is bound: a subscriptions row is saved (status ACTIVE, plan_id = the plan code, the row’s amount and currency, expires_on = its next_payment_date) and its code becomes the row’s subscription_id. The row is marked COMPLETED with amount = the verified requested_amount when it equals the plan’s amount (fees passed to the customer excluded, as for a one-time payment), else the charged amount, and quantity 1; payment_completed carries the charged amount and, when one was bound, subscription_id. The Paystack customer, plan, card (authorization_code, authorization_signature) and transaction time (paystack_created_at) are kept in ipn_dump. If the list call times out, is rate limited or gets a 5xx, nothing is written and the webhook fails (500) so Paystack redelivers it. If another delivery of the same charge completed the row first, the subscription found is still set on it, but only while it holds no subscription.
  • If the list has no such subscription yet, the payment still completes, unbound; subscription.create binds it later: it looks for this instance’s one COMPLETED, unbound subscription transaction with the event’s email (in any letter case), customer code, plan code and card, charged before the subscription was created, runs the same list call, and binds only if the event’s subscription is the one it returns. It then reports subscription_updated. If the only matching transactions (email and plan) are still PENDING or PROCESSING and were created in the last 48 hours (the first charge’s charge.success has not been handled yet), the webhook fails (500) so Paystack redelivers it after the payment completes; older ones are abandoned checkouts and do not block. An event for a subscription already bound, or one it cannot bind, reports event = None.
  • Which event reports the binding depends on the path: bound at charge.success, it is reported only by payment_completed (with subscription_id), and the later subscription.create reports event = None; bound at subscription.create, it is reported as subscription_updated.
  • Ambiguity is never guessed. Two subscriptions that still qualify (two checkouts in a row on the same card), or two unbound transactions that match one event, bind nothing: the case is logged and the subscription stays unbound (see Known limits).
  • Renewals come from invoice.update (Paystack’s final invoice status), which names the subscription and the charge’s reference. The subscription is found by its subscriptions row of this instance, as for the status events below. A paid invoice (paid: true, status: success) is re-read with GET /transaction/verify/{reference}; when Paystack says success (or reversed) for that reference, the subscriptions row gets the amount (the verified requested_amount when it equals the plan’s amount, fees passed to the customer excluded, as for the first charge; else the charged amount), the invoice’s next_payment_date as expires_on (only when later than the stored one, since invoices can arrive out of order), and ACTIVE again if it was PAST_DUE or NON_RENEWING (a charge means it was re-enabled); then a subscription_renewal transaction is saved with that reference as its transaction_id, reporting subscription_renewed with amount and currency. A fee-inclusive XOF or RWF amount off the x100 grid is recorded as the subscription’s amount, the raw value kept in ipn_dump.gateway_amount. An unpaid invoice, or a verified reference that differs, records nothing and reports event = None; any other verify status, or an unknown outcome, fails the webhook (500) so Paystack redelivers it. The renewal’s own charge.success (a reference Paystack generated) is acknowledged without a write before invoice.update is handled (logged at info level, as is any other database’s renewal charge on the account: no lookup is made), and is a repeat afterwards, so a renewal is recorded exactly once.
  • invoice.payment_failed sets the subscription PAST_DUE (subscription_past_due); Paystack does not retry the charge, the next cycle does. subscription.not_renew (cancelled at the period end, for example on the manage page) sets NON_RENEWING (subscription_updated), also when the event carries no status. subscription.disable sets CANCELLED, or COMPLETE when all invoices were billed (subscription_cancelled). An ended subscription is never changed again, and an event that changes nothing reports event = None. Renewal and status events for a subscription with no subscriptions row of this instance (not bound here) are acknowledged without a write.
  • subscription.expiring_cards and invoice.create are only logged.

Lifecycle. cancel_subscription reads the subscription (GET /subscription/{code}) for its email_token and calls POST /subscription/disable with code and token; it cancels immediately and returns status CANCELLED (the subscriptions row changes when subscription.disable arrives). Paystack’s API has no cancel at the period end, so at_period_end: True returns NOT_SUPPORTED (501); the manage page (below) offers that to the customer. Paystack has no plan change or pause: update_subscription, pause_subscription and resume_subscription return NOT_SUPPORTED (501). To move a customer to another plan, start a new subscription and cancel the old one.

Billing portal. get_billing_portal (user_id) returns the manage link (GET /subscription/{code}/manage/link) of the user’s newest subscription of this instance that has not ended, as portal_url, with its subscription_id; NO_ACTIVE_SUBSCRIPTION (404) when there is none. On that Paystack page the customer changes the card (Paystack makes a small refunded charge to check it) or cancels, which sends subscription.not_renew now and subscription.disable on the next payment date. return_url is not used.

Disputes (read-only). get_dispute reads GET /dispute/{id} and returns dispute_id, status and the full dispute. submit_dispute_evidence is not supported (501): answer disputes in the Paystack dashboard. charge.dispute.create reports dispute_created; charge.dispute.remind and charge.dispute.resolve report dispute_updated. The dispute is matched by its transaction.reference to a transaction of this instance (a payment or a renewal); the result carries dispute_id, dispute_status, resolution, transaction_id and user_id, and amount/currency from the dispute’s refund_amount in its own currency (converted like a refund) when both are present. The transaction row is not changed (a non-settled DISPUTED status would let a late completion event rewrite it). A dispute on a transaction this instance did not create is acknowledged with event = None.

Saved cards. Paystack saves no card without a charge, so a card is saved only while paying: initiate_payment with save_payment_method (and consent) on a one-time payment keeps the flag and consent in the row’s payment_meta. When that payment’s charge.success is confirmed by verify (as above), and before the row is marked COMPLETED, the verified authorization is saved when Paystack reports it reusable (card payments only; otherwise nothing is saved and the payment still completes): its authorization_code is the method’s gateway id, the customer code its gateway customer, with brand, last4, expiry, the consent, and in meta the card signature and the customer’s email. The email is kept because only the email an authorization was created with can charge it. Paystack can issue a new authorization_code per charge on the same card, so the newest code is kept: the user’s older active methods of this instance with the same signature are marked REVOKED locally (not deactivated at Paystack), and the new one becomes the default (before they are revoked) if one of them was. A save that fails fails the webhook (500) before the row is completed, so the redelivery saves it; a row already settled is never saved again (a late redelivery cannot bring back a removed card). Saving reports no event of its own: the payment reports payment_completed as usual. save_payment_method on a subscription is NOT_SUPPORTED (501): the saved authorization would be the one the subscription renews on, so removing the card would stop its renewals. start_payment_method_setup and attach_payment_method are NOT_SUPPORTED (501). detach_payment_method calls POST /customer/authorization/deactivate with the authorization_code; a 404 (Paystack no longer knows it) is treated as removed, so the row is still marked REVOKED; any other error raises and keeps the row. A card whose authorization_code started one of the user’s subscriptions of this instance that has not ended (any status but CANCELLED, COMPLETE or COMPLETED) is only marked REVOKED locally, without the deactivate call (logged): Paystack may renew that subscription on the same code. set_default_payment_method is local only (Paystack has no default card).

Off-session charges. charge_off_session on a Paystack card saves the PENDING off_session transaction, stores a new random reference kirak-<32 hex> as its transaction_id, then calls POST /transaction/charge_authorization with the authorization_code, the saved email, amount in subunits, currency, that reference and metadata (stringified JSON naming the transaction and instance). The reference is never derived from the idempotency_key, which another Kirak database on the same Paystack account could repeat; a retry with the same key is replayed from the row without calling Paystack. A card saved without an email is refused with MISSING_PAYMENT_EMAIL (400) before anything is saved. A reply naming another reference than the one sent is logged as a warning. The outcome:

  • success: the row stays PROCESSING and the call returns PROCESSING (COMPLETED only when charge.success completed the row first); the charge.success for the reference completes the row exactly once after verify confirms it, as for a checkout, and reports payment_completed. Fulfil on that event, not on the call’s answer.
  • paused with an authorization_url (the bank wants the customer to authorize the charge): REQUIRES_ACTION, and action_url is Paystack’s authorization_url for this very charge (no recovery checkout is made); a replay returns it too. No payment_action_required event is reported. When the customer authorizes it, charge.success completes the row.
  • failed (HTTP 200) or a 400 from Paystack: FAILED, with decline_code = Paystack’s gateway_response or error message. Another 4xx marks the row FAILED and raises.
  • pending or any other status: PROCESSING.
  • A timeout, 5xx, 429, a reply without data, or a 400 duplicate_reference (a transaction with the reference exists): PROCESSING with ipn_dump.outcome "unknown", never FAILED. Paystack sends no webhook for a failed charge: call verify_payment with the reference (gateway_payment_id) to mark it FAILED.

Currencies. Paystack amounts are integers in subunits = base amount x 100 for every currency. For XOF and RWF, which have no decimals in ISO 4217, Kirak multiplies by 100 on the way out and divides on the way back: pass "amount": 1500, "currency": "XOF" for 1,500 XOF (sent as 150000).

Known limits.

  • Refund webhooks carry no refund id. Kirak tells a repeat from a second partial refund by Paystack’s refund_reference; when Paystack sends none, a redelivery whose body differs byte for byte from the first is recorded as a second refund: refund_completed fires twice, and the doubled total can wrongly mark a partially refunded row REFUNDED.
  • A reference is at most 38 characters; Paystack does not document a maximum length.
  • A subscription is bound to its first charge only through the customer + plan list filtered by card and creation time (above). When that is ambiguous, or Paystack reports the subscription on another authorization than the first charge’s, nothing is bound: the payment is COMPLETED, but no subscriptions row exists, and that subscription’s renewals, status events and disputes on renewals are acknowledged without a write. Cancel it in the Paystack dashboard, or have the customer subscribe once.
  • A subscription.create whose email and plan match a first charge of this instance created in the last 48 hours that never completes (an abandoned checkout) fails until that row is 48 hours old or Paystack stops retrying.
  • Renewals are recorded only from invoice.update. If Paystack does not send a paid invoice.update for a renewal, the renewal is not recorded: its charge.success is only logged (at info level, for any charge on a plan whose reference no row of this instance holds).
  • The same user completing two subscription checkouts at once on the same card and plan can have one subscription bound to both of their own rows.
  • cancel_subscription always cancels immediately; cancelling at the period end is only possible on Paystack’s manage page.
  • Disputes are read-only; a dispute event that redelivers with different bytes is reported again.
  • A card can be saved only while paying (no setup without a charge, 501). Only the email a card was saved with can charge it; a customer who changes email keeps the card chargeable only under the old one.
  • Removing a saved card deactivates that authorization at Paystack; any other use of the same authorization_code outside Kirak stops working too.
  • Two saves of the same card at the same instant under different authorization codes can both be kept.
  • An older authorization code replaced by a newer one for the same card is revoked only in Kirak; it stays chargeable at Paystack.
  • A card that started a running subscription is not deactivated at Paystack when removed; it stays chargeable there until the subscription ends.

after_webhook receives these provider-neutral event names (see Webhook Hooks):

Paystack reports payment_completed (charge.success, once Paystack’s verify endpoint confirms it, including a subscription’s first charge and a charge_off_session charge; an unconfirmed one reports none, with event_type charge.success.unconfirmed), refund_pending (refund.pending, refund.processing), refund_completed (refund.processed), subscription_renewed (a paid invoice.update, once verify confirms its charge; an unpaid or unconfirmed one reports none, with event_type invoice.update.unpaid or invoice.update.unconfirmed), subscription_updated (subscription.create that binds a subscription, subscription.not_renew), subscription_past_due (invoice.payment_failed), subscription_cancelled (subscription.disable), dispute_created (charge.dispute.create) and dispute_updated (charge.dispute.remind, charge.dispute.resolve). A subscription bound when its first charge.success is handled is reported only by that payment_completed (which carries subscription_id), with no subscription_updated; one bound later by subscription.create is reported as subscription_updated.