Creating a Payment on the Backend
ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។
Table of Contents
- What your backend needs to do
- The purchase hash formula
- The widget approach vs. calling the API directly
- A worked example
- Beginner Pitfalls
1. What your backend needs to do
Your backend needs an endpoint (e.g. /create-payment) that:
- Generates a unique
tran_id(e.g.order-+ a timestamp). - Gets the current
req_time. - Builds the hash/signature using PayWay's specified formula for the endpoint you're calling — concatenate the required fields in the exact order PayWay's docs specify, then sign with the correct key.
- Returns these values to the frontend (or, as you'll see in Section 3, calls PayWay directly and returns its response instead).
This is the step where a lot of beginners struggle: the hash formula must match exactly what PayWay expects — field order and key material both matter — or the request is silently rejected as "Wrong Hash."
2. The purchase hash formula
Confirmed against PayWay's Purchase API docs: the hash is HMAC-SHA512, base64-encoded, over every field PayWay defines for this endpoint, concatenated in the exact order below — using an empty string for anything you're not using. Every field that's part of the hash must also be submitted in the request, even empty, or PayWay recomputes a different hash and rejects the request.
const fields = {
req_time,
merchant_id: MERCHANT_ID,
tran_id,
amount: '1.00',
items: '',
shipping: '0', // PayWay types this as a number — '' fails as "Wrong shipping price"
firstname: 'Test',
lastname: 'Customer',
email: '',
phone: '0123456789',
type: '',
payment_option: '',
return_url: RETURN_URL,
cancel_url: '',
continue_success_url: '',
return_deeplink: '',
currency: 'USD',
custom_fields: '',
return_params: '',
payout: '',
lifetime: '30', // minutes; PayWay requires >= 3
additional_params: '',
google_pay_token: '',
skip_success_page: '0',
};
const stringToHash = Object.values(fields).join('');
const hash = crypto
.createHmac('sha512', PUBLIC_KEY)
.update(stringToHash)
.digest('base64');
Remaining uncertainty, worth teaching explicitly: PayWay's sandbox
issues two kinds of key material — a Public Key and an RSA public/private
key pair — and which one signs this particular request (and whether it's
HMAC or RSA signing) isn't confirmed by PayWay's PHP sample, which just
calls it $api_key. This course assumes that maps to the Public Key from
credential_info.txt, but that mapping itself is unverified. If HMAC with
the Public Key fails for you, the next thing to try is RSA-signing with
PAYWAY_RSA_PRIVATE_KEY instead.
3. The widget approach vs. calling the API directly
Part 2 walked through
PayWay's documented default: fill in the hidden form fields, then call
AbaPayway.checkout() to open PayWay's hosted popup. That's the right
starting point, and it's what most integrations should use.
This course's own runnable example does something different: it skips the widget and calls the Purchase API directly from the server, then returns PayWay's JSON response — which already contains a ready-to-display QR code (base64 PNG) and an ABA Mobile deep link — straight to the frontend to render.
Why deviate from the documented path? During development, the widget's
popup got stuck on a loading spinner with no confirmed root cause (possibly
a browser extension — a CORS failure on checkout_cross.svg showed up
while debugging). Calling the Purchase API straight from the server
sidesteps that entirely. It also avoids a separate problem: PayWay's
Purchase API only supports full-page form navigation or server-to-server
calls, not cross-origin fetch()/XHR from the browser — so a
browser-side fetch() to PayWay directly would hit CORS regardless.
This is a genuine trade-off, not a strict improvement: you lose PayWay's hosted popup UI and take on rendering the QR code/deep link yourself, in exchange for sidestepping both issues above. Know both paths exist, and pick deliberately.
4. A worked example
The server-to-server version, as an Express route:
router.post('/create-payment', async (req, res) => {
const tran_id = `order-${Date.now()}`;
const req_time = Math.floor(Date.now() / 1000).toString();
const fields = { /* ...as in Section 2... */ };
const stringToHash = Object.values(fields).join('');
const hash = crypto.createHmac('sha512', PUBLIC_KEY).update(stringToHash).digest('base64');
const paywayRes = await fetch(PURCHASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ ...fields, hash }),
});
const data = await paywayRes.json();
// PayWay's purchase response doesn't echo tran_id back, but the frontend
// needs it to poll /check-payment-status (Part 4).
res.json({ ...data, tran_id });
});
And the frontend that calls it and renders PayWay's response directly, instead of invoking a widget:
const res = await fetch('/create-payment', { method: 'POST' });
const data = await res.json();
if (data.status?.code !== '00') {
// handle failure — code "00" only means the *request* was accepted,
// not that the customer has paid. More on this in Part 4.
} else {
// render data.qrImage and data.abapay_deeplink yourself
}
5. Beginner Pitfalls
- Every field in the hash formula must also be in the submitted request, even when its value is an empty string. Leaving a field out of the request body (rather than sending it empty) changes what PayWay recomputes and the hash check fails.
shippingis typed as a number by PayWay — sending''fails with "Wrong shipping price," even though most other unused fields are fine as an empty string.code: "00"in the purchase response means PayWay accepted the request, not that the customer paid. Don't treat it as "payment done" — that's what Part 4 is for.- If you go with the direct-API approach in Section 3, remember you're now
responsible for rendering
qrImage/abapay_deeplinkyourself — you no longer get PayWay's hosted popup UI for free.
ទិញកាហ្វេជូនខ្ញុំ
ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!