Integrating the VNPay Payment Gateway into a NestJS API: An End-to-End Guide
A practical, end-to-end guide to integrating Vietnam's VNPay payment gateway into a NestJS API: build the payment URL, sign vnp_SecureHash with HMAC-SHA512, handle Return URL vs IPN, verify signatures, and keep the order flow idempotent.
Most e-commerce products in Vietnam eventually have to accept online payments—and the first question is almost always "how do I integrate VNPay correctly?" It sounds simple: build a link, let the user click it, then read the result. But the parts that bite teams are the ones nobody sees: signing the hash in the wrong order, trusting a Return page the user can tamper with, or charging twice because the IPN fires more than once.
This post walks through integrating the VNPay gateway into a NestJS API the production way: building the payment URL, signing vnp_SecureHash with HMAC-SHA512, handling the Return URL and the IPN, and verifying signatures while keeping the flow idempotent. The approach is framework-agnostic, but it's packaged in NestJS's modular style so it stays testable and maintainable.
Note: the
vnp_*parameters, merchant code, and VND amounts here are illustrative. Before going to production, check VNPay's official documentation for the exact field list and response codes.
Why VNPay (and when to reach for Momo or ZaloPay)
VNPay is the most-searched and most-integrated payment gateway in Vietnam, partly because it folds many methods into a single flow: domestic ATM cards (via NAPAS), international cards, QR codes, and wallets. For most products that just want "one pay button for every bank," it's a sensible starting point.
That doesn't make the others worse—they solve a slightly different problem:
- Momo shines when your users already live in the Momo wallet and you want a smooth in-app payment experience.
- ZaloPay fits when your product is tied to the Zalo ecosystem and a younger user base.
- VNPay wins on bank coverage and bundling multiple methods, so it's the safe default for a general checkout.
The good news is that all three share the same mental model: build a signed request → redirect the user → receive a signed callback → verify, then update the order. Get the VNPay flow below right and adding Momo or ZaloPay later is mostly a matter of changing how you sign and the field names.
The VNPay payment flow, end to end
Before writing any code, get the flow clear. VNPay has two separate environments: sandbox for testing and production for real traffic, each with its own endpoint and key set (a vnp_TmnCode that identifies the merchant and a secret key used for signing). Never mix keys across environments.
The basic flow has four steps:
- Create — The backend builds a payment URL with the
vnp_*parameters (amount, order ref, timestamp…) and signs it at the end withvnp_SecureHash. - Redirect — The user is sent to VNPay to pick a bank/method and confirm.
- Return — After paying, the user's browser comes back to your
vnp_ReturnUrlwith the result. This is for display only. - IPN — In parallel, VNPay's server calls your IPN endpoint directly (server-to-server) with the result. This is the source of truth for updating the order.
The key thing to internalize up front: the Return and the IPN results can differ, and you should trust only the IPN. The "Return URL vs IPN" section below explains why.
Building the payment URL and signing the secure hash
Building the URL is the easiest place to get wrong, because the signature depends on the order and encoding of parameters. VNPay's general rule: collect all vnp_* parameters, sort them alphabetically by field name, join them into a query string, then sign that string with HMAC-SHA512 using the secret key. The vnp_SecureHash is appended to the end of the URL—it is not itself part of the signed data.
One detail teams miss: the amount is multiplied by 100. VNPay works in the smallest unit, so 50,000đ is sent as 5000000.
import { createHmac } from "node:crypto";
function buildPaymentUrl(params: {
amount: number; // VND, e.g. 50000
orderId: string;
ipAddr: string;
orderInfo: string;
}): string {
const vnpParams: Record<string, string> = {
vnp_Version: "2.1.0",
vnp_Command: "pay",
vnp_TmnCode: config.tmnCode,
vnp_Amount: String(params.amount * 100), // multiply by 100
vnp_CurrCode: "VND",
vnp_TxnRef: params.orderId,
vnp_OrderInfo: params.orderInfo,
vnp_OrderType: "other",
vnp_Locale: "vn",
vnp_ReturnUrl: config.returnUrl,
vnp_IpAddr: params.ipAddr,
vnp_CreateDate: formatVnpDate(new Date()), // yyyyMMddHHmmss
};
// 1) Sort parameters alphabetically
const sorted = Object.keys(vnpParams)
.sort()
.reduce<Record<string, string>>((acc, key) => {
acc[key] = vnpParams[key];
return acc;
}, {});
// 2) Build the query string (encoding must match between signing and sending)
const signData = new URLSearchParams(sorted).toString();
// 3) Sign with HMAC-SHA512 using the secret key
const secureHash = createHmac("sha512", config.hashSecret)
.update(signData)
.digest("hex");
return `${config.payUrl}?${signData}&vnp_SecureHash=${secureHash}`;
}The subtle reason a signature fails even when the logic "looks right": the string you sign and the string you actually send must match character for character, including how spaces and special characters are encoded. If you encode one way when signing and another way when building the URL, VNPay computes a different hash and rejects the transaction. Settle on one single way to build the query string for both.
Return URL vs IPN: which one to trust
This is what separates an integration that "works on the demo" from one that's safe in production.
The Return URL is where the user's browser lands after paying. The problem: it passes through the user's machine. They can close the tab right before it loads, the network can drop, or—worse—someone can hit that URL directly with fabricated parameters. So the Return URL should be used for display only: show a "Confirming your payment…" screen, and never use it to add money or mark an order as paid.
The IPN (Instant Payment Notification) is a server-to-server call from VNPay to your backend. It doesn't pass through the user's browser, so it's far more trustworthy. This is where you verify the signature, reconcile the order, and update the real status. VNPay expects your IPN endpoint to return a response in the agreed format so it knows you recorded the result; if it doesn't get a valid response, it may retry—which is exactly why your handler must be idempotent (see below).
The one-line principle: Return talks to the user, IPN talks to your system.
Verifying the signature and preventing tampering
Every callback from VNPay (both Return and IPN) carries a vnp_SecureHash. The first step when receiving a callback is always to recompute the hash from the received parameters and compare:
function verifySignature(query: Record<string, string>): boolean {
const received = query["vnp_SecureHash"];
// Remove the signature itself from the data before re-signing
const { vnp_SecureHash, vnp_SecureHashType, ...rest } = query;
const sorted = Object.keys(rest)
.sort()
.reduce<Record<string, string>>((acc, key) => {
acc[key] = rest[key];
return acc;
}, {});
const signData = new URLSearchParams(sorted).toString();
const expected = createHmac("sha512", config.hashSecret)
.update(signData)
.digest("hex");
return expected === received;
}But a valid signature only proves the data came from VNPay—not that it matches your order. After verifying the signature, also check:
- Reconcile the amount:
vnp_Amount(divided by 100) must equal the order amount stored in your DB. Never trust the callback's amount blindly. - Reconcile the order ref:
vnp_TxnRefmust point to a real order that is awaiting payment. - Check the result codes: only treat it as successful when both the transaction and response codes report success per VNPay's docs.
Only when both the signature and the business reconciliations pass do you move the order to a "paid" state.
Wrapping it in a NestJS payment module
None of this logic should be scattered across controllers. In the spirit of NestJS modular architecture, gather it into a PaymentModule with clear boundaries: the controller only handles HTTP, the service owns the business logic, and the secret key is injected through config.
@Controller("payment")
export class PaymentController {
constructor(private readonly payment: VnpayService) {}
@Post("create")
create(@Body() dto: CreatePaymentDto, @Req() req: Request) {
const url = this.payment.createPaymentUrl(dto, req.ip);
return { paymentUrl: url };
}
// Server-to-server endpoint that VNPay calls
@Get("ipn")
async ipn(@Query() query: Record<string, string>) {
return this.payment.handleIpn(query); // returns the response format VNPay expects
}
// For displaying to the user only
@Get("return")
return(@Query() query: Record<string, string>) {
const ok = this.payment.isSignatureValid(query);
return { display: ok ? "success" : "invalid" };
}
}VnpayService keeps all of the signing, verifying, and reconciliation; the secret key comes from ConfigService instead of being hardcoded. This lets you unit-test each piece (does signing produce the right hash, does verification reject a forged hash) without spinning up an HTTP server—true to using DTOs as the contract and keeping the service separate from the controller.
Common mistakes
Most VNPay integration bugs fall into a few familiar traps:
- Wrong sort order. Signing without sorting alphabetically, or sorting when building the URL but forgetting to sort when verifying—the hash will never match.
- Inconsistent encoding. The string you sign and the string you send are encoded differently (especially spaces and Vietnamese characters in
vnp_OrderInfo). Use one shared query-building function for both. - Forgetting to multiply the amount by 100. Customers get charged the wrong amount, or the transaction is rejected.
- Non-idempotent IPN. VNPay may call the IPN multiple times. If every call adds money / creates a new record, you double-charge. Check the order status before updating, and lock on
vnp_TxnRefso it's processed exactly once. - Trusting the Return URL. Updating an order based on the Return is a classic vulnerability—users can call that URL themselves.
- Mixing sandbox and production keys. Always separate config by environment.
Wrapping up and next steps
Integrating VNPay correctly really comes down to four things: sign consistently, trust the IPN, not the Return, verify both the signature and the amount/order, and handle it idempotently so you never charge twice. Get those four right and your payment flow is solid in production—and adding Momo or ZaloPay later is just the same framework with a different signing scheme.
If this payment API serves a delivery product, pair it with the fee and routing pieces: the Directions API for motorbike routing in Vietnam post shows how to compute real distance and ETA to derive the amount to collect—exactly the amount you feed into vnp_Amount. And when you ship to production, don't forget to package it properly per Dockerizing a NestJS App for Production.
Facing performance issues or scaling challenges?
I specialize in building low-latency map infrastructure, real-time streaming pipelines (Kafka, ClickHouse), and highly optimized backend systems. Let's work together to scale your product.
Related Articles
20 Jun 2026
PostgreSQL Partitioning: Taming 100M+ Row Tables with Declarative Partitioning
A practical guide to partitioning large PostgreSQL tables (100M+ rows): choosing RANGE/LIST/HASH, creating declarative partitions, using partition pruning to speed up queries, indexing per partition, and near-free data retention with DETACH/DROP PARTITION.
16 Jun 2026
Vietnam Directions API: Motorbike Routing & Turn-by-Turn the Google-Compatible Way
A developer how-to for the GoGoDuk Directions API: call /v1/directions, read real ETA and distance, decode the polyline onto your map, render turn-by-turn, and handle the ESTIMATED fallback.
12 Jun 2026
Migrating from Google Maps API to a Vietnam Map API: Cost & Code
Compare Google Maps Platform pricing and Vietnam limitations, then migrate geocoding, reverse geocoding, and address autocomplete to GoGoDuk with real code.
8 Jun 2026
Redis 8.8 Released: New Native Rate Limiter, Array Data Structure, and Up to +83% Performance Boost
A deep technical breakdown of the newly released Redis 8.8 (June 2, 2026). Explore the new O(1) sparse-friendly Array structure by antirez, the native INCREX rate-limiting command, and Hash field-level subkey notifications.