Bank Open API QR Payments — From Sandbox to Status Lifecycle
Building a QR yourself to the EMVCo standard only gets you as far as "generating an image for the customer to scan." A working payment system also needs to know which transactions have been paid, which have expired, and which have been refunded. For that, the bank needs to issue the QR, and I need to track its status.
I was building a page to accept contributions on a website. At first, I used a static PromptPay QR with an EMVCo string I assembled myself, as described in Generate a PromptPay QR with Pure Python. It worked well until more than ten transfers came in on the same day, and I had to sit down and match the payment slips against the amounts one by one. The problem was not the QR, but that nothing told my system which transactions had been paid. So I signed up for one Thai bank's Open API and worked through every part of its sandbox. In this article, I cover only the API formats described in the public documentation and the traps I ran into along the way. All credential values shown here are placeholders.
I compare only the fees and technical limitations of payment channels. This is not a recommendation to choose any particular provider, and it has nothing to do with investment returns. Fees vary by contract, so always use the quote you receive as your reference.
Where static QR stops, and why dynamic QR is needed
A static QR is an EMVCo string tied to my PromptPay proxy. I can print it for display at a shop or render it on a website. The advantage is that it requires no integration. The disadvantage is that it has no transaction reference, so when a payment arrives, my system does not know which order it belongs to.
A dynamic QR issued by a bank differs in that every API call includes my own transaction ID, and the bank returns a payload tied to that transaction. That lets me track the status against the correct order.
Build it yourself, no integration needed
- Recipient fee of 0 for personal PromptPay
- No transaction reference; manual reconciliation required
- Can fix the amount, but cannot identify who paid
- Foreign cards cannot scan it
Issued by the bank, tied to an order
- Per-transaction fees as specified in the contract
- Transaction IDs on both sides allow status tracking
- Cancellation/refunds available through the API
- Requires registration and document review, with a sandbox for testing
Comparing fees and limitations across payment channels
I compared figures from quotes and sandbox responses as ranges, rather than treating them as fixed prices.
There are three technical limitations I consider more important than the fees.
- Personal PromptPay has no API for confirming payments. Getting one requires moving to a corporate account and a bank's payment acceptance product.
- Overseas gateways charge a percentage plus a fixed fee per transaction. For small transactions in the tens of baht, the fixed component takes a large share.
- QR payments through a bank's Open API carry an MDR of around one percent, and the response breaks down the fees line by line into MDR and VAT.
Request a token first: client credentials + test mode
Every endpoint requires a bearer token, obtained through OAuth 2.0 client credentials. The pair used is the consumer id and consumer secret issued by the portal when creating the app. Join them with a colon and base64-encode the result for Basic auth.
# อย่าฝัง credential ลงสคริปต์ อ่านจาก environment แทน
BASIC=$(printf '%s' "$CONSUMER_ID:$CONSUMER_SECRET" | base64 -w0)
curl -sS -X POST 'https://openapi-sandbox.example.com/v2/oauth/token' \
-H "Authorization: Basic $BASIC" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'x-test-mode: true' \
-d 'grant_type=client_credentials'
{
"token_type": "Bearer",
"client_id": "<CONSUMER_ID>",
"access_token": "<ACCESS_TOKEN>",
"expires_in": "1799",
"status": "approved"
}
Two things to remember from the first line — a token lasts about 1799 seconds, roughly thirty minutes, and can be reused until it expires. There is no need to request a new one for every call. More importantly, the sandbox limits token requests to about five per thirty minutes per user. Code that requests a new token on every request will hit throttling during rapid testing, leaving me wasting time trying to figure out what is wrong with the code.
Cache the token with its expiry time, allowing a buffer of about 60 seconds before treating it as expired. This prevents both throttling and cases where the token expires while a request is in transit.
Issue a dynamic QR
The QR issuance endpoint takes a few main parameters: my transaction ID, merchant ID, QR type, amount, currency, and four reference fields for my own order numbers. Thai QR PromptPay and credit card QR types use different values, and the returned payloads differ in length.
{
"partnerTxnUid": "TXN-20261119-000117",
"partnerId": "<PARTNER_ID>",
"partnerSecret": "<PARTNER_SECRET>",
"requestDt": "2026-11-19T16:54:56+07:00",
"merchantId": "<MERCHANT_ID>",
"qrType": 3,
"txnAmount": 100.00,
"txnCurrencyCode": "THB",
"reference1": "INV-000117",
"reference2": "SUPPORT",
"reference3": "WEB",
"reference4": "INV-000117"
}
{
"partnerTxnUid": "TXN-20261119-000117",
"statusCode": "00",
"accountName": "<ACCOUNT_NAME>",
"qrCode": "00020101021230810016A000000677010112...5406100.005802TH6304xxxx",
"sof": ["PP"]
}
Notice that the value indicating "success" in this API family is not the HTTP status but statusCode in the body. A value of 00 means success; other values mean failure and require checking errorCode and errorDesc. Code that checks only for HTTP 200 will treat a request as successful even when no transaction was created.
Another detail I like is that the returned qrCode is a plain EMVCo payload. That means I can still render it as an image with any library, and inspect its fields myself to verify that the amount and country code match what I sent.
# แกะ TLV กลับมาดูว่าจำนวนเงิน (tag 54) กับประเทศ (tag 58) ตรงที่ขอไหม
def tlv(payload):
out, i = {}, 0
while i < len(payload):
tag, ln = payload[i:i+2], int(payload[i+2:i+4])
out[tag] = payload[i+4:i+4+ln]
i += 4 + ln
return out
fields = tlv(resp['qrCode'])
assert fields['54'] == '100.00' and fields['58'] == 'TH'
requestDt — where I went wrong the first time
This was the trap that cost me the most time during that round of testing. I sent a cancel request and got statusCode 10 back with a short message saying requestDt was invalid, even though every field in the payload was correct. Checking it ten times changed nothing.
The reason is that this API family compares requestDt against the bank server's current time. A timestamp too far in the past or future, outside its accepted window, gets rejected immediately. My mistake was that the clock on the machine running the tests was a full day behind, and I kept editing the payload instead of fixing the clock.
# สร้าง requestDt จากเวลาจริงเสมอ อย่า hardcode ค่าที่เคยใช้ผ่าน
TZ='Asia/Bangkok' date +%Y-%m-%dT%H:%M:%S+07:00
# เทียบนาฬิกาเครื่องกับ NTP ก่อนโทษ payload
timedatectl status | grep -i 'synchronized\|time zone'
{
"statusCode": "10",
"errorCode": "<ERROR_CODE>",
"errorDesc": "wrong requestDt"
}
APIs with timestamps in their payloads can quietly reject requests with incorrect times, giving only a vague reason. Before debugging the payload, check that the sending machine's clock is actually synchronized with NTP and that its timezone is correct. This cost me half a day unnecessarily.
Status lifecycle: REQUESTED, PAID, CANCELLED, VOIDED
Once the QR has been issued, the remaining work is tracking its status. The inquiry endpoint takes the original transaction ID sent during creation and returns one of four states.
{
"partnerTxnUid": "TXN-20261119-000117",
"statusCode": "00",
"txnStatus": "PAID",
"channel": "mobile-banking",
"txnAmount": "100.00",
"txnCurrencyCode": "THB",
"reference1": "INV-000117"
}
From the error codes the sandbox returned, I identified two business rules that need explicit conditions in the code.
- A QR can be cancelled only while its status is still REQUESTED. If it has already been paid or refunded, the system rejects the action with a reason code.
- Refunds are allowed only for paid transactions on the same day. After the day has passed or the transaction has entered settlement, the money must be transferred back through the normal process, not through this endpoint.
That means the "Cancel" and "Refund" buttons on my admin page need to be hidden or disabled according to the actual status returned by inquiry, rather than left enabled for every transaction until the bank says the action is not allowed.
Tracking status without overloading the system
The sandbox I used had no webhook to test, so I had to start with polling. My rule was frequent at first, then less often, because most transactions are paid within the first minute or two. A fixed interval of five seconds until expiry would waste quota.
// ถี่ช่วงแรกแล้วห่างออก + หยุดเมื่อถึงสถานะปลายทาง
$steps = [5, 5, 10, 10, 20, 30, 60, 60, 120];
foreach ($steps as $wait) {
$r = $qr->inquiry($txnUid);
if ($r['statusCode'] !== '00') { Log::warning('inquiry failed', $r); break; }
if (in_array($r['txnStatus'], ['PAID', 'CANCELLED', 'VOIDED'], true)) {
Payment::markFromGateway($txnUid, $r); // idempotent เขียนซ้ำได้
break;
}
sleep($wait);
}
The function that writes results back to the DB must tolerate repeated writes without corrupting the result, because polling and a webhook, if enabled later, can collide. I use a conditional update based on the previous status instead of blindly overwriting it.
UPDATE payments
SET status = 'paid', paid_at = ?, gateway_ref = ?
WHERE txn_uid = ? AND status IN ('pending', 'requested');
-- affected = 0 แปลว่ามีคนอื่นอัปเดตไปก่อนแล้ว ไม่ใช่ error
Fees reported in the response itself
During inquiry, the payment acceptance product also returns a fee breakdown. This is very useful for accounting, because I do not have to guess how much will actually reach the account.
{
"paymentStatus": "PAID",
"amount": "1000.50",
"feeBreakdown": {
"total": "10.70000",
"mdr": "10.00000",
"vat": "0.70000",
"wht": "0.30000"
},
"payoutStatus": "NOT READY",
"reconcileStatus": { "reconciledFlag": false }
}
This block shows that an amount of 1,000.50 baht incurs total fees of 10.70 baht, split into ten baht of MDR and seventy satang of VAT, or roughly one percent, with withholding tax on a separate line. The payoutStatus of NOT READY means the funds have not yet reached the payout cycle. This matters when building reports: do not display PAID as "money credited to the account," because those are different stages.
What to prepare before going live
- Keep separate sets of sandbox and production credentials, and read both from the environment. None of these values may be in the source code.
- Create a single wrapper for calls to every endpoint, and require it to log both request id and statusCode every time, removing secret fields before writing the log.
- Write terminal states to the DB idempotently, and check the affected row count every time.
- Set a longer per-request timeout than expected, and enable retries only for endpoints that are safe to call again.
- Test all deliberate failure cases, including cancelling a paid transaction, refunding an unpaid transaction, and refunding on a later day.
- Before switching to production, treat everything the sandbox shows as a format reference, not actual behavior.
I stress the last point because I have been burned by it before: response times, amounts deducted, and transactions that were created successfully but did not appear in search results. I covered those separately in Passing the Sandbox Does Not Mean It Works in Production.
consumer secret, partner secret, merchant id, and access token must never appear in source code, logs, or screenshots posted online. I put a filter directly in the log formatter to replace values matching a list of forbidden keys with asterisks before every file write.
In summary, a static QR I build myself is still worthwhile for small workloads where manual reconciliation is manageable. But once there are dozens of transactions a day, having the bank issue the QR and tracking its status really does solve reconciliation, in exchange for a fee of around one percent and another layer of code. Which route to take depends on the actual workload. If I have missed anything in the explanation or the bank's API format changes, I will come back and update this article.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first