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
| Item | Requirement |
|---|---|
| Runtime | Node.js 18 or later (uses the built-in fetch) |
| Module formats | ESM and CommonJS; TypeScript declarations included |
| Runtime dependencies | None |
| Keys | A secret key (sk_test_... for UAT). Optional: a publishable key (pk_test_...) for plan quotes, and a webhook secret (whsec_...) |
| API release | Return URLs, per-merchant webhook endpoints, getByReference and getPlans need the merchant API release of 2026-09-25 or later |
Install
npm install @qasimha/sdk
# or
pnpm add @qasimha/sdk
# or
yarn add @qasimha/sdkimport 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'); // CommonJSEvery 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
| Option | Type | Default | Meaning and validation |
|---|---|---|---|
apiKey | string | — | Required. Must match ^sk_(test|live)_[A-Za-z0-9]+$. Its prefix picks the environment. |
environment | 'sandbox' | 'production' | from the key prefix | Any other value throws. A value that contradicts the key prefix throws, unless baseUrl is also set. |
baseUrl | string | per environment | Only 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. |
timeout | number | 30000 | Milliseconds per attempt. Must be a positive finite number. |
maxRetries | number | 2 | Extra attempts after the first. Integer from 0 to 5. |
webhookSecret | string | — | Default secret for webhooks.constructEvent and webhooks.verifySignature. Must be a string. |
publishableKey | string | — | 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. |
userAgentSuffix | string | — | Printable ASCII. Appended to qasimha-sdk-node/<version> (node/<version>; <platform>), for example your plugin name and version. |
logger | (level, message, fields) => void | none | Receives request logs. See Logging and security. |
Environments
| Key prefix | environment | Base URL |
|---|---|---|
sk_test_ | sandbox | https://api-uat.qasimha.com |
sk_live_ | production | https://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
| Member | What it gives you |
|---|---|
checkout, checkoutLinks, orders, settlements, webhooks, webhookEndpoints | The resources described below |
environment, baseUrl, language, timeout, maxRetries, userAgent | The 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;
apiKeyis missing or does not look likesk_test_…/sk_live_…;environmentis not"sandbox"or"production", or contradicts the key prefix without abaseUrl;baseUrlis not an absolute URL, ishttp://on a host other thanlocalhost/127.0.0.1, or carries a query string or fragment;publishableKeydoes not look likepk_test_…/pk_live_…, or belongs to the other environment;timeoutis not a positive number, ormaxRetriesis not an integer from 0 to 5;languageis not"ar"or"en",userAgentSuffixis not printable ASCII,loggeris not a function, orwebhookSecretis 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
CreateSessionRequest| Field | Type | Required / default | Rules checked before sending |
|---|---|---|---|
orderReferenceId | string | Required | Not blank, at most 255 characters. Unique per order. |
totalAmount | number | Required | Money rules below; must satisfy the basket identity |
taxAmount | number | 0 | Money rules |
shippingAmount | number | 0 | Money rules |
discountAmount | number | 0 | Money rules; subtracted from the basket |
currencyCode | string | 'SAR' | The API accepts SAR only |
countryCode | string | 'SA' | |
description | string | — | |
paymentType | 'pay_by_instalments' | 'pay_now' | 'pay_next_month' | 'pay_in_full' | 'pay_by_instalments' | One of the four values (exported as PAYMENT_TYPES) |
instalments | number | 4 | Integer, at least 1 |
items | Item[] | Required | At least one entry |
consumer | Consumer | Required | See below |
shippingAddress | Address | Required | Required by the API, even for digital baskets |
billingAddress | Address | — | Same rules as shippingAddress |
riskAssessment | RiskAssessment | — | See below |
merchantUrl | MerchantUrl | — | See below |
Item:
| Field | Type | Required / default | Rules |
|---|---|---|---|
referenceId | string | Required | Your line or product id |
name | string | Required | |
type | 'physical' | 'digital' | Required | |
sku | string | — | |
quantity | number | Required | Integer, at least 1 |
unitPrice | number | Required | Money rules |
totalAmount | number | Required | quantity × unitPrice − discountAmount, to the halala |
taxAmount | number | 0 | Money rules. Not part of the line identity. |
discountAmount | number | 0 | Money rules |
imageUrl | string | — |
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.
| Field | Rules |
|---|---|
success | Required when merchantUrl is given |
failure | Required when merchantUrl is given |
cancel | Optional; the platform uses failure when it is missing |
notification | Reserved. 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 as0.1 + 0.2is sent as0.3. - No negative amounts.
item.totalAmount = quantity × unitPrice − item.discountAmounttotalAmount = Σ 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:
| Field | Type | Meaning |
|---|---|---|
orderId | string | The Qasimha order id. Store it; every later order call uses it. |
paymentUrl | string | null | The hosted checkout. Redirect the shopper here. |
providerSessionId | string | null | Session id from the platform |
status | string | null | AwaitingCheckout for a new session |
raw | object | The response data as received |
requestId | string | null | Quote it to support |
replayed | boolean | true when the API replayed an earlier answer for the same idempotency key |
| Response | What the SDK does |
|---|---|
201 | Returns 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 attempt | Throws DuplicateOrderReferenceError with existingOrderId and existingPaymentUrl (either may be null). |
409 DUPLICATE_ORDER_REFERENCE, on a retry | The 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_DECLINED | Throws OrderDeclinedError with orderId. Only a deployment that decides at creation sends this; the hosted journey declines on the page. |
400 | Throws 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| Field | createPosSession | createQrSession |
|---|---|---|
orderReferenceId | Required, ≤ 255 characters | Required, ≤ 255 |
totalAmount | Required | Required |
currencyCode | 'SAR' | 'SAR' |
countryCode | 'SA' | 'SA' |
items | Required, same Item rules | Required |
consumer | Required; email is required | Not accepted |
merchantUrl | Optional, same rules | Optional |
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.
| Result | Fields |
|---|---|
PosSession | orderId, checkoutUrl, providerSessionId, status, raw, requestId, replayed |
QrSession | orderId, 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:
| 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 |
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
webhooks.constructEventconstructEvent(rawBody, headers, secret?, options?) verifies the signature, parses the JSON body and returns a WebhookEvent.
| Parameter | Type | Notes |
|---|---|---|
rawBody | string | Buffer | Uint8Array | Exactly as received. A string is read as UTF-8. |
headers | Node req.headers, a Fetch Headers, or { signature, timestamp, eventId, event } | Header names are matched case-insensitively. Array values are joined with ,. |
secret | string | null | Optional. Falls back to the client's webhookSecret. With neither, the call throws ConfigurationError. |
options.tolerance | number | Seconds the delivery time may differ from now. Default 300 (DEFAULT_WEBHOOK_TOLERANCE_SECONDS). |
options.now | number | Date | The current time, in Unix seconds or as a Date. For tests. |
options.timestampHeader | string | Overrides the X-Qasimha-Timestamp value read from headers. |
WebhookEvent:
| Field | Type | Read from |
|---|---|---|
id | string | null | Body id (or legacy eventId), else X-Qasimha-Event-Id |
type | string | null | Body type (or legacy eventType), else X-Qasimha-Event |
orderId | string | null | Body orderId, else data.order.id. null in the legacy format. |
orderReferenceId | string | null | Body orderReferenceId, else data.order.orderReferenceId |
status | string | null | Body status, else data.order.status |
amount | number | null | Body amount, else data.order.totalAmount |
currency | string | null | Body currency, else data.order.currencyCode |
occurredAt | string | null | Body occurredAt, else createdAt |
raw | object | The 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:
| Format | Sent to | Checked |
|---|---|---|
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 endpoint | HMAC-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:
reason | Cause |
|---|---|
missing_header | No X-Qasimha-Signature header |
malformed_header | Neither 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_tolerance | The delivery time is more than tolerance seconds from now |
signature_mismatch | No signature matches the body with this secret |
err.code carries the same value as err.reason.
webhooks.verifySignature
webhooks.verifySignatureverifySignature(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);
}
});The SDK needs the Node.js runtime, so set runtime = 'nodejs' on each route that uses it. Keep the client in a module that only server code imports.
import Qasimha from '@qasimha/sdk';
export const qasimha = new Qasimha({
apiKey: process.env.QASIMHA_API_KEY!,
webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET,
});import { NextResponse } from 'next/server';
import { DuplicateOrderReferenceError, ValidationError } from '@qasimha/sdk';
import { qasimha } from '@/lib/qasimha';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const basket = await priceCart(await req.json()); // your server-side pricing
try {
const session = await qasimha.checkout.createSession({
...basket,
merchantUrl: {
success: `${process.env.STORE_BASE_URL}/checkout/qasimha/return`,
failure: `${process.env.STORE_BASE_URL}/checkout/qasimha/return`,
cancel: `${process.env.STORE_BASE_URL}/cart`,
},
});
await saveQasimhaOrderId(basket.orderReferenceId, session.orderId);
return NextResponse.json({ paymentUrl: session.paymentUrl });
} catch (error) {
if (error instanceof DuplicateOrderReferenceError && error.existingPaymentUrl) {
return NextResponse.json({ paymentUrl: error.existingPaymentUrl });
}
if (error instanceof ValidationError) {
return NextResponse.json({ fields: error.errors }, { status: 400 });
}
throw error;
}
}import { NextRequest, NextResponse } from 'next/server';
import { qasimha } from '@/lib/qasimha';
export const runtime = 'nodejs';
export async function GET(req: NextRequest) {
const orderId = req.nextUrl.searchParams.get('orderId');
if (!orderId) return NextResponse.redirect(new URL('/cart', req.url));
const order = await qasimha.orders.get(orderId);
const paid = ['Confirmed', 'FullyFulfilled', 'PartiallyFulfilled'].includes(order.status ?? '');
if (paid) await markPaid(order.orderReferenceId);
return NextResponse.redirect(new URL(paid ? '/thank-you' : '/cart?payment=failed', req.url));
}import { WebhookSignatureError } from '@qasimha/sdk';
import { qasimha } from '@/lib/qasimha';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const rawBody = await req.text(); // the exact body; do not call req.json()
try {
const event = qasimha.webhooks.constructEvent(rawBody, req.headers);
await enqueue(event); // store it quickly; process elsewhere
return new Response(null, { status: 200 });
} catch (error) {
return new Response(null, { status: error instanceof WebhookSignatureError ? 400 : 500 });
}
}Create the application with rawBody: true, then pass req.rawBody (a Buffer) to constructEvent. Never verify the parsed req.body.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const app = await NestFactory.create(AppModule, { rawBody: true });
await app.listen(3000);import { Module } from '@nestjs/common';
import Qasimha from '@qasimha/sdk';
import { QasimhaWebhookController } from './qasimha-webhook.controller';
@Module({
providers: [
{
provide: Qasimha,
useFactory: () =>
new Qasimha({ apiKey: process.env.QASIMHA_API_KEY!, webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET }),
},
],
controllers: [QasimhaWebhookController],
exports: [Qasimha],
})
export class QasimhaModule {}import { BadRequestException, Controller, HttpCode, Post, RawBodyRequest, Req } from '@nestjs/common';
import type { Request } from 'express';
import Qasimha, { WebhookSignatureError } from '@qasimha/sdk';
@Controller('webhooks')
export class QasimhaWebhookController {
constructor(private readonly qasimha: Qasimha) {}
@Post('qasimha')
@HttpCode(200)
async receive(@Req() req: RawBodyRequest<Request>) {
if (!req.rawBody) throw new BadRequestException('raw body missing');
try {
const event = this.qasimha.webhooks.constructEvent(req.rawBody, req.headers);
await enqueue(event);
} catch (error) {
if (error instanceof WebhookSignatureError) throw new BadRequestException(error.reason);
throw error;
}
}
}Inject the same Qasimha provider into your checkout and order services.
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| Method | Parameters | Returns | When 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?) | none | CancelResult { id, status, raw, requestId, replayed } | Voids an order that has not been fulfilled (Voided). 400 INVALID_STATUS otherwise. |
orders.capture(id, options?) | none | CaptureResult { 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);| Method | Notes |
|---|---|
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
Order modelStatus-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 field | Meaning |
|---|---|
capturedAmount, collectedAmount | What the customer has paid so far |
fulfilledAmount | What you have shipped |
refundedAmount, reversedAmount | What has been refunded |
refundableAmount | What you can still refund |
voidedAmount | What was cancelled after confirmation |
Enum names, exported as arrays with a normaliser each:
| Export | Values | Normaliser |
|---|---|---|
ORDER_STATUS_NAMES | Draft, New, Approved, Declined, Expired, Confirmed, FullyFulfilled, PartiallyFulfilled, FullyReversed, PartiallyReversed, Voided, Updated, AwaitingCheckout, Cancelled, Settled, WrittenOff | normaliseOrderStatus |
ORDER_CHANNEL_NAMES | Online, InStore, QrCode | normaliseOrderChannel |
PAYMENT_TYPE_NAMES | PayByInstalments, PayNow, PayNextMonth, PayInFull | normalisePaymentType |
INSTALLMENT_STATUS_NAMES | Scheduled, Paid, Late, Reversed, Waived | normaliseInstallmentStatus |
WEBHOOK_DELIVERY_STATUS_NAMES | Pending, Succeeded, Failed | normaliseWebhookDeliveryStatus |
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 expiredCreateCheckoutLinkRequest:
| Field | Type | Required | Rules |
|---|---|---|---|
amount | number | Yes | Money rules |
channel | 'Link' | 'Qr' | Yes | |
phone | string | For Link | The SDK refuses a Link without a phone before sending |
language | 'ar' | 'en' | No | The language the customer is contacted in and the page opens in |
orderReferenceId | string | No | Generated by the platform when omitted |
items | CheckoutLinkItemInput[] | No | Each: name (required), sku?, quantity (integer, at least 1), unitPrice (money), imageUrl? |
ejarContractNumber | string | No | Required by the API for a real-estate merchant; ignored for others |
merchantUrl | MerchantUrl | No | Same 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, rawget 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();| Method | Returns |
|---|---|
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);| Method | HTTP | Returns | Notes |
|---|---|---|---|
create({ url, description?, events? }, options?) | POST /api/v1/merchant/webhooks | WebhookEndpointWithSecret | secret is returned once. Store it straight away. |
list() | GET /api/v1/merchant/webhooks | WebhookEndpointList { 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} | WebhookEndpointResult | Omitted fields keep their value. events: [] means every event. |
delete(id, options?) | DELETE /api/v1/merchant/webhooks/{id} | void | Stops all deliveries. Deliveries waiting for a retry end as failed. |
rotateSecret(id, options?) | POST /api/v1/merchant/webhooks/{id}/rotate-secret | WebhookEndpointWithSecret | New secret, once. For the next 24 hours every delivery is signed with both secrets. |
ping(id, options?) | POST /api/v1/merchant/webhooks/{id}/ping | WebhookPingResult { eventId, endpointId } | A signed ping event is queued to the URL. |
listDeliveries(id, { status?, page?, pageSize? }) | GET /api/v1/merchant/webhooks/{id}/deliveries | Page<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}/replay | WebhookDeliveryResult | Queues 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, rawThe 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| Parameter | Type | Notes |
|---|---|---|
amount | number | Greater than zero, at most two decimals. Sent with currency=SAR. |
options.lang | 'ar' | 'en' | Defaults to the client language. |
options.publishableKey | string | Overrides 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 }.
| Method | HTTP | Returns | Notable errors |
|---|---|---|---|
checkout.createSession(request, options?) | POST /api/v1/pay/session/create | Session | ValidationError, DuplicateOrderReferenceError, OrderDeclinedError, ConflictError (MERCHANT_SUSPENDED) |
checkout.createPosSession(request, options?) | POST /api/v1/pay/pos/create | PosSession | Same as createSession |
checkout.createQrSession(request, options?) | POST /api/v1/pay/qr/create | QrSession | Same, plus OrderDeclinedError for a declined 201 |
checkout.getPlans(amount, { lang?, publishableKey? }) | GET /api/v1/widget/plans | Plans | ConfigurationError (no publishable key), AuthenticationError, ValidationError |
checkoutLinks.create(request, options?) | POST /api/v1/merchant/checkout-links | CheckoutLinkResult | ValidationError (PHONE_REQUIRED, ...), NotFoundError, ConflictError |
checkoutLinks.list() | GET /api/v1/merchant/checkout-links | CheckoutLinkList | |
checkoutLinks.get(id) | GET /api/v1/merchant/checkout-links/{id} | CheckoutLinkResult | NotFoundError |
checkoutLinks.cancel(id, options?) | POST /api/v1/merchant/checkout-links/{id}/cancel | CheckoutLinkResult | ConflictError (NOT_OPEN), NotFoundError |
orders.list({ status?, search?, page?, pageSize? }) | GET /api/v1/merchant/orders | Page<OrderSummary> | ValidationError (INVALID_STATUS) |
orders.get(id) | GET /api/v1/merchant/orders/{id} | Order | NotFoundError |
orders.getByReference(orderReferenceId) | GET /api/v1/merchant/orders/by-reference/{orderReferenceId} | Order | NotFoundError |
orders.capture(id, options?) | POST /api/v1/merchant/orders/{id}/capture | CaptureResult | ValidationError (INVALID_STATUS) |
orders.fulfill(id, { shippedAmount?, shippingCompany?, trackingNumber?, trackingUrl? }?, options?) | POST /api/v1/merchant/orders/{id}/fulfill | FulfillResult | ValidationError (INVALID_STATUS, INVALID_SHIPPED_AMOUNT) |
orders.refund(id, { amount, reason? }, options?) | POST /api/v1/merchant/orders/{id}/refund | RefundResult | ValidationError (AMOUNT_EXCEEDED, INVALID_AMOUNT, INVALID_STATUS) |
orders.cancel(id, options?) | POST /api/v1/merchant/orders/{id}/cancel | CancelResult | ValidationError (INVALID_STATUS) |
orders.counts() | GET /api/v1/merchant/orders/counts | OrderCounts | |
orders.contract(id) | GET /api/v1/merchant/orders/{id}/contract | ContractDocument | NotFoundError |
settlements.list({ page?, pageSize? }) | GET /api/v1/merchant/settlements | Page<Settlement> | PermissionError (settlements:read) |
settlements.overview() | GET /api/v1/merchant/settlements/overview | SettlementOverview | PermissionError (settlements:read) |
webhookEndpoints.create(request, options?) | POST /api/v1/merchant/webhooks | WebhookEndpointWithSecret | ValidationError (URL_*, INVALID_EVENT, LIMIT_EXCEEDED), PermissionError |
webhookEndpoints.list() | GET /api/v1/merchant/webhooks | WebhookEndpointList | PermissionError (webhooks:manage) |
webhookEndpoints.get(id) | GET /api/v1/merchant/webhooks/{id} | WebhookEndpointResult | NotFoundError |
webhookEndpoints.update(id, request, options?) | PUT /api/v1/merchant/webhooks/{id} | WebhookEndpointResult | ValidationError, NotFoundError |
webhookEndpoints.delete(id, options?) | DELETE /api/v1/merchant/webhooks/{id} | void | NotFoundError |
webhookEndpoints.rotateSecret(id, options?) | POST /api/v1/merchant/webhooks/{id}/rotate-secret | WebhookEndpointWithSecret | NotFoundError |
webhookEndpoints.ping(id, options?) | POST /api/v1/merchant/webhooks/{id}/ping | WebhookPingResult | NotFoundError |
webhookEndpoints.listDeliveries(id, { status?, page?, pageSize? }) | GET /api/v1/merchant/webhooks/{id}/deliveries | Page<WebhookDelivery> | ValidationError, NotFoundError |
webhookEndpoints.replayDelivery(id, deliveryId, options?) | POST /api/v1/merchant/webhooks/{id}/deliveries/{deliveryId}/replay | WebhookDeliveryResult | NotFoundError |
webhooks.constructEvent(rawBody, headers, secret?, options?) | none (local) | WebhookEvent | WebhookSignatureError, ConfigurationError, ValidationError (body not JSON) |
webhooks.verifySignature(rawBody, signatureHeader, secret?, options?) | none (local) | void | WebhookSignatureError, 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:
| Member | Type | Meaning |
|---|---|---|
name | string | The class name, for example 'ValidationError' |
message | string | The API's message (already in the client language), or the SDK's own |
code | string | null | The API code (VALIDATION_ERROR, INVALID_STATUS, ...), VALIDATION_ERROR for a local refusal, the reason for a webhook failure |
httpStatus | number | null | null when nothing was sent or no response came back |
requestId | string | null | The 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). |
raw | unknown | The decoded response envelope, or the response text |
cause | unknown | The transport error, on ConnectionError |
toJSON() | method | { name, message, code, httpStatus, requestId, errors } plus the subclass fields; raw is left out |
| Class | Extra fields | When |
|---|---|---|
ConfigurationError | Client misconfigured, a missing webhook secret or publishable key, or an empty id. Nothing is sent. | |
ValidationError | Any 400; a request refused locally (httpStatus: null); a webhook body that is not a JSON object | |
AuthenticationError | 401: bad or revoked key (often an empty body), or a bad publishable key on getPlans | |
PermissionError | 403: the key lacks the scope | |
NotFoundError | 404 | |
ConflictError | Any other 409: MERCHANT_SUSPENDED, NOT_OPEN, or REQUEST_IN_PROGRESS after the retries | |
DuplicateOrderReferenceError | existingOrderId, existingPaymentUrl | 409 DUPLICATE_ORDER_REFERENCE. Extends ConflictError. |
OrderDeclinedError | orderId | 422 ORDER_DECLINED, or a declined QR 201 |
RateLimitError | retryAfter (seconds, or null) | 429 after the retries |
ApiError | Any other status, including a redirect, 422 IDEMPOTENCY_KEY_REUSED, a 2xx whose envelope says success: false, or a response without the expected id | |
ServerError | 5xx. Extends ApiError. | |
ConnectionError | DNS, TLS, socket or timeout failure, after the retries. The transport error is in cause. | |
WebhookSignatureError | reason | Webhook 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.
timeoutapplies 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,504and409 REQUEST_IN_PROGRESS, up tomaxRetriesextra attempts. Never a500or any other4xx: a500on a write may have gone through. - Backoff.
min(2^n × 500 ms, 8 s)plus 0–250 ms of random jitter, wherenis 0 for the first retry: about 0.5 s, 1 s, 2 s, 4 s, 8 s. Retry-After. On a429, aRetry-Afterheader 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,PUTandDELETEcarries anIdempotency-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.GETrequests 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 answers422 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_REFERENCEon a retry ofcreateSession,createPosSessionorcreateQrSessionmeans 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.
| Level | Message | fields |
|---|---|---|
debug | request completed | method, path, status, durationMs, requestId, attempt, code |
warn | request rejected (an error status) | Same as above |
warn | request failed (no response) | Same, with status: null, plus error (the failure message) |
info | retrying request | method, path, status, requestId, attempt, code, delayMs |
info | duplicate order reference on a retry: the earlier attempt had succeeded | method, 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()returnssk_live_…AbCdfor 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
3xxfrom the API becomesApiError. - 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
fetchreadsHTTPS_PROXYonly on recent releases started withNODE_USE_ENV_PROXY=1. On older runtimes, register anundiciProxyAgentas 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.mjsimport 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
- Sign in to the UAT Merchant Portal at
https://portal-uat.qasimha.comand create ansk_test_key (and apk_test_key if you quote plans) under Settings → API Keys. The SDK sendssk_test_keys tohttps://api-uat.qasimha.comon its own; do not setbaseUrl. - 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 withURL_PRIVATE. Store the returnedwhsec_secret asQASIMHA_WEBHOOK_SECRET. - Call
webhookEndpoints.ping(endpoint.id)and check that your route accepts thepingevent. If it does not, readwebhookEndpoints.listDeliveries(endpoint.id):lastStatusCode,lastErrorandresponseSnippetshow what your server answered. - Create a session, open
paymentUrl, complete or cancel the hosted checkout, and check that your return route reads the order withorders.get. - Read the order back with
orders.getororders.getByReference, then tryfulfill,refundandcancelon 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 thesk_live_/pk_live_keys fromhttps://portal.qasimha.com. Remove anybaseUrlandenvironmentoverrides. - 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(ororders.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: falseon 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
How the signature is built and how rotation works.
Statuses and which operation applies when.
The parameters added to your return URL.
API error codes and rate limits.
Show plan quotes on product pages.
Compare the four SDKs and their shared behaviour.
Updated about 2 hours ago
