levilabs / laravel-zaincash
Modern Laravel SDK for the ZainCash Payment Gateway v2 (Iraq): OAuth2, hosted checkout, JWT redirect and webhooks, inquiry, refunds, and cash disbursement.
Requires
- php: ^8.2
- firebase/php-jwt: ^7.2
- illuminate/console: ^11.0 || ^12.0 || ^13.0
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.22
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.8 || ^4.4
- pestphp/pest-plugin-laravel: ^3.2 || ^4.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-06 11:16:29 UTC
README
A modern Laravel SDK for the ZainCash Payment Gateway API v2 (Iraq) — hosted wallet checkout, OAuth2 client credentials, JWT-signed redirect and webhook callbacks, transaction inquiry, full and partial refunds, reversal listing, and cash disbursement batches. Typed DTOs and enums, cached access tokens, optional database persistence, and Artisan sync commands when webhooks or polling are missed.
Maintained by Levi Labs (GitHub) — built by Nizam Omer
ZainCash: zaincash.iq · Integration: docs.zaincash.iq
This package targets API v2 only. Legacy v1 (
api.zaincash.iq/test.zaincash.iqmerchantId + secret) is not supported.
Table of Contents
- Requirements
- Installation
- Configuration
- Creating a payment
- Return URL and JWT token
- The webhook notification
- Checking payment status
- Missed notifications: sync command
- Refunding a payment
- Cash disbursement
- Multiple merchants
- Automatic persistence
- Events
- Error handling
- Security
- Testing
- Full example
- ZainCash API reference
- Changelog
- Contributing
- Author
- License
Requirements
- PHP 8.2+ (Laravel 13.x requires PHP 8.3+ on the application)
- Laravel 11.x, 12.x, or 13.x
- ZainCash merchant credentials:
client_id,client_secret, and API key (HS256 for redirect?token=and webhook JWTs)
Installation
composer require levilabs/laravel-zaincash php artisan vendor:publish --tag=zaincash-config php artisan migrate
Publish step copies config/zaincash.php. Migrations create zaincash_payments, zaincash_refunds, and zaincash_disbursement_batches; lifecycle events upsert rows automatically when listeners are registered (default).
Configuration
ZAINCASH_MERCHANT=default ZAINCASH_ENVIRONMENT=sandbox ZAINCASH_BASE_URL=https://pg-api-uat.zaincash.iq ZAINCASH_CLIENT_ID= ZAINCASH_CLIENT_SECRET= ZAINCASH_API_KEY= ZAINCASH_SCOPES="payment:read payment:write reverse:read reverse:write disbursement:read disbursement:write" ZAINCASH_SUCCESS_URL=https://your-app.test/payments/zaincash/return ZAINCASH_FAILURE_URL=https://your-app.test/payments/zaincash/return ZAINCASH_LANGUAGE=en ZAINCASH_CURRENCY=IQD
Production base URL: UAT is documented publicly as
https://pg-api-uat.zaincash.iq. Production base URL and credentials are issued after onboarding with the ZainCash business team — setZAINCASH_BASE_URLexplicitly before going live.
successUrl and failureUrl on each payment fall back to ZAINCASH_SUCCESS_URL and ZAINCASH_FAILURE_URL when omitted on InitTransactionRequest.
Creating a payment
Obtains a cached OAuth2 access token, calls transaction/init, and returns a redirectUrl — send the customer there to pay with their ZainCash wallet.
use LeviLabs\LaravelZaincash\Data\InitTransactionRequest; use LeviLabs\LaravelZaincash\Facades\ZaincashPayment; $payment = ZaincashPayment::initTransaction(new InitTransactionRequest( orderId: 'ORDER-'.$order->id, amount: (float) $order->total, serviceType: 'order-'.$order->id, customerPhone: $order->customer_phone, // optional, 96477… )); return redirect()->away($payment->redirectUrl);
Use a unique externalReferenceId per payment attempt (UUID generated automatically when omitted). serviceType must not contain + or , per ZainCash rules. Amounts are whole-number IQD by default (ZAINCASH_CURRENCY).
Return URL and JWT token
After checkout, ZainCash redirects to your successUrl or failureUrl with ?token=JWT. Verify the JWT with your API key (HS256) before updating order state:
use LeviLabs\LaravelZaincash\Facades\ZaincashPayment; public function return(Request $request) { $token = $request->query('token'); if (! is_string($token) || $token === '') { abort(400); } $payload = ZaincashPayment::verifyRedirectToken($token); if ($payload->isSuccessful()) { // Match orderId / merchantReferenceId and amount against your order } return view('payments.zaincash-return', ['payload' => $payload]); }
Treat redirect tokens as UX feedback; prefer webhooks (production) or inquiry() as the source of truth.
The webhook notification
ZainCash POSTs JSON with a webhook_token JWT when a payment reaches a final status. Webhooks are configured in production only (not UAT) — register a dedicated HTTPS URL with the business team, separate from success/failure URLs.
use LeviLabs\LaravelZaincash\Facades\ZaincashPayment; public function webhook(Request $request) { try { $notification = ZaincashPayment::parseWebhook($request->all()); } catch (\Throwable $e) { report($e); return response()->json(['message' => 'Invalid webhook'], 400); } if ($notification->isSuccessful()) { // Reconcile using payload->orderId, merchantReferenceId, transactionId, amount } return response()->noContent(ZaincashPayment::webhookAckStatus()); }
Deduplicate using eventId when the same notification is delivered more than once.
Checking payment status
Authoritative outcome for fulfillment — use from the webhook handler, return URL flow, or anywhere you need confirmation:
$status = ZaincashPayment::inquiry($transactionId); $status->status; // TransactionStatus::Success | Failed | … $status->isSuccessful(); $status->transactionId; $status->orderId; $status->externalReferenceId;
Missed notifications: sync command
php artisan zaincash:sync-statuses
Re-checks rows in zaincash_payments that are not in a terminal status via the inquiry API. Schedule if you rely on production webhooks:
->withSchedule(function (Illuminate\Console\Scheduling\Schedule $schedule) { $schedule->command('zaincash:sync-statuses')->everyFiveMinutes(); })
Refunding a payment
Refund endpoints may return HTTP 200 with status: FAILED — the SDK throws ZaincashException when the banking layer rejects the refund. Refund calls are not idempotent; on timeouts, call listReversals() before retrying.
ZaincashPayment::reverse($transactionId, 'Customer request'); ZaincashPayment::partialReverse($transactionId, 5000, 'Partial return');
Cash disbursement
Payroll-style payouts to ZainCash wallets. No webhooks — poll batch status until COMPLETED. Recipient MSISDN must start with 9647 and be exactly 13 characters.
use LeviLabs\LaravelZaincash\Data\CreateDisbursementBatchRequest; use LeviLabs\LaravelZaincash\Data\DisbursementBatchItemRequest; use LeviLabs\LaravelZaincash\Facades\ZaincashDisbursement; $batch = ZaincashDisbursement::createBatch(new CreateDisbursementBatchRequest( batchName: 'Payroll March', items: [ new DisbursementBatchItemRequest(recipientMsisdn: '9647701234567', amount: 50000), ], )); $final = ZaincashDisbursement::pollBatchUntilComplete($batch->externalReferenceId);
php artisan zaincash:sync-disbursement-batches
ZainCash documents some GET endpoints (/transaction/reversals, /cash-disbursement/search) with a JSON request body; this SDK sends them via Laravel HTTP withBody() on GET.
Multiple merchants
Add entries under merchants in config/zaincash.php, then pass the merchant key as the last argument:
ZaincashPayment::initTransaction($request, merchant: 'second_store'); ZaincashPayment::inquiry($transactionId, merchant: 'second_store'); ZaincashDisbursement::createBatch($batchRequest, merchant: 'second_store');
Automatic persistence
| Table | When rows update |
|---|---|
zaincash_payments |
PaymentCreated, PaymentStatusChecked, WebhookReceived |
zaincash_refunds |
PaymentRefunded |
zaincash_disbursement_batches |
DisbursementBatchCreated, DisbursementBatchPolled |
Optional payable morph on ZaincashPayment for linking to your Order model.
Events
PaymentCreatedPaymentStatusCheckedPaymentRefundedWebhookReceivedDisbursementBatchCreatedDisbursementBatchPolled
Error handling
use LeviLabs\LaravelZaincash\Exceptions\ZaincashException; use LeviLabs\LaravelZaincash\Exceptions\ZaincashMerchantException; try { ZaincashPayment::initTransaction($request); } catch (ZaincashException $e) { // API failure (PGW-* codes or refund rejection) } catch (ZaincashMerchantException $e) { // Unknown merchant or missing credentials in config }
Security
- Credentials belong in
.env, never in version control (ZAINCASH_CLIENT_SECRET,ZAINCASH_API_KEY). - Verify redirect and webhook JWTs with the API key before updating order state; reconcile
orderId,merchantReferenceId, and amount. - Use
inquiry()before fulfilling high-value orders — do not rely on redirect URLs alone. - TLS verification is never disabled in this SDK.
See SECURITY.md for vulnerability reporting.
Testing
composer test # Pest composer analyse # PHPStan level 8 composer format # Laravel Pint
Full example
See docs/examples/PaymentController.php for checkout, return handler, webhook, and refund routes.
ZainCash API reference
Official merchant integration guide:
- ZainCash
- Payment Gateway API v2 documentation
- Business onboarding: business wallet registration
- Support: 6698, info@zaincash.iq
SDK mapping
| SDK | HTTP |
|---|---|
| (token) | POST /oauth2/token (form client_credentials) |
ZaincashPayment::initTransaction() |
POST /api/v2/payment-gateway/transaction/init |
ZaincashPayment::inquiry() |
GET /api/v2/payment-gateway/transaction/inquiry/{transactionId} |
ZaincashPayment::reverse() |
POST /api/v2/payment-gateway/transaction/reverse |
ZaincashPayment::partialReverse() |
POST /api/v2/payment-gateway/transaction/partial-reverse |
ZaincashPayment::listReversals() |
GET /api/v2/payment-gateway/transaction/reversals (JSON body) |
ZaincashDisbursement::createBatch() |
POST /api/v2/cash-disbursement/bulk |
ZaincashDisbursement::getBatch() |
GET /api/v2/cash-disbursement/batch |
ZaincashDisbursement::getBatchItem() |
GET /api/v2/cash-disbursement/batch-items/{id} |
ZaincashDisbursement::searchBatches() |
GET /api/v2/cash-disbursement/search (JSON body) |
| Environment | Base URL (example) |
|---|---|
| UAT / sandbox | https://pg-api-uat.zaincash.iq |
| Production | Issued per merchant (set ZAINCASH_BASE_URL) |
| Status (payments) | Meaning |
|---|---|
SUCCESS |
Paid |
FAILED |
Failed |
REFUNDED / PARTIALLY_REFUNDED |
After reversals |
| JWT | Verify with |
|---|---|
Redirect ?token= |
ZAINCASH_API_KEY, HS256 |
Webhook webhook_token |
ZAINCASH_API_KEY, HS256 |
Changelog
See CHANGELOG.md.
Contributing
Bug reports and feature ideas are welcome via GitHub Issues. See CONTRIBUTING.md.
Author
Levi Labs — GitHub · hello@levilabs.dev
Created and maintained by Nizam Omer — nizaamomer.com · nizam@nizaamomer.com
License
MIT — see LICENSE.md.