Search by

qasimha / php-sdk

Qasimah-com

Server SDK for the Qasimha merchant API: checkout sessions, orders, payment links, settlements and webhook verification.

Package info

github.com/Qasimah-com/php-sdk

pkg:composer/qasimha/php-sdk

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-10-18 07:48 UTC

This package is auto-updated.

Last update: 2026-10-18 13:04:14 UTC


README

Server-side PHP client for the Qasimha merchant API: create checkout sessions, manage orders (capture, fulfil, refund, cancel), issue payment links and QR codes, quote instalment plans for an amount, read settlements, register webhook endpoints and verify their deliveries. It is the library the WooCommerce, Magento, OpenCart and PrestaShop plugins are built on, so it runs on ordinary shared hosting: PHP 8.1 or newer with ext-curl, ext-json and ext-mbstring, and nothing else.

Contents: Install · Configure · Create a checkout session and redirect · Handle the return · Look an order up by your reference · Webhooks · Manage webhook endpoints · Capture / fulfil / refund / cancel · Payment links & QR · Quote instalment plans for an amount · Errors · Retries & idempotency · Logging · Security notes · Demo store backend · Support

Install

composer require qasimha/php-sdk

The package depends only on the PSR HTTP interfaces (psr/http-client, psr/http-factory, psr/http-message). Without a PSR-18 client installed it talks to the API through its own curl-based transport, so no further packages are needed.

Configure

Create an API key in the Merchant Portal (Settings → API Keys). Sandbox keys start with sk_test_, production keys with sk_live_; the SDK picks the environment from the prefix.

use Qasimha\Client;
use Qasimha\Config;

$qasimha = new Client(Config::fromApiKey(getenv('QASIMHA_API_KEY'), [
    'language' => 'ar',                     // Accept-Language for API messages: "en" (default) or "ar"
    'webhookSecret' => getenv('QASIMHA_WEBHOOK_SECRET'),
    'userAgentSuffix' => 'my-store/2.4.0',  // appended to the SDK User-Agent
]));
Option Default Notes
environment inferred from the key "sandbox" or "production"; a value that contradicts the key prefix is refused
baseUrl per environment Override for a test server. Must be https:// (plain http:// only for localhost / 127.0.0.1)
timeout 30000 Milliseconds per attempt, connect and read included
maxRetries 2 0–5, see Retries & idempotency
language "en" "en" or "ar"
webhookSecret none Used by webhooks()->constructEvent() unless a secret is passed per call
publishableKey none pk_test_… / pk_live_…, used by checkout()->getPlans() unless a key is passed per call; must be of the same environment as the API key
userAgentSuffix none Identify your plugin, e.g. woocommerce/1.2.0
logger none function (string $level, string $message, array $fields): void
httpClient built-in curl A PSR-18 client, see below
requestFactory, streamFactory taken from httpClient when it implements them PSR-17 factories for the PSR-18 client

Environments:

environment base URL
production https://api.qasimha.com
sandbox https://merchant-api-uat.qasimha.com

Using Guzzle or Symfony HttpClient

The built-in transport is fine for most stores. If your application already has an HTTP client with connection pooling, hand it in:

// Guzzle 7
$config = Config::fromApiKey($key, [
    'httpClient' => new \GuzzleHttp\Client(['timeout' => 30, 'allow_redirects' => false]),
    'requestFactory' => new \GuzzleHttp\Psr7\HttpFactory(),
    'streamFactory' => new \GuzzleHttp\Psr7\HttpFactory(),
]);

// Symfony HttpClient (Psr18Client is client and factories in one)
$config = Config::fromApiKey($key, [
    'httpClient' => new \Symfony\Component\HttpClient\Psr18Client(
        \Symfony\Component\HttpClient\HttpClient::create(['timeout' => 30, 'max_redirects' => 0])
    ),
]);

With an injected client the SDK's timeout option is not applied — PSR-18 has no per-request timeout — so set it on the client as shown. Redirects must stay off; the API never redirects and the SDK reports a 3xx as an error.

Laravel

Bind the client once in a service provider and inject it where needed:

// app/Providers/AppServiceProvider.php
use Qasimha\Client;
use Qasimha\Config;

public function register(): void
{
    $this->app->singleton(Client::class, fn () => new Client(Config::fromApiKey(
        config('services.qasimha.key'),
        [
            'webhookSecret' => config('services.qasimha.webhook_secret'),
            'language' => app()->getLocale() === 'ar' ? 'ar' : 'en',
            'logger' => fn (string $level, string $message, array $fields) => logger()->log($level, $message, $fields),
        ]
    )));
}

Symfony

# config/services.yaml
services:
    Qasimha\Config:
        factory: ['Qasimha\Config', 'fromApiKey']
        arguments:
            - '%env(QASIMHA_API_KEY)%'
            - { webhookSecret: '%env(QASIMHA_WEBHOOK_SECRET)%' }
    Qasimha\Client: ~

WordPress / plain PHP

require __DIR__ . '/vendor/autoload.php';

$qasimha = new Qasimha\Client(Qasimha\Config::fromApiKey(
    get_option('qasimha_api_key'),
    ['webhookSecret' => get_option('qasimha_webhook_secret'), 'userAgentSuffix' => 'woocommerce/' . MY_PLUGIN_VERSION]
));

Create a checkout session and redirect

Send the basket exactly as the shopper sees it. The SDK checks it locally before anything is sent and throws ValidationException naming the failing field; the API applies the same rules.

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,                  // = Σ items.totalAmount + tax + shipping − discount
        'taxAmount' => 173.10,
        'shippingAmount' => 25.00,
        'discountAmount' => 50.00,
        'description' => 'Order #12345',
        'instalments' => 4,                        // default 4
        'paymentType' => 'pay_by_instalments',     // default
        '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' => 'customer@example.com'],
        'shippingAddress' => ['firstName' => 'Noura', 'lastName' => 'Al-Shammari', 'line1' => 'King Fahd Rd',
                              'city' => 'Riyadh', 'postalCode' => '12345', 'countryCode' => 'SA'],
        'merchantUrl' => [
            'success' => 'https://shop.example/checkout/qasimha/success?order=12345',
            'failure' => 'https://shop.example/checkout/qasimha/failure?order=12345',
            'cancel' => 'https://shop.example/checkout/qasimha/cancel?order=12345',
        ],
    ]);

    // Store $session->orderId against ORDER-12345: every later call uses it.
    header('Location: ' . $session->paymentUrl, true, 303);
    exit;
} catch (ValidationException $e) {
    // $e->errors: [FieldError { field: "Items[1].TotalAmount", message: "…" }, …]
} catch (OrderDeclinedException $e) {
    // The order exists ($e->orderId) but was not approved; show the shopper another payment method.
} catch (DuplicateOrderReferenceException $e) {
    // ORDER-12345 already has an open checkout: $e->existingOrderId, $e->existingPaymentUrl.
}

Rules the SDK enforces before sending (ValidationException, no request made):

  • orderReferenceId, totalAmount, at least one item, consumer (first name, last name, phone) and shippingAddress are required — the API's validator requires the shipping address even though the documented example omits it;
  • amounts are numbers with at most two decimals, never negative; quantity is at least 1;
  • item.totalAmount == quantity × unitPrice − item.discountAmount, and totalAmount == Σ item.totalAmount + taxAmount + shippingAmount − discountAmount, both to the halala;
  • paymentType is one of pay_by_instalments, pay_now, pay_next_month, pay_in_full;
  • consumer.nationalId, when given, is 10 digits starting with 1 or 2.

Amounts may be passed as float, int or a decimal string ('1302.10'); they are serialised as JSON numbers, never rounded.

merchantUrl is accepted on all four order-creating calls (session, POS, QR and checkout links) and is checked locally before sending: each URL is absolute, at most 1024 characters, carries no fragment and uses https:// — http:// only with localhost or 127.0.0.1, or a custom app scheme of at least three characters for mobile apps (myshop://qasimha/return; javascript:, data:, file:, blob: and vbscript: are refused). success and failure must be given together; cancel is optional and falls back to failure on the platform; notification is reserved and refused when set. Failures name the field (MerchantUrl.Success, …).

Merchant return URLs, per-merchant webhook endpoints and the t=,v1= signature format are available since the API release of 2026-09-25.

Two other ways to start an order share the basket rules:

$pos = $qasimha->checkout()->createPosSession([...]);   // in-store; consumer.email is required; $pos->checkoutUrl
$qr  = $qasimha->checkout()->createQrSession([...]);    // scan to pay; $qr->qrCodeUrl

A declined QR order is reported as OrderDeclinedException, the same as the session endpoint, even though the API answers it with a 201 today.

Handle the return

When the hosted checkout ends, the shopper is sent to the matching merchantUrl with three query parameters appended to it (your own query string is kept; ? or & is chosen accordingly):

Parameter Value
orderId the Qasimha order id
orderReferenceId your reference, URL-encoded
status confirmed on the success URL; declined, expired or failed on the failure URL; cancelled on the cancel URL

The success and failure screens also redirect on their own after five seconds; every pre-payment step offers "Cancel and return to store", which leaves the order AwaitingCheckout until it expires (or you cancel it through the API).

The return URL is a navigation, not a proof. Never mark an order paid from a query string: read the order back and act on its status, whether the shopper came back or a webhook arrived.

$orderId = (string) ($_GET['orderId'] ?? '');
$order = $qasimha->orders()->get($orderId);

if ($order->status === \Qasimha\Model\Enum\OrderStatus::CONFIRMED) {
    // The customer completed the hosted checkout and paid the first instalment.
    // Mark your order paid; call fulfill() when you ship.
}

Order exposes the fields a store needs — status, amounts, customer, items, installments, fulfillments, reversals, voids, statusHistory — and keeps the complete decoded response in $order->raw. Enum fields (status, channel, paymentType, installments[].status) are always the name ("Confirmed"), whichever form the API sends.

Lifecycle: an order that completes the hosted checkout is already Confirmed. capture() applies only to New/Approved orders and answers 400 INVALID_STATUS on a confirmed one. A store plugin therefore marks the order paid on Confirmed, calls fulfill() on shipment and refund() after that. refundableAmount is what the customer has paid so far minus refunds.

Look an order up by your reference

When all you hold is your own reference — a webhook, a return route, a store that never kept the Qasimha id — read the order by it:

$order = $qasimha->orders()->getByReference('ORDER-12345');   // the same Order as get()

The match is exact and case-sensitive; an unknown reference, or one that belongs to another merchant, is NotFoundException. A reference containing / is sent as it is (ORDER/2026/1 → /by-reference/ORDER/2026/1); every other reserved character is percent-encoded per segment.

To browse or filter instead:

$page = $qasimha->orders()->list(['search' => 'ORDER-12345']);   // also: status, page, pageSize
foreach ($page->items as $row) { /* OrderSummary */ }
$page->pagination->totalItems;

Webhooks

Register an HTTPS endpoint (see Manage webhook endpoints) and Qasimha POSTs a signed JSON event to it. Verify it with the raw request body — the bytes as received, never a re-serialised array — then confirm the state with orders()->get() before acting.

use Qasimha\Exception\WebhookSignatureException;

try {
    $event = $qasimha->webhooks()->constructEvent($rawBody, $headers);
} catch (WebhookSignatureException $e) {
    http_response_code(400);   // $e->reason: missing_header | malformed_header | timestamp_outside_tolerance | signature_mismatch
    exit;
}

// $event->type            "order.confirmed", "order.refunded", "ping", … (constants on Qasimha\Webhook\EventType)
// $event->orderId          the Qasimha order id (null on a "ping")
// $event->orderReferenceId, ->status, ->amount, ->currency, ->occurredAt, ->id, ->raw

$order = $event->orderId !== null
    ? $qasimha->orders()->get($event->orderId)
    : $qasimha->orders()->getByReference((string) $event->orderReferenceId);

Reading the raw body and headers per framework:

// Plain PHP / WordPress
$rawBody = file_get_contents('php://input');
$headers = function_exists('getallheaders') ? getallheaders() : $_SERVER;   // both shapes are accepted

// Laravel (exclude the route from CSRF verification)
Route::post('/webhooks/qasimha', function (Request $request, Qasimha\Client $qasimha) {
    $event = $qasimha->webhooks()->constructEvent($request->getContent(), $request->headers->all());
    // …
    return response()->noContent();
});

// Symfony
public function webhook(Request $request, Qasimha\Client $qasimha): Response
{
    $event = $qasimha->webhooks()->constructEvent($request->getContent(), $request->headers->all());
    // …
    return new Response('', 204);
}

Deliveries carry X-Qasimha-Signature, X-Qasimha-Timestamp, X-Qasimha-Event, X-Qasimha-Event-Id and X-Qasimha-Delivery-Attempt. The SDK verifies both signature formats:

  • t=<unix seconds>,v1=<hex>[,v1=<hex>] — what a registered endpoint receives: the timestamp is signed with the body and the tolerance (300 s by default; ['tolerance' => 600]) is enforced. For 24 hours after rotateSecret() every delivery carries one v1 per secret, so the old and the new secret both verify while you roll the new one out.
  • sha256=<hex> — the format of the account-level URL that Qasimha operations configure for an account with no registered endpoint: HMAC-SHA256 of the body alone; when the timestamp header is present the delivery is also rejected outside the tolerance, as defence in depth. That body carries orderReferenceId but no orderId, hence the getByReference() fallback above.

Event types: order.confirmed, order.declined, order.fulfilled, order.partially_fulfilled, order.refunded, order.partially_refunded, order.voided, order.cancelled, order.expired, checkout_link.expired, plus ping from ping() or the portal. order.completed is accepted as a subscription alias of order.confirmed for one release. The body carries id, type, createdAt, orderId, orderReferenceId, status, amount, currency and data.order.

There is no unsigned path: a delivery without a signature header fails with missing_header. A delivery your server does not answer with a 2xx is retried with backoff; respond quickly, process asynchronously, use X-Qasimha-Event-Id to de-duplicate, and read the attempts in listDeliveries().

Merchant return URLs, per-merchant webhook endpoints and the t=,v1= signature format are available since the API release of 2026-09-25.

verifySignature($rawBody, $signatureHeader, $secret, ['timestampHeader' => …, 'tolerance' => …]) is available when you need the check without the event.

Manage webhook endpoints

Endpoints are managed with a key that has the webhooks:manage scope (Merchant Portal → Settings → API Keys), up to five per account.

use Qasimha\Webhook\EventType;

$endpoints = $qasimha->webhookEndpoints();

$created = $endpoints->create([
    'url' => 'https://shop.example/webhooks/qasimha',                  // https only, a public host
    'description' => 'Production store',
    'events' => [EventType::ORDER_CONFIRMED, EventType::ORDER_REFUNDED],  // omit to receive every event
]);
$created->secret;       // whsec_… — returned once; it is the webhookSecret of this client
$created->secretHint;   // its last four characters, to tell secrets apart later

$endpoints->list();                                  // WebhookEndpoint[]
$endpoints->get($id);                                // url, description, events, isActive, secretHint, previousSecretExpiresAt, createdAt, updatedAt
$endpoints->update($id, ['isActive' => false]);      // also url, description, events ([] = every event)
$endpoints->delete($id);

$rotated = $endpoints->rotateSecret($id);            // $rotated->secret is new; the old one verifies until $rotated->previousSecretExpiresAt
$ping = $endpoints->ping($id);                       // queues a signed "ping" event; $ping->eventId
$page = $endpoints->listDeliveries($id, ['status' => 'Failed', 'pageSize' => 20]);   // Page<WebhookDelivery>
foreach ($page->items as $delivery) {
    // eventType, orderId, orderReferenceId, status (Pending | Succeeded | Failed), attempts, maxAttempts,
    // nextAttemptAt, lastAttemptAt, lastStatusCode, lastError, responseSnippet, durationMs, succeededAt, replayOfId
}
$endpoints->replayDelivery($id, $delivery->id);      // queues the same event again; replayOfId names the original

Before sending, the SDK refuses a url that is not https:// with a host, that carries credentials or a fragment, or that is longer than 1024 characters (Url), a description over 200 characters, and an events entry that is not one of the event types above (Events[i]). The API additionally refuses a private-network target (URL_PRIVATE) and a sixth endpoint (LIMIT_EXCEEDED).

The secret is returned by create() and rotateSecret() only, as WebhookEndpointWithSecret. It is masked in var_dump, print_r and json_encode, absent from raw, never logged, and the object refuses serialize(): read ->secret once and hand it to a secrets manager.

Capture / fulfil / refund / cancel

$orders = $qasimha->orders();

$orders->capture($orderId);                       // New/Approved → Confirmed (not needed after a hosted checkout)
$orders->fulfill($orderId);                       // ships everything outstanding
$orders->fulfill($orderId, ['shippedAmount' => 1200.00, 'shippingCompany' => 'Aramex',
                            'trackingNumber' => 'ARX123', 'trackingUrl' => 'https://…']);
$orders->refund($orderId, ['amount' => 300.00, 'reason' => 'Customer return']);
$orders->cancel($orderId);                        // voids an unfulfilled order
$orders->counts();                                // all, approved, authorised, captured, refunded, cancelled
$contract = $orders->contract($orderId);          // $contract->bytes, ->contentType, ->fileName (the financing contract PDF)

Each mutation returns a small typed result (CaptureResult, FulfillResult, RefundResult, CancelResult) with id, status and the amounts the API reports, plus raw.

Payment links & QR

For sales without a website (in store, WhatsApp, social):

$links = $qasimha->checkoutLinks();

$link = $links->create(['amount' => 250.00, 'channel' => 'Link', 'phone' => '+9665XXXXXXXX',
                        'language' => 'ar', 'orderReferenceId' => 'ORDER-12345']);
// Qasimha sends the link by SMS; $link->url is the hosted page, $link->expiresAt two hours out.

$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);     // 409 NOT_OPEN once the customer completed or the link expired

channel: "Link" requires a phone; the SDK refuses a missing one locally. Real-estate merchants pass ejarContractNumber. The three reads return different shapes today, so CheckoutLink makes every member optional except id and keeps the full response in raw.

Quote instalment plans for an amount

What the on-site widget shows under a price: every plan the merchant's policy offers for an amount, each with the instalment amount, the first and last instalment, the fees including VAT and the total payable. For a server that renders its own product pages, or that wants to decide whether to offer Qasimha at all. Available since the API release of 2026-09-25.

The call authenticates with a publishable key (pk_test_… / pk_live_…, Merchant Portal → Settings → API Keys), the key that is safe to embed in a page; the secret key is not sent on this request. Configure it once or pass it per call. It must be of the same environment as the secret key: sk_test_… with pk_live_… is refused at construction.

$qasimha = new Client(Config::fromApiKey(getenv('QASIMHA_API_KEY'), [
    'publishableKey' => getenv('QASIMHA_PUBLISHABLE_KEY'),
]));

$quote = $qasimha->checkout()->getPlans(1200.00, ['lang' => 'ar']);   // lang defaults to the client language

if ($quote->plans === []) {
    // outside [$quote->minAmount, $quote->maxAmount], or no active policy: show nothing
}
foreach ($quote->plans as $plan) {
    // $plan->instalments (2, 3, 4 …), $plan->frequency ("monthly"), $plan->instalmentAmount,
    // $plan->firstInstalmentAmount, $plan->lastInstalmentAmount, $plan->feesIncludingVat,
    // $plan->totalPayable, $plan->shariaCompliant
}

$qasimha->checkout()->getPlans(1200.00, ['publishableKey' => 'pk_test_…']);   // per call instead

Plans carries merchantName, currency (always SAR), minAmount, maxAmount and plans; every member is also in raw. The amount follows the same two-decimal rule as a basket and is refused locally when it is zero, negative or finer than a halala. Without a publishable key the call throws ConfigurationException before anything is sent; an unknown key is AuthenticationException (401 PUBLIC_KEY_REQUIRED / INVALID_PUBLIC_KEY), a refused amount ValidationException (400 INVALID_AMOUNT). The platform keeps a merchant's quote for an amount for one minute, so read it per page render rather than per visitor. Retried like every GET.

Errors

Every failure is a Qasimha\Exception\QasimhaException carrying errorCode (the API code or null), httpStatus (null for transport failures), requestId, errors (field errors as FieldError { field, message }, PascalCase as the API sends them) and the raw envelope. Messages come from the API in the language you configured.

Exception When
ConfigurationException bad options, invalid argument before sending
ValidationException refused locally, or 400 from the API (VALIDATION_ERROR, INVALID_STATUS, INVALID_AMOUNT, AMOUNT_EXCEEDED, PHONE_REQUIRED, URL_SCHEME, URL_PRIVATE, INVALID_EVENT, LIMIT_EXCEEDED, …)
AuthenticationException 401 — missing, invalid or revoked key (arrives with an empty body); on getPlans, PUBLIC_KEY_REQUIRED / INVALID_PUBLIC_KEY
PermissionException 403 — the key lacks the scope (orders:read, orders:write, settlements:read, webhooks:manage)
NotFoundException 404 — not found, or not yours
DuplicateOrderReferenceException 409 DUPLICATE_ORDER_REFERENCE — existingOrderId, existingPaymentUrl
ConflictException other 409 (MERCHANT_SUSPENDED, NOT_OPEN, INVALID_STATE)
OrderDeclinedException 422 ORDER_DECLINED — orderId
RateLimitException 429 — retryAfter seconds when the header is present
ServerException 5xx
ApiException anything else unexpected (a 3xx, a non-JSON body, a 2xx with success: false of another code)
ConnectionException DNS, TLS, socket or timeout failure; the transport error is getPrevious()
WebhookSignatureException see Webhooks; reason names the failure

The SDK never maps an error to a generic message and never swallows one.

Retries & idempotency

  • timeout applies per attempt.
  • Retried: connection failures, 429, 502, 503, 504 — on every GET, PUT and DELETE, and on a POST that carries an Idempotency-Key. Never other 4xx, never 500 (a 500 on a write may have committed).
  • Backoff: min(2^attempt × 500 ms, 8 s) plus up to 250 ms of jitter; Retry-After on a 429 is honoured (seconds or HTTP-date, capped at 30 s).
  • Every POST carries an Idempotency-Key — a fresh UUID v4 per call, reused across its retries — so a retried create either replays the stored response (replayed === true on the result) or gets 409 REQUEST_IN_PROGRESS, which is retried too and becomes ConflictException once retries are exhausted. Pass ['idempotencyKey' => 'your-own-key'] to reuse a key across your own retries or ['idempotencyKey' => false] to send without one, which also turns POST retries off.
  • A 409 DUPLICATE_ORDER_REFERENCE on the first attempt is an error; on a retry it is the earlier attempt that timed out after succeeding, and the SDK returns it as the created session (logged at info).
  • X-Correlation-Id is set per call; the API's uuid / X-Request-Id is on every result and exception as requestId. Quote it when contacting support.

Logging

Pass a callback; there is no logging-framework dependency.

'logger' => function (string $level, string $message, array $fields): void {
    $psrLogger->log($level, $message, $fields);   // level: debug | info | warn | error
},

Fields are method, path, status, durationMs, requestId, attempt, code and, on a retry, delayMs. The SDK never logs the API key, the publishable key, the webhook secret, query strings, request bodies or any customer data. Config::maskedKey() gives sk_live_…AbCd and Config::maskedPublishableKey() pk_live_…AbCd for your own logs; var_dump/json_encode of the client show the same masked forms, and the client cannot be serialised.

Security notes

  • TLS only. Certificate validation is PHP's default and there is no option to turn it off; there is no certificate pinning either (host certificates rotate).
  • Store the API key in a secrets manager or environment variable, never in the code base; it is shown once at creation and can be revoked from the Merchant Portal.
  • Proxies: the built-in transport honours the standard https_proxy environment variable through curl's default behaviour.
  • Webhooks: verify before parsing, keep the raw body, de-duplicate on the event id, and confirm state with orders()->get() before fulfilling.
  • Redirects are never followed.

Demo store backend

php-sdk-example is a complete merchant backend built on this SDK: plain PHP on the built-in server, Guzzle as the PSR-18 transport. It prices a basket from a catalogue, creates the session with checkout()->createSession(), confirms the outcome with orders()->get() and verifies webhooks on the raw body with webhooks()->constructEvent(). It exposes the same HTTP API as the other SDKs' demo backends, so the web demo store runs unchanged against it — in mock mode without any key (docker compose up --build in that folder), or live with sandbox keys.

Support

Merchant Portal → Settings → API Keys to create and revoke keys. For integration questions, contact your Qasimha integration manager with the requestId of the failing call.

Developing the SDK itself: composer install, then composer test, composer stan (PHPStan level 8) and composer cs (PSR-12). The live smoke test runs with QASIMHA_API_KEY=… QASIMHA_BASE_URL=… composer test:live; add QASIMHA_WEBHOOKS_API_KEY (a key with webhooks:manage) to include the endpoint round trip, which registers and then deletes https://example.com/qasimha-sdk-test, and QASIMHA_PUBLISHABLE_KEY to include the plan quote. QASIMHA_RESOLVE_TO=<ip> pins the API host to an address such as an SSH tunnel; when the suite runs inside Docker leave it unset and map the host with --add-host <api host>:host-gateway instead.