01 / Frontend
ໜ້າຊື້ປີ້, QR/loading/expiry, ສະຖານະ ແລະປີ້. ບໍ່ມີ private key ຫຼື webhook secret.
ຄູ່ມືສຳລັບ developer ແລະຜູ້ດູແລ: ກຽມ Partner, ລົງລາຍເຊັນ request, ສະແດງ LaoQR ແລະຮັບຜົນຊຳລະຜ່ານ webhook.
ເລີ່ມຈາກ workflow ↗ດາວໂຫຼດ Source Code ↓Web + API + PostgreSQL schema ແລະ migrations 6 ຊຸດ, ພ້ອມ tests ແລະ README ວິທີຕິດຕັ້ງ/ເຊື່ອມຕໍ່ EASYPOS.
ບໍ່ມີ credentials, private keys ຫຼືຂໍ້ມູນທຸລະກຳຈິງ. ບໍ່ຕ້ອງໃຊ້ລະຫັດ ZIP. ແຕກໄຟລ໌ແລ້ວອ່ານ README.md; ເລີ່ມໃນ local mock ແລະໃຊ້ credentials ຂອງທ່ານເອງ.
a3bef7579aa9219cc800ffbffba9d36318e0cba7bad394044dc27a5ddc0f2f83ແຍກວຽກຂອງ browser, Ticket backend ແລະ EASYPOS ໃຫ້ຊັດເຈນ.
Browser ສົ່ງ product, quantity, visitDate; ບໍ່ກຳນົດຍອດເງິນ.
Ticket ຄຳນວນລາຄາຝັ່ງ server, ບັນທຶກ order ແລະ idempotency, ລົງ RSA signature.
Backend POST new-transaction; ຮັບ transactionId, qrCode string ແລະ expiredTime.
Browser render string ເປັນຮູບ QR; ລູກຄ້າສະແກນຜ່ານ bank app.
EASYPOS ສົ່ງ PAYMENT_CONFIRMED; Ticket ກວດ HMAC/ຂໍ້ມູນ ແລະບັນທຶກຄັ້ງດຽວ.
Browser ອ່ານສະຖານະ order; ປີ້ທີ່ຢືນຢັນແລ້ວໃຊ້ເຂົ້າໄດ້ຄັ້ງດຽວ.
ໜ້າຊື້ປີ້, QR/loading/expiry, ສະຖານະ ແລະປີ້. ບໍ່ມີ private key ຫຼື webhook secret.
Order/payment/receipt/ticket, catalog price snapshot, idempotency, transaction ແລະ reconciliation.
Public HTTPS, raw body, signature/time/source validation, durable dedupe, monitoring ແລະ backup.
Partner/key lifecycle, secret storage, normal delivery + bounded retry, logs ແລະຜູ້ຮັບຜິດຊອບແກ້ລາຍການຄ້າງ.
ຂໍ້ມູນໃດໄດ້ຈາກ provider? ຂໍ້ມູນໃດສ້າງເອງ?
| ຂໍ້ມູນ | ທີ່ມາ / ວິທີໃຊ້ | ຈຸດເກັບ |
|---|---|---|
| API base URL + Partner ID + Merchant ID | EASYPOS ຢືນຢັນ onboarding/environment ແລະ merchant binding | Backend configuration |
| RSA private key | ທີມ Partner ສ້າງເອງ; ໃຊ້ sign outbound request | Protected server file / secret store · 0600 |
| RSA public key + fingerprint | ສະກັດຈາກ private key; upload public PEM ໃສ່ Portal | Portal key lifecycle + backend fingerprint |
| Webhook secret | ຜູ້ມີສິດຈັດການຜ່ານ Portal secret lifecycle | Protected file; ໃຊ້ກວດ HMAC ເທົ່ານັ້ນ |
| Portal login | ບັນຊີຜູ້ດູແລ; ບໍ່ແມ່ນ API credential | Password manager; ບໍ່ຝັງໃນ app/code |
ປຸ່ມ ແລະສິດອາດຕ່າງກັນຕາມ role. ໜ້ານີ້ບໍ່ປ່ຽນ IP allowlist, network policy, secret ຫຼື key ໃດໆ.
Private key ຢູ່ server ຂອງທ່ານ; EASYPOS ຮັບພຽງ public key.
# Example only: create NEW demo keys; never overwrite the active key.
umask 077
key_dir=$(mktemp -d ./partner-key-demo.XXXXXX)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$key_dir/partner-private.pem"
openssl pkey -in "$key_dir/partner-private.pem" -pubout -out "$key_dir/partner-public.pem"
openssl pkey -pubin -in "$key_dir/partner-public.pem" -outform DER | openssl dgst -sha256
# Upload ONLY partner-public.pem. Keep the private file server-side, mode 0600.PEM ທີ່ upload ຕ້ອງເລີ່ມ -----BEGIN PUBLIC KEY-----. Fingerprint ແມ່ນ SHA-256 hex ຂອງ DER SubjectPublicKeyInfo; ບໍ່ແມ່ນ hash ຂໍ້ຄວາມ PEM.

easypay-partner-v1
<exact X-Partner-Timestamp>
<exact X-Partner-Nonce>
<compact JSON body>ຕໍ່ແຕ່ລະແຖວດ້ວຍ LF (\n), UTF-8, ບໍ່ມີ newline ທ້າຍ. ສົ່ງ body string ດຽວກັບທີ່ sign; ຫ້າມ pretty-print ຫຼື serialize ໃໝ່ຫຼັງ sign.
import { sign, constants, randomUUID } from "node:crypto";
// Server-side only. Input order/price must already be validated and persisted.
export function prepareSignedRequest(payload, privateKey, fingerprint, operationKey) {
const timestamp = new Date().toISOString();
const nonce = randomUUID().replaceAll("-", "");
const body = JSON.stringify(payload);
const canonical = ["easypay-partner-v1", timestamp, nonce, body].join("\n");
const signature = sign("RSA-SHA1", Buffer.from(canonical, "utf8"), {
key: privateKey, padding: constants.RSA_PKCS1_PADDING
}).toString("base64");
return { body, headers: {
"Content-Type": "application/json",
"X-Partner-Timestamp": timestamp, "X-Partner-Nonce": nonce,
"X-Key-Fingerprint": fingerprint, "X-Signature": signature,
"X-Idempotency-Key": operationKey, "X-Correlation-ID": "corr-" + randomUUID()
}};
}
// No fetch here. Persist this exact envelope before an authorized send.ບໍ່ສັບສົນ API ຂອງ Ticket ກັບ API ຂອງ EASYPOS.
| ເສັ້ນທາງ | ຜູ້ເອີ້ນ → ຜູ້ຮັບ | ໜ້າທີ່ |
|---|---|---|
| POST /api/orders | Browser → Ticket | ສ້າງ order; header Idempotency-Key (ບໍ່ມີ X-) |
| POST /payment/api/PaymentQr/new-transaction | Ticket backend → EASYPOS | ຂໍ LaoQR; RSA-signed JSON, ບໍ່ໃຊ້ Portal password/Bearer token |
| GET /api/orders/{publicToken} | Browser → Ticket | ອ່ານ order/payment/ticket ຈາກ DB; ບໍ່ແມ່ນ provider lookup |
| POST /api/webhooks/easypos | EASYPOS → Ticket | ຮັບ signed payment/challenge/test |
| GET /payment/api/PaymentQr/status/{paymentId} | Authenticated customer → EASYPOS | Customer-owner-bound; ບໍ່ແມ່ນ Partner status API, ຫ້າມສົມມຸດວ່າ RSA ໃຊ້ໄດ້ |
{
"productId": "YOUR_PRODUCT_ID",
"productRevision": 1,
"quantity": 1,
"visitDate": "2026-10-01"
}Header: Content-Type: application/json, Idempotency-Key: order-<unique-id>; same-origin request. ສຳເລັດ 201 ສົ່ງ {"publicToken":"<private-token>","url":"<order-url>"}. Browser ໃຊ້ GET order ເພື່ອອ່ານສະຖານະ. ຢ່າສົ່ງ publicToken ຈິງໃນ logs/ຄູ່ມື: ມັນໃຫ້ສິດເບິ່ງ order.
https://api.qrlaos.com/payment/api/PaymentQr/new-transactionໃຊ້ຈາກ backend ເທົ່ານັ້ນ. ຢືນຢັນ environment ກັບ EASYPOS; ຄຳວ່າ UAT ບໍ່ຮັບປະກັນ sandbox ແລະ QR ອາດເປັນເງິນຈິງ.
// Server-side illustration ONLY. No call is made by this guide.
// envelope is the persisted output of prepareSignedRequest.
export async function sendOnce(envelope, approved = false) {
if (!approved) throw new Error("OUTBOUND_NOT_APPROVED");
const response = await fetch(
"https://api.qrlaos.com/payment/api/PaymentQr/new-transaction",
{ method: "POST", headers: envelope.headers, body: envelope.body,
redirect: "error", signal: AbortSignal.timeout(12000) }
);
return { status: response.status, text: await response.text() };
}
// No automatic retries. Validate/classify the response before saving a QR.
// Timeout/unknown outcome -> REVIEW/reconcile, not a new payment.
// Caller must enforce authorization, mode, freshness and idempotency guards.ຕົວຢ່າງ 12 ວິນາທີກົງກັບ client ນີ້, ບໍ່ແມ່ນ SLA ຂອງ provider. ບໍ່ຕັ້ງ Content-Length ເອງ; ໃຫ້ HTTP client ຄຳນວນຈາກ UTF-8 body. ຕົວຢ່າງນີ້ສະແດງ transport ເທົ່ານັ້ນ, ຕ້ອງມີ mode/permission guard, response-size limit, schema validation ແລະ DB commit ໃນ adapter ຈິງ.
| Header | ຮູບແບບ | ຫຼັກການ |
|---|---|---|
| Content-Type | application/json | JSON ແບບ UTF-8 |
| X-Partner-Timestamp | RFC3339 UTC | ຕ້ອງກົງກັບ canonical; ຄູ່ມື v1: ອະດີດ ≤300s / ອະນາຄົດ ≤60s, ກວດ policy ກັບ provider |
| X-Partner-Nonce | 16–128 chars | A–Z a–z 0–9 . _ ~ -; ໃໝ່ຕໍ່ logical operation |
| X-Key-Fingerprint | SHA-256 lowercase hex | DER SubjectPublicKeyInfo ຂອງ public key; ຄູ່ມືແນະນຳ, Ticket ສົ່ງສະເໝີ |
| X-Signature | Base64 | RSA PKCS#1 v1.5 + SHA-1 ຕາມ contract v1 |
| X-Idempotency-Key | 8–128 chars | [A-Za-z0-9][A-Za-z0-9._:-]{7,127}; 1 key / 1 operation |
| X-Correlation-ID | corr-<random UUID> | ແນະນຳສຳລັບໄລ່ logs ທັງສອງຝັ່ງ |
| Field | Type | ຄ່າ / ການກວດ |
|---|---|---|
| uuid | string · required | 14 ຕົວອັກສອນ; reference ບໍ່ຊ້ຳຕໍ່ payment |
| partnerId | string · required | ຮັບຈາກ EASYPOS; ກົງກັບ key/policy |
| amount | integer · required | ຈຳນວນເຕັມ LAK >0; server ຄຳນວນລາຄາ × ຈຳນວນ |
| description | string | Ticket ຈຳກັດ 1–30 ຕົວອັກສອນກ່ອນ sign |
| expiryTime | integer · required | ນາທີ; ຕົວຢ່າງ 15, ໃຊ້ຄ່າທີ່ provider ຕົກລົງ |
| merchantId | string · required | merchant ທີ່ provider ຜູກກັບ partner |
| platform | string · required | Ticket integration ນີ້ໃຊ້ IOS; ຢ່າປ່ຽນເປັນຊື່ໂໝດທົດສອບ |
| mobileNum | string · optional in PDF | Ticket ໃຊ້ 2000000000 ຖ້າຫວ່າງ; ຕັ້ງກ່ອນ serialize/sign |
| currency | string · required | LAK; ກວດກົງກັບ order ແລະ callback |
{
"uuid": "P0000000000001",
"partnerId": "YOUR_PARTNER_ID",
"amount": 30000,
"description": "Kuang Si admission",
"expiryTime": 15,
"merchantId": "YOUR_MERCHANT_ID",
"platform": "IOS",
"mobileNum": "2000000000",
"currency": "LAK"
}30,000 LAK ແມ່ນຕົວຢ່າງອະທິບາຍ, ບໍ່ແມ່ນລາຄາ catalog ປັດຈຸບັນ. ເກັບ unit price/revision ໃນ order ກ່ອນເອີ້ນ provider; ບໍ່ເຊື່ອ amount ຈາກ browser.
{
"success": true,
"transactionId": "EXAMPLE_TRANSACTION_ID",
"qrCode": "EXAMPLE_ONLY_NOT_A_PAYABLE_QR",
"expiredTime": "2026-09-18T03:15:00Z",
"amount": 30000,
"uuid": "P0000000000001"
}Response ອາດມີ id, code, message ເພີ່ມ. Adapter ຕ້ອງ validate success, transaction ID, QR string, expiry ແລະຂໍ້ມູນ order; HTTP 200 ຢ່າງດຽວບໍ່ພໍ.
import QRCode from "qrcode";
// qrCode comes from the validated, persisted provider response.
const imageUrl = await QRCode.toDataURL(qrCode, {
width: 300, margin: 2, errorCorrectionLevel: "M"
});
// Render <img src={imageUrl} alt="Payment QR" />.
// Do NOT encode the whole JSON, transaction ID, or order URL.ບໍ່ໃຊ້ third-party QR image service ເພາະຈະສົ່ງ payment payload ອອກຂ້າງນອກ. ຖ້າບໍ່ມີ QR string ຢ່າສ້າງ QR ປອມ; ສະແດງ pending/failed/review ຕາມຂໍ້ເທັດຈິງ.
RSA ໃຊ້ຂາອອກ; HMAC-SHA256 ໃຊ້ກວດ callback ຂາເຂົ້າ.
https://tickets.qrlaos.com/api/webhooks/easyposPortal → Partner → Webhook setup: ຕັ້ງ HTTPS endpoint ຂອງ server, ກວດ retry policy ແລະ audit reason. ຜູ້ມີສິດກຽມ secret ຜ່ານ protected channel ໃຫ້ backend ແລ້ວຈຶ່ງ Challenge/Test. ຢ່າ rotate secret ທີ່ໃຊ້ງານຢູ່ເພື່ອອ່ານຄ່າຄືນ.

{
"Content-Type": "application/json",
"X-EasyPay-Webhook-Id": "pwh-123",
"X-EasyPay-Webhook-Timestamp": "2026-09-18T03:01:00.000Z",
"X-EasyPay-Webhook-Signature-Version": "easypay-webhook-v1",
"X-EasyPay-Webhook-Signature": "sha256=<64 lowercase hex characters>"
}{
"eventType": "PAYMENT_CONFIRMED",
"deliveryId": "pwh-123",
"timestamp": "2026-09-18T03:01:00.000Z",
"replayProtected": true,
"data": {
"uuid": "P0000000000001",
"amount": 30000,
"billId": "EXAMPLE_TRANSACTION_ID",
"currency": "LAK",
"partnerId": "YOUR_PARTNER_ID",
"paymentAt": "2026-09-18T03:00:50.000000Z",
"paidStatus": "FNLD",
"paymenBank": "LDB"
}
}| Field / ກຸ່ມ | ຫຼັກການ |
|---|---|
| eventType / replayProtected | PAYMENT_CONFIRMED / true; strict schema |
| deliveryId / timestamp | ກົງກັບ headers ແບບ exact; delivery ID ຮູບ pwh-<positive int64> |
| data.billId / uuid / partnerId | Provider payment identity / Ticket reference / partner; ກົງກັບ order |
| data.amount / currency | ກົງກັບຍອດເງິນ ແລະ LAK ທີ່ບັນທຶກ |
| data.paidStatus | FNLD ແບບ exact; ຢ່າຮັບຄ່າອື່ນເປັນ PAID |
| data.paymentAt | ISO-8601 UTC; ຮັກສາ UTC/precision, ບໍ່ປ່ຽນເປັນເວລາລາວໃນ storage |
| data.paymenBank | ຊື່ field ຕາມ contract (paymenBank); ບໍ່ແກ້ການສະກົດເອງ |
easypay-webhook-v1
<deliveryId>
<exact timestamp header>
<eventType>
<lowercase SHA256 hex of raw UTF-8 body>HMAC-SHA256 ໃຊ້ UTF-8 bytes ຂອງ secret ໂດຍກົງ (ບໍ່ base64-decode). Output: sha256=<lowercase hex>. ຮັກສາ raw body ກ່ອນ JSON parsing; ບໍ່ hash JSON ທີ່ serialize ໃໝ່.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
// CRYPTO CHECK ONLY: also apply the validation/DB gates listed below.
export function matchesHmac(rawBody, deliveryId, timestamp, eventType, signature, secret) {
if (!/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const digest = createHash("sha256").update(rawBody, "utf8").digest("hex");
const canonical = ["easypay-webhook-v1", deliveryId, timestamp, eventType, digest].join("\n");
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(canonical, "utf8").digest();
return timingSafeEqual(Buffer.from(signature.slice(7), "hex"), expected);
}{"received":true,"duplicate":false}. DB ລົ້ມເຫຼວບໍ່ຕອບວ່າຮັບແລ້ວ.ໃຊ້ headers/HMAC algorithm ດຽວກັນ, ແຕ່ delivery ID ເປັນ UUID ແລະ eventType ຕ່າງກັນ. ຢ່ານຳ signature ຂອງ PAYMENT_CONFIRMED ມາໃຊ້ຊ້ຳ.
{
"eventType": "PARTNER_WEBHOOK_CHALLENGE",
"deliveryId": "00000000-0000-4000-8000-000000000001",
"timestamp": "2026-09-18T03:01:00.000Z",
"replayProtected": true,
"challenge": "0123456789abcdef0123456789abcdef"
}ຫຼັງ verify + durable replay claim ໃຫ້ຕອບ 200 {"challenge":"<same-value>"}.
{
"eventType": "PARTNER_WEBHOOK_TEST",
"deliveryId": "00000000-0000-4000-8000-000000000001",
"timestamp": "2026-09-18T03:01:00.000Z",
"replayProtected": true,
"challenge": null
}ຕອບ 200 {"received":true}; ບໍ່ຕ້ອງ echo challenge, ບໍ່ PAID/ອອກປີ້. ຕົວຢ່າງ IDs ໃນໜ້ານີ້ໃຊ້ອ່ານເທົ່ານັ້ນ.
ບໍ່ໃຫ້ timeout ກາຍເປັນການສ້າງ payment ຊ້ຳ.
| ເຫດການ | ວິທີຈັດການ |
|---|---|
| Refresh / double click | ໃຊ້ order/idempotency ເດີມ; ບໍ່ສ້າງ payment ໃໝ່ທຸກຄັ້ງ |
| Timeout / connection reset / ຜົນບໍ່ແນ່ນອນ | REVIEW + reconcile ກັບ provider; ບໍ່ສະຫຼຸບ FAILED/NO_RECORD ເອງ ແລະບໍ່ blind retry ດ້ວຍ UUID ໃໝ່ |
| 400 / 401 / 403 | ກວດ payload, exact signing, active key/partner ແລະ policy; ຢ່າ retry ບໍ່ຈຳກັດ ຫຼືປ່ຽນ IP policy ເອງ |
| 429 / temporary failure | ກວດ outcome/idempotency/freshness ກ່ອນ retry ຕາມ contract; bounded backoff + jitter |
| Webhook 503 / DB failure | Provider retry ຕາມ policy: delivery ID ເດີມ, timestamp/signature ໃໝ່; receiver ບໍ່ commit ເຄິ່ງທາງ |
| QR ໝົດອາຍຸ / late callback | UI ສະແດງ EXPIRED; ການຫັກເງິນ/late confirmation ຕ້ອງ reconcile ຕາມ business policy |
ເກັບ correlation ID, UTC start/end, HTTP status, exception name/code, UTF-8 byte count ແລະ SHA-256 ຂອງ request body. ຢ່າ log password/private key/webhook secret/token/signature ຫຼື QR payload ແບບເປີດສາທາລະນະ. ການເພີ່ມ timeout ຢ່າງດຽວບໍ່ໄດ້ພິສູດວ່າ transport ຖືກແກ້.
ຮູບສຳລັບອະທິບາຍ, ບໍ່ແມ່ນຄຳຢືນຢັນ runtime ຫຼືການຈ່າຍໃໝ່.



Local tests → server readiness → provider delivery → ທົດສອບເງິນຈິງທີ່ອະນຸມັດ.
ອ່ານ PARTNER_PAYMENT_GATEWAY_API_EN.pdf v1.0 (20/08/2026), EASYPOS-Payment-Gateway-Partner.postman_collection.json ແລະ Ticket provider / payment-webhook-contract / orders / fulfillment code. ພາບ Portal/Ticket ກວດວັນທີ 18/09/2026 ແບບ read-only.
Postman ມີ create ແລະ replay ທີ່ໃຊ້ new-transaction ເສັ້ນດຽວ; replay ບໍ່ແມ່ນ endpoint ໃໝ່. ບໍ່ພົບ contract Partner query/list/refund/settlement ທີ່ຢືນຢັນໃນຊຸດນີ້, ຈຶ່ງບໍ່ປະດິດ URL/payload. ຂໍ specification ຈາກ EASYPOS ກ່ອນເພີ່ມ.
Webhook envelope ໃນໜ້ານີ້ອີງ receiver ປັດຈຸບັນ, ແທນ unsigned sample ເກົ່າ. ຮູບ/ຕົວຢ່າງບໍ່ແມ່ນ live configuration ຫຼືຫຼັກຖານການທົດສອບເງິນຈິງ.