Verifying Payment Status
Table of Contents
1. Why this step exists
This is PayWay's documented Step 2, "Verify Payment Status"
(official reference)
— the part a lot of beginner guides skip. A code: "00" response from
Part 3's purchase call
only means PayWay accepted the request; it says nothing about whether the
customer actually paid. You confirm the real outcome by polling a second
endpoint with the tran_id from Part 3.
Don't skip this because you're also planning the callback (Part 5). They serve different purposes — polling gives your frontend a synchronous way to show a result to the customer right now; the callback is the asynchronous, server-to-server source of truth you should ultimately rely on for updating your database. A production integration typically uses both.
2. The check-transaction hash
function utcTimestamp() {
return new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
}
router.post('/check-payment-status', async (req, res) => {
const { tran_id } = req.body;
const req_time = utcTimestamp(); // UTC 'YYYYMMDDHHmmss' — NOT unix seconds
const stringToHash = `${req_time}${MERCHANT_ID}${tran_id}`;
const hash = crypto.createHmac('sha512', PUBLIC_KEY).update(stringToHash).digest('base64');
const paywayRes = await fetch(CHECK_TRANSACTION_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ req_time, merchant_id: MERCHANT_ID, tran_id, hash }),
});
res.json(await paywayRes.json());
});
Confirmed against PayWay's Check Transaction API docs:
the hash is HMAC-SHA512(req_time + merchant_id + tran_id), base64-encoded,
signed with the same Public Key as the purchase call in Part 3 — but a
different req_time format. The purchase call uses unix seconds; this
call uses UTC YYYYMMDDHHmmss (e.g. 20260816143000). Reusing the wrong
format produces a wrong hash, not a clear error.
The response's key fields are data.payment_status — confirmed live
against the sandbox: uppercase values like PENDING, APPROVED,
DECLINED, CANCELLED, REFUNDED — and the top-level status.code
("00" means the status check itself succeeded, separate from
payment_status). Note the status fields are nested under data, not
top-level — easy to miss.
PayWay documents a rate limit of 600 requests/second for this endpoint and asks integrators to stop polling once a result is returned — don't poll forever, and don't poll faster than needed.
3. Polling from the frontend
async function pollPaymentStatus(tran_id) {
const POLL_INTERVAL_MS = 3000;
const MAX_ATTEMPTS = 20; // ~1 minute for a teaching example
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
const res = await fetch('/check-payment-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tran_id }),
});
const data = await res.json();
const paymentStatus = data.data?.payment_status;
if (paymentStatus && paymentStatus !== 'PENDING') {
return paymentStatus; // show this to the customer
}
}
// still pending after MAX_ATTEMPTS — let the customer refresh
}
Kick this off right after showing the QR code / deep link from Part 3, and
stop as soon as payment_status leaves PENDING.
4. Beginner Pitfalls
- Don't reuse the purchase call's
req_timeformat here — this endpoint wants UTCYYYYMMDDHHmmss, not unix seconds. A wrong format produces a wrong hash with no obviously-related error message. - The status fields are nested under
data.data/response.datadepending on how you name your variables — check the nesting yourself rather than assumingpayment_statusis top-level. - Treating this step as skippable because you also have a callback is the single most common mistake here — see the note in Section 1.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!