Accepting VietQR bank transfers with webhook confirmation
The VietQR payment flow we designed for Dailerc Tools Store: an order code in the transfer content, an authenticated webhook, exact amount checks, duplicate-safe processing and a manual fallback.
In Vietnam, paying by scanning a QR code in a banking app is the most familiar way to pay. For a small product, VietQR is cheaper and quicker to set up than a card gateway. The hard part is knowing for sure that the money arrived and which order it belongs to. This post walks through the design we are building for Dailerc Tools Store. The feature is still in progress, so this is the design and the reasoning behind it, not production experience.
What a VietQR code carries
VietQR is the NAPAS QR standard, based on the EMVCo format. The code holds the receiving bank and account number, and can also hold an amount and the transfer content. When the customer scans it, the banking app fills those fields in. We use SePay's QR image, built from a URL with acc, bank, amount and des (content) parameters. SePay also watches the account for incoming transactions and calls our webhook.
Step 1: the order code goes in the transfer content
When a customer clicks Buy, the API creates a PENDING order that expires after 24 hours, with a unique code such as DLT8F3K2Q7M: a fixed prefix plus 8 Crockford base32 characters. That alphabet leaves out look-alike letters such as I, L, O and U, so a customer who types it by hand is less likely to get it wrong. The code is placed in the QR's transfer content.
Banking apps often add a prefix, strip spaces or change the case. So we never compare the whole string; we search for the code anywhere in it:
const ORDER_CODE = /DLT[0-9A-HJKMNP-TV-Z]{8}/i
const code = content.match(ORDER_CODE)?.[0]?.toUpperCase()
Step 2: authenticate the webhook
A webhook is a public URL, so anyone can post a fake "payment received" message. SePay sends an Authorization: Apikey ... header, which we compare with the key from the environment using timingSafeEqual. All provider-specific logic sits behind a PaymentProvider interface. A provider that signs with an HMAC implements the same verifyWebhook method over the raw body, so the webhook route keeps the raw body from the start.
Step 3: idempotency and matching
Providers retry, so the same notification can arrive more than once. Each one is stored as a PaymentEvent with a unique constraint on (provider, providerTxnId). A retry hits that constraint, is recorded as DUPLICATE and still gets a 200, so the provider stops retrying. Then:
- Only incoming transactions are processed; outgoing ones are ignored.
- No code, or an unknown code:
UNMATCHED, left for an admin. - The amount differs from the order's:
AMOUNT_MISMATCH, and the order stays pending. The comparison is exact; "close enough" is not accepted. - The order is already paid:
DUPLICATE. - The order expired but the money arrived within 7 days: access is granted anyway, because the customer really did pay.
Step 4: grant access in one transaction
On a match, a single Prisma transaction sets the order to PAID and creates the entitlement. A one-time unlock never expires; buying the same time-limited pass again extends it from its current expiry instead of restarting from today. Afterwards the customer's entitlement cache is cleared and admins are notified.
await prisma.$transaction(async (tx) => {
await tx.toolOrder.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } })
await grantEntitlement(tx, order)
})
What not to do: trust the browser
The checkout page shows the QR code and polls the order status every 5 seconds for up to 15 minutes. But it only reads the status. There is no path from the browser that marks an order as paid; only the webhook or an admin grants access. If the browser could report "paid", anyone with DevTools would get access for free.
A manual fallback
Bank transfers are done by people: a customer may forget the content, mistype the code or send the wrong amount. So the design gives admins two actions: confirm a pending order (a note is required) and match an unmatched transaction to the right order. A background job runs every 15 minutes and moves overdue orders to EXPIRED. Refunds are manual too, and revoke the entitlement.
Summary
- Put a hard-to-mistype order code in the transfer content and find it with a regex.
- Authenticate every webhook and store every notification, including the ones that do not match.
- Use a database unique constraint as the idempotency key.
- Compare amounts exactly and grant access inside one transaction.
- Never grant access from the browser, and always keep a manual path.
Related Posts
ISR and on-demand revalidation in Next.js 15
How dailerc.com serves static pages fast and still updates the moment an admin edits content: tagged fetches, revalidateTag after every change, and a guarded revalidate route for deploys.
Read MoreSelf-hosting with Docker Compose, Caddy and a GitHub self-hosted runner
How dailerc.com runs on its own server: one origin behind Caddy, services in Docker Compose with health checks, and every commit on main deployed by a self-hosted runner with backups and rollback.
Read MoreRate limiting and login lockout in NestJS with Redis
Two layers of protection for an API: per-IP request limits with @nestjs/throttler stored in Redis, and a temporary per-email lock after repeated failed logins that never reveals which accounts exist.
Read More