Paystack — payment gateway integration profile
Also known as: Paystack Payments
Paystack Checkout
Category: african-aggregator
Regions: Pan-African
Countries: NG
GH
ZA
KE
CI
Currencies: NGN
GHS
ZAR
KES
USD
Payment methods: card
bank_transfer
ussd
mobile_money
bank
qr
eft

Authentication:
model: bearer-secret-key
key_types: sk_test_
sk_live_
pk_test_
pk_live_
sandbox_instant: yes
kyc_required_for_live: yes


Payment initiation:
flow: redirect
endpoint: POST https://api.paystack.co/transaction/initialize
http_method: POST
required_params: email
amount
returns: authorization_url
access_code
reference
amount_unit: minor-subunit


Status handling:
mechanisms: webhook
poll
redirect-return
webhook_verification: Header 'x-paystack-signature'; HMAC-SHA512 of the RAW request body using the merchant SECRET KEY (sk_); no separate webhook secret; RAW bytes required; ack 200 fast (Paystack retries up to 72h)
raw_status_values: success
failed
abandoned
pending
ongoing
processing
queued
reversed
charge.success
refund.processed
refund.pending
refund.failed
dispute.create
dispute.created
status_map: success: paid
charge.success: paid
failed: failed
abandoned: failed
refund.failed: failed
pending: pending
ongoing: pending
processing: pending
queued: pending
refund.pending: pending
reversed: refunded
refund.processed: refunded
dispute.create: disputed
dispute.created: disputed

Capabilities:
redirect: yes
poll: yes
webhook: yes
mobile_push: no
refund: yes
recurring: yes

Credentials required:
key: secretKey
label: Secret Key (sk_)
secret: yes
note: server-side auth for all API calls AND the HMAC key used to verify webhooks
key: publicKey
label: Public Key (pk_)
secret: no
note: optional — only for Paystack Inline / Popup on the client; not needed for server-side redirect flow

Sandbox:
test_credentials: note: Instant test keys on signup from dashboard → Settings → API Keys & Webhooks, no KYC. KYC only required to activate live mode.
test_cards: number: 4084084084084081
cvv: 408
result: success
number: 4084080000005408
cvv: 001
result: decline
number: 5060666666666666666
pin: 1234
otp: 123456
result: success (Verve, PIN+OTP)
notes: Test mode only accepts Paystack test cards — real cards are declined in test mode. Test bank: Zenith account 0000000000, OTP 123456. Base URL is the same host (api.paystack.co) for test and live; the sk_test_/sk_live_ key prefix selects the mode.

Known pain points and mitigations:
problem: 100x over/undercharge
cause: amount must be sent in SUBUNITS (kobo/pesewas/cents) but merchants send major units — NGN 100 sent as 100 instead of 10000
mitigation: Expose major units in the ManishaPay API and multiply by 100 to an integer internally; validate the result is a positive integer; never trust the caller to pre-format
severity: critical
problem: Marking an unpaid transaction as paid
cause: the top-level 'status:true' in a verify response only means the API call succeeded — the real payment outcome is data.status
mitigation: Key the normalizer ONLY on data.status (must equal 'success'); additionally re-check data.amount and data.currency against the order before fulfilling
severity: critical
problem: Webhook signature verification fails
cause: HMAC is computed over a re-serialised JSON body instead of the raw bytes, or the wrong key is used
mitigation: Compute HMAC-SHA512 over the RAW request buffer (before any JSON parser) using the merchant SECRET KEY, and compare against the x-paystack-signature header with a constant-time compare
severity: critical
problem: Missed webhooks on localhost / slow endpoint / CSRF-protected route
cause: Paystack cannot reach a local dev URL, times out on a slow handler, or the framework's CSRF filter blocks the POST
mitigation: Acknowledge with 200 synchronously and do the work asynchronously; exempt the webhook route from CSRF; make side-effects idempotent on the reference; use verify-poll as a backstop
severity: high
problem: Redirect return beats the webhook (race) — or vice versa
cause: the customer's browser redirect (callback_url) and the async webhook are not ordered
mitigation: Treat the webhook as primary and verify-poll as a backstop; dedupe both on the unique reference with a monotonic state machine; never gate fulfillment on the browser redirect alone
severity: high
problem: Currency not enabled on the account
cause: initializing in a currency the merchant has not activated (e.g. USD/GHS/ZAR/KES) returns a 4xx
mitigation: Validate the requested currency against the merchant's enabled currencies and surface a clear resolution message
severity: medium
problem: Duplicate or reused reference
cause: reusing a reference across attempts collides — Paystack requires it to be unique
mitigation: Generate a unique reference per attempt and map it to the ManishaPay transaction id; use it as the idempotency key across initiate/verify/webhook
severity: medium
problem: Failed recurring charge is not retried
cause: Paystack does not automatically retry a failed subscription/charge_authorization
mitigation: Implement your own retry/dunning schedule around charge_authorization; monitor invoice.payment_failed events
severity: low

SDKs:
node: @paystack/paystack-sdk
php: yabacon/paystack-php
rest_direct_ok: yes

Notes: Zimbabwe is NOT a supported Paystack merchant country — only NG, GH, ZA, KE and CI can register as merchants. It is a regional (not ZW-local) rail useful for ManishaPay merchants operating in those markets or charging in USD. Recurring is supported via charge_authorization (reuse the authorization_code from a verify response for a no-interaction server-side charge) or plans + subscriptions. Refunds via POST /refund (full, or partial by passing amount in subunits).

Fields not yet cross-checked against official docs: exact set of enabled currencies varies per merchant account
full list of raw refund/dispute status strings