Search by

levilabs / laravel-zaincash

nizaamomer

Modern Laravel SDK for the ZainCash Payment Gateway v2 (Iraq): OAuth2, hosted checkout, JWT redirect and webhooks, inquiry, refunds, and cash disbursement.

dev-main / 1.0.x-dev 2026-11-05 13:07 UTC

This package is not auto-updated.

Last update: 2026-11-06 11:16:29 UTC


README

Latest Version on Packagist Tests Total Downloads PHP Version Laravel Version License

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.iq merchantId + secret) is not supported.

Table of Contents

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 — set ZAINCASH_BASE_URL explicitly 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

  • PaymentCreated
  • PaymentStatusChecked
  • PaymentRefunded
  • WebhookReceived
  • DisbursementBatchCreated
  • DisbursementBatchPolled

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:

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.