qasimha / php-sdk
Server SDK for the Qasimha merchant API: checkout sessions, orders, payment links, settlements and webhook verification.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- ext-mbstring: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.10
Suggests
- guzzlehttp/guzzle: PSR-18 transport with connection pooling; pass it as the httpClient option
- symfony/http-client: PSR-18 transport via Symfony\Component\HttpClient\Psr18Client
Provides
None
Conflicts
None
Replaces
None
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) andshippingAddressare 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;
quantityis at least 1; item.totalAmount == quantity × unitPrice − item.discountAmount, andtotalAmount == Σ item.totalAmount + taxAmount + shippingAmount − discountAmount, both to the halala;paymentTypeis one ofpay_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 afterrotateSecret()every delivery carries onev1per 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 carriesorderReferenceIdbut noorderId, hence thegetByReference()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
timeoutapplies per attempt.- Retried: connection failures,
429,502,503,504— on everyGET,PUTandDELETE, and on aPOSTthat carries anIdempotency-Key. Never other 4xx, never500(a 500 on a write may have committed). - Backoff:
min(2^attempt × 500 ms, 8 s)plus up to 250 ms of jitter;Retry-Afteron 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 === trueon the result) or gets409 REQUEST_IN_PROGRESS, which is retried too and becomesConflictExceptiononce 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_REFERENCEon 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 atinfo). X-Correlation-Idis set per call; the API'suuid/X-Request-Idis on every result and exception asrequestId. 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_proxyenvironment 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.