otatechie / laravel-paystack-connect
Marketplace payments for Laravel on Paystack: seller onboarding, subaccounts, split payments with platform fees, and webhooks that are safe to trust.
Package info
github.com/otatechie/laravel-paystack-connect
pkg:composer/otatechie/laravel-paystack-connect
Requires
- php: ^8.3
- laravel/framework: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.0||^5.0
- pestphp/pest-plugin-laravel: ^4.0||^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-24 10:17:25 UTC
README
Marketplace payments for Laravel on Paystack. Your customers pay a seller, Paystack pays the seller's share into their bank or mobile money wallet without it passing through you, and your platform keeps a fee.
Paystack's API gives you subaccounts and split payments. This package gives you everything around them that you would otherwise build by hand: seller onboarding, fee rules, local records, and webhooks you can trust.
- Exact money. Amounts are integers in pesewas, kobo or cents.
19.99is always1999, never1998. - Seller onboarding. Bank and mobile money lists come live from Paystack, account holders are verified where Paystack allows it, and connecting a seller twice updates their subaccount instead of creating a duplicate.
- Platform fees. A percentage plus a flat amount, with a minimum and a maximum per currency.
- Webhooks that are safe to trust. Signatures are checked against the raw body, every event is stored once so Paystack's retries are ignored, and a payment is only marked paid when the amount and currency match exactly.
- Nothing fails silently. Every Paystack error throws with Paystack's own message, and webhook failures are logged and retried.
What it doesn't do: each payment goes to one seller, so a cart with
several sellers needs one payment per seller (or Paystack's
multi-split payments, which this
package doesn't wrap yet). Payouts to sellers happen through Paystack's
settlements, not this package, and disputes are only surfaced as raw
WebhookReceived events. For anything else Paystack offers,
PaystackConnect::client() gives you an authenticated client for its API.
How it works
- You connect each seller once. Their bank or mobile money account becomes a Paystack subaccount, and the package records which of your models it belongs to.
- At checkout you name the seller. The customer pays on Paystack's payment form. The money goes to Paystack, never to you.
- Paystack splits the payment itself: your platform fee to your Paystack
balance, the rest to the seller's subaccount, and Paystack's own fee taken
from your share or the seller's, depending on
bearer. The package never holds or moves money; it tells Paystack how to split, and records what happened. - Paystack pays out to you and to each seller on its settlement schedule, not the moment the customer pays. In Ghana and Nigeria that's the next working day by default (Paystack: getting your money).
- Your app learns the outcome from Paystack's webhook, which is the
source of truth, or from
verify()on your callback page, whichever arrives first. Either way the payment is settled once, and your listeners run once.
Payments without a seller, such as a donation or your own shop's orders, skip the split: the whole amount goes to your balance and no fee is taken.
Contents: How it works · Installation · Onboard a seller · Take a payment · React to payments · Refunds · Fees · Currencies · Money · Errors · Moving an existing app over · Testing your app · Trying it against Paystack's test mode · Security · Configuration · Paystack references
Requirements
PHP 8.3+ and Laravel 12 or 13. PHP 8.5 is supported on Laravel 13.
Installation
Install it, then publish the migration and config:
composer require otatechie/laravel-paystack-connect php artisan vendor:publish --tag="paystack-connect-migrations" php artisan migrate php artisan vendor:publish --tag="paystack-connect-config"
Add your keys to .env:
PAYSTACK_SECRET_KEY=sk_test_xxx PAYSTACK_PUBLIC_KEY=pk_test_xxx # Your Paystack account's currency: GHS, NGN, KES, ZAR, XOF, EGP or RWF PAYSTACK_CURRENCY=GHS
In your Paystack dashboard, under Settings → API Keys & Webhooks, put
https://your-app.com/paystack/webhook in the Webhook URL field. Not the
Callback URL field: each checkout sends its own. Test and live mode each have
their own webhook URL, and Paystack must be able to reach it: a .test or
localhost address won't work.
Onboard a seller
Add the trait to the model that gets paid, such as a business, vendor or school:
use Otatechie\PaystackConnect\Concerns\HasPaystackSubaccount; class Business extends Model { use HasPaystackSubaccount; }
Show the seller Paystack's list of banks or mobile money networks, and keep the code they pick:
use Otatechie\PaystackConnect\Facades\PaystackConnect; PaystackConnect::banks()->list('ghana'); // banks PaystackConnect::banks()->mobileMoney('ghana'); // MTN, Telecel, AirtelTigo
Countries use Paystack's names: ghana, nigeria, kenya, south africa,
côte d'ivoire, egypt and rwanda.
Then connect their account:
use Otatechie\PaystackConnect\Support\SettlementAccount; $business->connectPaystackAccount( SettlementAccount::mobileMoney('Kofi Prints', 'MTN', '0241234567', 'GHS') ->withContact(email: $owner->email, name: $owner->name), ); $business->canReceivePaystackPayments(); // true
In Ghana and Nigeria, the account holder's name is checked with Paystack
first (Paystack only offers this lookup there).
If it can't be resolved, a PaystackException explains why and nothing is
created. Paystack has no such lookup in other countries, so there it checks
the account itself when the subaccount is created. To skip the lookup, set
sellers.verify_accounts to false.
An onboarding page
Paystack has no hosted onboarding, so sellers connect their account on a page in your app. The package ships no views; here's a minimal one to copy and adapt, whether you use Blade, Inertia or Livewire.
use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Otatechie\PaystackConnect\Banks; use Otatechie\PaystackConnect\Exceptions\PaystackException; use Otatechie\PaystackConnect\Facades\PaystackConnect; use Otatechie\PaystackConnect\Support\SettlementAccount; class PayoutAccountController { // Your Paystack account's currency, and Paystack's name for its country: // GHS → "ghana", NGN → "nigeria", KES → "kenya", and so on. private function currency(): string { return strtoupper(config('paystack-connect.currency')); } private function country(): string { return Banks::COUNTRIES[$this->currency()]; } public function edit() { return view('payout-account', [ 'banks' => PaystackConnect::banks()->list($this->country())->sortBy('name'), ]); } public function update(Request $request) { $data = $request->validate([ 'bank_code' => ['required', 'string'], 'account_number' => ['required', 'string'], ]); $bank = PaystackConnect::banks()->find($this->country(), $data['bank_code']) ?? throw ValidationException::withMessages(['bank_code' => 'Pick a bank or network from the list.']); $business = $request->user()->business; $account = $bank['type'] === 'mobile_money' ? SettlementAccount::mobileMoney($business->name, $bank['code'], $data['account_number'], $this->currency(), $bank['name']) : SettlementAccount::bank($business->name, $bank['code'], $data['account_number'], $this->currency(), $bank['name']); try { $business->connectPaystackAccount($account->withContact(email: $request->user()->email)); } catch (PaystackException $e) { // For example "Could not resolve account name" for a mistyped number. return back()->withInput()->withErrors(['account_number' => $e->getMessage()]); } return back()->with('status', 'Payout account connected.'); } }
The view needs a <select name="bank_code"> of $banks (value code,
label name), an account_number input, and a submit button.
If your sellers are in several countries, let them pick a country first and
use it in place of country().
Submitting the form again updates the same subaccount.
Other seller helpers
PaystackConnect::subaccounts()->for($business); // the Subaccount, or null PaystackConnect::banks()->find('ghana', 'MTN'); // one bank or network, or null PaystackConnect::banks()->resolve('0241234567', 'MTN'); // the account holder's name (Ghana and Nigeria)
The subaccount record
$business->paystackSubaccount is a Subaccount model, stored in
paystack_subaccounts.
| Field or method | Meaning |
|---|---|
subaccount_code |
Paystack's code for the subaccount, ACCT_.... |
business_name, bank_name, account_name |
What was connected, and the holder's name where Paystack looks it up. |
maskedAccountNumber() |
"•••• 4567", for display. The full number is encrypted at rest and never included in JSON. |
active |
Whether Paystack will settle to it. canReceivePaystackPayments() checks this. |
owner, payments() |
The seller model, and every payment made to them. |
Take a payment
$payment = PaystackConnect::checkout() ->amount('250.00', 'GHS') ->email($client->email) ->seller($business) // settles to the seller, minus your fee ->for($invoice) // optional: what is being paid for ->callbackUrl(route('invoices.paid', $invoice)) ->create(); return redirect($payment->authorization_url);
The fee comes from your config and is sent to Paystack as the transaction's
transaction_charge, a flat amount that goes to your account whatever the
subaccount's percentage says (split payments).
To override it for one payment, use ->fee('10.00'); to make the seller pay
Paystack's fee for one payment, use ->bearer('subaccount') (see Fees).
Other options:
->channels(['card', 'mobile_money']) // limit how the customer can pay ->metadata(['order_id' => $order->id]) // sent to Paystack, shown in its dashboard ->reference('INV-2026-0042') // your own reference; must be unique
Channels Paystack accepts: card, bank, apple_pay, ussd, qr,
mobile_money, bank_transfer, eft, capitec_pay and payattitude; which
ones the customer sees depends on their country. A reference may contain only
letters, digits, -, ., = and _ (Transaction API;
_ isn't listed there, but Paystack accepts it).
Without ->seller(), the whole amount goes to your own Paystack balance and
no fee is taken.
Redirect or popup
The customer pays on Paystack's own payment form. Every checkout gives you two ways to show it, for the same transaction:
- Redirect with
$payment->authorization_url: the customer leaves your site for Paystack's page, pays, and comes back to yourcallbackUrl. No JavaScript needed. This is what the example above does. - Popup with
$payment->access_code: Paystack's JavaScript opens the form on top of your page, and the customer never leaves. Smoother, but it needs a little JavaScript.
With Inertia, a plain redirect() to Paystack fails with a CORS error,
because the browser won't follow an XHR redirect to another site. Use a full
page visit instead, or the popup:
return Inertia::location($payment->authorization_url);
For the popup, return the access code instead of redirecting, and verify the payment on your server when the popup reports success:
// routes/web.php Route::post('/pay', [PaymentController::class, 'store']); Route::get('/pay/verify/{reference}', [PaymentController::class, 'verify']); // PaymentController: start the payment and hand back the access code. public function store(Request $request) { $data = $request->validate(['amount' => ['required', 'numeric', 'min:1'], 'email' => ['required', 'email']]); $payment = PaystackConnect::checkout()->amount((string) $data['amount'])->email($data['email'])->create(); return response()->json(['access_code' => $payment->access_code, 'reference' => $payment->reference]); } // PaymentController: the page calls this once the popup reports success. public function verify(string $reference) { return response()->json(['paid' => (bool) PaystackConnect::verify($reference)?->isSuccessful()]); }
In the page, post your form to /pay with fetch(), then open the popup
with the code it returns:
// <script src="https://js.paystack.co/v2/inline.js"></script> new PaystackPop().resumeTransaction(access_code, { onSuccess: () => fetch(`/pay/verify/${reference}`), // then show the result onCancel: () => { /* the customer closed the popup */ }, });
Never treat the popup's success as final: it runs in the customer's browser,
which can be tampered with. Only your server's verify() (or the webhook)
decides whether the payment went through. For the exact options of
Paystack's script, see accept payments.
Linking a payment to what it's for
->for($invoice) records what a payment is for, so from the payment you can
always find the invoice: $payment->payable. That's handy in a listener
("this payment succeeded; which invoice was it?").
Your app usually needs the other direction: starting from the invoice, has it
been paid? Add HasPaystackPayments to the model you pass to ->for():
use Otatechie\PaystackConnect\Concerns\HasPaystackPayments; class Invoice extends Model { use HasPaystackPayments; } $invoice->paystackPayments; // every attempt to pay it $invoice->latestPaystackPayment(); // the most recent one, or null $invoice->isPaidOnPaystack(); // a payment succeeded (and wasn't fully refunded)
One invoice can have several payments, because each try is its own payment:
a customer might give up, then come back and pay. isPaidOnPaystack() looks
for a success among all of them. A payment that isn't for a record of yours,
such as a donation, doesn't need ->for() or the trait.
The payment record
create() returns a Payment model, stored in paystack_payments. Amounts
are in minor units; the helpers give you Money objects.
| Field or method | Meaning |
|---|---|
reference |
Sent to Paystack. Generated as pc_... unless you set one. |
status |
pending, success, failed, amount_mismatch or refunded (a PaymentStatus enum). |
total(), platformFee(), sellerShare() |
What the customer paid, your fee, and the seller's share before Paystack's own fee. |
paystack_fee |
Paystack's fee, once the payment has succeeded. |
channel, paid_at |
How and when the customer paid. |
failure_reason |
Paystack's reason when a payment failed. |
isPending(), isSuccessful(), isRefunded() |
Status checks. |
payable, subaccount |
The model being paid for, and the seller's subaccount. |
paystack_data |
Paystack's full transaction data, for anything else you need. |
access_code and paystack_data are left out of the model's JSON: one opens
the checkout, the other holds card and customer details.
On your callback page, confirm the payment straight away. verify() returns
null when no payment has that reference:
$payment = PaystackConnect::verify($request->query('reference')); if ($payment?->isSuccessful()) { return redirect()->route('invoices.show', $payment->payable)->with('status', 'Paid, thank you.'); } return redirect()->route('invoices.index')->with('error', 'The payment did not go through.');
The webhook is still the source of truth. Verifying and the webhook both update the payment, and it only ever settles once.
React to payments
use Otatechie\PaystackConnect\Events\PaymentSucceeded; Event::listen(function (PaymentSucceeded $event) { $invoice = $event->payment->payable; $invoice->markPaid(); Mail::to($invoice->client)->queue(new ReceiptMail($invoice)); });
| Event | When |
|---|---|
PaymentSucceeded |
Paystack confirmed the charge and the amount and currency match. |
PaymentFailed |
The charge was declined. The customer can still pay on the same checkout, so PaymentSucceeded may follow. |
PaymentAmountMismatch |
Paystack charged a different amount or currency. The payment is not marked paid; review it. |
PaymentRefunded |
Paystack processed a refund. $event->amount is how much went back. |
SubaccountConnected |
A seller's subaccount was created or updated. |
WebhookReceived |
Any verified webhook, including events this package doesn't handle itself. Fires before the webhook is marked handled. |
WebhookHandled |
A webhook was handled and the payment saved. Listen here when you need the payment's new state. |
To handle an event the package doesn't, such as a dispute, listen for
WebhookReceived. It carries the event name and Paystack's full payload:
use Otatechie\PaystackConnect\Events\WebhookReceived; Event::listen(function (WebhookReceived $event) { if ($event->event === 'charge.dispute.create') { // $event->payload['data'] ... } });
A checkout the customer hasn't paid yet stays pending, even though Paystack
reports it as "abandoned" (verify payments):
they can still come back and pay. To clean up old unpaid checkouts, query
pending payments older than you care about.
Listeners run once per payment, even when Paystack retries a webhook or two
deliveries overlap. If a listener throws, the webhook returns an error, the
event is kept, and it's processed again by Paystack's next retry or by
paystack-connect:retry-webhooks (below), whichever comes first. In live
mode Paystack retries every 3 minutes for the first 4 tries, then hourly for
72 hours; in test mode, hourly for 10 hours. You can also resend events from
the Paystack dashboard (webhooks).
Paystack gives each delivery 30 seconds, so keep listeners quick and queue slow work such as emails, as above.
Keeping webhooks healthy
Add these to your scheduler (routes/console.php):
use Illuminate\Support\Facades\Schedule; use Otatechie\PaystackConnect\Models\WebhookEvent; // Process again any webhook that failed, without waiting for Paystack. Schedule::command('paystack-connect:retry-webhooks')->hourly(); // Remove processed webhooks older than webhook.keep_days (30 by default). Schedule::command('model:prune', ['--model' => WebhookEvent::class])->daily();
Failed webhooks are never pruned, so they can always be retried. You can also
run php artisan paystack-connect:retry-webhooks by hand; it lists anything
still failing.
Refunds
PaystackConnect::refund($payment); // everything PaystackConnect::refund($payment, Money::major('50.00', 'GHS')); // part of it
Paystack processes refunds in the background, which can take a while. Until
it does, the amount is held as pending (per refund, by Paystack's refund id),
so the same money can't be refunded twice. When the refund.processed
webhook arrives, the payment's refunded_amount goes up and
PaymentRefunded is dispatched. Refunds made from the Paystack dashboard are
recorded the same way. Once the whole
amount is back, the status becomes refunded. If Paystack fails the refund
(refund.failed), the amount can be refunded again. If Paystack needs the
customer's bank details first (refund.needs-attention), the refund stays
pending until you provide them through Paystack's retry endpoint or dashboard
(refunds).
$payment->pendingRefundAmount(); // GHS 50.00 until Paystack processes it $payment->refundedAmount(); // GHS 50.00 after $payment->refundableAmount(); // GHS 200.00: not refunded and not pending $payment->isRefunded(); // false until everything is back
Fees
'fees' => [ 'default' => ['percentage' => 2.5, 'flat' => 0, 'min' => null, 'max' => null], 'currencies' => [ 'NGN' => ['min' => 250, 'max' => 5000], 'KES' => ['percentage' => 3], 'ZAR' => ['percentage' => 3.5, 'flat' => 1.50], ], ],
A fee is a percentage plus a flat amount, kept between a minimum and a maximum. Amounts are in major units (NGN 250, not 25,000 kobo), and a currency without its own rule uses the default. Payments without a seller have no fee. To preview one:
PaystackConnect::feeFor(Money::major('10.00', 'GHS')); // GHS 0.25; the seller gets GHS 9.75
Your fee must cover Paystack's. By default your platform pays Paystack's
own fee out of your fee (bearer set to account), so a fee below
Paystack's loses you money on every payment. The defaults above are set to
cover Paystack's published rates: 1.95% in Ghana; 1.5% + NGN 100 in Nigeria
(the NGN 100 only over NGN 2,500, capped at NGN 2,000); up to 2.9% in Kenya
(cards); 2.9% + R1 + VAT in South Africa
(pricing). Paystack hasn't published rates for
Côte d'Ivoire, Egypt and Rwanda, so check yours there. Alternatively, set
bearer to subaccount to have sellers pay Paystack's fee instead.
The fee is never more than the payment, so a minimum above a small payment takes all of it: with a NGN 250 minimum, a NGN 200 payment leaves the seller nothing.
Currencies
Each Paystack account charges in its own country's currency, plus USD in some countries if Paystack has enabled it for you. Minimums and the XOF rule below are from Paystack's supported currency table; Egypt and Rwanda aren't in that table yet, though Paystack's API lists them. Anything else is refused with "Currency not supported by merchant".
| Country | Currency | Paystack's minimum | Account holder lookup |
|---|---|---|---|
| Ghana | GHS | GHS 0.10 | yes |
| Nigeria | NGN | NGN 50.00 | yes |
| Kenya | KES | KES 3.00 | no |
| South Africa | ZAR | ZAR 1.00 | no |
| Côte d'Ivoire | XOF | XOF 1 | no |
| Egypt | EGP | not published | no |
| Rwanda | RWF | not published | no |
USD has a minimum of USD 2.00; Paystack documents it for Kenya and Nigeria.
XOF and RWF have no subunit, so amounts must be whole:
Money::major('10.50', 'XOF') throws instead of Paystack silently charging
XOF 10. Fees in these currencies are rounded to whole units.
Money
Amounts are Money objects: an integer in minor units plus a currency, so
19.99 is always 1999 and never a float.
use Otatechie\PaystackConnect\Support\Money; $price = Money::major('19.99', 'GHS'); // from a major-unit string (or int or float) $price = Money::minor(1999, 'GHS'); // from minor units, as Paystack sends them $price->minor; // 1999 $price->toMajorString(); // "19.99" (string) $price; // "GHS 19.99" $price->add(Money::major('5.00', 'GHS')); // GHS 24.99; mixing currencies throws json_encode($price); // {"amount":1999,"currency":"GHS","formatted":"GHS 19.99"}
Invalid amounts throw InvalidAmount: negative, unparseable ("19.999",
"1,000"), mixed currencies, or a fraction of XOF or RWF.
Errors
Everything Paystack refuses throws PaystackException with Paystack's own
message, and $e->body holds its full response:
use Otatechie\PaystackConnect\Exceptions\PaystackException; try { $payment = PaystackConnect::checkout()->amount('50')->email($email)->create(); } catch (PaystackException $e) { $e->getMessage(); // "Paystack POST /transaction/initialize failed (403): Currency not supported by merchant" $e->getCode(); // 403, or 0 when Paystack couldn't be reached $e->body; // Paystack's JSON response, or null }
A missing secret key and network failures throw it too. Mistakes in your own
calls, such as a checkout with no email or a seller with no subaccount, throw
InvalidArgumentException before anything is sent.
Moving an existing app over
If your sellers already have subaccounts, copy them into the local table:
php artisan paystack-connect:import-subaccounts
Subaccounts created by this package carry their owner in Paystack's metadata, so they are linked to the right model. Older ones are imported without an owner; link each to its seller, which also records the owner on Paystack:
PaystackConnect::subaccounts()->attach($business, 'ACCT_8f4s1eq7ml6rlzj');
To see bank and network codes:
php artisan paystack-connect:banks ghana --type=mobile_money
Testing your app
PaystackConnect::fake() replaces Paystack for the rest of the test. Checkouts,
subaccounts, bank lists, account checks and refunds all get realistic
responses, and nothing leaves your machine.
use Otatechie\PaystackConnect\Facades\PaystackConnect; it('marks the invoice paid', function () { $paystack = PaystackConnect::fake(); $this->post(route('invoices.pay', $invoice))->assertRedirect(); $paystack->assertCheckoutCreated(fn ($data) => $data['amount'] === 25000); $paystack->pay(Payment::first()); // as if charge.success arrived; your listeners run expect($invoice->refresh()->paid)->toBeTrue(); });
| Method | What it does |
|---|---|
pay($payment) |
Settles the payment as paid. verify() reports it as paid from then on; before that, it reports "abandoned" like Paystack does, and the payment stays pending. |
fail($payment, $reason) |
Settles the payment as failed. |
refunded($payment, ?Money) |
Records a refund, as the refund.processed webhook would. |
assertCheckoutCreated(?callable) |
A checkout was started. The callback receives what was sent to Paystack. |
assertSubaccountCreated(?callable) |
A seller's subaccount was created. |
assertNothingSent() |
Nothing was sent to Paystack. |
requests() |
Everything sent to Paystack, for assertions of your own. |
The fake throws on any other endpoint. For those, use Http::fake().
Trying it against Paystack's test mode
- Pay with Paystack's "no validation" test card:
4084 0840 8408 4081, CVV408, any future expiry. Other cards and channels are on Paystack's test payments page. - Connecting a seller needs a real account or wallet number, even in test
mode. No money moves. Paystack allows only 3 lookups of real accounts a day
in test mode, and its test bank code
001works for lookups but not for creating a subaccount. Neither is in Paystack's docs; both come from its API's own error messages. - Webhooks need a public URL, so on your machine use a tunnel such as
herd share,exposeorngrok(see Installation). - Test refunds can stay pending for a while before Paystack processes them.
Security
Every webhook's signature is checked against the raw request body. To also
accept webhooks only from Paystack's servers, uncomment their IP addresses
under webhook.allowed_ips in the config (the three IPs Paystack publishes
on its webhooks page). If your app sits behind a
proxy or load balancer, set up Laravel's trusted proxies first, or every
webhook will be rejected.
To add middleware in front of the webhook, such as a throttle, list it under
webhook.middleware. To register the route yourself, set webhook.enabled
to false and point your route at WebhookController, keeping the
VerifyPaystackSignature middleware.
Sellers' account numbers are encrypted in the database and left out of the model's JSON. Only the last four digits are stored in the clear, for display.
To report a vulnerability, see SECURITY.md. Please don't open a public issue.
Configuration
Publish config/paystack-connect.php to change any of these. Each setting is
explained in the file.
| Setting | Default | What it does |
|---|---|---|
secret_key, public_key |
.env |
Your Paystack keys. The public key is only for your own frontend. |
currency |
GHS |
Currency for amounts given without one. |
fees |
2.5%, per-currency min/max | Your platform fee on payments to sellers. |
bearer |
account |
Who pays Paystack's fee: account (you) or subaccount (the seller). |
sellers.verify_accounts |
true |
Look up the account holder before creating a subaccount (Ghana and Nigeria). |
sellers.percentage_charge |
0 |
Your share on payments made outside the package, such as Paystack payment pages. |
webhook.enabled |
true |
Register the webhook route. |
webhook.path |
paystack/webhook |
The webhook URL path. |
webhook.middleware |
[] |
Extra middleware in front of the signature check. |
webhook.keep_days |
30 |
Days to keep processed webhook events; null keeps them forever. |
webhook.allowed_ips |
none | Only accept webhooks from these IPs. |
banks_cache_ttl |
24 hours | How long Paystack's bank list is cached, in seconds. |
log_channel |
default channel | Where webhook problems are logged. |
base_url, timeout |
Paystack's API, 15 s | For proxies and slow networks. |
Paystack references
The package's behaviour follows these pages of Paystack's documentation:
- Supported currencies: subunits, minimums, the XOF rule
- Transaction API: checkout parameters, channels, references
- Split payments and Subaccount API: subaccounts,
transaction_charge,bearer - Verify payments: transaction statuses, including "abandoned"
- Webhooks: signatures, IPs, retries, 30-second timeout
- Refunds: refund statuses and webhook events
- Verify account number: lookups in Ghana and Nigeria
- Test payments: test cards
Two behaviours come from Paystack's API rather than its docs: the test-mode
limit on account lookups, and the requested_amount field that lets payments
settle when you pass Paystack's fee on to the customer.
Contributing
composer test
composer analyse
composer format
License
MIT. See LICENSE.md.