Cambodia's Modern Payment Gateway Engine
Type-safe, timing-safe TypeScript monorepo combining ABA PayWay popup/direct checkout and National Bank of Cambodia’s Bakong KHQR EMVCo generator.
SobinPay Documentation
SobinPay is an enterprise-grade TypeScript payment gateway SDK for Cambodia engineered by Sobin Digital. It delivers a unified, zero-dependency, type-safe interface for ABA PayWay and National Bank of Cambodia’s Bakong KHQR.
No bloated HTTP or crypto libraries. Relies strictly on native Node.js 18+ runtime APIs.
Constant-time HMAC-SHA512 verification and 2048-bit RSA encryption to prevent side-channel timing attacks.
Pre-compiled with dual modern ESM and CommonJS bundles with complete TypeScript .d.ts declarations.
Installation
Install the main unified facade package @sobindigital/pay using your preferred package manager:
npm install @sobindigital/payAlternatively, if you only need a specific provider, you can install individual scoped packages:
@sobindigital/provider-payway— Dedicated ABA PayWay client@sobindigital/provider-khqr— Dedicated Bakong KHQR generator & parser@sobindigital/core— Shared types, money math, and crypto primitives
Equip your AI assistant with official SobinPay rules, security constraints, and TypeScript payment patterns:
npx @sobindigital/pay skills add cursor claude copilotcursor: .cursor/rules/sobinpay.mdc• claude: .claude/skills/sobinpay.md• copilot: .github/copilot-instructions.mdArchitecture & Multi-Provider Design
SobinPay is structured as an enterprise TypeScript monorepo with high separation of concerns:
Unified developer entrypoint providing sobin.aba.*, sobin.abapay.*, and SobinPay.khqr.*.
Implements ABA PayWay REST & Web checkout APIs, HMAC-SHA512 hashing, status checks, close transaction, exchange rates, and RSA refunds.
Implements National Bank of Cambodia EMVCo KHQR specifications, Tag-Length-Value parsing, 16-bit CRC-CCITT calculation, and deep links.
Shared type contracts, error hierarchy (SobinPayError, ProviderApiError), timing-safe crypto, and safe money math.
Core Concepts & Financial Safety
1. Zero Floating-Point Precision Loss
Standard JavaScript floating-point numbers can cause subtle precision errors (e.g. 0.1 + 0.2 === 0.30000000000000004). In financial transaction processing, even a 1-cent discrepancy results in cryptographic hash mismatches and rejected payments.
SobinPay automatically normalizes amounts according to currency rules: USD amounts are formatted strictly with 2 fixed decimal places (e.g. 12.50), while KHR amounts are rounded to integer units (e.g. 50000).
2. Timing-Safe Cryptographic Comparisons
Comparing webhook signatures with standard string equality (a === b) leaks byte-by-byte comparison timing, allowing remote attackers to deduce valid cryptographic digests through side-channel attacks. SobinPay always uses Node’s crypto.timingSafeEqual for signature validation.
import { timingSafeEqualString } from "@sobindigital/pay";
// Safely compares received signature without timing leakage
const isValid = timingSafeEqualString(receivedSignature, computedSignature);Live Sandbox & QR Tester
Live ExecutionInteractive multi-provider sandbox. Test ABA PayWay popups and render scannable Bakong KHQR codes in real time.
Transaction Parametersdecimal: 2
Signed PayWay Session
Ready to Test Popup Checkout
Select your payment method (ABA PAY KHQR or Cards), then click "Launch ABA PayWay Popup" to test the native popup modal.
const sobin = new SobinPay({ aba: { merchantId, apiKey } });
const purchase = sobin.aba.createPurchase({ amount: 1.5, currency: "USD", tranId: "sb-100001" });
ABA PayWay Quickstart
Initialize the SobinPay client by supplying your ABA PayWay credentials. In production, provide your live merchant ID and API key. In development, point to the ABA sandbox gateway.
12345678910111213import { SobinPay } from "@sobindigital/pay"; const sobin = new SobinPay({ aba: { merchantId: process.env.ABA_MERCHANT_ID || "your_merchant_id", apiKey: process.env.ABA_API_KEY || "your_aba_api_key", apiUrl: process.env.ABA_API_URL || "https://checkout-sandbox.payway.com.kh", timeoutMs: 15000, // Optional request timeout (default: 15s) }, }); // Access the PayWay client via any of these equivalent aliases: // sobin.aba, sobin.abapay, or sobin.payway
Web Checkout (Popup & Hosted)
Generate an authenticated checkout session for web browsers using sobin.aba.createPurchase(...). This method synchronously calculates the HMAC-SHA512 signature, packages the items and customer info into Base64 format, and generates a ready-to-submit HTML form.
1234567891011121314151617181920212223242526272829import { AbaPaymentOption, AbaViewType } from "@sobindigital/pay"; const purchase = sobin.aba.createPurchase({ amount: 25.0, currency: "USD", returnUrl: "https://example.com/checkout/success", cancelUrl: "https://example.com/checkout/cancel", paymentOption: AbaPaymentOption.ABAPAY_KHQR, // or "cards", "alipay", etc. viewType: AbaViewType.POPUP, // or AbaViewType.HOSTED customer: { firstName: "Dara", lastName: "Chan", email: "dara@example.com", phone: "012345678", }, items: [ { name: "Pro Developer License", quantity: 1, price: 25.0 }, ], }); // Returns: // { // actionUrl: "https://checkout-sandbox.payway.com.kh/api/payment-gateway/v1/payments/purchase", // payload: { req_time, merchant_id, tran_id, amount, hash, ... }, // checkoutUrl: "...", // formHtml: "<form method='POST' action='...'>...</form>", // hash: "...", // tranId: "sb-..." // }
Add jQuery and the ABA PayWay checkout scripts to your HTML header or root layout:<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://checkout.payway.com.kh/plugins/checkout2-0.js"></script>
When the form with target="aba_webservice" is mounted into the DOM, simply call window.AbaPayway.checkout() to display the native ABA modal popup.
Direct KHQR & Deeplinks (createQR)
For mobile applications, automated kiosks, or custom POS screens, you can request a dynamic ABA PayWay KHQR code directly via server-to-server REST API.
12345678910111213141516const qr = await sobin.aba.createQR({ amount: 10.0, currency: "USD", returnUrl: "https://example.com/checkout/success", customer: { firstName: "Sok", lastName: "Chan", phone: "012345678", }, }); if (qr.isSuccess) { console.log("Raw QR String:", qr.qrString); console.log("ABA Mobile App Deeplink:", qr.abapayDeeplink); console.log("QR Image URL:", qr.checkoutQrUrl); }
Transaction Status Check (checkTransaction)
Poll or verify the current status of any transaction by invoking sobin.aba.checkTransaction(tranId). This calls ABA PayWay’s latest check-transaction-2 endpoint.
123456789101112import { AbaPaymentStatus } from "@sobindigital/pay"; const status = await sobin.aba.checkTransaction("sb-n80c75123"); if (status.isApproved) { console.log("Payment completed! Status:", status.status); // AbaPaymentStatus.APPROVED console.log("Amount paid:", status.originalAmount, status.originalCurrency); } else if (status.status === AbaPaymentStatus.PENDING) { console.log("Payment is still awaiting customer scan/authorization."); } else { console.log("Payment was not approved:", status.status); }
Transaction Details (getTransactionDetail)
Retrieve complete transaction metadata including bank authorization codes (APV), card brand information, masked card numbers, and raw gateway response dumps.
const details = await sobin.aba.getTransactionDetail("sb-123456");
console.log("Bank Reference:", details.bankRef);
console.log("Payment Status:", details.paymentStatus);
console.log("Gateway Response:", details.rawResponse);Cancel / Close Transaction (closeTransaction)
Explicitly cancel or void a pending, uncompleted transaction on the ABA PayWay gateway.
const result = await sobin.aba.closeTransaction("sb-123456");
if (result.isSuccess) {
console.log("Transaction successfully voided:", result.message);
}Live Exchange Rates (getExchangeRate)
Query the official real-time USD to KHR currency exchange rate provided directly by ABA Bank.
const rate = await sobin.aba.getExchangeRate();
console.log("1 USD =", rate.usdToKhr, "KHR");Webhook Verification (verifyWebhook)
ABA PayWay sends server-to-server pushback notifications upon payment completion. You must verify the cryptographic signature to guard against fraudulent or replayed requests.
123456789101112131415// Inside your webhook route handler (e.g. Next.js, Express, or NestJS) const payload = req.body; // Incoming JSON pushback payload const signature = req.headers["x-payway-hmac-sha512"] || payload.hash; const verification = sobin.aba.verifyWebhook(payload, signature); if (!verification.isValid) { console.error("Untrusted pushback! Cryptographic signature mismatch."); return res.status(401).send("Invalid Signature"); } if (verification.status === "APPROVED") { // Safe to credit customer balance or mark order as paid await markOrderPaid(verification.tranId); }
Full & Partial Refunds (refundTransaction)
Execute full or partial refunds directly against settled transactions. ABA PayWay requires refund payloads to be encrypted using 2048-bit RSA-SHA512 with the bank's public key.
12345678910const refundResult = await sobin.aba.refundTransaction({ tranId: "sb-123456", amount: 10.0, // Amount to refund reason: "Customer order cancellation", rsaPublicKey: process.env.ABA_RSA_PUBLIC_KEY, // Or configured in client init }); if (refundResult.isSuccess) { console.log("Refund processed successfully! Refund ID:", refundResult.refundId); }
Webhook Signature Debugger
HMAC-SHA512Constant-TimePaste any ABA PayWay pushback webhook payload to inspect the canonical normalization string and verify the signature in constant time.
Webhook Request Body (JSON)
Cryptographic Trace
Ready to Verify Webhook
Click a quick preset above or paste your payload to inspect canonical string generation and verify HMAC signatures.
SobinPay strictly uses Node.js crypto.timingSafeEqual under the hood to ensure execution time does not leak cryptographic digest bytes.
Individual EMVCo KHQR Generation
Generate standard National Bank of Cambodia EMVCo QR codes for individual Bakong account IDs (e.g. username@bank or phone number accounts).
1234567891011121314import { SobinPay } from "@sobindigital/pay"; // Generate a Dynamic Individual KHQR (with specified amount & invoice) const qrString = SobinPay.khqr.generateIndividual({ bakongAccountId: "sobin_digital@aclb", // Recipient Bakong Account ID merchantName: "Sobin Digital", // Payee display name (max 25 chars) merchantCity: "Phnom Penh", // Operational city (default: Phnom Penh) amount: 15.50, // Amount (USD or KHR) currency: "USD", // "USD" (code 840) or "KHR" (code 116) billNumber: "INV-2026-001", // Optional invoice number (Tag 62) }); console.log(qrString); // Output: 00020101021229300018sobin_digital@aclb520459995303840540515.505802KH5913Sobin Digital6010Phnom Penh62160112INV-2026-0016304...
If you omit the amount parameter, SobinPay automatically generates a Static QR (Point of Initiation Tag 01 = "11"), allowing the payer to enter any custom amount in their banking app. When amount is provided, it generates a Dynamic QR (Tag 01 = "12").
Merchant EMVCo KHQR Generation
For registered commercial businesses and organizations, generate merchant QR codes with acquiring bank credentials, merchant IDs, and store/terminal identifiers (EMVCo Tag 30 structure).
123456789101112const merchantQr = SobinPay.khqr.generateMerchant({ bakongAccountId: "sobin_digital@aclb", merchantId: "000000001", // Acquiring Bank Merchant ID acquiringBank: "Bakong Bank", // Acquiring Bank Name merchantName: "Sobin Digital Store", merchantCity: "Phnom Penh", amount: 60000, currency: "KHR", billNumber: "ORD-9912", storeLabel: "Central Branch", terminalLabel: "POS-03", });
KHQR Parser & CRC-16 Validation (parse)
Parse any incoming or scanned KHQR string into structured JavaScript objects and automatically verify the 16-bit CRC checksum (polynomial 0x1021, initial value 0xFFFF).
123456789101112const parsed = SobinPay.khqr.parse(qrString); if (!parsed.isValid) { throw new Error("Invalid KHQR string or CRC16 checksum mismatch!"); } console.log("Account ID:", parsed.bakongAccountId); // e.g. "sobin_digital@aclb" console.log("Payee Name:", parsed.merchantName); // e.g. "Sobin Digital" console.log("Amount:", parsed.amount); // e.g. 15.5 console.log("Currency:", parsed.currency); // "USD" or "KHR" console.log("CRC:", parsed.crc); // e.g. "E5F2" console.log("All EMVCo Tags:", parsed.tags); // { "00": "01", "01": "12", ... }
Bakong Universal Deeplinks (deeplink)
Generate universal deep links that seamlessly open installed banking applications on iOS and Android devices, prompting users directly to the confirmation screen.
const deeplink = SobinPay.khqr.deeplink(qrString);
// Returns:
// "https://bakong.nbc.org.kh/deeplink?qr=000201010212..."
// Or native scheme: "bakong://qr?data=000201010212..."Because Bakong KHQR is Cambodia’s national standard, this deep link and QR code works across all major Cambodian banking applications: ABA Mobile, ACLEDA mobile, Wing Bank, Canadia Bank, Sathapana, and Chip Mong Bank.
EMVCo KHQR Tag Inspector & CRC16 Validator
Tag-Length-ValueCRC-16/CCITTDeconstruct any raw Bakong KHQR EMVCo string down to individual TLV tags and verify the 16-bit CRC checksum.
TypeScript Enums & Constants
SobinPay ships 100% strict TypeScript types and enums for compile-time safety and IDE autocompletion.
AbaPaymentStatus
| Enum Member | String Value | Description |
|---|---|---|
| APPROVED | "APPROVED" | Payment completed and settled successfully |
| PENDING | "PENDING" | Awaiting customer scan or approval |
| PRE_AUTH | "PRE-AUTH" | Funds authorized / held on card |
| DECLINED | "DECLINED" | Payment declined by bank or insufficient funds |
| CANCELLED | "CANCELLED" | Customer cancelled or session closed |
| REFUNDED | "REFUNDED" | Transaction has been refunded |
AbaPaymentOption
| Enum Member | Value | Method |
|---|---|---|
| ABAPAY_KHQR | "abapay_khqr" | ABA Pay + National Bakong KHQR |
| ABAPAY_KHQR_DEEPLINK | "abapay_khqr_deeplink" | Mobile deeplink directly launching ABA Mobile |
| CARDS | "cards" | Visa, Mastercard, JCB, and UnionPay |
| ALIPAY | "alipay" | Alipay Global |
| "wechat" | WeChat Pay | |
| GOOGLE_PAY | "google_pay" | Google Pay Mobile Wallet |
AbaStatusCode (Gateway Status Codes)
| Code | Meaning | Resolution |
|---|---|---|
| "00" | Success | Request processed successfully |
| "1" | Invalid Hash | Verify your API key matches the merchant ID |
| "2" | Invalid Parameters | Check required fields, amount decimals, and URLs |
| "5" | Transaction Not Found | Transaction ID does not exist in gateway records |
| "26" | Invalid Merchant | Merchant account inactive or unwhitelisted |
Error Handling & Hierarchy
SobinPay errors inherit from the native JavaScript Error class and include typed machine-readable error codes.
1234567891011121314import { SobinPay, SobinPayError, ProviderApiError } from "@sobindigital/pay"; try { const status = await sobin.aba.checkTransaction(tranId); } catch (err: unknown) { if (err instanceof ProviderApiError) { console.error("Gateway HTTP Status:", err.statusCode); console.error("Gateway Message:", err.message); } else if (err instanceof SobinPayError) { console.error("SobinPay Code:", err.code); // e.g. "INVALID_TRANSACTION_ID" } else { console.error("Unexpected error:", err); } }
Next.js App Router Recipe
Production recipe for Next.js App Router utilizing Server Actions for purchase generation and Route Handlers for pushback webhooks.
123456789101112131415161718192021222324252627"use server"; import { SobinPay, AbaPaymentOption, AbaViewType } from "@sobindigital/pay"; const sobin = new SobinPay({ aba: { merchantId: process.env.ABA_MERCHANT_ID!, apiKey: process.env.ABA_API_KEY!, apiUrl: process.env.ABA_API_URL, }, }); export async function createCheckoutSession(amount: number) { const purchase = sobin.aba.createPurchase({ amount, currency: "USD", returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success`, cancelUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/cancel`, paymentOption: AbaPaymentOption.ABAPAY_KHQR, viewType: AbaViewType.POPUP, }); return { actionUrl: purchase.actionUrl, payload: purchase.payload, }; }
12345678910111213141516171819202122232425262728293031import { NextResponse } from "next/server"; import { SobinPay } from "@sobindigital/pay"; const sobin = new SobinPay({ aba: { merchantId: process.env.ABA_MERCHANT_ID!, apiKey: process.env.ABA_API_KEY!, }, }); export async function POST(req: Request) { try { const payload = await req.json(); const signature = req.headers.get("x-payway-hmac-sha512") || payload.hash; const verification = sobin.aba.verifyWebhook(payload, signature); if (!verification.isValid) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } if (verification.status === "APPROVED") { // Perform database update in constant time console.log("Order paid:", verification.tranId); } return NextResponse.json({ success: true }); } catch (err: unknown) { return NextResponse.json({ error: "Webhook processing error" }, { status: 400 }); } }
Express.js Recipe
Standard Node.js / Express integration with pushback webhook verification.
1234567891011121314151617181920212223242526272829303132333435363738import express from "express"; import { SobinPay, AbaPaymentOption } from "@sobindigital/pay"; const app = express(); app.use(express.json()); const sobin = new SobinPay({ aba: { merchantId: process.env.ABA_MERCHANT_ID!, apiKey: process.env.ABA_API_KEY!, }, }); // Create checkout app.post("/api/checkout", (req, res) => { const purchase = sobin.aba.createPurchase({ amount: req.body.amount, currency: "USD", returnUrl: "https://example.com/success", paymentOption: AbaPaymentOption.ABAPAY_KHQR, }); res.json(purchase); }); // Receive Pushback Webhook app.post("/api/payway/webhook", (req, res) => { const signature = req.headers["x-payway-hmac-sha512"] as string; const verification = sobin.aba.verifyWebhook(req.body, signature); if (!verification.isValid) { return res.status(401).send("Unauthorized Webhook"); } // Handle successful transaction res.status(200).send("OK"); }); app.listen(4000, () => console.log("Server listening on port 4000"));
NestJS Recipe (Service & Guard)
Enterprise NestJS provider injection and custom canActivate guard.
123456789101112131415161718import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { SobinPay } from "@sobindigital/pay"; @Injectable() export class SobinPayService { public readonly client: SobinPay; constructor(private configService: ConfigService) { this.client = new SobinPay({ aba: { merchantId: this.configService.getOrThrow<string>("ABA_MERCHANT_ID"), apiKey: this.configService.getOrThrow<string>("ABA_API_KEY"), apiUrl: this.configService.get<string>("ABA_API_URL"), }, }); } }
Frequently Asked Questions
Comprehensive answers to common architectural, security, and integration questions regarding ABA PayWay, Bakong KHQR, and SobinPay.
SobinPay is the official enterprise-grade TypeScript payment gateway monorepo for Cambodia, developed and maintained by Sobin Digital. It unifies Cambodia’s two primary payment channels — ABA PayWay (Advanced Bank of Asia) and Bakong KHQR (National Bank of Cambodia) — under a single, strictly-typed SDK.
Unlike legacy unmaintained scripts, SobinPay is built for production enterprise workloads with zero runtime dependencies, dual ESM/CJS output, and active long-term maintenance by Sobin Digital.