PHP SDK
Integrate Qasimha into your PHP backend with @qasimha/php-sdk to create checkouts, retrieve and confirm orders, verify webhook signatures, and keep order data in sync.
@qasimha/php-sdk is the official PHP server SDK for the Qasimha merchant API. Use it in your backend to create instalment checkouts, confirm orders after checkout, verify webhook deliveries, fulfil, refund, or cancel orders, generate payment links and QR codes, retrieve settlements, register webhook endpoints, and quote available payment plans for a purchase amount.
The SDK is designed exclusively for trusted server environments because it uses your Qasimha secret key to authenticate API requests. Your secret key must never be exposed to browsers, mobile apps, or any client-side code.
Qasimha keeps payment collection separate from your application. Customers complete their payment on the Qasimha hosted checkout, while your browser, mobile app, or other client applications communicate only with your backend. Your PHP backend creates checkout sessions, retrieves order details, validates webhook signatures, and performs any other operations through @qasimha/php-sdk.
This architecture helps keep sensitive credentials on the server while simplifying PCI-related requirements, because neither the SDK nor your application handles card data directly.
@qasimha/php-sdk is also the foundation the official WooCommerce, Magento, OpenCart, and PrestaShop plugins are built on, so you get the same reliability whether you use a plugin or write custom code.
For client-side integrations, see the Web SDKs and Mobile SDKs documentation.
Requirements
| Item | Requirement |
|---|---|
| Runtime | PHP 8.1 or later with ext-curl, ext-json and ext-mbstring |
| Dependencies | psr/http-client, psr/http-factory and psr/http-message (interfaces only). A built-in curl transport is used unless you pass a PSR-18 client |
| Optional | Guzzle 7 (guzzlehttp/guzzle) or Symfony HttpClient (symfony/http-client) as a PSR-18 transport |
| Keys | A secret key (sk_test_... for UAT, sk_live_... for production). Optional: a publishable key (pk_test_...) for plan quotes, and a webhook secret (whsec_...) |
| API release | Return URLs, per-merchant webhook endpoints, getByReference and getPlans need the merchant API release of 2026-09-25 or later |
Install
composer require qasimha/php-sdkrequire __DIR__ . '/vendor/autoload.php'; // frameworks load this for you
use Qasimha\Client;
use Qasimha\Config;Every class lives under the Qasimha\ namespace: Qasimha\Exception\*, Qasimha\Model\*, Qasimha\Model\Enum\*, Qasimha\Request\* and Qasimha\Webhook\*.
Configure
Create keys in the Merchant Portal under Settings → API Keys. Keep them in environment variables:
QASIMHA_API_KEY=sk_test_...
QASIMHA_PUBLISHABLE_KEY=pk_test_...
QASIMHA_WEBHOOK_SECRET=whsec_...use Qasimha\Client;
use Qasimha\Config;
$qasimha = new Client(Config::fromApiKey((string) getenv('QASIMHA_API_KEY'), [
'webhookSecret' => getenv('QASIMHA_WEBHOOK_SECRET') ?: null,
'publishableKey' => getenv('QASIMHA_PUBLISHABLE_KEY') ?: null,
'language' => 'ar', // "en" (default) or "ar"
'userAgentSuffix' => 'my-store/2.4.0',
]));
// Shortcut with the same options:
$qasimha = Client::create((string) getenv('QASIMHA_API_KEY'), ['language' => 'ar']);getenv() returns false for an unset variable. ?: null turns it into "not set", which the options accept. An empty string is refused.
Create one client per process and reuse it. It is immutable, so one instance is safe to share.
Options
Config::fromApiKey(string $apiKey, array $options = []) takes these keys. Any other key throws ConfigurationException (Unknown option "...").
| Option | Type | Default | Meaning and validation |
|---|---|---|---|
environment | Qasimha\Environment or string | from the key prefix | "sandbox" or "production" (case-insensitive). A value that contradicts the key prefix is refused |
baseUrl | string | per environment | Override for a test server you run yourself. Absolute, https:// (http:// only for localhost / 127.0.0.1), no query string or fragment |
timeout | int | 30000 | Milliseconds per attempt, connect and read. Must be greater than zero. Not applied to an injected PSR-18 client |
maxRetries | int | 2 | 0 to 5. Retries after the first attempt |
language | string | "en" | "en" or "ar" (case-insensitive). Sent as Accept-Language, so API messages come back in that language. Also the default lang of getPlans() |
webhookSecret | string | none | Used by webhooks()->constructEvent() and verifySignature() unless a secret is passed per call. Must not be empty |
publishableKey | string | none | pk_test_... / pk_live_..., letters and digits after the prefix. Used by checkout()->getPlans(). Must belong to the same environment as the API key |
userAgentSuffix | string | none | Appended to the User-Agent, for example woocommerce/1.2.0. Non-printable characters are removed |
logger | callable(string, string, array): void | none | Receives $level, $message, $fields. See Logging and security |
httpClient | Psr\Http\Client\ClientInterface | built-in curl | A PSR-18 client (Guzzle 7, Symfony Psr18Client). Keep redirects off on it |
requestFactory | Psr\Http\Message\RequestFactoryInterface | from httpClient | PSR-17 request factory for the PSR-18 client. Taken from httpClient when it implements the interface |
streamFactory | Psr\Http\Message\StreamFactoryInterface | from httpClient | PSR-17 stream factory for the PSR-18 client. Taken from httpClient when it implements the interface |
transport | Qasimha\Http\TransportInterface | none | Your own transport. Wins over httpClient |
sleeper | callable(int): void | usleep | Called with the wait in milliseconds between retries. For tests that must not sleep |
Environments
The SDK picks the API host from the key prefix. You never set a URL.
| Key prefix | Environment | Base URL |
|---|---|---|
sk_test_ | sandbox | https://api-uat.qasimha.com |
sk_live_ | production | https://api.qasimha.com |
environmentis optional. If you set it and it contradicts the key (sk_live_with"sandbox"), construction fails.baseUrlskips that check, but still requires TLS. Leave it unset in UAT and production.- A publishable key of the other environment (
sk_test_withpk_live_) is refused. - All calls go to
{baseUrl}/api/v1/....
Choose a transport
The built-in curl transport is fine for most stores. If your application already has a PSR-18 client with connection pooling, pass it in:
// Guzzle 7
$config = Config::fromApiKey($key, [
'httpClient' => new \GuzzleHttp\Client(['timeout' => 30, 'connect_timeout' => 10, 'allow_redirects' => false]),
'requestFactory' => new \GuzzleHttp\Psr7\HttpFactory(),
'streamFactory' => new \GuzzleHttp\Psr7\HttpFactory(),
]);
// Symfony HttpClient: Psr18Client is the client and both factories
$config = Config::fromApiKey($key, [
'httpClient' => new \Symfony\Component\HttpClient\Psr18Client(
\Symfony\Component\HttpClient\HttpClient::create(['timeout' => 30, 'max_redirects' => 0])
),
]);PSR-18 has no per-request timeout, so the timeout option does not reach an injected client. Set the timeout on the client, as above. A httpClient without factories throws ConfigurationException.
To write your own transport, implement Qasimha\Http\TransportInterface::send(TransportRequest $request): TransportResponse. TransportRequest carries method, url, headers, body and timeoutMs. Return new TransportResponse(int $statusCode, array $headers, string $body) with lower-cased header names mapped to lists of values. Throw Qasimha\Exception\ConnectionException on a DNS, TLS, socket or timeout failure.
Client and config members
| Member | Returns |
|---|---|
new Client(Config $config) | The client |
Client::create(string $apiKey, array $options = []) | new Client(Config::fromApiKey($apiKey, $options)) |
$client->checkout(), orders(), checkoutLinks(), settlements(), webhooks(), webhookEndpoints() | The resources below |
$client->config() | The Config |
$client->maskedKey() | sk_live_…AbCd: prefix and last four characters |
$config->environment | Environment::Sandbox or Environment::Production (->value is "sandbox" / "production") |
$config->baseUrl, timeoutMs, maxRetries, language, userAgentSuffix, transport | Public read-only properties |
$config->maskedKey(), maskedPublishableKey() | Masked keys for your own logs (maskedPublishableKey() is null when none is set) |
$config->webhookSecret() | The configured webhook secret or null |
$config->userAgent() | qasimha-sdk-php/0.1.0 (php/<version>; <os family>) plus your suffix |
Environment::fromApiKey(string $key), $environment->baseUrl() | The environment a key belongs to, and its base URL |
var_dump and print_r of the client show masked keys and *** for the webhook secret; json_encode leaves the keys out. serialize() of the client or config throws LogicException.
Construction errors
Every one of these throws Qasimha\Exception\ConfigurationException before any request:
| Cause | Message starts with |
|---|---|
| Key missing or malformed (whitespace, newline, other prefix) | apiKey is required and must look like sk_test_… or sk_live_… |
| Unknown option key | Unknown option |
environment not sandbox / production | environment must be "sandbox" or "production" |
environment contradicts the key | environment "..." contradicts the API key prefix |
baseUrl not absolute, not TLS, or with a query/fragment | baseUrl must ... |
timeout zero or negative | timeout must be a positive number of milliseconds |
maxRetries outside 0–5 | maxRetries must be between 0 and 5 |
language not en / ar | language must be "en" or "ar" |
webhookSecret empty string | webhookSecret must not be empty when given |
publishableKey malformed or of the other environment | publishableKey must look like ... / publishableKey belongs to ... |
httpClient without PSR-17 factories | httpClient needs PSR-17 factories |
Create a checkout session
Send the basket exactly as the shopper sees it. The amounts below add up: items 899.00 + 135.00 + 120.00 = 1154.00, plus tax 173.10, plus shipping 25.00, minus discount 50.00 = 1302.10.
use Qasimha\Exception\DuplicateOrderReferenceException;
use Qasimha\Exception\OrderDeclinedException;
use Qasimha\Exception\ValidationException;
try {
$session = $qasimha->checkout()->createSession([
'orderReferenceId' => 'ORDER-12345', // your own id, unique per order, ≤ 255 chars
'totalAmount' => 1302.10,
'taxAmount' => 173.10,
'shippingAmount' => 25.00,
'discountAmount' => 50.00,
'description' => 'Order #12345',
'items' => [
['referenceId' => 'SKU-1', 'name' => 'Wireless Headphones', 'type' => 'physical',
'sku' => 'SKU-1', 'quantity' => 1, 'unitPrice' => 899.00, 'totalAmount' => 899.00],
['referenceId' => 'SKU-2', 'name' => 'USB-C Cable', 'type' => 'physical',
'sku' => 'SKU-2', 'quantity' => 3, 'unitPrice' => 45.00, 'totalAmount' => 135.00],
['referenceId' => 'SKU-3', 'name' => 'Extended Warranty', 'type' => 'digital',
'sku' => 'SKU-3', 'quantity' => 1, 'unitPrice' => 120.00, 'totalAmount' => 120.00],
],
'consumer' => ['firstName' => 'Noura', 'lastName' => 'Al-Shammari',
'phoneNumber' => '+9665XXXXXXXX', 'email' => '[email protected]'],
'shippingAddress' => ['firstName' => 'Noura', 'lastName' => 'Al-Shammari', 'line1' => 'King Fahd Rd',
'city' => 'Riyadh', 'postalCode' => '12345', 'countryCode' => 'SA'],
'merchantUrl' => [
'success' => 'https://shop.example/checkout/qasimha/return?order=ORDER-12345',
'failure' => 'https://shop.example/checkout/qasimha/return?order=ORDER-12345',
'cancel' => 'https://shop.example/cart',
],
]);
saveQasimhaOrderId('ORDER-12345', $session->orderId); // every later call uses this id
header('Location: ' . $session->paymentUrl, true, 303);
exit;
} catch (ValidationException $e) {
// $e->errors: list of FieldError with ->field ("Items[1].TotalAmount") and ->message
} catch (DuplicateOrderReferenceException $e) {
// already open: $e->existingOrderId, $e->existingPaymentUrl (may be null)
} catch (OrderDeclinedException $e) {
// $e->orderId exists but was not approved: offer another payment method
}createSession(array|CreateSessionRequest $request, array $options = []): Session calls POST /api/v1/pay/session/create and needs the orders:write scope. $options takes idempotencyKey (see Retries, timeouts and idempotency).
Request fields
Amounts (money below) are float, int or a decimal string such as '1302.10'. They must have at most two decimals and must not be negative. They are sent as JSON numbers and never rounded.
| Field | Type | Required / default | Rules |
|---|---|---|---|
orderReferenceId | string | Required | Your own order id, at most 255 characters, unique per order |
totalAmount | money | Required | Σ items[].totalAmount + taxAmount + shippingAmount − discountAmount |
taxAmount | money | 0 | |
shippingAmount | money | 0 | |
discountAmount | money | 0 | Order-level discount |
currencyCode | string | "SAR" | At most 3 characters. The API accepts SAR only |
countryCode | string | "SA" | At most 2 characters |
description | string | none | At most 1000 characters |
paymentType | string | "pay_by_instalments" | pay_by_instalments, pay_now, pay_next_month or pay_in_full (case-insensitive) |
instalments | int | 4 | Whole number, at least 1 |
items | array | Required, at least one | See Item |
consumer | array | Required | See Consumer |
shippingAddress | array | Required | See Address. The API requires it |
billingAddress | array | none | See Address |
riskAssessment | array | none | See Risk assessment |
merchantUrl | array | none | See Return URLs |
merchantId and decisionFeatures are server-controlled. The SDK drops them from the body.
Item
| Field | Type | Required / default | Rules |
|---|---|---|---|
referenceId | string | Required | At most 255 characters |
name | string | Required | At most 500 characters |
type | string | Required | At most 50 characters, for example physical or digital |
sku | string | none | At most 255 characters |
quantity | int | Required | Whole number, at least 1 |
unitPrice | money | Required | |
totalAmount | money | Required | quantity × unitPrice − discountAmount |
taxAmount | money | 0 | |
discountAmount | money | 0 | Line discount |
imageUrl | string | none | At most 2048 characters |
Consumer
| Field | Type | Required | Rules |
|---|---|---|---|
firstName | string | Yes | At most 100 characters |
lastName | string | Yes | At most 100 characters |
phoneNumber | string | Yes | At most 20 characters, for example +9665XXXXXXXX |
email | string | No | At most 255 characters. Required on POS sessions |
nationalId | string | No | 10 digits starting with 1 or 2 |
Address (shippingAddress, billingAddress): firstName, lastName, line1, line2, city, region, postalCode, countryCode, phoneNumber, all strings. The SDK checks only that shippingAddress is a non-empty array; the API checks the fields.
Risk assessment (riskAssessment): customerDob (string), customerGender (string), isExistingCustomer (bool), orderIsDigital (bool), totalOrderCount (int), dateOfFirstTransaction (string).
Return URLs (merchantUrl): success, failure and optional cancel. Each URL must be absolute, at most 1024 characters, without whitespace or a #fragment. Allowed schemes: https; http only with localhost or 127.0.0.1; or a custom app scheme of at least three characters (myshop://qasimha/return). javascript, data, file, blob and vbscript are refused. success and failure must be given together. cancel falls back to failure on the platform. notification is reserved and refused. See Return URLs.
Checked before sending
The SDK validates the request and throws ValidationException without sending anything. Every failing field is listed in $e->errors, named the way the API names it (TotalAmount, Items[0].Quantity, Consumer.NationalId, MerchantUrl.Success). Use $e->fieldMessage('TotalAmount') to read one. A local refusal has errorCode VALIDATION_ERROR and httpStatus null.
Both basket identities are checked to the halala:
item.totalAmount = quantity × unitPrice − item.discountAmounttotalAmount = Σ item.totalAmount + taxAmount + shippingAmount − discountAmount
If your cart computes amounts with floats, pass them as strings ('1302.10') or round them to two decimals first. A value with a third decimal is refused, not rounded.
You can also build a validated request object first. It throws the same ValidationException:
use Qasimha\Request\CreateSessionRequest;
$request = CreateSessionRequest::fromArray($input); // validates now
$session = $qasimha->checkout()->createSession($request);Result
Qasimha\Model\Session:
| Property | Type | Meaning |
|---|---|---|
orderId | string | The Qasimha order id (installmentId in the API). Store it against your reference |
paymentUrl | ?string | The hosted checkout. Redirect the shopper here. Always present on 201 |
providerSessionId | ?string | Provider session id |
status | ?string | AwaitingCheckout on a new session |
raw | array | The decoded data object |
requestId | ?string | The API request id. Quote it to support |
replayed | bool | true when the API replayed a stored response for the same Idempotency-Key |
Outcomes
| API answer | What the SDK does |
|---|---|
201 SESSION_CREATED | Returns Session. The credit decision happens on the hosted page, so a first-time shopper is never refused here |
409 DUPLICATE_ORDER_REFERENCE on the first attempt | Throws DuplicateOrderReferenceException with existingOrderId and existingPaymentUrl (may be null). Send the shopper to the open checkout |
409 DUPLICATE_ORDER_REFERENCE on a retry | The earlier attempt timed out after the order was created. The SDK returns that order as the Session and logs it at info |
422 ORDER_DECLINED | Throws OrderDeclinedException with orderId. Only on deployments that still decide at creation |
400 | Throws ValidationException (VALIDATION_ERROR with field errors, or MERCHANT_REQUIRED) |
409 MERCHANT_SUSPENDED | Throws ConflictException |
422 IDEMPOTENCY_KEY_REUSED | Throws ApiException: you reused your own idempotencyKey with a different body |
The hosted checkout expires after two hours. See Checkout flows.
In-store (POS) and QR sessions
Two more calls create an order and return a hosted page. They take the same item shape and the same merchantUrl rules. Both need orders:write.
// In store: the customer opens the link on their phone. consumer.email is required.
$pos = $qasimha->checkout()->createPosSession([
'orderReferenceId' => 'POS-0001',
'totalAmount' => 899.00, // = Σ items[].totalAmount
'items' => [
['referenceId' => 'SKU-1', 'name' => 'Wireless Headphones', 'type' => 'physical',
'quantity' => 1, 'unitPrice' => 899.00, 'totalAmount' => 899.00],
],
'consumer' => ['firstName' => 'Noura', 'lastName' => 'Al-Shammari',
'phoneNumber' => '+9665XXXXXXXX', 'email' => '[email protected]'],
]);
$pos->checkoutUrl; // hosted page; $pos->status is AwaitingCheckout
// Scan to pay: render the URL as a QR code.
$qr = $qasimha->checkout()->createQrSession([
'orderReferenceId' => 'QR-0001',
'totalAmount' => 899.00,
'items' => [
['referenceId' => 'SKU-1', 'name' => 'Wireless Headphones', 'type' => 'physical',
'quantity' => 1, 'unitPrice' => 899.00, 'totalAmount' => 899.00],
],
]);
$qr->qrCodeUrl;| Call | HTTP | Request fields | Result |
|---|---|---|---|
createPosSession(array|CreatePosSessionRequest $request, array $options = []): PosSession | POST /api/v1/pay/pos/create | orderReferenceId, totalAmount, currencyCode (SAR), countryCode (SA), items, consumer (email required), merchantUrl | orderId, checkoutUrl, providerSessionId, status, raw, requestId, replayed |
createQrSession(array|CreateQrSessionRequest $request, array $options = []): QrSession | POST /api/v1/pay/qr/create | orderReferenceId, totalAmount, currencyCode (SAR), countryCode (SA), items, merchantUrl | orderId, qrCodeUrl, providerSessionId, status, raw, requestId, replayed |
- These requests have no tax, shipping or discount fields. The SDK checks
totalAmount = Σ items[].totalAmountand each item line, to the halala. - A declined QR order comes back from the API as
201with an emptyqrCodeUrlorstatusDeclined. The SDK turns that intoOrderDeclinedException(errorCodeORDER_DECLINED,orderIdset), the same as the session call. - Duplicates behave as for
createSession: an exception on the first attempt, the earlier order on a retry.
Confirm the order on the return route
The hosted checkout sends the shopper to your merchantUrl and appends three query parameters to it. Your own query string is kept.
| Parameter | Value |
|---|---|
orderId | The Qasimha order id |
orderReferenceId | Your reference |
status | confirmed on the success URL; declined, expired or failed on the failure URL; cancelled on the cancel URL |
These parameters are not signed. Anyone can type ?status=confirmed. Read the order before you mark it paid:
use Qasimha\Model\Enum\OrderStatus;
$orderId = (string) ($_GET['orderId'] ?? ''); // or the id you stored at creation
$order = $qasimha->orders()->get($orderId);
if ($order->orderReferenceId === $expectedReference && $order->status === OrderStatus::CONFIRMED) {
// Paid its first instalment: mark your order paid. Call fulfill() when you ship.
}If you only hold your own reference, use $qasimha->orders()->getByReference('ORDER-12345'). It returns the same Order, matches exactly (case-sensitive), and throws NotFoundException for an unknown reference. A shopper who cancels leaves the order AwaitingCheckout until it expires, or until you cancel it. See Return URLs.
Handle webhooks
Verify every delivery with the raw request body: the bytes as received, never a decoded and re-encoded array. Then confirm state with orders()->get() before you act.
use Qasimha\Exception\WebhookSignatureException;
use Qasimha\Webhook\EventType;
$rawBody = file_get_contents('php://input');
$headers = function_exists('getallheaders') ? getallheaders() : $_SERVER;
try {
$event = $qasimha->webhooks()->constructEvent($rawBody, $headers);
} catch (WebhookSignatureException $e) {
http_response_code(400); // $e->reason names the failure
exit;
}
if (alreadyProcessed($event->id)) { // retries repeat the event id
http_response_code(204);
exit;
}
if ($event->type !== EventType::PING) {
$order = $event->orderId !== null
? $qasimha->orders()->get($event->orderId)
: $qasimha->orders()->getByReference((string) $event->orderReferenceId);
// update your order from $order->status
}
http_response_code(204);Respond with a 2xx within 15 seconds. Do slow work in the background. A delivery without a 2xx is retried, with the same event id. See Receiving webhooks.
constructEvent and verifySignature
$qasimha->webhooks()->constructEvent(
string $rawBody,
array $headers,
?string $secret = null,
array $options = [], // ['tolerance' => 300, 'now' => time()]
): WebhookEvent;
$qasimha->webhooks()->verifySignature(
string $rawBody,
?string $signatureHeader,
?string $secret = null,
array $options = [], // ['timestampHeader' => ..., 'tolerance' => 300, 'now' => time()]
): void; // throws on failure| Parameter | Meaning |
|---|---|
$rawBody | The body exactly as received |
$headers | Request headers in any of these shapes: a framework map (any case, string or list values), getallheaders(), $_SERVER (HTTP_X_QASIMHA_SIGNATURE), or ['signature' => ..., 'timestamp' => ...] |
$secret | Wins over the client's webhookSecret. If neither is set: ConfigurationException |
tolerance | Seconds, default 300 (Webhooks::DEFAULT_TOLERANCE_SECONDS). Negative: ConfigurationException |
now | Unix seconds to compare against. Default time(). For tests |
timestampHeader | verifySignature() only: the X-Qasimha-Timestamp value |
To change the tolerance, pass null as the secret: constructEvent($rawBody, $headers, null, ['tolerance' => 600]).
constructEvent() also requires the body to be a JSON object; anything else throws ValidationException (field body).
Signature formats
The SDK verifies both formats with a constant-time comparison:
| Format | Sent to | Signed material | Tolerance |
|---|---|---|---|
t=<unix>,v1=<hex>[,v1=<hex>] | A registered webhook endpoint | HMAC-SHA256 of t + "." + rawBody with the endpoint secret | Always enforced on t |
sha256=<hex> | The older account-level URL set up by Qasimha operations | HMAC-SHA256 of the raw body | Enforced only when X-Qasimha-Timestamp is present |
During a secret rotation the header carries one v1 per secret for 24 hours, so the old and the new secret both verify. The sha256= body has no order id, so use getByReference() there. See Verifying webhook signatures.
WebhookSignatureException::$reason is one of:
| Constant | Value | Cause |
|---|---|---|
MISSING_HEADER | missing_header | No X-Qasimha-Signature. There is no unsigned path |
MALFORMED_HEADER | malformed_header | No t or no valid v1, a bad sha256= digest, or a non-numeric timestamp |
TIMESTAMP_OUTSIDE_TOLERANCE | timestamp_outside_tolerance | The timestamp is more than tolerance seconds from now |
SIGNATURE_MISMATCH | signature_mismatch | No signature matches the body and secret |
WebhookEvent
Qasimha\Model\WebhookEvent has public read-only properties. Each one reads the current body field first and falls back as shown.
| Property | Type | Source |
|---|---|---|
id | ?string | id, then eventId, then the X-Qasimha-Event-Id header |
type | ?string | type, then eventType, then the X-Qasimha-Event header |
orderId | ?string | orderId, then data.order.id. null on ping and on sha256= deliveries |
orderReferenceId | ?string | orderReferenceId, then data.order.orderReferenceId |
status | ?string | status, then data.order.status |
amount | ?float | amount, then data.order.totalAmount |
currency | ?string | currency, then data.order.currencyCode |
occurredAt | ?string | occurredAt, then createdAt |
deliveryAttempt | ?int | The X-Qasimha-Delivery-Attempt header |
raw | array | The decoded body |
Event types
Qasimha\Webhook\EventType holds the names as constants:
| Constant | Value |
|---|---|
ORDER_CONFIRMED | order.confirmed |
ORDER_DECLINED | order.declined |
ORDER_FULFILLED | order.fulfilled |
ORDER_PARTIALLY_FULFILLED | order.partially_fulfilled |
ORDER_REFUNDED | order.refunded |
ORDER_PARTIALLY_REFUNDED | order.partially_refunded |
ORDER_VOIDED | order.voided |
ORDER_CANCELLED | order.cancelled |
ORDER_EXPIRED | order.expired |
CHECKOUT_LINK_EXPIRED | checkout_link.expired |
PING | ping (test event, not a subscription) |
ORDER_COMPLETED | order.completed: accepted as a subscription alias of order.confirmed for one release |
EventType::ALL lists the ten subscribable events. EventType::normalize(string $name): ?string returns the canonical name or null. The older sha256= format also sends order.completed and order.reversed.
Framework integration
Laravel
Put the keys in config/services.php:
'qasimha' => [
'key' => env('QASIMHA_API_KEY'),
'webhook_secret' => env('QASIMHA_WEBHOOK_SECRET'),
'publishable_key' => env('QASIMHA_PUBLISHABLE_KEY'),
],Bind Client as a singleton in a service provider:
use Illuminate\Support\Facades\Log;
use Qasimha\Client;
use Qasimha\Config;
public function register(): void
{
$this->app->singleton(Client::class, function (): Client {
$secrets = array_filter([ // drops unset (null) and empty values
'webhookSecret' => config('services.qasimha.webhook_secret'),
'publishableKey' => config('services.qasimha.publishable_key'),
]);
return new Client(Config::fromApiKey((string) config('services.qasimha.key'), $secrets + [
'language' => 'ar',
'userAgentSuffix' => 'my-store/1.0.0',
'logger' => static function (string $level, string $message, array $fields): void {
Log::log($level === 'warn' ? 'warning' : $level, 'qasimha: ' . $message, $fields);
},
]));
});
}Then type-hint Qasimha\Client in a controller constructor or a route closure.
Exclude the webhook route from CSRF verification. Otherwise Laravel answers the delivery with 419.
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/qasimha',
]);
})protected $except = [
'webhooks/qasimha',
];Read the raw body with $request->getContent() and pass $request->headers->all(). The SDK accepts its lower-case names and list values:
use Illuminate\Http\Request;
use Qasimha\Client;
use Qasimha\Exception\WebhookSignatureException;
Route::post('/webhooks/qasimha', function (Request $request, Client $qasimha) {
try {
$event = $qasimha->webhooks()->constructEvent($request->getContent(), $request->headers->all());
} catch (WebhookSignatureException $e) {
return response('', 400);
}
// de-duplicate on $event->id, then queue the work
return response()->noContent();
});Never verify json_encode($request->all()) or $request->json(): re-encoding changes the bytes and the signature no longer matches.
Symfony
Register the config with fromApiKey as its factory and let autowiring build the client:
services:
_defaults:
autowire: true
autoconfigure: true
Qasimha\Config:
factory: ['Qasimha\Config', 'fromApiKey']
arguments:
- '%env(QASIMHA_API_KEY)%'
- { webhookSecret: '%env(QASIMHA_WEBHOOK_SECRET)%', language: 'ar' }
Qasimha\Client: ~Add publishableKey: '%env(QASIMHA_PUBLISHABLE_KEY)%' to the options only when the variable is set; an empty value is refused. To wire the logger, build the client in a small factory service instead:
namespace App\Qasimha;
use Psr\Log\LoggerInterface;
use Qasimha\Client;
final class QasimhaClientFactory
{
public function __construct(private readonly LoggerInterface $logger)
{
}
public function create(string $apiKey, string $webhookSecret): Client
{
return Client::create($apiKey, [
'webhookSecret' => $webhookSecret,
'logger' => function (string $level, string $message, array $fields): void {
$this->logger->log($level === 'warn' ? 'warning' : $level, 'qasimha: ' . $message, $fields);
},
]);
}
}services:
# replaces the Qasimha\Config and Qasimha\Client entries above
Qasimha\Client:
factory: ['@App\Qasimha\QasimhaClientFactory', 'create']
arguments: ['%env(QASIMHA_API_KEY)%', '%env(QASIMHA_WEBHOOK_SECRET)%']Symfony has no CSRF check on a plain controller. Read the raw body with $request->getContent():
namespace App\Controller;
use Qasimha\Client;
use Qasimha\Exception\WebhookSignatureException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; // Symfony 6.4+; older: Annotation\Route
final class QasimhaWebhookController
{
public function __construct(private readonly Client $qasimha)
{
}
#[Route('/webhooks/qasimha', methods: ['POST'])]
public function __invoke(Request $request): Response
{
try {
$event = $this->qasimha->webhooks()->constructEvent($request->getContent(), $request->headers->all());
} catch (WebhookSignatureException $e) {
return new Response('', 400);
}
// de-duplicate on $event->id, then dispatch the work (for example with Messenger)
return new Response('', 204);
}
}Plain PHP and WordPress
require __DIR__ . '/vendor/autoload.php';
$qasimha = Qasimha\Client::create((string) getenv('QASIMHA_API_KEY'), [
'webhookSecret' => getenv('QASIMHA_WEBHOOK_SECRET') ?: null,
]);
$rawBody = file_get_contents('php://input');
$headers = function_exists('getallheaders') ? getallheaders() : $_SERVER;
$event = $qasimha->webhooks()->constructEvent($rawBody, $headers);getallheaders() and $_SERVER both work: the SDK reads HTTP_X_QASIMHA_SIGNATURE style keys. In WordPress, read the keys from get_option() instead of the environment, and register the webhook with register_rest_route(); there, $request->get_body() is the raw body and $request->get_headers() the header map.
Fulfil, capture, refund and cancel
All four take the Qasimha order id, need the orders:write scope, and return a small typed result with raw, requestId and replayed. An empty id throws ConfigurationException before sending.
$orders = $qasimha->orders();
$orders->fulfill($orderId); // ship everything outstanding
$orders->fulfill($orderId, ['shippedAmount' => 1200.00, 'shippingCompany' => 'Aramex',
'trackingNumber' => 'ARX123', 'trackingUrl' => 'https://shop.example/track/ARX123']);
$orders->refund($orderId, ['amount' => 300.00, 'reason' => 'Customer return']);
$orders->cancel($orderId); // voids an unfulfilled order
$orders->capture($orderId); // New / Approved orders only| Method | Request | Result | Errors |
|---|---|---|---|
fulfill(string $id, array|FulfillRequest $request = [], array $options = []): FulfillResult | shippedAmount (money, optional: omit to fulfil everything outstanding), shippingCompany (≤ 200), trackingNumber (≤ 200), trackingUrl (≤ 2048). Other keys are not sent | id, status (PartiallyFulfilled or FullyFulfilled), fulfilledAmount, fulfilledAt | Local: negative amount. API: 400 INVALID_STATUS; 400 INVALID_SHIPPED_AMOUNT for 0; above the outstanding balance refused; 404 |
refund(string $id, array|RefundRequest $request, array $options = []): RefundResult | amount (money, required, greater than zero), reason (≤ 500) | id, status, refundedAmount | Local: zero or missing amount. API: 400 INVALID_STATUS, 400 INVALID_AMOUNT, 400 AMOUNT_EXCEEDED, 404 |
cancel(string $id, array $options = []): CancelResult | none | id, status (Voided) | 400 INVALID_STATUS once fulfilled, 404 |
capture(string $id, array $options = []): CaptureResult | none | id, status (Confirmed), confirmedAt | 400 INVALID_STATUS, 404 |
- An order that completes the hosted checkout is already
Confirmed. You don't capture it;capture()on it answers400 INVALID_STATUS. - The most you can refund is
fulfilledAmountminus what was already refunded. ReadrefundableAmounton the order. - Every
400becomesValidationException; read$e->errorCode. $optionstakesidempotencyKey. See Order lifecycle.
Read orders
These calls need the orders:read scope. An order of another merchant answers 404 (NotFoundException).
use Qasimha\Model\Enum\OrderStatus;
$order = $qasimha->orders()->get($orderId);
$order = $qasimha->orders()->getByReference('ORDER-12345'); // same Order
$page = $qasimha->orders()->list(['status' => OrderStatus::CONFIRMED, 'search' => 'Noura', 'page' => 1, 'pageSize' => 20]);
foreach ($page->items as $row) { // OrderSummary
echo $row->orderReferenceId, ' ', $row->status, ' ', $row->totalAmount, PHP_EOL;
}
$page->pagination->totalPages;
$counts = $qasimha->orders()->counts(); // $counts->all, ->approved, ->authorised, ->captured, ->refunded, ->cancelled
$contract = $qasimha->orders()->contract($orderId);
file_put_contents('/tmp/' . ($contract->fileName ?? 'contract.pdf'), $contract->bytes);| Method | HTTP | Returns |
|---|---|---|
get(string $id): Order | GET /api/v1/merchant/orders/{id} | Order |
getByReference(string $orderReferenceId): Order | GET /api/v1/merchant/orders/by-reference/{reference} | Order. Exact, case-sensitive match. Each /-separated segment is percent-encoded and / is kept (ORDER/2026/1) |
list(array $params = []): Page | GET /api/v1/merchant/orders | Page of OrderSummary |
counts(): OrderCounts | GET /api/v1/merchant/orders/counts | all, approved, authorised, captured, refunded, cancelled (ints) |
contract(string $id): Contract | GET /api/v1/merchant/orders/{id}/contract | bytes (the financing contract PDF), contentType, fileName, requestId |
list() parameters (all optional, omitted when null):
| Key | Default (API) | Meaning |
|---|---|---|
status | none | A status name (Confirmed, FullyFulfilled, Voided ...) or a tab alias: approved, authorised, captured, refunded, cancelled. Anything else: 400 INVALID_STATUS |
search | none | Matches your order reference, customer name, email or phone |
page | 1 | Page number |
pageSize | 20 | Rows per page |
Page has items, pagination (totalItems, page, pageSize, totalPages), raw, requestId and replayed. The list is newest first.
Models
Models are immutable objects with public read-only properties. Every model keeps the decoded response in raw; anything not listed here is reachable only through raw and is not part of the contract. Amounts are ?float; dates are ISO 8601 strings.
Order
id, orderReferenceId, status, paymentType, channel, checkoutChannel,
totalAmount, taxAmount, shippingAmount, discountAmount, currencyCode, countryCode, description,
instalments, downPaymentAmount, approvedAmount,
capturedAmount, fulfilledAmount, refundedAmount, refundableAmount, collectedAmount, voidedAmount, reversedAmount,
paymentUrl, providerSessionId, providerOrderId,
createdAt, confirmedAt, fulfilledAt, voidedAt, reversedAt, settledAt, checkoutExpiresAt, expiresAt,
ejarContractNumber, notificationLanguage, contractNumber,
customer ?Customer id, name, email, phone, nationalId (always null: the API does not return it)
items OrderItem[] referenceId, name, type, sku, quantity, unitPrice, totalAmount, taxAmount, discountAmount, imageUrl
installments Installment[] sequence, dueDate, amount, principal, profit, status, paidAmount, paidAt, providerRef
fulfillments Fulfillment[] id, amount, currencyCode, shippingCompany, trackingNumber, trackingUrl, providerCaptureId, createdAt
reversals Reversal[] id, amount, currencyCode, comment, fulfillmentId, providerRefundId, createdAt
voids OrderVoid[] id, amount, currencyCode, reason, providerCancelId, createdAt
statusHistory StatusChange[] fromStatus, toStatus, trigger, metadata, createdAt
raw, requestId, replayed
OrderSummary
id, orderReferenceId, status, policyCode, policyType, totalAmount, currencyCode, createdAt,
customerId, customerName, customerEmail, customerPhone, instalments, channel,
capturedAmount, refundedAmount, ejarContractNumber, raw| Amount | Meaning |
|---|---|
capturedAmount, collectedAmount | What the customer has paid so far |
fulfilledAmount | What you have shipped |
refundedAmount, reversedAmount | What has been refunded |
refundableAmount | What you can still refund |
voidedAmount | What was cancelled after confirmation |
Enums
Enum fields are always the name ("Confirmed"), whether the API sends a name or a number. An unknown value is passed through as a string, never thrown on. Compare against the constants:
| Class | Constants |
|---|---|
Qasimha\Model\Enum\OrderStatus | DRAFT Draft, NEW New, APPROVED Approved, DECLINED Declined, EXPIRED Expired, CONFIRMED Confirmed, FULLY_FULFILLED FullyFulfilled, PARTIALLY_FULFILLED PartiallyFulfilled, FULLY_REVERSED FullyReversed, PARTIALLY_REVERSED PartiallyReversed, VOIDED Voided, UPDATED Updated, AWAITING_CHECKOUT AwaitingCheckout, CANCELLED Cancelled, SETTLED Settled, WRITTEN_OFF WrittenOff |
Qasimha\Model\Enum\OrderChannel | ONLINE Online, IN_STORE InStore, QR_CODE QrCode |
Qasimha\Model\Enum\PaymentType | PAY_BY_INSTALMENTS PayByInstalments, PAY_NOW PayNow, PAY_NEXT_MONTH PayNextMonth, PAY_IN_FULL PayInFull. REQUEST_VALUES holds the request spellings (pay_by_instalments ...) |
Qasimha\Model\Enum\InstallmentItemStatus | SCHEDULED Scheduled, PAID Paid, LATE Late, REVERSED Reversed, WAIVED Waived |
Treat Confirmed, PartiallyFulfilled and FullyFulfilled as paid. See Order lifecycle.
Payment links and QR codes
For sales without a website checkout: in store, by WhatsApp, on social media. Qasimha creates the order up front (AwaitingCheckout) and hosts the checkout. Needs orders:write.
$links = $qasimha->checkoutLinks();
$link = $links->create([
'amount' => 250.00,
'channel' => 'Link', // default; the customer gets the link by SMS
'phone' => '+9665XXXXXXXX',
'language' => 'ar',
'orderReferenceId' => 'ORDER-12346',
]);
$link->url; // hosted page
$link->expiresAt; // two hours from now
$qr = $links->create(['amount' => 250.00, 'channel' => 'Qr']); // render $qr->url as a QR code
$links->get($link->id); // status, completed, declined, decisionStatus, expiresAt
$links->list(); // the 50 most recent, each with a waiting flag
$links->cancel($link->id);Create fields (create(array|CreateCheckoutLinkRequest $request, array $options = []): CheckoutLink, POST /api/v1/merchant/checkout-links):
| Field | Type | Required / default | Rules |
|---|---|---|---|
amount | money | Required | Greater than zero, at most two decimals |
channel | string | "Link" | "Link" or "Qr" (case-insensitive). Link sends the link by SMS, push and a call |
phone | string | Required for Link | At most 20 characters. The SDK refuses a Link without it |
language | string | none | "ar" or "en": the language of the SMS, the call and the hosted page. If omitted, Accept-Language decides, falling back to Arabic |
orderReferenceId | string | generated | At most 255 characters. Generated as LINK-yyyyMMdd-XXXXXX when omitted |
ejarContractNumber | string | none | At most 100 characters. Required for real-estate merchants, ignored for others |
items | array | none | Each: name (required, ≤ 500), sku (≤ 255), quantity (required, ≥ 1), unitPrice (required), imageUrl (≤ 2048) |
merchantUrl | array | none | Same rules as on a session |
CheckoutLink has id (always set), url, channel, amount, currency, phone (masked on reads), language, orderReferenceId, ejarContractNumber, items, status, decisionStatus, expiresAt, createdAt, completed, declined, waiting, raw, requestId, replayed. The create, get and list answers carry different subsets, so every member except id may be null.
| Method | HTTP | Errors |
|---|---|---|
create(...) | POST /api/v1/merchant/checkout-links | 400 INVALID_AMOUNT, PHONE_REQUIRED, EJAR_CONTRACT_NUMBER_REQUIRED, INVALID_EJAR_CONTRACT_NUMBER; 404 MERCHANT_NOT_FOUND; 409 MERCHANT_SUSPENDED, DUPLICATE_ORDER_REFERENCE |
list(): array (list of CheckoutLink) | GET /api/v1/merchant/checkout-links | |
get(string $id): CheckoutLink | GET /api/v1/merchant/checkout-links/{id} | 404 NOT_FOUND |
cancel(string $id, array $options = []): CheckoutLink | POST /api/v1/merchant/checkout-links/{id}/cancel | 409 NOT_OPEN (ConflictException) once the customer completed checkout or the link expired; 404 NOT_FOUND |
A duplicate reference on a retried create returns the earlier link, as on a session. The link is an order: once completed is true, read it with orders()->get($link->id) and fulfil it as usual.
Settlements
Needs the settlements:read scope; a key without it gets PermissionException.
$page = $qasimha->settlements()->list(['page' => 1, 'pageSize' => 20]);
foreach ($page->items as $settlement) {
// id, providerSettlementId, amount, currencyCode, settledAt, status, orderCount, raw
}
$overview = $qasimha->settlements()->overview();
// totalSettled, count, lastSettlement (array), settlementCycle, settlementModel,
// minimumSettlementAmount, remainingForNextSettlement, raw| Method | HTTP | Returns |
|---|---|---|
list(array $params = []): Page | GET /api/v1/merchant/settlements | Page of Settlement, newest first. page (default 1), pageSize (default 20) |
overview(): SettlementOverview | GET /api/v1/merchant/settlements/overview | Headline figures, including the balance not yet paid |
Settlement batch amounts are net of fees. The not-yet-paid balance in the overview is total minus refunds and voids, less the MDR fee and VAT on that fee.
Manage webhook endpoints
Needs a key with the webhooks:manage scope. You can also manage endpoints in the Merchant Portal under Settings → Webhooks.
use Qasimha\Webhook\EventType;
$endpoints = $qasimha->webhookEndpoints();
$created = $endpoints->create([
'url' => 'https://shop.example/webhooks/qasimha',
'description' => 'Production store',
'events' => [EventType::ORDER_CONFIRMED, EventType::ORDER_REFUNDED], // omit for every event
]);
storeSecret($created->secret); // whsec_..., returned once; this is your webhookSecret
$created->secretHint; // its last characters, to tell secrets apart later
$endpoints->list();
$endpoints->get($created->id);
$endpoints->update($created->id, ['isActive' => false]);
$endpoints->delete($created->id);
$rotated = $endpoints->rotateSecret($created->id); // new ->secret; old one valid until ->previousSecretExpiresAt
$ping = $endpoints->ping($created->id); // queues a signed "ping"; $ping->eventId
$page = $endpoints->listDeliveries($created->id, ['status' => 'Failed', 'pageSize' => 20]);
$endpoints->replayDelivery($created->id, $page->items[0]->id);| Method | HTTP | Returns |
|---|---|---|
create(array|CreateWebhookEndpointRequest $request, array $options = []): WebhookEndpointWithSecret | POST /api/v1/merchant/webhooks | The endpoint plus secret, shown once |
list(): array (list of WebhookEndpoint) | GET /api/v1/merchant/webhooks | Every endpoint, newest first. No secrets |
get(string $id): WebhookEndpoint | GET /api/v1/merchant/webhooks/{id} | |
update(string $id, array|UpdateWebhookEndpointRequest $request): WebhookEndpoint | PUT /api/v1/merchant/webhooks/{id} | Omitted fields keep their value |
delete(string $id): void | DELETE /api/v1/merchant/webhooks/{id} | Stops all deliveries; pending retries end as failed |
rotateSecret(string $id, array $options = []): WebhookEndpointWithSecret | POST /api/v1/merchant/webhooks/{id}/rotate-secret | New secret, shown once. For 24 hours deliveries are signed with both secrets |
ping(string $id, array $options = []): WebhookPing | POST /api/v1/merchant/webhooks/{id}/ping | eventId, endpointId. A signed ping event arrives at the URL |
listDeliveries(string $id, array $params = []): Page | GET /api/v1/merchant/webhooks/{id}/deliveries | Page of WebhookDelivery. status (Pending, Succeeded, Failed), page, pageSize (max 100) |
replayDelivery(string $id, string $deliveryId, array $options = []): WebhookDelivery | POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replay | The new delivery; replayOfId names the original |
Fields. create takes url (required), description, events. update takes url, description, events ([] means every event) and isActive (bool).
Checked before sending (ValidationException): url must be https:// with a host, without credentials, whitespace or a fragment, and at most 1024 characters (Url); description at most 200 characters; every events entry a known event name (Events[0] ...). Event names are lower-cased and de-duplicated, and order.completed becomes order.confirmed.
API refusals (400, ValidationException): URL_REQUIRED, URL_INVALID, URL_SCHEME, URL_PRIVATE (a private or internal address), URL_TOO_LONG, INVALID_EVENT, LIMIT_EXCEEDED (more than 5 endpoints per account).
Models.
WebhookEndpoint:id,url,description,events,isActive,secretHint,previousSecretExpiresAt,createdAt,updatedAt,raw,requestId,replayed.WebhookEndpointWithSecret: the same plussecret. The secret is masked invar_dump,print_randjson_encode, left out ofraw, never logged, andserialize()throws. Read->secretonce and store it in a secrets manager.WebhookDelivery:id,eventId,eventType,orderId,orderReferenceId,status,attempts,maxAttempts,nextAttemptAt,lastAttemptAt,lastStatusCode,lastError,responseSnippet(start of your server's answer),durationMs,succeededAt,replayOfId,createdAt,raw.WebhookPing:eventId,endpointId,raw.
Register separate endpoints in UAT and production. Each has its own secret.
Quote plans with the publishable key
checkout()->getPlans() returns the plans your policy offers for an amount: the same quote the on-site widget shows. It sends the publishable key in X-Public-Key and never the secret key.
$quote = $qasimha->checkout()->getPlans(1200.00, ['lang' => 'ar']);
if ($quote->plans === []) {
// outside [minAmount, maxAmount], or no active policy: show nothing
}
foreach ($quote->plans as $plan) {
// $plan->instalments, $plan->frequency, $plan->instalmentAmount,
// $plan->firstInstalmentAmount, $plan->lastInstalmentAmount,
// $plan->feesIncludingVat, $plan->totalPayable, $plan->shariaCompliant
}
$qasimha->checkout()->getPlans('1200.00', ['publishableKey' => 'pk_test_...']); // key per callgetPlans(float|int|string $amount, array $options = []): Plans calls GET /api/v1/widget/plans?amount=...¤cy=SAR&lang=....
| Option | Default | Meaning |
|---|---|---|
lang | the client language | "ar" or "en"; anything else throws ConfigurationException |
publishableKey | the client option | Overrides the configured key. Same format and environment rules |
Plans has merchantName, currency, minAmount, maxAmount, plans (list of Plan), raw, requestId, replayed. Plan has instalments, frequency, instalmentAmount, firstInstalmentAmount, lastInstalmentAmount, feesIncludingVat, totalPayable, shariaCompliant, raw.
- Without a publishable key the call throws
ConfigurationExceptionbefore sending. - A zero, negative or sub-halala amount throws
ValidationException(fieldAmount) before sending. - An unknown key is
AuthenticationException(401 PUBLIC_KEY_REQUIRED/INVALID_PUBLIC_KEY); a refused amount isValidationException(400 INVALID_AMOUNT). - An empty
planslist is a normal answer, not an exception. - The platform caches a quote for one minute. Read it per page render, not per visitor. It is retried like every
GET.
See Plans widget.
Method reference
$options on every POST method takes idempotencyKey (string|false). Money is float|int|string.
| Method | HTTP | Returns | Notable errors |
|---|---|---|---|
checkout()->createSession(array|CreateSessionRequest $request, array $options = []) | POST /api/v1/pay/session/create | Session | ValidationException, DuplicateOrderReferenceException, OrderDeclinedException, ConflictException (MERCHANT_SUSPENDED) |
checkout()->createPosSession(array|CreatePosSessionRequest $request, array $options = []) | POST /api/v1/pay/pos/create | PosSession | ValidationException, DuplicateOrderReferenceException, ConflictException |
checkout()->createQrSession(array|CreateQrSessionRequest $request, array $options = []) | POST /api/v1/pay/qr/create | QrSession | OrderDeclinedException on a declined 201, ValidationException, DuplicateOrderReferenceException |
checkout()->getPlans(float|int|string $amount, array $options = []) | GET /api/v1/widget/plans | Plans | ConfigurationException (no publishable key), AuthenticationException, ValidationException |
orders()->list(array $params = []) | GET /api/v1/merchant/orders | Page of OrderSummary | ValidationException (INVALID_STATUS), PermissionException |
orders()->get(string $id) | GET /api/v1/merchant/orders/{id} | Order | NotFoundException |
orders()->getByReference(string $orderReferenceId) | GET /api/v1/merchant/orders/by-reference/{orderReferenceId} | Order | NotFoundException |
orders()->capture(string $id, array $options = []) | POST /api/v1/merchant/orders/{id}/capture | CaptureResult | ValidationException (INVALID_STATUS) |
orders()->fulfill(string $id, array|FulfillRequest $request = [], array $options = []) | POST /api/v1/merchant/orders/{id}/fulfill | FulfillResult | ValidationException (INVALID_STATUS, INVALID_SHIPPED_AMOUNT) |
orders()->refund(string $id, array|RefundRequest $request, array $options = []) | POST /api/v1/merchant/orders/{id}/refund | RefundResult | ValidationException (INVALID_STATUS, INVALID_AMOUNT, AMOUNT_EXCEEDED) |
orders()->cancel(string $id, array $options = []) | POST /api/v1/merchant/orders/{id}/cancel | CancelResult | ValidationException (INVALID_STATUS) |
orders()->counts() | GET /api/v1/merchant/orders/counts | OrderCounts | |
orders()->contract(string $id) | GET /api/v1/merchant/orders/{id}/contract | Contract | NotFoundException |
checkoutLinks()->create(array|CreateCheckoutLinkRequest $request, array $options = []) | POST /api/v1/merchant/checkout-links | CheckoutLink | ValidationException (PHONE_REQUIRED ...), ConflictException |
checkoutLinks()->list() | GET /api/v1/merchant/checkout-links | list of CheckoutLink | |
checkoutLinks()->get(string $id) | GET /api/v1/merchant/checkout-links/{id} | CheckoutLink | NotFoundException |
checkoutLinks()->cancel(string $id, array $options = []) | POST /api/v1/merchant/checkout-links/{id}/cancel | CheckoutLink | ConflictException (NOT_OPEN) |
settlements()->list(array $params = []) | GET /api/v1/merchant/settlements | Page of Settlement | PermissionException |
settlements()->overview() | GET /api/v1/merchant/settlements/overview | SettlementOverview | PermissionException |
webhookEndpoints()->create(array|CreateWebhookEndpointRequest $request, array $options = []) | POST /api/v1/merchant/webhooks | WebhookEndpointWithSecret | ValidationException (URL_PRIVATE, LIMIT_EXCEEDED ...), PermissionException |
webhookEndpoints()->list() | GET /api/v1/merchant/webhooks | list of WebhookEndpoint | PermissionException |
webhookEndpoints()->get(string $id) | GET /api/v1/merchant/webhooks/{id} | WebhookEndpoint | NotFoundException |
webhookEndpoints()->update(string $id, array|UpdateWebhookEndpointRequest $request) | PUT /api/v1/merchant/webhooks/{id} | WebhookEndpoint | ValidationException, NotFoundException |
webhookEndpoints()->delete(string $id) | DELETE /api/v1/merchant/webhooks/{id} | void | NotFoundException |
webhookEndpoints()->rotateSecret(string $id, array $options = []) | POST /api/v1/merchant/webhooks/{id}/rotate-secret | WebhookEndpointWithSecret | NotFoundException |
webhookEndpoints()->ping(string $id, array $options = []) | POST /api/v1/merchant/webhooks/{id}/ping | WebhookPing | NotFoundException |
webhookEndpoints()->listDeliveries(string $id, array $params = []) | GET /api/v1/merchant/webhooks/{id}/deliveries | Page of WebhookDelivery | ValidationException, NotFoundException |
webhookEndpoints()->replayDelivery(string $id, string $deliveryId, array $options = []) | POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replay | WebhookDelivery | NotFoundException |
webhooks()->constructEvent(string $rawBody, array $headers, ?string $secret = null, array $options = []) | none (local) | WebhookEvent | WebhookSignatureException, ConfigurationException, ValidationException |
webhooks()->verifySignature(string $rawBody, ?string $signatureHeader, ?string $secret = null, array $options = []) | none (local) | void | WebhookSignatureException, ConfigurationException |
Any call can also throw AuthenticationException, RateLimitException, ServerException, ApiException or ConnectionException. An empty id throws ConfigurationException before sending.
Errors
Every failure is a Qasimha\Exception\QasimhaException, which extends RuntimeException. Catch it to handle every SDK error in one place.
| Member | Type | Meaning |
|---|---|---|
getMessage() | string | The API message, in the configured language, or a fixed message when the body is empty |
errorCode | ?string | The API code (INVALID_STATUS ...); VALIDATION_ERROR for a local refusal; null when there is none |
httpStatus | ?int | The HTTP status; null for local refusals and transport failures |
requestId | ?string | The API request id (envelope uuid, else X-Request-Id). Quote it to support |
errors | list<FieldError> | Field errors, each with field and message. Field names are PascalCase, as the API sends them |
envelope | ?array | The decoded error body |
data() | ?array | The envelope's data |
getPrevious() | ?Throwable | The transport exception on a ConnectionException |
The SDK picks the class by HTTP status, then by code:
| Exception | Extends | When | Extra members |
|---|---|---|---|
ConfigurationException | QasimhaException | Bad options, missing key or secret, empty id, bad idempotencyKey. Thrown before sending | |
ValidationException | QasimhaException | Refused locally, or any 400 (VALIDATION_ERROR, INVALID_STATUS, INVALID_AMOUNT, AMOUNT_EXCEEDED, PHONE_REQUIRED, URL_PRIVATE, LIMIT_EXCEEDED ...) | fieldMessage(string $field): ?string |
AuthenticationException | QasimhaException | 401: missing, invalid or revoked key (the body is empty); PUBLIC_KEY_REQUIRED / INVALID_PUBLIC_KEY on getPlans | |
PermissionException | QasimhaException | 403: the key lacks the scope (orders:read, orders:write, settlements:read, webhooks:manage) | |
NotFoundException | QasimhaException | 404: not found, or another merchant's | |
DuplicateOrderReferenceException | ConflictException | 409 DUPLICATE_ORDER_REFERENCE on the first attempt | existingOrderId, existingPaymentUrl, attempt |
ConflictException | QasimhaException | Other 409: MERCHANT_SUSPENDED, NOT_OPEN, INVALID_STATE, or REQUEST_IN_PROGRESS after the retries | |
OrderDeclinedException | QasimhaException | ORDER_DECLINED (422), or a declined QR session | orderId |
RateLimitException | QasimhaException | 429 after the retries | retryAfter (?int seconds) |
ServerException | ApiException | 5xx | |
ApiException | QasimhaException | Anything else: a 3xx, a non-JSON body, a 2xx without the expected data, 422 IDEMPOTENCY_KEY_REUSED | |
ConnectionException | QasimhaException | DNS, TLS, socket or timeout failure | curlErrorNumber (?int, built-in transport) |
WebhookSignatureException | QasimhaException | Webhook verification failed | reason |
A 2xx answer with success: false in the body is mapped the same way, by its code. See Errors and rate limits.
use Qasimha\Exception\QasimhaException;
use Qasimha\Exception\ValidationException;
try {
$qasimha->orders()->refund($orderId, ['amount' => 300.00]);
} catch (ValidationException $e) {
if ($e->errorCode === 'AMOUNT_EXCEEDED') {
// more than refundableAmount
}
} catch (QasimhaException $e) {
error_log(sprintf('Qasimha %s %s request %s', $e->httpStatus ?? '-', $e->errorCode ?? '-', $e->requestId ?? '-'));
throw $e;
}Retries, timeouts and idempotency
- Timeout.
timeout(default30000ms) applies to each attempt, connect and read included, on the built-in transport. There is no per-call timeout. - What retries. Connection failures,
429,502,503,504, and409 REQUEST_IN_PROGRESS, up tomaxRetries(default2, so at most 3 attempts). This applies to everyGET,PUTandDELETE, and to everyPOSTthat carries anIdempotency-Key. Other4xxand500are never retried; a500on a write may have committed. - Backoff. Before retry n the SDK waits
min(2^n × 500 ms, 8 s)plus 0–250 ms of jitter: about 1 s, then 2 s. - Retry-After. On a
429withRetry-After(seconds or an HTTP date), the SDK waits that long instead, capped at 30 seconds.RateLimitException::$retryAftercarries the value when the retries run out. - Idempotency-Key. Every
POSTcarries a fresh UUID v4Idempotency-Key, reused across that call's retries. A retried create either replays the stored answer or gets409 REQUEST_IN_PROGRESS, which is retried too. - Your own key. Pass
['idempotencyKey' => 'order-12345-refund-1']to keep one key across your own retries (for example after a crash). It must be non-empty printable ASCII without spaces, otherwiseConfigurationException. Sending the same key with a different body answers422 IDEMPOTENCY_KEY_REUSED. - No key.
['idempotencyKey' => false]sends no key and turns retries off for thatPOST. - Duplicates.
409 DUPLICATE_ORDER_REFERENCEon a retry ofcreateSession,createPosSession,createQrSessionorcheckoutLinks()->createis the earlier attempt that succeeded. The SDK returns it as the result. - Results. Every result has
requestIdandreplayed(truewhen the response carriedIdempotent-Replay: true). - Correlation. Each call sends a UUID v4
X-Correlation-Id, the same on each attempt.
The only per-call request option is idempotencyKey, on these methods: createSession, createPosSession, createQrSession, orders()->capture, fulfill, refund, cancel, checkoutLinks()->create, cancel, and webhookEndpoints()->create, rotateSecret, ping, replayDelivery. getPlans() has its own options (lang, publishableKey). See Idempotency.
Logging and security
Pass a callback. There is no logging-framework dependency, and nothing is logged by default.
'logger' => function (string $level, string $message, array $fields): void {
// The SDK uses "info" and "warn". PSR-3 loggers (Monolog, Laravel, Symfony) call it "warning".
$psrLogger->log($level === 'warn' ? 'warning' : $level, 'qasimha: ' . $message, $fields);
},| Message | Level | Fields |
|---|---|---|
request completed | info on 2xx, warn otherwise | method, path, status, durationMs, requestId, attempt, code, replayed |
retrying request | warn | method, path, status, requestId, attempt, code, delayMs |
request failed to reach the API | warn | method, path, status (null), durationMs, requestId (null), attempt, code (null), error |
duplicate order reference on retry treated as the earlier success | info | method, path, status, requestId, attempt, code |
path is the API path after /api/v1, without the query string. attempt starts at 1.
- The SDK never logs the API key, the publishable key, the webhook secret, query strings, request bodies or customer data.
$client->maskedKey()and$config->maskedPublishableKey()givesk_live_…AbCd/pk_live_…AbCdfor your own logs.var_dumpandprint_rshow the same masked forms,json_encodeleaves keys out, and the client and config cannot be serialised.- Keep the secret key and webhook secret in a secrets manager or environment variable, never in the code base. Never send them to a browser.
- TLS only. Certificate checks use PHP's defaults and cannot be turned off. There is no certificate pinning.
- The built-in transport never follows redirects; a
3xxis anApiException. Keep redirects off on an injected PSR-18 client too. - The built-in transport honours the standard
https_proxyenvironment variable through curl. There is no proxy option; configure a proxy on your PSR-18 client if you inject one. - Requests carry
User-Agent: qasimha-sdk-php/0.1.0 (...), so adduserAgentSuffixto identify your plugin. - Customers pay on the hosted checkout, so no card data passes through your server.
Complete example
A Laravel store: the shopper pays for an order, comes back to a return route that confirms it, webhooks keep it in sync, and staff endpoints ship, refund or cancel. It assumes an Order Eloquent model with reference, qasimha_order_id (nullable) and status columns, the config/services.php entry and the singleton binding from Framework integration, and webhooks/qasimha excluded from CSRF.
use App\Http\Controllers\QasimhaController;
use Illuminate\Support\Facades\Route;
Route::post('/checkout/qasimha/{reference}', [QasimhaController::class, 'checkout'])->name('qasimha.checkout');
Route::get('/checkout/qasimha/return', [QasimhaController::class, 'handleReturn'])->name('qasimha.return');
Route::post('/webhooks/qasimha', [QasimhaController::class, 'webhook']);
Route::middleware('auth')->prefix('admin/orders/{reference}')->group(function () {
Route::post('ship', [QasimhaController::class, 'ship']);
Route::post('refund', [QasimhaController::class, 'refund']);
Route::post('cancel', [QasimhaController::class, 'cancel']);
});<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Qasimha\Client;
use Qasimha\Exception\DuplicateOrderReferenceException;
use Qasimha\Exception\OrderDeclinedException;
use Qasimha\Exception\QasimhaException;
use Qasimha\Exception\ValidationException;
use Qasimha\Exception\WebhookSignatureException;
use Qasimha\Model\Enum\OrderStatus;
use Qasimha\Webhook\EventType;
class QasimhaController extends Controller
{
private const PAID = [OrderStatus::CONFIRMED, OrderStatus::PARTIALLY_FULFILLED, OrderStatus::FULLY_FULFILLED];
public function __construct(private readonly Client $qasimha)
{
}
// 1. Create the session and send the shopper to the hosted checkout.
public function checkout(string $reference): RedirectResponse
{
$order = Order::where('reference', $reference)->firstOrFail();
$returnUrl = route('qasimha.return', ['order' => $order->reference]);
try {
$session = $this->qasimha->checkout()->createSession([
'orderReferenceId' => $order->reference,
// Build these from your cart. Strings avoid float rounding.
'totalAmount' => '1302.10', // 1154.00 + 173.10 + 25.00 − 50.00
'taxAmount' => '173.10',
'shippingAmount' => '25.00',
'discountAmount' => '50.00',
'items' => [
['referenceId' => 'SKU-1', 'name' => 'Wireless Headphones', 'type' => 'physical',
'quantity' => 1, 'unitPrice' => '899.00', 'totalAmount' => '899.00'],
['referenceId' => 'SKU-2', 'name' => 'USB-C Cable', 'type' => 'physical',
'quantity' => 3, 'unitPrice' => '45.00', 'totalAmount' => '135.00'],
['referenceId' => 'SKU-3', 'name' => 'Extended Warranty', 'type' => 'digital',
'quantity' => 1, 'unitPrice' => '120.00', 'totalAmount' => '120.00'],
],
'consumer' => ['firstName' => 'Noura', 'lastName' => 'Al-Shammari',
'phoneNumber' => '+9665XXXXXXXX', 'email' => '[email protected]'],
'shippingAddress' => ['firstName' => 'Noura', 'lastName' => 'Al-Shammari',
'line1' => 'King Fahd Rd', 'city' => 'Riyadh', 'countryCode' => 'SA'],
'merchantUrl' => ['success' => $returnUrl, 'failure' => $returnUrl, 'cancel' => url('/cart')],
]);
} catch (DuplicateOrderReferenceException $e) {
if ($e->existingPaymentUrl !== null) {
return redirect()->away($e->existingPaymentUrl); // checkout already open
}
return redirect('/cart')->withErrors(['payment' => 'This order already has a Qasimha checkout.']);
} catch (OrderDeclinedException) {
return redirect('/cart')->withErrors(['payment' => 'Please choose another payment method.']);
} catch (ValidationException $e) {
Log::error('QASIMHA_SESSION_REFUSED', [
'reference' => $order->reference,
'code' => $e->errorCode,
'fields' => array_map(fn ($f) => $f->field . ': ' . $f->message, $e->errors),
'requestId' => $e->requestId,
]);
return redirect('/cart')->withErrors(['payment' => 'Qasimha is unavailable for this basket.']);
}
$order->update(['qasimha_order_id' => $session->orderId, 'status' => 'awaiting_payment']);
return redirect()->away((string) $session->paymentUrl);
}
// 2. The shopper comes back. The query string is not proof: read the order.
public function handleReturn(Request $request): RedirectResponse
{
$order = Order::where('reference', (string) $request->query('order'))
->whereNotNull('qasimha_order_id')
->firstOrFail();
$remote = $this->qasimha->orders()->get($order->qasimha_order_id);
if ($remote->orderReferenceId === $order->reference && in_array($remote->status, self::PAID, true)) {
$order->update(['status' => 'paid']);
return redirect('/orders/' . $order->reference);
}
return redirect('/cart')->withErrors(['payment' => 'The payment was not completed.']);
}
// 3. Webhooks: verify the raw body, answer fast, de-duplicate, then read the order.
public function webhook(Request $request): Response
{
try {
$event = $this->qasimha->webhooks()->constructEvent($request->getContent(), $request->headers->all());
} catch (WebhookSignatureException $e) {
Log::warning('QASIMHA_WEBHOOK_REJECTED', ['reason' => $e->reason]);
return response('', 400);
}
if ($event->type === EventType::PING || $event->id === null) {
return response()->noContent();
}
if (!Cache::add('qasimha:event:' . $event->id, true, now()->addDays(3))) {
return response()->noContent(); // a retry of an event already handled
}
$orderId = $event->orderId;
$reference = $event->orderReferenceId;
dispatch(static function () use ($orderId, $reference): void {
$qasimha = app(Client::class);
$remote = $orderId !== null
? $qasimha->orders()->get($orderId)
: $qasimha->orders()->getByReference((string) $reference);
$local = Order::where('reference', $remote->orderReferenceId)->first();
if ($local !== null && $local->status !== 'paid' && in_array($remote->status, self::PAID, true)) {
$local->update(['status' => 'paid']);
}
if ($local !== null && in_array($remote->status, [OrderStatus::DECLINED, OrderStatus::EXPIRED], true)) {
$local->update(['status' => 'payment_failed']);
}
})->afterResponse();
return response()->noContent();
}
// 4. Staff actions.
public function ship(Request $request, string $reference): JsonResponse
{
$options = array_filter([
'shippedAmount' => $request->input('amount'), // omit to ship everything outstanding
'shippingCompany' => $request->input('carrier'),
'trackingNumber' => $request->input('tracking'),
], fn ($value) => $value !== null && $value !== '');
return $this->act(fn (string $id) => $this->qasimha->orders()->fulfill($id, $options), $reference);
}
public function refund(Request $request, string $reference): JsonResponse
{
$body = ['amount' => $request->input('amount'), 'reason' => $request->input('reason')];
return $this->act(fn (string $id) => $this->qasimha->orders()->refund($id, array_filter($body)), $reference);
}
public function cancel(string $reference): JsonResponse
{
return $this->act(fn (string $id) => $this->qasimha->orders()->cancel($id), $reference);
}
private function act(callable $call, string $reference): JsonResponse
{
$order = Order::where('reference', $reference)->whereNotNull('qasimha_order_id')->firstOrFail();
try {
$result = $call($order->qasimha_order_id);
} catch (ValidationException $e) { // INVALID_STATUS, AMOUNT_EXCEEDED, local checks ...
return response()->json(['error' => $e->errorCode, 'message' => $e->getMessage()], 422);
} catch (QasimhaException $e) {
return response()->json(['error' => $e->errorCode, 'requestId' => $e->requestId], 502);
}
Log::info('QASIMHA_ORDER_ACTION', ['reference' => $reference, 'status' => $result->status,
'requestId' => $result->requestId, 'actor' => auth()->id()]);
return response()->json(['status' => $result->status]);
}
}The webhook work runs after the response is sent (afterResponse()), so Qasimha gets its 204 at once. On a busy store, move it to a queued job.
Test in UAT
-
In the UAT Merchant Portal (
https://portal-uat.qasimha.com), open Settings → API Keys and create ask_test_key (and apk_test_key for plan quotes). The SDK sendssk_test_keys tohttps://api-uat.qasimha.comon its own. -
Register a webhook endpoint for UAT, in the portal under Settings → Webhooks or with code, and keep its secret as
QASIMHA_WEBHOOK_SECRET:$created = $qasimha->webhookEndpoints()->create(['url' => 'https://staging.shop.example/webhooks/qasimha']); echo $created->secret; // shown onceThe URL must be public HTTPS; private addresses are refused (
URL_PRIVATE). To receive webhooks on a laptop, expose it through an HTTPS tunnel. -
Send a test event and check that your verification passes:
$ping = $qasimha->webhookEndpoints()->ping($created->id); $deliveries = $qasimha->webhookEndpoints()->listDeliveries($created->id, ['pageSize' => 5]); foreach ($deliveries->items as $delivery) { echo $delivery->eventType, ' ', $delivery->status, ' ', $delivery->lastStatusCode, PHP_EOL; } -
Create a session, open
paymentUrland complete the hosted checkout. Return URLs may usehttp://localhostwhile you develop. -
Read the order back with
orders()->get()orgetByReference(), then tryfulfill(),refund()andcancel()on test orders.
UAT is for test data only. Never send real customer names, phone numbers or national IDs there.
Go-live checklist
- Swap
QASIMHA_API_KEY(andQASIMHA_PUBLISHABLE_KEY) for thesk_live_/pk_live_keys fromhttps://portal.qasimha.com. No code change: the SDK switches tohttps://api.qasimha.com. - Remove any
baseUrlorenvironmentoverride. - Register the production webhook endpoint and set its
whsec_secret asQASIMHA_WEBHOOK_SECRET. UAT secrets don't verify production deliveries. - Point
merchantUrlat your production site overhttps. - Mark orders paid only after
orders()->get()returnsConfirmed(or a fulfilled status), never from the return query string or the webhook body alone. - De-duplicate webhooks on the event id and answer within 15 seconds.
- Keep the default retries and automatic
Idempotency-Key. - Log
requestIdon errors, and make sure your logs only ever show masked keys. - Give each key only the scopes it needs.
Troubleshooting
ConfigurationException when the client is created
apiKey is required and must look like sk_test_… or sk_live_…: the variable is unset (getenv()returnedfalse, cast to""), has a trailing newline or quotes, or holds apk_key.environment "..." contradicts the API key prefix: removeenvironment, or use the key of that environment.baseUrl must use https://: removebaseUrl. It is only for a local test server.Unknown option "...": check the spelling. The options are camelCase (webhookSecret, notwebhook_secret).webhookSecret must not be empty when given/publishableKey must look like ...: passnull(or leave the key out) instead of an empty string.
WebhookSignatureException: signature_mismatch
- The body was re-encoded. Pass
$request->getContent()orfile_get_contents('php://input'), neverjson_encode($request->all()). - Wrong secret. Each endpoint has its own secret, and UAT and production differ. Compare the end of your secret with
secretHintfromwebhookEndpoints()->list(). - After a rotation, deploy the new secret within 24 hours.
WebhookSignatureException: missing_header or timestamp_outside_tolerance
missing_header: you passed the wrong array (for example$request->all()), or a proxy stripsX-Qasimha-*headers. Pass$request->headers->all(),getallheaders()or$_SERVER.timestamp_outside_tolerance: your server clock is more than 300 seconds off. Sync it with NTP. Raise the tolerance only as a stopgap:constructEvent($raw, $headers, null, ['tolerance' => 600]).
Laravel answers the webhook with 419
CSRF verification rejected the delivery. Add webhooks/qasimha to validateCsrfTokens(except: [...]) in bootstrap/app.php (Laravel 11+) or to $except in VerifyCsrfToken (Laravel 10).
AuthenticationException (401) with an empty message body
A 401 has no body, so the SDK uses its own message. The key is wrong, revoked, or belongs to the other environment. A sk_test_ key only works on UAT. Check $qasimha->maskedKey() against the key list in the portal.
PermissionException (403)
The key lacks the scope. Settlements need settlements:read; webhook endpoints need webhooks:manage; reads need orders:read; creates and order actions need orders:write. Create a key with the scope in Settings → API Keys.
DuplicateOrderReferenceException (409 DUPLICATE_ORDER_REFERENCE)
The orderReferenceId already has an order. If existingPaymentUrl is set, send the shopper there. To start a fresh checkout for the same cart, use a new reference (for example ORDER-12345-2).
ValidationException 400 INVALID_STATUS on capture()
A hosted-checkout order is already Confirmed, so there is nothing to capture. Call fulfill() when you ship. INVALID_STATUS on cancel() means the order is already fulfilled: use refund().
ValidationException before sending: TotalAmount or Items[n].TotalAmount
The basket does not add up to the halala. The message shows the expected and the received amount. Check item discounts (quantity × unitPrice − discountAmount) and the order-level taxAmount, shippingAmount and discountAmount. Pass amounts as two-decimal strings if your cart uses floats. httpStatus is null because nothing was sent.
getPlans() throws ConfigurationException
No publishable key. Set the publishableKey option or pass ['publishableKey' => 'pk_test_...']. The key must match the secret key's environment.
Monolog or Laravel throws on the log level warn
The SDK calls the logger with info and warn. PSR-3 loggers name the second level warning. Map it in your callback: $level === 'warn' ? 'warning' : $level.
ApiException with a 3xx status or a non-JSON body
Something between you and the API answered instead of the API: a proxy, a captive portal, or a PSR-18 client that follows redirects. Turn redirects off (allow_redirects => false in Guzzle, max_redirects => 0 in Symfony HttpClient) and check proxy settings.
ConnectionException or timeouts with Guzzle or Symfony HttpClient
The timeout option is not applied to an injected PSR-18 client. Set timeout (and connect_timeout) on the client itself. On the built-in transport, curlErrorNumber holds the curl error code.
ApiException 422 IDEMPOTENCY_KEY_REUSED
You passed your own idempotencyKey and reused it with a different body. Use one key per logical operation, or leave the option out so the SDK generates one.
Next steps
Events, payload, headers and delivery retries.
How the signature is built and how rotation works.
Statuses and which operation applies when.
What the hosted checkout appends to your URL.
Error codes and which ones to retry.
Compare the four SDKs and run the demo backend.
Updated 41 minutes ago
