laravel-qicard maintained by nizaamomer
Laravel QiCard SDK — Payment Gateway, Refunds & Signature-Verified Webhooks
A modern Laravel SDK for the QiCard Payment Gateway (Iraq) — hosted card payments via the Secure Payment Form, status polling, cancellation, full/partial refunds, and RSA-signature-verified webhook notifications. Typed DTOs and enums for every documented response shape, multi-terminal support, and automatic status persistence.
Built by Nizam Omer — nizaamomer.com
Table of Contents
- Requirements
- Installation
- Creating a payment
- The webhook notification — always verify the signature
- Checking payment status
- Missed notifications: the sync command
- Cancelling a payment
- Refunding a payment
- Mobile payments
- Multiple terminals
- Automatic persistence
- Error handling
- Full example
- Security
- Testing
- QiCard API Reference
- Changelog
- Author
- License
Requirements
- PHP 8.2+
- Laravel 11.x, 12.x or 13.x
Installation
composer require nizaamomer/laravel-qicard
Publish the config file:
php artisan vendor:publish --tag="qicard-config"
Run the migrations — creates qicard_payments and qicard_refunds; every payment, status check, cancellation, and refund is persisted automatically, no manual tracking code needed:
php artisan migrate
Add your QiCard Merchant Terminal credentials to .env (provided by your acquirer):
QICARD_ENVIRONMENT=sandbox # label only — see "Base URL" note below
QICARD_TERMINAL_ID=237984 # X-Terminal-Id, from your acquirer
QICARD_USERNAME=your-username # Basic Auth username
QICARD_PASSWORD=your-password # Basic Auth password — keep this out of version control
QICARD_BASE_URL=https://uat-sandbox-3ds-api.qi.iq/api/v1 # sandbox is public; production is issued per merchant, see below
QICARD_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" # for webhook signature verification, literal \n for newlines
QICARD_CURRENCY=IQD
QICARD_FINISH_URL=https://your-app.test/checkout/finish # where QiCard redirects the payer after processing completes
QICARD_NOTIFICATION_URL=https://your-app.test/qicard/webhook # where QiCard POSTs the webhook notification
Base URL: the sandbox host (
uat-sandbox-3ds-api.qi.iq) is public and safe to default to. QiCard does not publish a single production URL — it's issued to you directly by your acquirer alongside your terminal credentials. SetQICARD_BASE_URLexplicitly once you have it; there is no environment-name-based fallback for production, on purpose, so a misconfigured.envfails loudly instead of silently hitting the wrong host.
Creating a payment
Creates a Payment and returns a formUrl — redirect the payer there to complete checkout on QiCard's hosted Secure Payment Form.
use Nizaamomer\LaravelQicard\Facades\QicardPayment;
use Nizaamomer\LaravelQicard\Data\CustomerInfo;
$payment = QicardPayment::create(
amount: 256.89,
customerInfo: new CustomerInfo(
firstName: 'John',
lastName: 'Doe',
email: 'j.doe@gmail.com',
phone: '009647xxxxxxxxx',
),
additionalInfo: ['order_id' => (string) $order->id], // up to 10 string key/value pairs, echoed back on every later response
);
return redirect($payment->formUrl);
currency, finishUrl, and notificationUrl all fall back to QICARD_CURRENCY/QICARD_FINISH_URL/QICARD_NOTIFICATION_URL when omitted. requestId is auto-generated (a UUID, well within QiCard's 36-character limit) if you don't pass one — QiCard requires it to be unique per request.
browserInfo is optional but improves the 3DS authentication experience. If you include it, every one of its fields is required (QiCard rejects the request otherwise) — collect the real values client-side in JS where you can:
use Nizaamomer\LaravelQicard\Data\BrowserInfo;
QicardPayment::create(256.89, browserInfo: new BrowserInfo(
browserAcceptHeader: $request->header('Accept'),
browserIp: $request->ip(),
browserJavaEnabled: false,
browserLanguage: 'en-US',
browserColorDepth: '24', // screen.colorDepth
browserScreenWidth: '1920', // screen.width
browserScreenHeight: '1080', // screen.height
browserTZ: '-180', // Date().getTimezoneOffset()
browserUserAgent: $request->userAgent(),
));
BrowserInfo::fromRequest($request) builds a best-effort fallback from server-side headers alone (used in the full example) for flows that can't collect real screen/Java/timezone values.
The webhook notification — always verify the signature
QiCard POSTs the full Payment object to notificationUrl once a payment reaches a terminal status (SUCCESS, FAILED, AUTHENTICATION_FAILED, ERROR, or EXPIRED), signing it with the terminal's private key so you can tell a genuine notification from a forged one. Never trust the body until the signature checks out:
Route::post('/qicard/webhook', function (\Illuminate\Http\Request $request) {
$signature = $request->header('X-Signature');
if (! $signature || ! QicardPayment::verifyWebhookSignature($request->all(), $signature)) {
return response()->json(['message' => 'Invalid signature'], 400);
}
// Re-fetch the authoritative status rather than reading it off the
// payload — this also fires the event that persists to qicard_payments.
$status = QicardPayment::status($request->input('paymentId'));
if ($status->isSuccessful()) {
// fulfil the order
}
return response()->noContent();
})->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]);
QiCard retries with backoff until your endpoint returns HTTP 200 — make sure it always does once the notification has been recorded, even for a rejected signature, or QiCard will keep retrying indefinitely.
verifyWebhookSignature() implements the algorithm exactly as documented in the "Signature verification in notifications" section of the Payment Gateway API reference: the five fields paymentId|amount|currency|creationDate|status are concatenated with | (a missing/null value becomes a literal -), the amount is formatted to exactly two decimal places, and the resulting string's SHA-256 hash is checked against the Base64-decoded X-Signature header using the terminal's RSA public key (QICARD_PUBLIC_KEY).
Checking payment status
The authoritative source of truth for a payment's outcome — call this from the webhook handler above, from the page the payer lands on after finishUrl, or anywhere else you need to confirm a result:
$status = QicardPayment::status($paymentId);
$status->status; // PaymentStatus::Success | Failed | AuthenticationFailed | Expired | … (13 values total)
$status->status->isTerminal(); // bool — true once the payment will never change status again
$status->isSuccessful(); // bool — shorthand for status === PaymentStatus::Success
$status->amount;
$status->confirmedAmount;
$status->details?->maskedPan; // e.g. "521372******8582"
$status->details?->paymentSystem; // "VISA" | "MASTER_CARD"
Lost the paymentId (e.g. the create() response never arrived)? Look it up by the requestId you generated instead:
$status = QicardPayment::statusByRequest($requestId);
Missed notifications: the sync command
A webhook is best-effort — if notificationUrl was misconfigured, your endpoint was briefly down, or delivery was simply dropped, a payment can sit in a non-terminal status forever with nothing else prompting a re-check. Run:
php artisan qicard:sync-statuses
to re-check every payment still in a non-terminal status (CREATED, FORM_SHOWED, AUTHENTICATION_REQUIRED, etc.) directly against QiCard. Schedule it in bootstrap/app.php:
->withSchedule(function (Illuminate\Console\Scheduling\Schedule $schedule) {
$schedule->command('qicard:sync-statuses')->everyFiveMinutes();
})
Cancelling a payment
Only valid while a payment hasn't yet entered processing (CREATED/FORM_SHOWED), or once it's SUCCESS but still awaiting confirmation — QiCard rejects a cancel attempt outside those windows. Use refund for an already-settled payment instead.
$cancellation = QicardPayment::cancel($paymentId); // omit $amount for a full cancel, or pass one for a partial cancel
$cancellation->canceled; // bool
Refunding a payment
Full or partial refund to the payer. The sum of all refunds on a payment must never exceed its original amount. This moves real money — put your own approval workflow in front of it.
$refund = QicardPayment::refund(
paymentId: $paymentId,
amount: 100.00, // omit entirely for a full refund
message: 'Requested by customer',
);
$refund->isSuccessful(); // bool
$refund->status; // RefundStatus::Success | Failed | Processing
$refund->refundId;
Mobile payments
Payments initiated from a mobile app must be created with appChannel: true so the gateway tailors the authentication flow for an in-app context — omitting this returns a payment is not in app channel error the moment your mobile SDK tries to use it:
QicardPayment::create(256.89, appChannel: true);
Set QICARD_APP_CHANNEL=true in .env instead if every payment your app creates is a mobile payment, so you don't have to pass it on every call.
Multiple terminals
Add more entries under terminals in config/qicard.php to accept payments through multiple QiCard Merchant Terminals — each with its own credentials, base URL, and public key — then pass the terminal name as the last argument of any SDK call:
QicardPayment::create($amount, terminal: 'second_terminal');
QicardPayment::status($paymentId, terminal: 'second_terminal');
Automatic persistence
Every create(), status(), cancel(), and refund() call fires an event (PaymentCreated, PaymentStatusChecked, PaymentCancelled, PaymentRefunded) that this package listens to and upserts into qicard_payments/qicard_refunds automatically — no manual tracking code required. Link your own models via the payable polymorphic relation:
use Nizaamomer\LaravelQicard\Models\QicardPayment;
QicardPayment::where('payment_id', $paymentId)->first()?->payable()->associate($order)->save();
Error handling
QiCard returns HTTP 200 with the resource body on success, or HTTP 400/500 with {"error": {"code": int, "message": string}} on failure — this SDK normalizes both into a single QicardException with a readable message:
use Nizaamomer\LaravelQicard\Exceptions\QicardException;
try {
QicardPayment::refund($paymentId);
} catch (QicardException $e) {
// "QiCard payment refund request failed [REFUNDS_NOT_ALLOWED] (code 18): ..."
}
All 34 of QiCard's documented error codes (QicardException::ERROR_CODES) are mapped to their string names, so the exception message is readable even when the API response omits the string message and returns only a numeric code — which it does for some failures.
Full example
docs/examples/PaymentController.php is a complete, heavily-commented controller covering every public method — creating a payment, the signature-verified webhook, the finishUrl return page, cancel, and refund. It's illustrative (not autoloaded), so copy what you need into your own app.
Security
- TLS verification is never disabled. This SDK does not expose a way to set Guzzle's
verify => false. - Webhook payloads are never trusted without a valid signature.
verifyWebhookSignature()implements the exact algorithm from QiCard's own "Signature verification in notifications" documentation — field order, the-placeholder for missing values, and two-decimal amount formatting — rather than assuming a prior integration's format was correct. - QiCard's per-outcome HTTP status is handled for you. A 400/500 with an
{"error": {...}}body is normalized into a thrownQicardException, with all 34 documented error codes mapped to readable names even when the response omits the string message. - Amounts must be greater than zero, and
requestIdis checked against QiCard's 36-character limit, before any request is sent. - Credentials live in
.env, never in version control. RotateQICARD_PASSWORDimmediately if it's ever exposed.
If you discover a security issue, please see SECURITY.md instead of using the public issue tracker.
Testing
composer test # Pest
composer analyse # Larastan / PHPStan (level 8)
composer format # Laravel Pint
QiCard API Reference
See the QiCard Developer Documentation for the underlying REST API this SDK wraps.
Changelog
See CHANGELOG.md for what's changed in each release.
Author
Nizam Omer — nizaamomer.com · nizaamomer@gmail.com
License
MIT. See LICENSE.md.