Node.js SDK

Integrate Qasimha into your Node.js backend with @qasimha/sdk to create checkouts, retrieve and confirm orders, verify webhook signatures, and keep order data in sync.

@qasimha/sdk is the official Node.js 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 backend creates checkout sessions, retrieves order details, validates webhook signatures, and performs any other operations through @qasimha/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.

For client-side integrations, see the Web SDKs and Mobile SDKs documentation.

Requirements

ItemRequirement
RuntimeNode.js 18 or later (uses the built-in fetch)
Module formatsESM and CommonJS; TypeScript declarations included
Runtime dependenciesNone
KeysA secret key (sk_test_... for UAT). 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

npm install @qasimha/sdk
# or
pnpm add @qasimha/sdk
# or
yarn add @qasimha/sdk
import Qasimha from '@qasimha/sdk';                    // ESM / TypeScript (default export)
import { Qasimha, Client } from '@qasimha/sdk';        // the same class as named exports
const { Qasimha } = require('@qasimha/sdk');           // CommonJS

Every model is exported as a TypeScript type, for example import type { CreateSessionRequest, Order, WebhookEvent } from '@qasimha/sdk'.

Configure

Create keys in the Merchant Portal under Settings → API Keys. Keep them in environment variables or a secrets manager:

QASIMHA_API_KEY=sk_test_...
QASIMHA_PUBLISHABLE_KEY=pk_test_...
QASIMHA_WEBHOOK_SECRET=whsec_...
import Qasimha from '@qasimha/sdk';

const qasimha = new Qasimha({
  apiKey: process.env.QASIMHA_API_KEY!,                  // required
  webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET,     // for webhooks.constructEvent
  publishableKey: process.env.QASIMHA_PUBLISHABLE_KEY,   // for checkout.getPlans
  language: 'en',                                        // 'ar' | 'en'
  userAgentSuffix: 'my-store/2.4.1',
});

Create one client when your process starts and reuse it. It holds no connections of its own, so there is nothing to close.

Options

OptionTypeDefaultMeaning and validation
apiKeystring—Required. Must match ^sk_(test|live)_[A-Za-z0-9]+$. Its prefix picks the environment.
environment'sandbox' | 'production'from the key prefixAny other value throws. A value that contradicts the key prefix throws, unless baseUrl is also set.
baseUrlstringper environmentOnly for a test server you run yourself. Absolute https:// URL; http:// only for localhost and 127.0.0.1; no query string or fragment. A trailing / is removed. When set, it wins over environment.
timeoutnumber30000Milliseconds per attempt. Must be a positive finite number.
maxRetriesnumber2Extra attempts after the first. Integer from 0 to 5.
webhookSecretstring—Default secret for webhooks.constructEvent and webhooks.verifySignature. Must be a string.
publishableKeystring—For checkout.getPlans. Must match ^pk_(test|live)_[A-Za-z0-9]+$ and belong to the same environment as apiKey.
language'ar' | 'en''en'Sent as Accept-Language. API messages come back in this language, and the hosted checkout opens in it.
userAgentSuffixstring—Printable ASCII. Appended to qasimha-sdk-node/<version> (node/<version>; <platform>), for example your plugin name and version.
logger(level, message, fields) => voidnoneReceives request logs. See Logging and security.

Environments

Key prefixenvironmentBase URL
sk_test_sandboxhttps://api-uat.qasimha.com
sk_live_productionhttps://api.qasimha.com

You do not set a URL. Switching from UAT to production is a key change, not a code change. The publishable key must follow the secret key: sk_test_ with pk_live_ throws. See Environments.

Client members

MemberWhat it gives you
checkout, checkoutLinks, orders, settlements, webhooks, webhookEndpointsThe resources described below
environment, baseUrl, language, timeout, maxRetries, userAgentThe resolved settings (read-only)
maskedKey()sk_live_…AbCd: the prefix and the last four characters, for your own logs
maskedPublishableKey()pk_live_…AbCd, or null when no publishable key is set
toJSON(){ environment, baseUrl, apiKey, publishableKey } with both keys masked
toString(), util.inspect()Qasimha(sandbox, https://api-uat.qasimha.com, sk_test_…AbCd)

The package also exports VERSION (the SDK version sent in the User-Agent), ENVIRONMENT_BASE_URLS, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRIES and maskApiKey(key).

Construction errors

The constructor throws ConfigurationError before any request when:

  • the options object is missing;
  • apiKey is missing or does not look like sk_test_… / sk_live_…;
  • environment is not "sandbox" or "production", or contradicts the key prefix without a baseUrl;
  • baseUrl is not an absolute URL, is http:// on a host other than localhost / 127.0.0.1, or carries a query string or fragment;
  • publishableKey does not look like pk_test_… / pk_live_…, or belongs to the other environment;
  • timeout is not a positive number, or maxRetries is not an integer from 0 to 5;
  • language is not "ar" or "en", userAgentSuffix is not printable ASCII, logger is not a function, or webhookSecret is not a string.

The message never contains a full key.

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.

import Qasimha, { DuplicateOrderReferenceError, OrderDeclinedError, ValidationError } from '@qasimha/sdk';

try {
  const session = await qasimha.checkout.createSession({
    orderReferenceId: 'ORDER-12345',      // your own id, unique per order, ≤ 255 characters
    totalAmount: 1302.10,
    taxAmount: 173.10,
    shippingAmount: 25.00,
    discountAmount: 50.00,
    description: 'Order #12345',
    instalments: 4,
    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', 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',
    },
  });

  await saveQasimhaOrderId('ORDER-12345', session.orderId);   // every later call uses this id
  redirect(session.paymentUrl);                                 // send the shopper to the hosted checkout
} catch (error) {
  if (error instanceof DuplicateOrderReferenceError) {
    if (error.existingPaymentUrl) redirect(error.existingPaymentUrl);   // double submit
  } else if (error instanceof OrderDeclinedError) {
    // error.orderId exists but was not approved: offer another payment method
  } else if (error instanceof ValidationError) {
    // error.errors names each failing field
  } else {
    throw error;
  }
}

CreateSessionRequest

FieldTypeRequired / defaultRules checked before sending
orderReferenceIdstringRequiredNot blank, at most 255 characters. Unique per order.
totalAmountnumberRequiredMoney rules below; must satisfy the basket identity
taxAmountnumber0Money rules
shippingAmountnumber0Money rules
discountAmountnumber0Money rules; subtracted from the basket
currencyCodestring'SAR'The API accepts SAR only
countryCodestring'SA'
descriptionstring—
paymentType'pay_by_instalments' | 'pay_now' | 'pay_next_month' | 'pay_in_full''pay_by_instalments'One of the four values (exported as PAYMENT_TYPES)
instalmentsnumber4Integer, at least 1
itemsItem[]RequiredAt least one entry
consumerConsumerRequiredSee below
shippingAddressAddressRequiredRequired by the API, even for digital baskets
billingAddressAddress—Same rules as shippingAddress
riskAssessmentRiskAssessment—See below
merchantUrlMerchantUrl—See below

Item:

FieldTypeRequired / defaultRules
referenceIdstringRequiredYour line or product id
namestringRequired
type'physical' | 'digital'Required
skustring—
quantitynumberRequiredInteger, at least 1
unitPricenumberRequiredMoney rules
totalAmountnumberRequiredquantity × unitPrice − discountAmount, to the halala
taxAmountnumber0Money rules. Not part of the line identity.
discountAmountnumber0Money rules
imageUrlstring—

Consumer: firstName, lastName and phoneNumber are required strings. email is optional here (required for POS). nationalId is optional and, when given, must be 10 digits starting with 1 or 2.

Address: firstName, lastName, line1, city and countryCode are required strings; line2, region, postalCode and phoneNumber are optional.

RiskAssessment (all optional): customerDob (string), customerGender (string), isExistingCustomer (boolean), orderIsDigital (boolean), totalOrderCount (integer, 0 or more), dateOfFirstTransaction (string).

MerchantUrl: where the hosted checkout sends the shopper afterwards.

FieldRules
successRequired when merchantUrl is given
failureRequired when merchantUrl is given
cancelOptional; the platform uses failure when it is missing
notificationReserved. Leave it out; any value is refused. Webhooks are registered with webhookEndpoints.

Each URL must be absolute, at most 1024 characters, with no fragment, and use https, http only on localhost / 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. See Return URLs.

Money and basket rules

Amounts are JavaScript numbers, never strings:

  • At most two decimals. A real third decimal (1.005) is refused, never rounded. Float noise such as 0.1 + 0.2 is sent as 0.3.
  • No negative amounts.
  • item.totalAmount = quantity × unitPrice − item.discountAmount
  • totalAmount = Σ item.totalAmount + taxAmount + shippingAmount − discountAmount

The SDK compares both identities in integer halalas before sending. A mismatch throws ValidationError naming the line and the expected value, for example items[1].totalAmount: must equal quantity x unitPrice - discountAmount (expected 135.00, got 134.00). The API checks the same rules again. To build baskets safely, the package exports toHalalas(amount), canonicalAmount(amount) and isTwoDecimalAmount(value).

Result and outcomes

createSession returns a Session:

FieldTypeMeaning
orderIdstringThe Qasimha order id. Store it; every later order call uses it.
paymentUrlstring | nullThe hosted checkout. Redirect the shopper here.
providerSessionIdstring | nullSession id from the platform
statusstring | nullAwaitingCheckout for a new session
rawobjectThe response data as received
requestIdstring | nullQuote it to support
replayedbooleantrue when the API replayed an earlier answer for the same idempotency key
ResponseWhat the SDK does
201Returns the Session. The credit decision happens on the hosted page, so a first-time shopper is never refused at this call.
409 DUPLICATE_ORDER_REFERENCE, first attemptThrows DuplicateOrderReferenceError with existingOrderId and existingPaymentUrl (either may be null).
409 DUPLICATE_ORDER_REFERENCE, on a retryThe earlier attempt succeeded. If the response names the order, the SDK returns it as the Session and logs it at info; otherwise it throws.
422 ORDER_DECLINEDThrows OrderDeclinedError with orderId. Only a deployment that decides at creation sends this; the hosted journey declines on the page.
400Throws ValidationError with the API's field errors.

The hosted checkout page is valid for two hours. See Checkout flows.

In-store (POS) and QR sessions

Use these when the customer completes checkout on their own phone: a POS order opened at the till, or a QR code they scan. Both create the order up front and return a hosted URL.

const pos = await qasimha.checkout.createPosSession({
  orderReferenceId: 'POS-0001',
  totalAmount: 450.00,
  items: [{ referenceId: 'SKU-9', name: 'Desk Lamp', type: 'physical', quantity: 2, unitPrice: 225.00, totalAmount: 450.00 }],
  consumer: { firstName: 'Noura', lastName: 'Al-Shammari', phoneNumber: '+9665XXXXXXXX', email: '[email protected]' },
});
pos.checkoutUrl;   // the hosted /pay/{orderId} page; status AwaitingCheckout

const qr = await qasimha.checkout.createQrSession({
  orderReferenceId: 'QR-0001',
  totalAmount: 450.00,
  items: [{ referenceId: 'SKU-9', name: 'Desk Lamp', type: 'physical', quantity: 2, unitPrice: 225.00, totalAmount: 450.00 }],
});
qr.qrCodeUrl;      // encode this URL in the QR code you show
FieldcreatePosSessioncreateQrSession
orderReferenceIdRequired, ≤ 255 charactersRequired, ≤ 255
totalAmountRequiredRequired
currencyCode'SAR''SAR'
countryCode'SA''SA'
itemsRequired, same Item rulesRequired
consumerRequired; email is requiredNot accepted
merchantUrlOptional, same rulesOptional

There is no tax, shipping or discount on these calls, so totalAmount must equal the sum of the item totals. The SDK checks this before sending.

ResultFields
PosSessionorderId, checkoutUrl, providerSessionId, status, raw, requestId, replayed
QrSessionorderId, qrCodeUrl, providerSessionId, status, raw, requestId, replayed

A declined QR order can come back as 201 with status: "Declined". The SDK turns that into OrderDeclinedError (code ORDER_DECLINED, with orderId), so all three create calls report a decline the same way. The duplicate-reference rules of createSession apply to both calls.

Confirm the order on the return route

The hosted checkout sends the shopper to your merchantUrl and appends three query parameters to your own query string:

ParameterValue
orderIdThe Qasimha order id
orderReferenceIdYour reference, URL-encoded
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:

app.get('/checkout/qasimha/return', async (req, res) => {
  const order = await qasimha.orders.get(String(req.query.orderId));

  if (['Confirmed', 'FullyFulfilled', 'PartiallyFulfilled'].includes(order.status ?? '')) {
    await markPaid(order.orderReferenceId);
    return res.redirect('/thank-you');
  }
  res.redirect('/checkout?payment=failed');
});

Check that order.orderReferenceId belongs to the shopper's basket before you mark it paid. If you only hold your own reference, use qasimha.orders.getByReference('ORDER-12345'). It returns the same Order, matches exactly (case-sensitive), and throws NotFoundError for an unknown reference. See Return URLs.

Handle webhooks

Verify every delivery with the raw request body: the bytes as received, not a parsed and re-serialised object. Then confirm state with orders.get before you act.

const event = qasimha.webhooks.constructEvent(rawBody, req.headers);
// event: { id, type, orderId, orderReferenceId, status, amount, currency, occurredAt, raw }

webhooks.constructEvent

constructEvent(rawBody, headers, secret?, options?) verifies the signature, parses the JSON body and returns a WebhookEvent.

ParameterTypeNotes
rawBodystring | Buffer | Uint8ArrayExactly as received. A string is read as UTF-8.
headersNode req.headers, a Fetch Headers, or { signature, timestamp, eventId, event }Header names are matched case-insensitively. Array values are joined with ,.
secretstring | nullOptional. Falls back to the client's webhookSecret. With neither, the call throws ConfigurationError.
options.tolerancenumberSeconds the delivery time may differ from now. Default 300 (DEFAULT_WEBHOOK_TOLERANCE_SECONDS).
options.nownumber | DateThe current time, in Unix seconds or as a Date. For tests.
options.timestampHeaderstringOverrides the X-Qasimha-Timestamp value read from headers.

WebhookEvent:

FieldTypeRead from
idstring | nullBody id (or legacy eventId), else X-Qasimha-Event-Id
typestring | nullBody type (or legacy eventType), else X-Qasimha-Event
orderIdstring | nullBody orderId, else data.order.id. null in the legacy format.
orderReferenceIdstring | nullBody orderReferenceId, else data.order.orderReferenceId
statusstring | nullBody status, else data.order.status
amountnumber | nullBody amount, else data.order.totalAmount
currencystring | nullBody currency, else data.order.currencyCode
occurredAtstring | nullBody occurredAt, else createdAt
rawobjectThe whole parsed body

A body that is not a JSON object throws ValidationError after the signature passes.

Signature formats

The SDK accepts both formats and compares in constant time:

FormatSent toChecked
t=<unix>,v1=<hex>[,v1=<hex>]Endpoints you register (Merchant Portal or webhookEndpoints.create)t must be within tolerance; HMAC-SHA256(secret, t + "." + rawBody) must match any v1. Two v1 values appear after a secret rotation. Unknown elements are ignored.
sha256=<hex>Legacy accounts with one URL set up by Qasimha and no registered endpointHMAC-SHA256(secret, rawBody) must match. X-Qasimha-Timestamp, when present, must be within tolerance.

There is no unsigned path. Any failure throws WebhookSignatureError with reason:

reasonCause
missing_headerNo X-Qasimha-Signature header
malformed_headerNeither format; no t= or no v1=; two t=; a digest that is not 64 hex characters; a timestamp that is not Unix seconds
timestamp_outside_toleranceThe delivery time is more than tolerance seconds from now
signature_mismatchNo signature matches the body with this secret

err.code carries the same value as err.reason.

webhooks.verifySignature

verifySignature(rawBody, signatureHeader, secret?, options?) checks only the signature and returns nothing. Pass the X-Qasimha-Signature value as signatureHeader. For the sha256= format, pass options.timestampHeader to get the tolerance check. It throws the same errors as constructEvent.

Standalone forms exist for code that has no client: verifyWebhookSignature(rawBody, signatureHeader, secret, options?) and constructWebhookEvent(rawBody, headers, secret, options?), where secret is required. readWebhookHeader(headers, 'signature' | 'timestamp' | 'eventId' | 'event') reads one Qasimha header from any supported headers shape.

Event types

WEBHOOK_EVENT_TYPES lists the events of a registered endpoint: order.confirmed, order.declined, order.fulfilled, order.partially_fulfilled, order.refunded, order.partially_refunded, order.voided, order.cancelled, order.expired and checkout_link.expired. A ping event arrives when you test an endpoint. The legacy format sends order.completed, order.declined, order.voided and order.reversed, and carries no orderId: resolve it with orders.getByReference(event.orderReferenceId).

Answer 2xx quickly and do the work afterwards. Retries repeat the event id, so de-duplicate on event.id. Events can arrive out of order. See Webhooks and Webhook signatures.

Framework integration

The rule is the same in every framework: the webhook route must see the raw bytes. Parse JSON for your other routes as usual.

Register express.raw on the webhook route, before any global express.json(). express.raw needs Express 4.17 or later.

const express = require('express');
const { Qasimha, WebhookSignatureError } = require('@qasimha/sdk');

const app = express();
const qasimha = new Qasimha({ apiKey: process.env.QASIMHA_API_KEY, webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET });

app.post('/webhooks/qasimha', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = qasimha.webhooks.constructEvent(req.body, req.headers);   // req.body is a Buffer here
  } catch (error) {
    return res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
  }
  res.sendStatus(200);
  queue(event);           // process after answering; de-duplicate on event.id
});

app.use(express.json());   // for the rest of the app, after the webhook route

app.post('/checkout', async (req, res, next) => {
  try {
    const session = await qasimha.checkout.createSession(await buildSessionFromCart(req));
    res.json({ paymentUrl: session.paymentUrl });
  } catch (error) {
    next(error);
  }
});

Fulfil, capture, refund and cancel

An order that completes the hosted checkout is already Confirmed: the customer signed the contract and paid the first instalment. Mark it paid on Confirmed, call fulfill when you ship, and refund after that. These calls need the orders:write scope and take the Qasimha order id.

await qasimha.orders.fulfill(orderId, {               // on shipment; omit shippedAmount to fulfil everything
  shippedAmount: 1200.00,
  shippingCompany: 'Aramex',
  trackingNumber: 'ARX123',
  trackingUrl: 'https://shop.example/track/ARX123',
});
await qasimha.orders.refund(orderId, { amount: 300.00, reason: 'Customer return' });   // ≤ order.refundableAmount
await qasimha.orders.cancel(orderId);                 // voids an unfulfilled order
await qasimha.orders.capture(orderId);                // New / Approved orders only
MethodParametersReturnsWhen it applies and errors
orders.fulfill(id, request?, options?)shippedAmount? (money; omit or null to fulfil everything outstanding), shippingCompany?, trackingNumber?, trackingUrl?FulfillResult { id, status, fulfilledAmount, raw, requestId, replayed }A partial amount moves the order to PartiallyFulfilled, the full amount to FullyFulfilled. 400 INVALID_SHIPPED_AMOUNT for 0; 400 INVALID_STATUS in the wrong status.
orders.refund(id, request, options?)amount (required, greater than zero, two decimals), reason?RefundResult { id, status, refundedAmount, raw, requestId, replayed }Up to fulfilledAmount minus refunds so far (order.refundableAmount). 400 AMOUNT_EXCEEDED, 400 INVALID_AMOUNT, 400 INVALID_STATUS.
orders.cancel(id, options?)noneCancelResult { id, status, raw, requestId, replayed }Voids an order that has not been fulfilled (Voided). 400 INVALID_STATUS otherwise.
orders.capture(id, options?)noneCaptureResult { id, status, confirmedAt, raw, requestId, replayed }Moves a New / Approved order to Confirmed. A hosted-checkout order is already Confirmed, so capture answers 400 INVALID_STATUS.

Every call also throws NotFoundError for an order that does not exist or is not yours, and ConfigurationError for an empty id. options is the request options object; see Retries, timeouts and idempotency. See Order lifecycle.

Read orders

Reads need the orders:read scope.

const order = await qasimha.orders.get(orderId);
const same  = await qasimha.orders.getByReference('ORDER-12345');

const page = await qasimha.orders.list({ status: 'Confirmed', search: 'Noura', page: 1, pageSize: 20 });
page.items;        // OrderSummary[]
page.pagination;   // { totalItems, page, pageSize, totalPages }

const counts = await qasimha.orders.counts();          // { all, approved, authorised, captured, refunded, cancelled }

const contract = await qasimha.orders.contract(orderId);
await fs.promises.writeFile(contract.fileName ?? 'contract.pdf', contract.bytes);
MethodNotes
orders.get(id)The full Order. NotFoundError for an unknown id or another merchant's order.
orders.getByReference(orderReferenceId)The same Order, by your reference. Exact, case-sensitive match. A reference with / (ORDER/2026/1) is sent with each segment percent-encoded and the / kept.
orders.list(params?)status, search, page (API default 1), pageSize (API default 20). status takes a status name (Confirmed, FullyFulfilled, Voided, ...) or a dashboard alias (approved, authorised, captured, refunded, cancelled); anything else is 400 INVALID_STATUS. search matches your reference, the customer's name, email or phone. Newest first.
orders.counts()OrderCounts { all, approved, authorised, captured, refunded, cancelled }, each number | null.
orders.contract(id)The financing contract: ContractDocument { bytes: Buffer, contentType, fileName }. fileName comes from Content-Disposition.

The Order model

Status-like fields always carry the name, even if the API sends a number; an unknown value comes through as a string and never throws. Dates are ISO-8601 strings. Every field is null when the API did not send it, and the full response stays in order.raw.

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        { id, name, email, phone }
  items[]         { referenceId, name, type, sku, quantity, unitPrice, totalAmount, taxAmount, discountAmount, imageUrl }
  installments[]  { sequence, dueDate, amount, principal, profit, status, paidAmount, paidAt, providerRef }
  fulfillments[]  { id, amount, currencyCode, shippingCompany, trackingNumber, trackingUrl, providerCaptureId, createdAt }
  reversals[]     { id, amount, currencyCode, comment, fulfillmentId, providerRefundId, createdAt }
  voids[]         { id, amount, currencyCode, reason, providerCancelId, createdAt }
  statusHistory[] { fromStatus, toStatus, trigger, createdAt }
  raw, requestId, replayed

OrderSummary (orders.list rows)
  id, orderReferenceId, status, policyCode, policyType, totalAmount, currencyCode, createdAt,
  customerId, customerName, customerEmail, customerPhone, instalments, channel,
  capturedAmount, refundedAmount, ejarContractNumber, raw
Amount fieldMeaning
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

Enum names, exported as arrays with a normaliser each:

ExportValuesNormaliser
ORDER_STATUS_NAMESDraft, New, Approved, Declined, Expired, Confirmed, FullyFulfilled, PartiallyFulfilled, FullyReversed, PartiallyReversed, Voided, Updated, AwaitingCheckout, Cancelled, Settled, WrittenOffnormaliseOrderStatus
ORDER_CHANNEL_NAMESOnline, InStore, QrCodenormaliseOrderChannel
PAYMENT_TYPE_NAMESPayByInstalments, PayNow, PayNextMonth, PayInFullnormalisePaymentType
INSTALLMENT_STATUS_NAMESScheduled, Paid, Late, Reversed, WaivednormaliseInstallmentStatus
WEBHOOK_DELIVERY_STATUS_NAMESPending, Succeeded, FailednormaliseWebhookDeliveryStatus

The request field paymentType uses the snake_case codes (pay_by_instalments); order.paymentType comes back as the name (PayByInstalments).

Payment links and QR codes

Without a website, or in a shop, create the order up front and let the customer complete the same hosted journey. channel: 'Link' sends the link to the customer's phone and needs phone; channel: 'Qr' returns a URL you show as a QR code.

const link = await qasimha.checkoutLinks.create({
  amount: 250.00,
  channel: 'Link',
  phone: '+9665XXXXXXXX',
  language: 'ar',
  orderReferenceId: 'ORDER-12345',          // generated when omitted
  items: [{ name: 'Shoes', sku: 'SH-1', quantity: 1, unitPrice: 250.00 }],
});
link.id;    // the Qasimha order id
link.url;   // the hosted /pay/{orderId} page

const status = await qasimha.checkoutLinks.get(link.id);   // status, completed, declined, expiresAt, ...
const recent = await qasimha.checkoutLinks.list();         // { items }: your 50 most recent links
await qasimha.checkoutLinks.cancel(link.id);               // 409 NOT_OPEN once completed or expired

CreateCheckoutLinkRequest:

FieldTypeRequiredRules
amountnumberYesMoney rules
channel'Link' | 'Qr'Yes
phonestringFor LinkThe SDK refuses a Link without a phone before sending
language'ar' | 'en'NoThe language the customer is contacted in and the page opens in
orderReferenceIdstringNoGenerated by the platform when omitted
itemsCheckoutLinkItemInput[]NoEach: name (required), sku?, quantity (integer, at least 1), unitPrice (money), imageUrl?
ejarContractNumberstringNoRequired by the API for a real-estate merchant; ignored for others
merchantUrlMerchantUrlNoSame rules as on a checkout session

create, get and cancel return a CheckoutLinkResult; list returns CheckoutLinkList { items, requestId, replayed }. The three reads return different shapes, so every member except id and raw is optional:

CheckoutLink
  id, url, channel, amount, currency, phone, language, orderReferenceId, ejarContractNumber,
  items[] { name, sku, quantity, unitPrice, total, imageUrl },
  status, decisionStatus, expiresAt, createdAt, completed, declined, waiting, raw

get returns a masked phone plus completed and declined; list adds a waiting flag for links still open. Links expire after two hours. Create errors: 400 INVALID_AMOUNT, PHONE_REQUIRED, EJAR_CONTRACT_NUMBER_REQUIRED, INVALID_EJAR_CONTRACT_NUMBER (ValidationError); 404 MERCHANT_NOT_FOUND (NotFoundError); 409 MERCHANT_SUSPENDED (ConflictError). cancel answers 409 NOT_OPEN (ConflictError) when the link is no longer open. See Checkout flows.

Settlements

Settlements need a key with the settlements:read scope. Without it, both calls throw PermissionError.

const settlements = await qasimha.settlements.list({ page: 1, pageSize: 20 });   // Page<Settlement>, newest first
const overview = await qasimha.settlements.overview();
MethodReturns
settlements.list(params?)Page<Settlement>: items and pagination. Settlement { id, providerSettlementId, amount, currencyCode, settledAt, status, orderCount, raw }. page and pageSize default to 1 and 20 on the API.
settlements.overview()SettlementOverview: the response data passed through, with totalSettled, count, lastSettlement, settlementCycle, settlementModel, minimumSettlementAmount, remainingForNextSettlement and any other figures, plus requestId and replayed.

Settlement amounts are net of fees. See Order lifecycle.

Manage webhook endpoints

A key with the webhooks:manage scope can register up to five HTTPS endpoints per account and read their delivery log. You can do the same in the Merchant Portal under Settings → Webhooks.

const endpoint = await qasimha.webhookEndpoints.create({
  url: 'https://shop.example/webhooks/qasimha',
  description: 'Production store',
  events: ['order.confirmed', 'order.refunded'],     // omit for every event
});
await secrets.put('QASIMHA_WEBHOOK_SECRET', endpoint.secret);   // whsec_..., shown once

await qasimha.webhookEndpoints.ping(endpoint.id);                // queues a signed ping event

const rotated = await qasimha.webhookEndpoints.rotateSecret(endpoint.id);
rotated.secret;                    // the new secret, shown once
rotated.previousSecretExpiresAt;   // until then, deliveries carry two v1 signatures

const log = await qasimha.webhookEndpoints.listDeliveries(endpoint.id, { status: 'Failed', page: 1, pageSize: 20 });
await qasimha.webhookEndpoints.replayDelivery(endpoint.id, log.items[0].id);
MethodHTTPReturnsNotes
create({ url, description?, events? }, options?)POST /api/v1/merchant/webhooksWebhookEndpointWithSecretsecret is returned once. Store it straight away.
list()GET /api/v1/merchant/webhooksWebhookEndpointList { items }Newest first. No secrets.
get(id)GET /api/v1/merchant/webhooks/{id}WebhookEndpointResult
update(id, { url?, description?, events?, isActive? }, options?)PUT /api/v1/merchant/webhooks/{id}WebhookEndpointResultOmitted fields keep their value. events: [] means every event.
delete(id, options?)DELETE /api/v1/merchant/webhooks/{id}voidStops all deliveries. Deliveries waiting for a retry end as failed.
rotateSecret(id, options?)POST /api/v1/merchant/webhooks/{id}/rotate-secretWebhookEndpointWithSecretNew secret, once. For the next 24 hours every delivery is signed with both secrets.
ping(id, options?)POST /api/v1/merchant/webhooks/{id}/pingWebhookPingResult { eventId, endpointId }A signed ping event is queued to the URL.
listDeliveries(id, { status?, page?, pageSize? })GET /api/v1/merchant/webhooks/{id}/deliveriesPage<WebhookDelivery>status: Pending, Succeeded or Failed. page and pageSize are integers of at least 1 (API default 20, maximum 100).
replayDelivery(id, deliveryId, options?)POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replayWebhookDeliveryResultQueues the same event again as a new delivery with replayOfId set.
WebhookEndpoint
  id, url, description, events[], isActive, secretHint, previousSecretExpiresAt, createdAt, updatedAt, raw
WebhookEndpointWithSecret = WebhookEndpoint + secret + requestId + replayed

WebhookDelivery
  id, eventId, eventType, orderId, orderReferenceId, status, attempts, maxAttempts,
  nextAttemptAt, lastAttemptAt, lastStatusCode, lastError, responseSnippet, durationMs,
  succeededAt, replayOfId, createdAt, raw

The SDK never logs the secret and keeps it out of raw. No later read returns it; secretHint (its last characters) tells you which secret a server holds.

Rules checked before sending, as ValidationError: url must be an absolute https:// URL; description at most 200 characters; every events entry must be in WEBHOOK_EVENT_TYPES (order.completed is still accepted as an alias of order.confirmed); isActive a boolean. API refusals arrive as ValidationError with URL_REQUIRED, URL_INVALID, URL_SCHEME, URL_PRIVATE (private or internal address), URL_TOO_LONG, INVALID_EVENT or LIMIT_EXCEEDED (more than five endpoints). A key without webhooks:manage gets PermissionError; another merchant's endpoint is NotFoundError.

Quote plans with the publishable key

checkout.getPlans returns the plans your policy offers for an amount: the same quote the widget shows. It sends the publishable key in X-Public-Key and never sends the secret key.

const quote = await qasimha.checkout.getPlans(1200.00, { lang: 'ar' });

if (quote.plans.length === 0) {
  // amount outside [quote.minAmount, quote.maxAmount], or no active policy: do not offer instalments
}
for (const plan of quote.plans) {
  plan.instalments; plan.frequency; plan.instalmentAmount;
  plan.firstInstalmentAmount; plan.lastInstalmentAmount;
  plan.feesIncludingVat; plan.totalPayable; plan.shariaCompliant;
}

await qasimha.checkout.getPlans(1200.00, { publishableKey: 'pk_test_...' });   // a key per call
ParameterTypeNotes
amountnumberGreater than zero, at most two decimals. Sent with currency=SAR.
options.lang'ar' | 'en'Defaults to the client language.
options.publishableKeystringOverrides the client's publishableKey. Must be pk_test_… / pk_live_… of the secret key's environment.

Plans { merchantName, currency, minAmount, maxAmount, plans, raw, requestId, replayed }; each Plan { instalments, frequency, instalmentAmount, firstInstalmentAmount, lastInstalmentAmount, feesIncludingVat, totalPayable, shariaCompliant }.

An empty plans list is an answer, not an error. Without a publishable key the call throws ConfigurationError before any request. A missing or unknown key answers 401 PUBLIC_KEY_REQUIRED / INVALID_PUBLIC_KEY (AuthenticationError); 400 INVALID_AMOUNT / UNSUPPORTED_CURRENCY is a ValidationError. The call is a GET and retries like every read. The platform keeps a quote for one minute, so cache it on your side too. See Plans widget.

Method reference

All paths are under the environment's base URL. options is { idempotencyKey?: string | false }.

MethodHTTPReturnsNotable errors
checkout.createSession(request, options?)POST /api/v1/pay/session/createSessionValidationError, DuplicateOrderReferenceError, OrderDeclinedError, ConflictError (MERCHANT_SUSPENDED)
checkout.createPosSession(request, options?)POST /api/v1/pay/pos/createPosSessionSame as createSession
checkout.createQrSession(request, options?)POST /api/v1/pay/qr/createQrSessionSame, plus OrderDeclinedError for a declined 201
checkout.getPlans(amount, { lang?, publishableKey? })GET /api/v1/widget/plansPlansConfigurationError (no publishable key), AuthenticationError, ValidationError
checkoutLinks.create(request, options?)POST /api/v1/merchant/checkout-linksCheckoutLinkResultValidationError (PHONE_REQUIRED, ...), NotFoundError, ConflictError
checkoutLinks.list()GET /api/v1/merchant/checkout-linksCheckoutLinkList
checkoutLinks.get(id)GET /api/v1/merchant/checkout-links/{id}CheckoutLinkResultNotFoundError
checkoutLinks.cancel(id, options?)POST /api/v1/merchant/checkout-links/{id}/cancelCheckoutLinkResultConflictError (NOT_OPEN), NotFoundError
orders.list({ status?, search?, page?, pageSize? })GET /api/v1/merchant/ordersPage<OrderSummary>ValidationError (INVALID_STATUS)
orders.get(id)GET /api/v1/merchant/orders/{id}OrderNotFoundError
orders.getByReference(orderReferenceId)GET /api/v1/merchant/orders/by-reference/{orderReferenceId}OrderNotFoundError
orders.capture(id, options?)POST /api/v1/merchant/orders/{id}/captureCaptureResultValidationError (INVALID_STATUS)
orders.fulfill(id, { shippedAmount?, shippingCompany?, trackingNumber?, trackingUrl? }?, options?)POST /api/v1/merchant/orders/{id}/fulfillFulfillResultValidationError (INVALID_STATUS, INVALID_SHIPPED_AMOUNT)
orders.refund(id, { amount, reason? }, options?)POST /api/v1/merchant/orders/{id}/refundRefundResultValidationError (AMOUNT_EXCEEDED, INVALID_AMOUNT, INVALID_STATUS)
orders.cancel(id, options?)POST /api/v1/merchant/orders/{id}/cancelCancelResultValidationError (INVALID_STATUS)
orders.counts()GET /api/v1/merchant/orders/countsOrderCounts
orders.contract(id)GET /api/v1/merchant/orders/{id}/contractContractDocumentNotFoundError
settlements.list({ page?, pageSize? })GET /api/v1/merchant/settlementsPage<Settlement>PermissionError (settlements:read)
settlements.overview()GET /api/v1/merchant/settlements/overviewSettlementOverviewPermissionError (settlements:read)
webhookEndpoints.create(request, options?)POST /api/v1/merchant/webhooksWebhookEndpointWithSecretValidationError (URL_*, INVALID_EVENT, LIMIT_EXCEEDED), PermissionError
webhookEndpoints.list()GET /api/v1/merchant/webhooksWebhookEndpointListPermissionError (webhooks:manage)
webhookEndpoints.get(id)GET /api/v1/merchant/webhooks/{id}WebhookEndpointResultNotFoundError
webhookEndpoints.update(id, request, options?)PUT /api/v1/merchant/webhooks/{id}WebhookEndpointResultValidationError, NotFoundError
webhookEndpoints.delete(id, options?)DELETE /api/v1/merchant/webhooks/{id}voidNotFoundError
webhookEndpoints.rotateSecret(id, options?)POST /api/v1/merchant/webhooks/{id}/rotate-secretWebhookEndpointWithSecretNotFoundError
webhookEndpoints.ping(id, options?)POST /api/v1/merchant/webhooks/{id}/pingWebhookPingResultNotFoundError
webhookEndpoints.listDeliveries(id, { status?, page?, pageSize? })GET /api/v1/merchant/webhooks/{id}/deliveriesPage<WebhookDelivery>ValidationError, NotFoundError
webhookEndpoints.replayDelivery(id, deliveryId, options?)POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replayWebhookDeliveryResultNotFoundError
webhooks.constructEvent(rawBody, headers, secret?, options?)none (local)WebhookEventWebhookSignatureError, ConfigurationError, ValidationError (body not JSON)
webhooks.verifySignature(rawBody, signatureHeader, secret?, options?)none (local)voidWebhookSignatureError, ConfigurationError

Every API call can also throw AuthenticationError, RateLimitError, ServerError and ConnectionError. Every method that takes an id throws ConfigurationError for an empty id, before sending. Page<T> is { items: T[], pagination: { totalItems, page, pageSize, totalPages }, requestId, replayed }.

Errors

Every error extends QasimhaError, which extends Error:

MemberTypeMeaning
namestringThe class name, for example 'ValidationError'
messagestringThe API's message (already in the client language), or the SDK's own
codestring | nullThe API code (VALIDATION_ERROR, INVALID_STATUS, ...), VALIDATION_ERROR for a local refusal, the reason for a webhook failure
httpStatusnumber | nullnull when nothing was sent or no response came back
requestIdstring | nullThe request id. Quote it to support.
errors{ field: string | null, message: string | null }[]Field errors; [] when none. API field names come back as the API sends them (for example TotalAmount); local ones use the SDK names (items[1].totalAmount).
rawunknownThe decoded response envelope, or the response text
causeunknownThe transport error, on ConnectionError
toJSON()method{ name, message, code, httpStatus, requestId, errors } plus the subclass fields; raw is left out
ClassExtra fieldsWhen
ConfigurationErrorClient misconfigured, a missing webhook secret or publishable key, or an empty id. Nothing is sent.
ValidationErrorAny 400; a request refused locally (httpStatus: null); a webhook body that is not a JSON object
AuthenticationError401: bad or revoked key (often an empty body), or a bad publishable key on getPlans
PermissionError403: the key lacks the scope
NotFoundError404
ConflictErrorAny other 409: MERCHANT_SUSPENDED, NOT_OPEN, or REQUEST_IN_PROGRESS after the retries
DuplicateOrderReferenceErrorexistingOrderId, existingPaymentUrl409 DUPLICATE_ORDER_REFERENCE. Extends ConflictError.
OrderDeclinedErrororderId422 ORDER_DECLINED, or a declined QR 201
RateLimitErrorretryAfter (seconds, or null)429 after the retries
ApiErrorAny other status, including a redirect, 422 IDEMPOTENCY_KEY_REUSED, a 2xx whose envelope says success: false, or a response without the expected id
ServerError5xx. Extends ApiError.
ConnectionErrorDNS, TLS, socket or timeout failure, after the retries. The transport error is in cause.
WebhookSignatureErrorreasonWebhook verification failed
import { QasimhaError, RateLimitError } from '@qasimha/sdk';

try {
  await qasimha.orders.refund(orderId, { amount: 300.00 });
} catch (error) {
  if (error instanceof RateLimitError) {
    retryLater(error.retryAfter);
  } else if (error instanceof QasimhaError) {
    log.warn('refund failed', { code: error.code, httpStatus: error.httpStatus, requestId: error.requestId });
  }
  throw error;
}

See Errors.

Retries, timeouts and idempotency

  • Timeout. timeout applies to each attempt, from sending the request to reading the whole response. A timeout counts as a connection failure.
  • What retries. Connection failures, 429, 502, 503, 504 and 409 REQUEST_IN_PROGRESS, up to maxRetries extra attempts. Never a 500 or any other 4xx: a 500 on a write may have gone through.
  • Backoff. min(2^n × 500 ms, 8 s) plus 0–250 ms of random jitter, where n is 0 for the first retry: about 0.5 s, 1 s, 2 s, 4 s, 8 s.
  • Retry-After. On a 429, a Retry-After header in seconds or as an HTTP date replaces the backoff, capped at 30 seconds. An unreadable header falls back to the backoff.
  • Idempotency-Key. Every POST, PUT and DELETE carries an Idempotency-Key: a UUID v4 generated for the call and reused on every attempt. A retry then gets the stored answer (result.replayed === true) instead of a second order. GET requests carry none and always retry.
  • Your own key. Pass { idempotencyKey: '<uuid>' } as the last argument. Use a fresh UUID v4 per operation: a key is remembered for 24 hours for your account, method and path, and the request body is compared. Reusing a key with a different body answers 422 IDEMPOTENCY_KEY_REUSED (ApiError).
  • No key. Pass { idempotencyKey: false } to send none. That call is then never retried.
  • Correlation. Each call sends one X-Correlation-Id (a UUID v4), the same on every attempt.
  • Duplicate on a retry. A 409 DUPLICATE_ORDER_REFERENCE on a retry of createSession, createPosSession or createQrSession means the first attempt succeeded. The SDK returns that order as the result when the response names it.

RequestOptions has one member, idempotencyKey?: string | false. It is accepted by every write: the three checkout.create* calls, checkoutLinks.create and cancel, orders.capture, fulfill, refund and cancel, and every webhookEndpoints write. There is no per-call timeout or retry setting; create a second client if one job needs different values.

Every result carries requestId (the response uuid, or the X-Request-Id header) and replayed (true when the API sent Idempotent-Replay: true).

Each request also sends X-Api-Key (or X-Public-Key on getPlans), Accept, Accept-Language and the User-Agent. See Idempotency.

Logging and security

const qasimha = new Qasimha({
  apiKey: process.env.QASIMHA_API_KEY!,
  logger: (level, message, fields) => log[level](message, fields),
});

The logger type is (level: 'debug' | 'info' | 'warn' | 'error', message: string, fields: Record<string, unknown>) => void (exported as Logger and LogLevel). There is no logging by default.

LevelMessagefields
debugrequest completedmethod, path, status, durationMs, requestId, attempt, code
warnrequest rejected (an error status)Same as above
warnrequest failed (no response)Same, with status: null, plus error (the failure message)
inforetrying requestmethod, path, status, requestId, attempt, code, delayMs
infoduplicate order reference on a retry: the earlier attempt had succeededmethod, path, status, requestId, attempt, code

path has no query string, so a search term never reaches your logs. code is the API code or null. attempt starts at 1.

  • The SDK never passes a key, the webhook secret, a request or response body, or customer data to the logger, an error message or a printed client. maskedKey() returns sk_live_…AbCd for your own logs.
  • Keep the secret key and webhook secret in a secrets manager or environment variable. Never ship them to a browser or mobile app. The publishable key opens only the plan quote and may sit in a page.
  • TLS only. Certificate checks use the runtime's defaults and cannot be switched off by an option, and there is no certificate pinning.
  • Redirects are never followed. A 3xx from the API becomes ApiError.
  • The SDK calls the runtime's global fetch. There is no option to pass your own HTTP client, agent or proxy.
  • Proxies: Node's built-in fetch reads HTTPS_PROXY only on recent releases started with NODE_USE_ENV_PROXY=1. On older runtimes, register an undici ProxyAgent as the global dispatcher in your application.
  • No card data passes through your server; customers pay on the hosted checkout, so you stay in PCI DSS SAQ-A scope.

Complete example

One Express server: it prices the basket on the server, creates the session, confirms the order on the return route, verifies webhooks with the raw body, and exposes fulfil, refund and cancel endpoints for your back office. Replace the in-memory maps with your database, and protect the /admin routes with your own authentication.

npm install express @qasimha/sdk
QASIMHA_API_KEY=sk_test_... QASIMHA_WEBHOOK_SECRET=whsec_... STORE_BASE_URL=http://localhost:3000 node server.mjs
import express from 'express';
import { randomUUID } from 'node:crypto';
import Qasimha, {
  ConnectionError,
  DuplicateOrderReferenceError,
  NotFoundError,
  OrderDeclinedError,
  QasimhaError,
  WebhookSignatureError,
  toHalalas,
} from '@qasimha/sdk';

const STORE_BASE_URL = process.env.STORE_BASE_URL ?? 'http://localhost:3000';
const PAID = new Set(['Confirmed', 'FullyFulfilled', 'PartiallyFulfilled']);

const qasimha = new Qasimha({
  apiKey: process.env.QASIMHA_API_KEY,
  webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET,
  language: 'en',
  userAgentSuffix: 'example-store/1.0.0',
  logger: (level, message, fields) => console.log(JSON.stringify({ level, message, ...fields })),
});

// The store's own data. Use your database instead.
const CATALOGUE = new Map([
  ['SKU-1', { name: 'Wireless Headphones', type: 'physical', unitPrice: 899.00 }],
  ['SKU-2', { name: 'USB-C Cable', type: 'physical', unitPrice: 45.00 }],
]);
const SHIPPING = 25.00;
const orders = new Map();        // orderReferenceId -> { qasimhaOrderId, status, paid }
const seenEvents = new Set();    // webhook event ids already handled

function priceBasket(lines) {
  if (!Array.isArray(lines) || lines.length === 0) throw new Error('The basket is empty');
  const items = lines.map(({ sku, quantity }) => {
    const product = CATALOGUE.get(sku);
    if (!product || !Number.isInteger(quantity) || quantity < 1) throw new Error(`Invalid line ${sku}`);
    return {
      referenceId: sku,
      sku,
      name: product.name,
      type: product.type,
      quantity,
      unitPrice: product.unitPrice,
      totalAmount: (toHalalas(product.unitPrice) * quantity) / 100,
    };
  });
  const itemsHalalas = items.reduce((sum, item) => sum + toHalalas(item.totalAmount), 0);
  return { items, shippingAmount: SHIPPING, totalAmount: (itemsHalalas + toHalalas(SHIPPING)) / 100 };
}

function sendError(res, error) {
  if (error instanceof QasimhaError) {
    const status = error instanceof ConnectionError || (error.httpStatus ?? 0) >= 500 ? 502 : 422;
    return res.status(status).json({ code: error.code, message: error.message, fields: error.errors, requestId: error.requestId });
  }
  console.error(error);
  return res.status(500).json({ code: 'INTERNAL_ERROR' });
}

const route = (handler) => (req, res) => handler(req, res).catch((error) => sendError(res, error));

async function qasimhaIdFor(reference) {
  const local = orders.get(reference);
  if (local) return local.qasimhaOrderId;
  return (await qasimha.orders.getByReference(reference)).id;
}

async function handleEvent(event) {
  if (event.type === 'ping') return;
  const order = event.orderId
    ? await qasimha.orders.get(event.orderId)
    : await qasimha.orders.getByReference(event.orderReferenceId);
  const local = orders.get(order.orderReferenceId);
  if (!local) return;
  local.status = order.status;
  if (PAID.has(order.status)) local.paid = true;
}

const app = express();

// 1. Webhooks: raw body, registered before express.json().
app.post('/webhooks/qasimha', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = qasimha.webhooks.constructEvent(req.body, req.headers);
  } catch (error) {
    console.warn('webhook rejected', error instanceof WebhookSignatureError ? error.reason : error.message);
    return res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
  }
  res.sendStatus(200);
  if (event.id && seenEvents.has(event.id)) return;
  if (event.id) seenEvents.add(event.id);
  handleEvent(event).catch((error) => console.error('webhook processing failed', error.message));
});

app.use(express.json());

// 2. Create the session. Body: { items: [{ sku, quantity }], customer: { firstName, lastName, phoneNumber, email }, address: { line1, city } }
app.post('/checkout', route(async (req, res) => {
  let basket;
  try {
    basket = priceBasket(req.body.items);
  } catch (error) {
    return res.status(400).json({ code: 'INVALID_BASKET', message: error.message });
  }
  const { customer = {}, address = {} } = req.body;
  const orderReferenceId = `ORDER-${randomUUID()}`;
  const returnUrl = `${STORE_BASE_URL}/checkout/qasimha/return`;

  try {
    const session = await qasimha.checkout.createSession({
      orderReferenceId,
      ...basket,
      description: `Order ${orderReferenceId}`,
      consumer: {
        firstName: customer.firstName,
        lastName: customer.lastName,
        phoneNumber: customer.phoneNumber,
        email: customer.email,
      },
      shippingAddress: {
        firstName: customer.firstName,
        lastName: customer.lastName,
        line1: address.line1,
        city: address.city,
        countryCode: 'SA',
      },
      merchantUrl: { success: returnUrl, failure: returnUrl, cancel: returnUrl },
    });
    orders.set(orderReferenceId, { qasimhaOrderId: session.orderId, status: session.status, paid: false });
    res.status(201).json({ orderReferenceId, paymentUrl: session.paymentUrl });
  } catch (error) {
    if (error instanceof DuplicateOrderReferenceError && error.existingPaymentUrl) {
      return res.json({ orderReferenceId, paymentUrl: error.existingPaymentUrl });
    }
    if (error instanceof OrderDeclinedError) {
      return res.status(422).json({ code: 'DECLINED', message: 'Choose another payment method.' });
    }
    throw error;
  }
}));

// 3. The return route: never trust the query string, read the order.
app.get('/checkout/qasimha/return', route(async (req, res) => {
  const orderId = typeof req.query.orderId === 'string' ? req.query.orderId : null;
  if (!orderId) return res.redirect('/cart');
  let order;
  try {
    order = await qasimha.orders.get(orderId);
  } catch (error) {
    if (error instanceof NotFoundError) return res.redirect('/cart');
    throw error;
  }
  const local = orders.get(order.orderReferenceId);
  if (!local || local.qasimhaOrderId !== order.id) return res.redirect('/cart');
  local.status = order.status;
  if (PAID.has(order.status)) {
    local.paid = true;
    return res.redirect(`/thank-you?order=${encodeURIComponent(order.orderReferenceId)}`);
  }
  res.redirect('/cart?payment=failed');
}));

// 4. Back office: fulfil, refund, cancel. Protect these routes.
app.post('/admin/orders/:reference/fulfil', route(async (req, res) => {
  const { shippedAmount, shippingCompany, trackingNumber, trackingUrl } = req.body ?? {};
  const result = await qasimha.orders.fulfill(await qasimhaIdFor(req.params.reference), {
    shippedAmount, shippingCompany, trackingNumber, trackingUrl,
  });
  res.json({ status: result.status, fulfilledAmount: result.fulfilledAmount });
}));

app.post('/admin/orders/:reference/refund', route(async (req, res) => {
  const { amount, reason } = req.body ?? {};
  const result = await qasimha.orders.refund(await qasimhaIdFor(req.params.reference), { amount, reason });
  res.json({ status: result.status, refundedAmount: result.refundedAmount });
}));

app.post('/admin/orders/:reference/cancel', route(async (req, res) => {
  const result = await qasimha.orders.cancel(await qasimhaIdFor(req.params.reference));
  res.json({ status: result.status });
}));

app.listen(Number(process.env.PORT ?? 3000), () => {
  console.log(`Listening with ${qasimha}`);   // prints the environment, host and masked key
});

With the sample catalogue, a basket of [{ "sku": "SKU-1", "quantity": 1 }, { "sku": "SKU-2", "quantity": 3 }] is sent as items 899.00 + 135.00, shipping 25.00 and totalAmount: 1059.00, so both basket identities hold.

Test in UAT

  1. Sign in to the UAT Merchant Portal at https://portal-uat.qasimha.com and create an sk_test_ key (and a pk_test_ key if you quote plans) under Settings → API Keys. The SDK sends sk_test_ keys to https://api-uat.qasimha.com on its own; do not set baseUrl.
  2. Register a webhook endpoint for UAT, in the portal under Settings → Webhooks or with webhookEndpoints.create. The URL must be public HTTPS: private or internal addresses are refused with URL_PRIVATE. Store the returned whsec_ secret as QASIMHA_WEBHOOK_SECRET.
  3. Call webhookEndpoints.ping(endpoint.id) and check that your route accepts the ping event. If it does not, read webhookEndpoints.listDeliveries(endpoint.id): lastStatusCode, lastError and responseSnippet show what your server answered.
  4. Create a session, open paymentUrl, complete or cancel the hosted checkout, and check that your return route reads the order with orders.get.
  5. Read the order back with orders.get or orders.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

  • Replace sk_test_ / pk_test_ with the sk_live_ / pk_live_ keys from https://portal.qasimha.com. Remove any baseUrl and environment overrides.
  • Register the production webhook endpoint and set its whsec_ secret. UAT and production have separate endpoints and secrets.
  • Return URLs point at your production site over https.
  • Your return route and webhook handler confirm with orders.get (or orders.getByReference) before marking an order paid, shipping or refunding.
  • Webhooks are verified on the raw body and de-duplicated on event.id.
  • Logs use maskedKey() and never print keys, secrets or customer data.
  • Keep the default retries and idempotency keys. Do not pass idempotencyKey: false on order creation.
  • Give each key only the scopes it needs (orders:read, orders:write, settlements:read, webhooks:manage).

Troubleshooting

ConfigurationError when the client is created

The message names the option. Common causes: environment "production" contradicts the sk_test_ key prefix (remove environment, or use the matching key); a pk_live_ key next to an sk_test_ key; a baseUrl with http:// on a host other than localhost / 127.0.0.1; maxRetries above 5; an empty QASIMHA_API_KEY because the environment variable did not load.

WebhookSignatureError: signature_mismatch

The body changed before verification, or the secret is wrong. Make sure express.json() (or any body parser) does not run before the webhook route, that Next.js reads await req.text() and not req.json(), and that NestJS passes req.rawBody. Check that the secret belongs to this endpoint and this environment, and that you stored the new secret after rotateSecret.

WebhookSignatureError: timestamp_outside_tolerance

Your server clock is more than 300 seconds away from the delivery time. Sync the clock with NTP. If you verify deliveries later from a queue, verify on receipt instead, or pass a larger tolerance.

WebhookSignatureError: missing_header or malformed_header

A proxy or load balancer stripped or rewrote X-Qasimha-Signature, or the request did not come from Qasimha. Pass the original request headers to constructEvent.

AuthenticationError (401) with an empty message body

The key is revoked, mistyped, or belongs to the other environment while a baseUrl override points elsewhere. Create a new key in the portal of the right environment. On getPlans, PUBLIC_KEY_REQUIRED or INVALID_PUBLIC_KEY means the publishable key is missing or unknown.

PermissionError (403)

The key lacks a scope. settlements.list and settlements.overview need settlements:read; every webhookEndpoints call needs webhooks:manage; reads need orders:read; creates, capture, fulfil, refund and cancel need orders:write. Scopes are fixed when a key is created: create a new key with the scopes you need in the Merchant Portal and revoke the old one.

DuplicateOrderReferenceError (409 DUPLICATE_ORDER_REFERENCE)

The orderReferenceId was already used. On a double submit, redirect to error.existingPaymentUrl when it is set. If the shopper is starting a new attempt after a failed or expired one, use a new reference for each attempt.

ValidationError (400 INVALID_STATUS) on capture

A hosted-checkout order is already Confirmed. Do not call capture; call fulfill when you ship. INVALID_STATUS on fulfill, refund or cancel means the order is not in a status that allows it: read it with orders.get and check status, fulfilledAmount and refundableAmount.

ValidationError with httpStatus null

The SDK refused the request before sending. error.errors lists each field, for example items[1].totalAmount: must equal quantity x unitPrice - discountAmount (expected 135.00, got 134.00) or shippingAddress: is required. Compute totals in halalas (toHalalas) and check both basket identities.

checkout.getPlans throws ConfigurationError

No publishable key is set. Add publishableKey to the client or pass { publishableKey } to the call. The key must be pk_test_… with an sk_test_ key, and pk_live_… with an sk_live_ key.

Next.js: the SDK fails to load in a route

The route runs on the Edge runtime, which has no node:crypto or Buffer. Add export const runtime = 'nodejs' to every route that imports the SDK, and import the client only from server code.

NestJS: req.rawBody is undefined

The application was created without rawBody: true. Pass it to NestFactory.create(AppModule, { rawBody: true }).

ConnectionError or timeouts

The server cannot reach the API host, or each attempt took longer than timeout. Check outbound HTTPS to https://api.qasimha.com or https://api-uat.qasimha.com, and your proxy setup (see Logging and security). error.cause holds the transport error.

If you still need help, email [email protected] and quote the requestId of the failing call.

Next steps


Did this page help you?