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

ItemRequirement
RuntimePHP 8.1 or later with ext-curl, ext-json and ext-mbstring
Dependenciespsr/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
OptionalGuzzle 7 (guzzlehttp/guzzle) or Symfony HttpClient (symfony/http-client) as a PSR-18 transport
KeysA 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 releaseReturn URLs, per-merchant webhook endpoints, getByReference and getPlans need the merchant API release of 2026-09-25 or later

Install

composer require qasimha/php-sdk
require __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 "...").

OptionTypeDefaultMeaning and validation
environmentQasimha\Environment or stringfrom the key prefix"sandbox" or "production" (case-insensitive). A value that contradicts the key prefix is refused
baseUrlstringper environmentOverride for a test server you run yourself. Absolute, https:// (http:// only for localhost / 127.0.0.1), no query string or fragment
timeoutint30000Milliseconds per attempt, connect and read. Must be greater than zero. Not applied to an injected PSR-18 client
maxRetriesint20 to 5. Retries after the first attempt
languagestring"en""en" or "ar" (case-insensitive). Sent as Accept-Language, so API messages come back in that language. Also the default lang of getPlans()
webhookSecretstringnoneUsed by webhooks()->constructEvent() and verifySignature() unless a secret is passed per call. Must not be empty
publishableKeystringnonepk_test_... / pk_live_..., letters and digits after the prefix. Used by checkout()->getPlans(). Must belong to the same environment as the API key
userAgentSuffixstringnoneAppended to the User-Agent, for example woocommerce/1.2.0. Non-printable characters are removed
loggercallable(string, string, array): voidnoneReceives $level, $message, $fields. See Logging and security
httpClientPsr\Http\Client\ClientInterfacebuilt-in curlA PSR-18 client (Guzzle 7, Symfony Psr18Client). Keep redirects off on it
requestFactoryPsr\Http\Message\RequestFactoryInterfacefrom httpClientPSR-17 request factory for the PSR-18 client. Taken from httpClient when it implements the interface
streamFactoryPsr\Http\Message\StreamFactoryInterfacefrom httpClientPSR-17 stream factory for the PSR-18 client. Taken from httpClient when it implements the interface
transportQasimha\Http\TransportInterfacenoneYour own transport. Wins over httpClient
sleepercallable(int): voidusleepCalled 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 prefixEnvironmentBase URL
sk_test_sandboxhttps://api-uat.qasimha.com
sk_live_productionhttps://api.qasimha.com
  • environment is optional. If you set it and it contradicts the key (sk_live_ with "sandbox"), construction fails.
  • baseUrl skips that check, but still requires TLS. Leave it unset in UAT and production.
  • A publishable key of the other environment (sk_test_ with pk_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

MemberReturns
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->environmentEnvironment::Sandbox or Environment::Production (->value is "sandbox" / "production")
$config->baseUrl, timeoutMs, maxRetries, language, userAgentSuffix, transportPublic 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:

CauseMessage starts with
Key missing or malformed (whitespace, newline, other prefix)apiKey is required and must look like sk_test_… or sk_live_…
Unknown option keyUnknown option
environment not sandbox / productionenvironment must be "sandbox" or "production"
environment contradicts the keyenvironment "..." contradicts the API key prefix
baseUrl not absolute, not TLS, or with a query/fragmentbaseUrl must ...
timeout zero or negativetimeout must be a positive number of milliseconds
maxRetries outside 0–5maxRetries must be between 0 and 5
language not en / arlanguage must be "en" or "ar"
webhookSecret empty stringwebhookSecret must not be empty when given
publishableKey malformed or of the other environmentpublishableKey must look like ... / publishableKey belongs to ...
httpClient without PSR-17 factorieshttpClient 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.

FieldTypeRequired / defaultRules
orderReferenceIdstringRequiredYour own order id, at most 255 characters, unique per order
totalAmountmoneyRequiredΣ items[].totalAmount + taxAmount + shippingAmount − discountAmount
taxAmountmoney0
shippingAmountmoney0
discountAmountmoney0Order-level discount
currencyCodestring"SAR"At most 3 characters. The API accepts SAR only
countryCodestring"SA"At most 2 characters
descriptionstringnoneAt most 1000 characters
paymentTypestring"pay_by_instalments"pay_by_instalments, pay_now, pay_next_month or pay_in_full (case-insensitive)
instalmentsint4Whole number, at least 1
itemsarrayRequired, at least oneSee Item
consumerarrayRequiredSee Consumer
shippingAddressarrayRequiredSee Address. The API requires it
billingAddressarraynoneSee Address
riskAssessmentarraynoneSee Risk assessment
merchantUrlarraynoneSee Return URLs

merchantId and decisionFeatures are server-controlled. The SDK drops them from the body.

Item

FieldTypeRequired / defaultRules
referenceIdstringRequiredAt most 255 characters
namestringRequiredAt most 500 characters
typestringRequiredAt most 50 characters, for example physical or digital
skustringnoneAt most 255 characters
quantityintRequiredWhole number, at least 1
unitPricemoneyRequired
totalAmountmoneyRequiredquantity × unitPrice − discountAmount
taxAmountmoney0
discountAmountmoney0Line discount
imageUrlstringnoneAt most 2048 characters

Consumer

FieldTypeRequiredRules
firstNamestringYesAt most 100 characters
lastNamestringYesAt most 100 characters
phoneNumberstringYesAt most 20 characters, for example +9665XXXXXXXX
emailstringNoAt most 255 characters. Required on POS sessions
nationalIdstringNo10 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.discountAmount
  • totalAmount = Σ 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:

PropertyTypeMeaning
orderIdstringThe Qasimha order id (installmentId in the API). Store it against your reference
paymentUrl?stringThe hosted checkout. Redirect the shopper here. Always present on 201
providerSessionId?stringProvider session id
status?stringAwaitingCheckout on a new session
rawarrayThe decoded data object
requestId?stringThe API request id. Quote it to support
replayedbooltrue when the API replayed a stored response for the same Idempotency-Key

Outcomes

API answerWhat the SDK does
201 SESSION_CREATEDReturns 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 attemptThrows DuplicateOrderReferenceException with existingOrderId and existingPaymentUrl (may be null). Send the shopper to the open checkout
409 DUPLICATE_ORDER_REFERENCE on a retryThe earlier attempt timed out after the order was created. The SDK returns that order as the Session and logs it at info
422 ORDER_DECLINEDThrows OrderDeclinedException with orderId. Only on deployments that still decide at creation
400Throws ValidationException (VALIDATION_ERROR with field errors, or MERCHANT_REQUIRED)
409 MERCHANT_SUSPENDEDThrows ConflictException
422 IDEMPOTENCY_KEY_REUSEDThrows 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;
CallHTTPRequest fieldsResult
createPosSession(array|CreatePosSessionRequest $request, array $options = []): PosSessionPOST /api/v1/pay/pos/createorderReferenceId, totalAmount, currencyCode (SAR), countryCode (SA), items, consumer (email required), merchantUrlorderId, checkoutUrl, providerSessionId, status, raw, requestId, replayed
createQrSession(array|CreateQrSessionRequest $request, array $options = []): QrSessionPOST /api/v1/pay/qr/createorderReferenceId, totalAmount, currencyCode (SAR), countryCode (SA), items, merchantUrlorderId, qrCodeUrl, providerSessionId, status, raw, requestId, replayed
  • These requests have no tax, shipping or discount fields. The SDK checks totalAmount = Σ items[].totalAmount and each item line, to the halala.
  • A declined QR order comes back from the API as 201 with an empty qrCodeUrl or status Declined. The SDK turns that into OrderDeclinedException (errorCode ORDER_DECLINED, orderId set), 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.

ParameterValue
orderIdThe Qasimha order id
orderReferenceIdYour reference
statusconfirmed 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
ParameterMeaning
$rawBodyThe body exactly as received
$headersRequest headers in any of these shapes: a framework map (any case, string or list values), getallheaders(), $_SERVER (HTTP_X_QASIMHA_SIGNATURE), or ['signature' => ..., 'timestamp' => ...]
$secretWins over the client's webhookSecret. If neither is set: ConfigurationException
toleranceSeconds, default 300 (Webhooks::DEFAULT_TOLERANCE_SECONDS). Negative: ConfigurationException
nowUnix seconds to compare against. Default time(). For tests
timestampHeaderverifySignature() 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:

FormatSent toSigned materialTolerance
t=<unix>,v1=<hex>[,v1=<hex>]A registered webhook endpointHMAC-SHA256 of t + "." + rawBody with the endpoint secretAlways enforced on t
sha256=<hex>The older account-level URL set up by Qasimha operationsHMAC-SHA256 of the raw bodyEnforced 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:

ConstantValueCause
MISSING_HEADERmissing_headerNo X-Qasimha-Signature. There is no unsigned path
MALFORMED_HEADERmalformed_headerNo t or no valid v1, a bad sha256= digest, or a non-numeric timestamp
TIMESTAMP_OUTSIDE_TOLERANCEtimestamp_outside_toleranceThe timestamp is more than tolerance seconds from now
SIGNATURE_MISMATCHsignature_mismatchNo 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.

PropertyTypeSource
id?stringid, then eventId, then the X-Qasimha-Event-Id header
type?stringtype, then eventType, then the X-Qasimha-Event header
orderId?stringorderId, then data.order.id. null on ping and on sha256= deliveries
orderReferenceId?stringorderReferenceId, then data.order.orderReferenceId
status?stringstatus, then data.order.status
amount?floatamount, then data.order.totalAmount
currency?stringcurrency, then data.order.currencyCode
occurredAt?stringoccurredAt, then createdAt
deliveryAttempt?intThe X-Qasimha-Delivery-Attempt header
rawarrayThe decoded body

Event types

Qasimha\Webhook\EventType holds the names as constants:

ConstantValue
ORDER_CONFIRMEDorder.confirmed
ORDER_DECLINEDorder.declined
ORDER_FULFILLEDorder.fulfilled
ORDER_PARTIALLY_FULFILLEDorder.partially_fulfilled
ORDER_REFUNDEDorder.refunded
ORDER_PARTIALLY_REFUNDEDorder.partially_refunded
ORDER_VOIDEDorder.voided
ORDER_CANCELLEDorder.cancelled
ORDER_EXPIREDorder.expired
CHECKOUT_LINK_EXPIREDcheckout_link.expired
PINGping (test event, not a subscription)
ORDER_COMPLETEDorder.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',
    ]);
})

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
MethodRequestResultErrors
fulfill(string $id, array|FulfillRequest $request = [], array $options = []): FulfillResultshippedAmount (money, optional: omit to fulfil everything outstanding), shippingCompany (≤ 200), trackingNumber (≤ 200), trackingUrl (≤ 2048). Other keys are not sentid, status (PartiallyFulfilled or FullyFulfilled), fulfilledAmount, fulfilledAtLocal: 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 = []): RefundResultamount (money, required, greater than zero), reason (≤ 500)id, status, refundedAmountLocal: zero or missing amount. API: 400 INVALID_STATUS, 400 INVALID_AMOUNT, 400 AMOUNT_EXCEEDED, 404
cancel(string $id, array $options = []): CancelResultnoneid, status (Voided)400 INVALID_STATUS once fulfilled, 404
capture(string $id, array $options = []): CaptureResultnoneid, status (Confirmed), confirmedAt400 INVALID_STATUS, 404
  • An order that completes the hosted checkout is already Confirmed. You don't capture it; capture() on it answers 400 INVALID_STATUS.
  • The most you can refund is fulfilledAmount minus what was already refunded. Read refundableAmount on the order.
  • Every 400 becomes ValidationException; read $e->errorCode.
  • $options takes idempotencyKey. 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);
MethodHTTPReturns
get(string $id): OrderGET /api/v1/merchant/orders/{id}Order
getByReference(string $orderReferenceId): OrderGET /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 = []): PageGET /api/v1/merchant/ordersPage of OrderSummary
counts(): OrderCountsGET /api/v1/merchant/orders/countsall, approved, authorised, captured, refunded, cancelled (ints)
contract(string $id): ContractGET /api/v1/merchant/orders/{id}/contractbytes (the financing contract PDF), contentType, fileName, requestId

list() parameters (all optional, omitted when null):

KeyDefault (API)Meaning
statusnoneA status name (Confirmed, FullyFulfilled, Voided ...) or a tab alias: approved, authorised, captured, refunded, cancelled. Anything else: 400 INVALID_STATUS
searchnoneMatches your order reference, customer name, email or phone
page1Page number
pageSize20Rows 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
AmountMeaning
capturedAmount, collectedAmountWhat the customer has paid so far
fulfilledAmountWhat you have shipped
refundedAmount, reversedAmountWhat has been refunded
refundableAmountWhat you can still refund
voidedAmountWhat 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:

ClassConstants
Qasimha\Model\Enum\OrderStatusDRAFT 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\OrderChannelONLINE Online, IN_STORE InStore, QR_CODE QrCode
Qasimha\Model\Enum\PaymentTypePAY_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\InstallmentItemStatusSCHEDULED 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):

FieldTypeRequired / defaultRules
amountmoneyRequiredGreater than zero, at most two decimals
channelstring"Link""Link" or "Qr" (case-insensitive). Link sends the link by SMS, push and a call
phonestringRequired for LinkAt most 20 characters. The SDK refuses a Link without it
languagestringnone"ar" or "en": the language of the SMS, the call and the hosted page. If omitted, Accept-Language decides, falling back to Arabic
orderReferenceIdstringgeneratedAt most 255 characters. Generated as LINK-yyyyMMdd-XXXXXX when omitted
ejarContractNumberstringnoneAt most 100 characters. Required for real-estate merchants, ignored for others
itemsarraynoneEach: name (required, ≤ 500), sku (≤ 255), quantity (required, ≥ 1), unitPrice (required), imageUrl (≤ 2048)
merchantUrlarraynoneSame 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.

MethodHTTPErrors
create(...)POST /api/v1/merchant/checkout-links400 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): CheckoutLinkGET /api/v1/merchant/checkout-links/{id}404 NOT_FOUND
cancel(string $id, array $options = []): CheckoutLinkPOST /api/v1/merchant/checkout-links/{id}/cancel409 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
MethodHTTPReturns
list(array $params = []): PageGET /api/v1/merchant/settlementsPage of Settlement, newest first. page (default 1), pageSize (default 20)
overview(): SettlementOverviewGET /api/v1/merchant/settlements/overviewHeadline 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);
MethodHTTPReturns
create(array|CreateWebhookEndpointRequest $request, array $options = []): WebhookEndpointWithSecretPOST /api/v1/merchant/webhooksThe endpoint plus secret, shown once
list(): array (list of WebhookEndpoint)GET /api/v1/merchant/webhooksEvery endpoint, newest first. No secrets
get(string $id): WebhookEndpointGET /api/v1/merchant/webhooks/{id}
update(string $id, array|UpdateWebhookEndpointRequest $request): WebhookEndpointPUT /api/v1/merchant/webhooks/{id}Omitted fields keep their value
delete(string $id): voidDELETE /api/v1/merchant/webhooks/{id}Stops all deliveries; pending retries end as failed
rotateSecret(string $id, array $options = []): WebhookEndpointWithSecretPOST /api/v1/merchant/webhooks/{id}/rotate-secretNew secret, shown once. For 24 hours deliveries are signed with both secrets
ping(string $id, array $options = []): WebhookPingPOST /api/v1/merchant/webhooks/{id}/pingeventId, endpointId. A signed ping event arrives at the URL
listDeliveries(string $id, array $params = []): PageGET /api/v1/merchant/webhooks/{id}/deliveriesPage of WebhookDelivery. status (Pending, Succeeded, Failed), page, pageSize (max 100)
replayDelivery(string $id, string $deliveryId, array $options = []): WebhookDeliveryPOST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replayThe 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 plus secret. The secret is masked in var_dump, print_r and json_encode, left out of raw, never logged, and serialize() throws. Read ->secret once 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 call

getPlans(float|int|string $amount, array $options = []): Plans calls GET /api/v1/widget/plans?amount=...&currency=SAR&lang=....

OptionDefaultMeaning
langthe client language"ar" or "en"; anything else throws ConfigurationException
publishableKeythe client optionOverrides 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 ConfigurationException before sending.
  • A zero, negative or sub-halala amount throws ValidationException (field Amount) before sending.
  • An unknown key is AuthenticationException (401 PUBLIC_KEY_REQUIRED / INVALID_PUBLIC_KEY); a refused amount is ValidationException (400 INVALID_AMOUNT).
  • An empty plans list 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.

MethodHTTPReturnsNotable errors
checkout()->createSession(array|CreateSessionRequest $request, array $options = [])POST /api/v1/pay/session/createSessionValidationException, DuplicateOrderReferenceException, OrderDeclinedException, ConflictException (MERCHANT_SUSPENDED)
checkout()->createPosSession(array|CreatePosSessionRequest $request, array $options = [])POST /api/v1/pay/pos/createPosSessionValidationException, DuplicateOrderReferenceException, ConflictException
checkout()->createQrSession(array|CreateQrSessionRequest $request, array $options = [])POST /api/v1/pay/qr/createQrSessionOrderDeclinedException on a declined 201, ValidationException, DuplicateOrderReferenceException
checkout()->getPlans(float|int|string $amount, array $options = [])GET /api/v1/widget/plansPlansConfigurationException (no publishable key), AuthenticationException, ValidationException
orders()->list(array $params = [])GET /api/v1/merchant/ordersPage of OrderSummaryValidationException (INVALID_STATUS), PermissionException
orders()->get(string $id)GET /api/v1/merchant/orders/{id}OrderNotFoundException
orders()->getByReference(string $orderReferenceId)GET /api/v1/merchant/orders/by-reference/{orderReferenceId}OrderNotFoundException
orders()->capture(string $id, array $options = [])POST /api/v1/merchant/orders/{id}/captureCaptureResultValidationException (INVALID_STATUS)
orders()->fulfill(string $id, array|FulfillRequest $request = [], array $options = [])POST /api/v1/merchant/orders/{id}/fulfillFulfillResultValidationException (INVALID_STATUS, INVALID_SHIPPED_AMOUNT)
orders()->refund(string $id, array|RefundRequest $request, array $options = [])POST /api/v1/merchant/orders/{id}/refundRefundResultValidationException (INVALID_STATUS, INVALID_AMOUNT, AMOUNT_EXCEEDED)
orders()->cancel(string $id, array $options = [])POST /api/v1/merchant/orders/{id}/cancelCancelResultValidationException (INVALID_STATUS)
orders()->counts()GET /api/v1/merchant/orders/countsOrderCounts
orders()->contract(string $id)GET /api/v1/merchant/orders/{id}/contractContractNotFoundException
checkoutLinks()->create(array|CreateCheckoutLinkRequest $request, array $options = [])POST /api/v1/merchant/checkout-linksCheckoutLinkValidationException (PHONE_REQUIRED ...), ConflictException
checkoutLinks()->list()GET /api/v1/merchant/checkout-linkslist of CheckoutLink
checkoutLinks()->get(string $id)GET /api/v1/merchant/checkout-links/{id}CheckoutLinkNotFoundException
checkoutLinks()->cancel(string $id, array $options = [])POST /api/v1/merchant/checkout-links/{id}/cancelCheckoutLinkConflictException (NOT_OPEN)
settlements()->list(array $params = [])GET /api/v1/merchant/settlementsPage of SettlementPermissionException
settlements()->overview()GET /api/v1/merchant/settlements/overviewSettlementOverviewPermissionException
webhookEndpoints()->create(array|CreateWebhookEndpointRequest $request, array $options = [])POST /api/v1/merchant/webhooksWebhookEndpointWithSecretValidationException (URL_PRIVATE, LIMIT_EXCEEDED ...), PermissionException
webhookEndpoints()->list()GET /api/v1/merchant/webhookslist of WebhookEndpointPermissionException
webhookEndpoints()->get(string $id)GET /api/v1/merchant/webhooks/{id}WebhookEndpointNotFoundException
webhookEndpoints()->update(string $id, array|UpdateWebhookEndpointRequest $request)PUT /api/v1/merchant/webhooks/{id}WebhookEndpointValidationException, NotFoundException
webhookEndpoints()->delete(string $id)DELETE /api/v1/merchant/webhooks/{id}voidNotFoundException
webhookEndpoints()->rotateSecret(string $id, array $options = [])POST /api/v1/merchant/webhooks/{id}/rotate-secretWebhookEndpointWithSecretNotFoundException
webhookEndpoints()->ping(string $id, array $options = [])POST /api/v1/merchant/webhooks/{id}/pingWebhookPingNotFoundException
webhookEndpoints()->listDeliveries(string $id, array $params = [])GET /api/v1/merchant/webhooks/{id}/deliveriesPage of WebhookDeliveryValidationException, NotFoundException
webhookEndpoints()->replayDelivery(string $id, string $deliveryId, array $options = [])POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replayWebhookDeliveryNotFoundException
webhooks()->constructEvent(string $rawBody, array $headers, ?string $secret = null, array $options = [])none (local)WebhookEventWebhookSignatureException, ConfigurationException, ValidationException
webhooks()->verifySignature(string $rawBody, ?string $signatureHeader, ?string $secret = null, array $options = [])none (local)voidWebhookSignatureException, 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.

MemberTypeMeaning
getMessage()stringThe API message, in the configured language, or a fixed message when the body is empty
errorCode?stringThe API code (INVALID_STATUS ...); VALIDATION_ERROR for a local refusal; null when there is none
httpStatus?intThe HTTP status; null for local refusals and transport failures
requestId?stringThe API request id (envelope uuid, else X-Request-Id). Quote it to support
errorslist<FieldError>Field errors, each with field and message. Field names are PascalCase, as the API sends them
envelope?arrayThe decoded error body
data()?arrayThe envelope's data
getPrevious()?ThrowableThe transport exception on a ConnectionException

The SDK picks the class by HTTP status, then by code:

ExceptionExtendsWhenExtra members
ConfigurationExceptionQasimhaExceptionBad options, missing key or secret, empty id, bad idempotencyKey. Thrown before sending
ValidationExceptionQasimhaExceptionRefused locally, or any 400 (VALIDATION_ERROR, INVALID_STATUS, INVALID_AMOUNT, AMOUNT_EXCEEDED, PHONE_REQUIRED, URL_PRIVATE, LIMIT_EXCEEDED ...)fieldMessage(string $field): ?string
AuthenticationExceptionQasimhaException401: missing, invalid or revoked key (the body is empty); PUBLIC_KEY_REQUIRED / INVALID_PUBLIC_KEY on getPlans
PermissionExceptionQasimhaException403: the key lacks the scope (orders:read, orders:write, settlements:read, webhooks:manage)
NotFoundExceptionQasimhaException404: not found, or another merchant's
DuplicateOrderReferenceExceptionConflictException409 DUPLICATE_ORDER_REFERENCE on the first attemptexistingOrderId, existingPaymentUrl, attempt
ConflictExceptionQasimhaExceptionOther 409: MERCHANT_SUSPENDED, NOT_OPEN, INVALID_STATE, or REQUEST_IN_PROGRESS after the retries
OrderDeclinedExceptionQasimhaExceptionORDER_DECLINED (422), or a declined QR sessionorderId
RateLimitExceptionQasimhaException429 after the retriesretryAfter (?int seconds)
ServerExceptionApiException5xx
ApiExceptionQasimhaExceptionAnything else: a 3xx, a non-JSON body, a 2xx without the expected data, 422 IDEMPOTENCY_KEY_REUSED
ConnectionExceptionQasimhaExceptionDNS, TLS, socket or timeout failurecurlErrorNumber (?int, built-in transport)
WebhookSignatureExceptionQasimhaExceptionWebhook verification failedreason

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 (default 30000 ms) 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, and 409 REQUEST_IN_PROGRESS, up to maxRetries (default 2, so at most 3 attempts). This applies to every GET, PUT and DELETE, and to every POST that carries an Idempotency-Key. Other 4xx and 500 are never retried; a 500 on 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 429 with Retry-After (seconds or an HTTP date), the SDK waits that long instead, capped at 30 seconds. RateLimitException::$retryAfter carries the value when the retries run out.
  • Idempotency-Key. Every POST carries a fresh UUID v4 Idempotency-Key, reused across that call's retries. A retried create either replays the stored answer or gets 409 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, otherwise ConfigurationException. Sending the same key with a different body answers 422 IDEMPOTENCY_KEY_REUSED.
  • No key. ['idempotencyKey' => false] sends no key and turns retries off for that POST.
  • Duplicates. 409 DUPLICATE_ORDER_REFERENCE on a retry of createSession, createPosSession, createQrSession or checkoutLinks()->create is the earlier attempt that succeeded. The SDK returns it as the result.
  • Results. Every result has requestId and replayed (true when the response carried Idempotent-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);
},
MessageLevelFields
request completedinfo on 2xx, warn otherwisemethod, path, status, durationMs, requestId, attempt, code, replayed
retrying requestwarnmethod, path, status, requestId, attempt, code, delayMs
request failed to reach the APIwarnmethod, path, status (null), durationMs, requestId (null), attempt, code (null), error
duplicate order reference on retry treated as the earlier successinfomethod, 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() give sk_live_…AbCd / pk_live_…AbCd for your own logs. var_dump and print_r show the same masked forms, json_encode leaves 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 3xx is an ApiException. Keep redirects off on an injected PSR-18 client too.
  • The built-in transport honours the standard https_proxy environment 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 add userAgentSuffix to 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

  1. In the UAT Merchant Portal (https://portal-uat.qasimha.com), open Settings → API Keys and create a sk_test_ key (and a pk_test_ key for plan quotes). The SDK sends sk_test_ keys to https://api-uat.qasimha.com on its own.

  2. 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 once

    The URL must be public HTTPS; private addresses are refused (URL_PRIVATE). To receive webhooks on a laptop, expose it through an HTTPS tunnel.

  3. 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;
    }
  4. Create a session, open paymentUrl and complete the hosted checkout. Return URLs may use http://localhost while you develop.

  5. Read the order back with orders()->get() or getByReference(), then try fulfill(), refund() and cancel() 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 (and QASIMHA_PUBLISHABLE_KEY) for the sk_live_ / pk_live_ keys from https://portal.qasimha.com. No code change: the SDK switches to https://api.qasimha.com.
  • Remove any baseUrl or environment override.
  • Register the production webhook endpoint and set its whsec_ secret as QASIMHA_WEBHOOK_SECRET. UAT secrets don't verify production deliveries.
  • Point merchantUrl at your production site over https.
  • Mark orders paid only after orders()->get() returns Confirmed (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 requestId on 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() returned false, cast to ""), has a trailing newline or quotes, or holds a pk_ key.
  • environment "..." contradicts the API key prefix: remove environment, or use the key of that environment.
  • baseUrl must use https://: remove baseUrl. It is only for a local test server.
  • Unknown option "...": check the spelling. The options are camelCase (webhookSecret, not webhook_secret).
  • webhookSecret must not be empty when given / publishableKey must look like ...: pass null (or leave the key out) instead of an empty string.
WebhookSignatureException: signature_mismatch
  • The body was re-encoded. Pass $request->getContent() or file_get_contents('php://input'), never json_encode($request->all()).
  • Wrong secret. Each endpoint has its own secret, and UAT and production differ. Compare the end of your secret with secretHint from webhookEndpoints()->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 strips X-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


Did this page help you?