@hadawi/sdk
v1.2.0
Published
Official QattaPay SDK — group contribution checkout for your storefront
Downloads
1,106
Maintainers
Readme
@hadawi/sdk
Official QattaPay SDK — add group contribution checkout to any storefront.
QattaPay lets groups of people split the cost of a purchase. The SDK handles:
- Server-side: creating checkout intents (a session identifier for one purchase) using your merchant API key.
- Client-side: official branded checkout buttons plus redirect/popup open helpers.
- Webhooks: verifying and parsing events sent to your server when a session is funded.
Requirements & platforms
| Surface | Package | Runs on |
| ------- | ------- | ------- |
| Server (intents, orders, webhooks) | @hadawi/sdk | Node.js ≥ 18 |
| Browser (branded button + open checkout) | @hadawi/sdk/browser | Any modern browser |
Supported storefronts: plain HTML, React, Next.js, Vue, Svelte, Angular, Laravel Blade, and Flutter. There is no separate React/Vue package — mount into a DOM node with mountButton(). For Flutter use qattapay_flutter (in-app WebView or system browser).
Not supported yet: native iOS/Android or React Native SDKs. Checkout is the hosted web flow (popup / redirect on web; full-page in-app WebView or external browser on Flutter).
Installation
npm install @hadawi/sdk
# or
pnpm add @hadawi/sdk
# or
yarn add @hadawi/sdk
# or
bun add @hadawi/sdkQuick start
1 — Browser: mount a branded QattaPay button
Use the SDK button — do not invent your own “Pay / Split” CTA. Official variants keep QattaPay branding consistent on every storefront. On click, call your server (step 2) for an intentId.
React / Next.js
Import from @hadawi/sdk/browser, mount into a ref, and call destroy() on unmount:
"use client"; // Next.js App Router only
import { useEffect, useRef } from "react";
import { QattaPayCheckout } from "@hadawi/sdk/browser";
export function QattaPayPayButton({ productId }: { productId: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const checkout = new QattaPayCheckout({ mode: "live" }); // or "dev"
const button = checkout.mountButton({
container: ref.current,
variant: "primary",
label: "split",
getIntentId: async () => {
const res = await fetch("/api/create-contribution", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
const data = await res.json();
return data.intentId;
},
open: {
mode: "popup",
// Optional: also pass returnUrl so hosted checkout can redirect/deep-link back
// returnUrl: "https://yourstore.com/thank-you",
onSuccess: () => {
window.location.href = "/thank-you";
},
},
});
return () => button.destroy();
}, [productId]);
return <div ref={ref} />;
}Vue 3
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";
import { QattaPayCheckout } from "@hadawi/sdk/browser";
const el = ref(null);
let button;
onMounted(() => {
const checkout = new QattaPayCheckout({ mode: "live" });
button = checkout.mountButton({
container: el.value,
variant: "primary",
label: "split",
getIntentId: async () => {
const res = await fetch("/api/create-contribution", { method: "POST" });
const data = await res.json();
return data.intentId;
},
open: {
mode: "popup",
onSuccess: () => {
location.href = "/thank-you";
},
},
});
});
onBeforeUnmount(() => button?.destroy());
</script>
<template>
<div ref="el" />
</template>Plain HTML (no bundler)
<div id="qattapay-checkout"></div>
<script src="https://cdn.jsdelivr.net/npm/@hadawi/sdk/dist/browser.iife.js"></script>
<script>
const checkout = new QattaPay.QattaPayCheckout({ mode: "live" });
checkout.mountButton({
container: "#qattapay-checkout",
variant: "primary",
label: "split",
getIntentId: async () => {
const { intentId } = await fetch("/api/create-contribution", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId: "watch-001" }),
}).then((r) => r.json());
return intentId;
},
open: {
mode: "popup",
onSuccess: () => (location.href = "/thank-you"),
},
});
</script>Imperative open() (advanced)
If you already have an intentId and need full control, you can still call checkout.open(intentId, { mode: 'popup' | 'redirect', … }). Prefer mountButton() for the shopper-facing CTA.
2 — Server: create a checkout intent
Power the button’s getIntentId call with your API key from the merchant dashboard.
import { QattaPayClient } from "@hadawi/sdk";
const qattapay = new QattaPayClient({
apiKey: process.env.QATTAPAY_API_KEY!, // from QattaPay merchant dashboard
mode: "live", // or "dev" — SDK resolves the API host
webhookSecret: process.env.QATTAPAY_WEBHOOK_SECRET, // per-merchant secret (Developer → Webhook)
});
// e.g. POST /api/create-contribution
const { intent, redirectUrl } = await qattapay.intents.create({
itemSnapshot: [
{
name: "Luxury Watch Gift Set",
nameAr: "طقم ساعة فاخرة",
price: 150000, // amount in halalas (150,000 = 1,500.00 SAR)
image: "https://example.com/watch.jpg",
reference: "watch-001", // your internal SKU
},
],
totalAmount: 150000, // must equal sum of itemSnapshot[].price
currency: "SAR",
metadata: { orderId: "ord_abc123" }, // echoed back in webhook events
});
// Return intentId to the browser button
res.json({ intentId: intent.id, redirectUrl });Note on amounts: All monetary values are in the currency's smallest unit. For SAR, that is halalas (100 halalas = 1 SAR).
Button variants
| variant | Look |
| ---------- | ------------------------------------------------- |
| primary | Purple gradient fill (default) |
| dark | Solid deep purple |
| light | White fill, purple text |
| outline | Transparent with purple border |
| label | English text |
| ------------- | ---------------------------- |
| split | Split with Friends (default) |
| split_cart | Split Cart with Friends |
| pay | Pay with QattaPay |
| (string) | Your custom text |
locale: 'ar' switches copy to Arabic and sets dir="rtl".
Checkout modes
| Mode | Behaviour | Best for |
| ---------- | ------------------------------------------------ | ------------------------------------- |
| redirect | Navigates the current tab to the hosted checkout | Simple integrations |
| popup | Opens a new browser window (~520×700 px) | SPAs that want to keep page state |
Returning to the merchant
Hosted checkout accepts returnUrl on open() / mountButton({ open }):
| Option | When to use |
| --- | --- |
| onSuccess / onCancel | Popup — listen for qattapay:success / qattapay:cancel postMessage |
| returnUrl | Redirect (and popup/mobile fallback) — QattaPay navigates here after success/cancel with intentId, sessionId, and status=success\|cancel\|failed |
checkout.open(intentId, {
mode: "redirect",
returnUrl: "https://yourstore.com/thank-you",
});
// Lands on:
// https://yourstore.com/thank-you?intentId=…&sessionId=…&status=successLaravel Blade mirrors this as success-url (popup onSuccess) and return-url (returnUrl). Flutter uses returnUrl as a deep link or https URL so the in-app WebView can close.
checkout.open() returns a close() function — call it to dismiss a popup programmatically:
const close = checkout.open(intentId, { mode: "popup", onCancel: () => {} });
// …later:
close();Why there is no modal / iframe mode
The hosted payment page responds with X-Frame-Options: deny, so browsers refuse to render it inside any <iframe> (including nested frames).
If checkout were embedded in a merchant-site iframe, the user would hit a blank/blocked frame at pay time. For that reason the SDK only supports:
redirect— full top-level navigationpopup— a separate top-level window (same-origin browsing context for QattaPay, then top-level navigation to the payment page)
Do not wrap /checkout/{intentId} in your own iframe either — payment will fail for the same reason.
Webhook handling
QattaPay POSTs a signed JSON event to your webhookUrl (set in the merchant dashboard) whenever a session changes state.
Each merchant has a unique signing secret (whsec_…) issued by QattaPay. Copy it from Developer → Webhook → Reveal into QATTAPAY_WEBHOOK_SECRET. Merchants cannot choose this value — only reveal or rotate it.
Register your webhook URL (one-time setup)
Set the webhook URL in the QattaPay merchant dashboard:
- Log in to the merchant portal
- Open Developer → Webhook
- Enter your HTTPS endpoint (e.g.
https://yourstore.com/webhooks/qattapay) - Save, then Reveal the signing secret (
whsec_…) and store it asQATTAPAY_WEBHOOK_SECRET
Webhook URL configuration is dashboard-only — it is not exposed through the SDK.
Verify and handle events
import { QattaPayClient } from "@hadawi/sdk";
import express from "express";
const qattapay = new QattaPayClient({
apiKey: process.env.QATTAPAY_API_KEY!,
mode: "live",
webhookSecret: process.env.QATTAPAY_WEBHOOK_SECRET!,
});
const app = express();
// ⚠️ Use raw body parser — do NOT parse JSON before this route
app.post(
"/webhooks/qattapay",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["x-qattapay-signature"] as string;
let event;
try {
event = qattapay.webhooks.constructEvent(req.body, signature);
} catch (err) {
console.error("Webhook signature mismatch:", err);
return res.status(400).send("Invalid signature");
}
switch (event.type) {
case "order.funded":
await qattapay.orders.fulfill(event.payload.order_id!);
console.log(
"Order funded, fulfillment started:",
event.payload.order_id,
);
break;
case "order.partially_funded":
console.log("Partial funding — awaiting organiser decision");
break;
case "order.cancelled":
case "order.expired":
console.log("Session ended:", event.type, event.payload.session_id);
break;
}
res.sendStatus(200);
},
);Webhook event shape
interface QattaPayWebhookEvent {
type:
| "order.funded"
| "order.partially_funded"
| "order.cancelled"
| "order.expired";
payload: {
event: string;
order_id?: string; // present when an Order record exists
session_id: string;
merchant_id: string;
items: ItemSnapshot[];
total_amount: number; // halalas
currency: string;
funded_at: string; // ISO 8601
};
}The X-QattaPay-Signature header is an HMAC-SHA256 hex digest of the raw JSON body, keyed with your per-merchant QATTAPAY_WEBHOOK_SECRET (not a global platform secret).
Orders API
Once a session is funded and an order exists, use qattapay.orders to manage fulfillment:
// List all orders
const { orders } = await qattapay.orders.list();
// Get detail for a single order (includes contribution breakdown)
const { order, contributions } = await qattapay.orders.get(orderId);
// Mark as being fulfilled (you've started processing)
await qattapay.orders.fulfill(orderId);
// Mark as delivered (item shipped / handed over)
await qattapay.orders.deliver(orderId);
// Refund every captured contribution on the order (e.g. out of stock)
await qattapay.orders.refund(orderId, { reason: 'Out of stock' });
// Refund a single contributor within the order (partial refund)
await qattapay.orders.refundContribution(orderId, contributionId, {
reason: 'Contributor requested to back out',
});Order status lifecycle
pending_funding → funded → notified → fulfilling → delivered
↘ cancelledRefunds
orders.refund() and orders.refundContribution() process the refund synchronously against the original payment method and notify each affected contributor. Both accept an optional reason string that's stored on the refund record (not shown to contributors).
A refund request is rejected with a QattaPayApiError (400) when:
- The order has already been included in a payout request — refunds must go through support at that point.
- The underlying session is already
refunding,refunded, orcancelled. - (
refundContributiononly) the specific contribution was never captured or was already refunded.
Refunding via API key is scoped to that key's environment — a dev key cannot refund an order that was processed with live credentials, and vice versa.
TypeScript
The SDK is written in TypeScript and ships full declaration files. All types are exported from @hadawi/sdk (server) and @hadawi/sdk/browser (client).
import type {
QattaPayClientConfig,
CreateIntentParams,
CreateIntentResponse,
Order,
OrderDetail,
QattaPayWebhookEvent,
WebhookEventType,
} from "@hadawi/sdk";
import type {
QattaPayCheckoutConfig,
CheckoutOpenOptions,
CheckoutMode,
} from "@hadawi/sdk/browser";Environments (mode)
Merchants only set mode — URLs are resolved inside the SDK:
| mode | Checkout (web) | API |
|--------|----------------|-----|
| dev | https://dev.qatta.sa | https://dev.qatta.sa/api |
| live | https://qatta.sa | https://qatta.sa/api |
Checkout opens {host}/checkout/{intentId}.
// Production
new QattaPayClient({ apiKey, mode: "live", webhookSecret });
new QattaPayCheckout({ mode: "live" });
// Staging / sandbox
new QattaPayClient({ apiKey, mode: "dev", webhookSecret });
new QattaPayCheckout({ mode: "dev" });Local development
Set these environment variables (.env):
QATTAPAY_API_KEY=mk_test_... # from seed output / merchant dashboard
QATTAPAY_WEBHOOK_SECRET=change-me-in-productionOverride hosts only when running against a local stack (baseUrl wins over mode):
// Server
const qattapay = new QattaPayClient({
apiKey: process.env.QATTAPAY_API_KEY!,
baseUrl: "http://localhost:4000",
});
// Browser
const checkout = new QattaPayCheckout({ baseUrl: "http://localhost:3000" });Demo store reference integration
The demo-store/ app (sibling of this monorepo, under infra/) is a complete
working example. See demo-store/server.js for the server-side intent
creation and demo-store/public/index.html for the browser-side checkout
trigger. It depends on this SDK via file:../hadawi/packages/sdk, so run
pnpm sdk:build here before starting it.
API reference docs
API reference page: /docs/sdk/api (embeds TypeDoc from /sdk-api/).
Merchant-facing quick start: /docs/sdk.
cd packages/sdk
pnpm run docs # local preview → packages/sdk/docs/
pnpm run docs:web # sync into packages/web/public/sdk-api/ for deploySee PUBLISHING.md for the release checklist.
Publishing
Releases are published to npm as @hadawi/sdk via GitHub Actions when you push
a matching tag:
# after bumping version + CHANGELOG
git tag sdk-v0.1.0
git push origin sdk-v0.1.0Full checklist (secrets, provenance, dry-run): PUBLISHING.md.
License
MIT
