NHT

Tích hợp cổng thanh toán VNPay vào API NestJS: Hướng dẫn từ A đến Z

Hướng dẫn tích hợp cổng thanh toán VNPay vào API NestJS end-to-end: tạo URL thanh toán, ký HMAC-SHA512 vnp_SecureHash, xử lý Return URL và IPN, xác thực chữ ký và đảm bảo idempotency cho luồng thanh toán đơn hàng.

Nguyen Hoang TuanNguyen Hoang Tuan20 thg 6, 202610 phút đọc

Phần lớn các sản phẩm thương mại điện tử ở Việt Nam đều đến lúc phải nhận thanh toán online — và câu hỏi đầu tiên gần như luôn là "tích hợp VNPay thế nào cho đúng". Nghe thì đơn giản: tạo một đường link, cho người dùng bấm vào, rồi nhận kết quả. Nhưng phần khiến nhiều đội ngũ trả giá lại nằm ở những chỗ ít ai nhìn thấy: ký chữ ký sai thứ tự, tin vào trang Return mà người dùng có thể tự sửa, hay cộng tiền hai lần vì IPN gọi lặp.

Bài viết này hướng dẫn tích hợp cổng thanh toán VNPay vào một API NestJS theo đúng luồng production: từ việc dựng URL thanh toán, ký vnp_SecureHash bằng HMAC-SHA512, xử lý Return URL và IPN, cho đến xác thực chữ ký và đảm bảo idempotency. Cách tiếp cận ở đây không phụ thuộc framework nào — nhưng được đóng gói theo phong cách module hoá của NestJS để dễ kiểm thử và bảo trì.

Lưu ý: các tham số vnp_*, mã merchant và số tiền VND trong bài là ví dụ minh hoạ. Trước khi lên production, hãy đối chiếu với tài liệu chính thức của VNPay cho danh sách trường và mã phản hồi chuẩn xác.

Vì sao chọn VNPay (và khi nào dùng Momo, ZaloPay)

VNPay là cổng thanh toán được tìm kiếm và tích hợp nhiều nhất tại Việt Nam, một phần vì nó gom nhiều phương thức vào một luồng duy nhất: thẻ ATM nội địa (qua NAPAS), thẻ quốc tế, QR code và ví. Với phần lớn sản phẩm cần "một nút thanh toán cho mọi ngân hàng", đây là điểm khởi đầu hợp lý.

Điều đó không có nghĩa là các cổng khác kém hơn — chúng giải quyết bài toán hơi khác:

  • Momo mạnh khi người dùng của bạn đã quen ví Momo và bạn muốn trải nghiệm thanh toán trong ứng dụng (in-app) mượt mà.
  • ZaloPay hợp lý khi sản phẩm gắn với hệ sinh thái Zalo và tệp người dùng trẻ.
  • VNPay thắng ở độ phủ ngân hàng và việc gộp nhiều phương thức, nên là lựa chọn an toàn cho luồng checkout tổng quát.

Tin tốt là cả ba đều chia sẻ cùng một bộ khung tư duy: tạo yêu cầu đã ký → chuyển hướng người dùng → nhận callback đã ký → xác thực rồi mới cập nhật đơn hàng. Nắm chắc luồng VNPay dưới đây thì việc thêm Momo hay ZaloPay sau này chủ yếu là đổi cách ký và tên trường.

Luồng thanh toán VNPay end-to-end

Trước khi viết dòng code nào, hãy hình dung rõ luồng. VNPay có hai môi trường tách biệt: sandbox để thử nghiệm và production để chạy thật, mỗi môi trường có endpoint và bộ khoá riêng (vnp_TmnCode định danh merchant và một secret key dùng để ký). Đừng bao giờ trộn lẫn khoá giữa hai môi trường.

Luồng cơ bản gồm bốn bước:

  1. Create — Backend dựng URL thanh toán với các tham số vnp_* (số tiền, mã đơn, thời gian...) và ký vào cuối bằng vnp_SecureHash.
  2. Redirect — Người dùng được chuyển sang cổng VNPay để chọn ngân hàng/phương thức và xác nhận.
  3. Return — Sau khi thanh toán, trình duyệt người dùng quay về vnp_ReturnUrl của bạn kèm kết quả. Đây chỉ để hiển thị cho người dùng.
  4. IPN — Song song đó, server VNPay gọi thẳng đến endpoint IPN của bạn (server-to-server) để báo kết quả. Đây mới là nguồn sự thật để cập nhật đơn hàng.

Điểm cốt lõi cần nhớ ngay từ đầu: kết quả ở bước Return và bước IPN có thể khác nhau, và bạn chỉ được tin IPN. Phần "Return URL vs IPN" bên dưới sẽ giải thích vì sao.

Tạo URL thanh toán và ký Secure Hash

Bước tạo URL là nơi dễ sai nhất, vì chữ ký phụ thuộc vào thứ tựcách encode tham số. Quy tắc chung của VNPay: gom tất cả tham số vnp_*, sắp xếp theo thứ tự alphabet của tên trường, nối thành chuỗi query, rồi ký chuỗi đó bằng HMAC-SHA512 với secret key. Chữ ký vnp_SecureHash được gắn vào cuối URL — bản thân nó không tham gia vào dữ liệu được ký.

Một chi tiết hay bị bỏ sót: số tiền phải nhân 100. VNPay làm việc với đơn vị nhỏ nhất, nên 50.000đ được gửi đi là 5000000.

import { createHmac } from "node:crypto";

function buildPaymentUrl(params: {
  amount: number; // VND, ví dụ 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), // nhân 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) Sắp xếp tham số theo alphabet
  const sorted = Object.keys(vnpParams)
    .sort()
    .reduce<Record<string, string>>((acc, key) => {
      acc[key] = vnpParams[key];
      return acc;
    }, {});

  // 2) Dựng chuỗi query (chú ý cách encode phải nhất quán giữa lúc ký và lúc gửi)
  const signData = new URLSearchParams(sorted).toString();

  // 3) Ký HMAC-SHA512 bằng secret key
  const secureHash = createHmac("sha512", config.hashSecret)
    .update(signData)
    .digest("hex");

  return `${config.payUrl}?${signData}&vnp_SecureHash=${secureHash}`;
}

Điểm mấu chốt khiến chữ ký fail dù logic "trông đúng": chuỗi dùng để ký và chuỗi thực sự gửi lên phải khớp nhau từng ký tự, kể cả cách encode khoảng trắng và ký tự đặc biệt. Nếu lúc ký bạn encode một kiểu mà lúc tạo URL lại encode kiểu khác, VNPay sẽ tính ra hash khác và từ chối giao dịch. Hãy thống nhất một cách build query duy nhất cho cả hai.

Return URL vs IPN: tin cái nào

Đây là phần phân biệt một tích hợp "chạy được trên demo" với một tích hợp an toàn trên production.

Return URL là nơi trình duyệt người dùng quay về sau khi thanh toán. Vấn đề: nó đi qua máy của người dùng. Người dùng có thể đóng tab ngay trước khi nó load, mạng có thể rớt, hoặc — tệ hơn — ai đó có thể tự gọi URL này với tham số bịa ra. Vì vậy Return URL chỉ nên dùng để hiển thị một màn hình "Đang xác nhận thanh toán...", tuyệt đối không dùng nó để cộng tiền hay đánh dấu đơn đã thanh toán.

IPN (Instant Payment Notification) là cuộc gọi server-to-server từ VNPay đến backend của bạn. Nó không đi qua trình duyệt người dùng, nên đáng tin hơn nhiều. Đây là nơi bạn xác thực chữ ký, đối chiếu đơn hàng và cập nhật trạng thái thật. VNPay kỳ vọng endpoint IPN trả về một phản hồi theo định dạng quy ước để biết bạn đã ghi nhận; nếu không nhận được phản hồi hợp lệ, hệ thống có thể gọi lại — đó chính là lý do bạn bắt buộc phải xử lý idempotent (xem phần sau).

Nguyên tắc một câu: Return để nói chuyện với người dùng, IPN để nói chuyện với hệ thống của bạn.

Xác thực chữ ký và chống giả mạo

Mọi callback từ VNPay (cả Return lẫn IPN) đều kèm vnp_SecureHash. Bước đầu tiên khi nhận callback luôn là tự tính lại hash từ các tham số nhận được và so sánh:

function verifySignature(query: Record<string, string>): boolean {
  const received = query["vnp_SecureHash"];

  // Loại bỏ chính chữ ký ra khỏi dữ liệu trước khi ký lại
  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;
}

Nhưng chữ ký hợp lệ mới chỉ chứng minh dữ liệu đến từ VNPay — chưa chứng minh nó khớp với đơn hàng của bạn. Sau khi verify chữ ký, hãy kiểm tra thêm:

  • Đối chiếu số tiền: vnp_Amount (đã chia 100) phải bằng đúng số tiền đơn hàng lưu trong DB. Đừng bao giờ tin số tiền trong callback một cách mù quáng.
  • Đối chiếu mã đơn: vnp_TxnRef phải trỏ tới một đơn hàng có thật và đang ở trạng thái chờ thanh toán.
  • Kiểm tra mã kết quả: chỉ coi là thành công khi mã giao dịch và mã phản hồi đều báo thành công theo tài liệu VNPay.

Chỉ khi cả chữ ký lẫn các đối chiếu nghiệp vụ đều đạt, bạn mới chuyển đơn sang trạng thái "đã thanh toán".

Đóng gói thành Payment Module trong NestJS

Phần logic trên không nên nằm rải rác trong controller. Theo tinh thần kiến trúc modular của NestJS, hãy gom nó vào một PaymentModule với ranh giới rõ ràng: controller chỉ nhận HTTP, service làm chủ nghiệp vụ, còn secret key được tiêm qua 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 };
  }

  // Endpoint server-to-server cho VNPay gọi tới
  @Get("ipn")
  async ipn(@Query() query: Record<string, string>) {
    return this.payment.handleIpn(query); // trả về phản hồi theo định dạng VNPay yêu cầu
  }

  // Chỉ để hiển thị cho người dùng
  @Get("return")
  return(@Query() query: Record<string, string>) {
    const ok = this.payment.isSignatureValid(query);
    return { display: ok ? "success" : "invalid" };
  }
}

VnpayService giữ toàn bộ phần ký, verify và đối chiếu; secret key được cung cấp qua ConfigService thay vì hardcode. Mô hình này giúp bạn viết unit test cho từng phần (ký đúng chưa, verify chặn được hash giả chưa) mà không cần dựng cả HTTP server, đúng tinh thần dùng DTO làm hợp đồng và tách service khỏi controller.

Những sai lầm thường gặp

Hầu hết bug tích hợp VNPay rơi vào vài cái bẫy quen thuộc:

  • Sai thứ tự sort tham số. Ký mà không sort theo alphabet, hoặc sort ở bước tạo URL nhưng quên sort ở bước verify — hash sẽ không bao giờ khớp.
  • Encode không nhất quán. Chuỗi lúc ký và chuỗi lúc gửi encode khác nhau (đặc biệt với khoảng trắng và ký tự tiếng Việt trong vnp_OrderInfo). Dùng chung một hàm build query cho cả hai.
  • Quên nhân 100 cho số tiền. Khách hàng bị tính sai tiền hoặc giao dịch bị từ chối.
  • IPN không idempotent. VNPay có thể gọi IPN nhiều lần. Nếu mỗi lần gọi đều cộng tiền/ghi đơn mới, bạn sẽ cộng trùng. Hãy kiểm tra trạng thái đơn trước khi cập nhật, và khoá theo vnp_TxnRef để xử lý đúng một lần duy nhất.
  • Tin Return URL. Cập nhật đơn dựa trên Return là lỗ hổng kinh điển — người dùng có thể tự gọi URL đó.
  • Trộn khoá sandbox và production. Luôn tách cấu hình theo môi trường.

Tổng kết và bước tiếp theo

Tích hợp VNPay đúng cách thực chất xoay quanh bốn việc: ký chữ ký nhất quán, tin IPN chứ không tin Return, xác thực cả chữ ký lẫn số tiền/đơn hàng, và xử lý idempotent để không cộng tiền hai lần. Nắm chắc bốn điều này thì luồng thanh toán của bạn sẽ vững trên production, và việc thêm Momo hay ZaloPay sau này chỉ là lặp lại cùng một bộ khung với cách ký khác.

Nếu API thanh toán này phục vụ một sản phẩm giao hàng, hãy ghép nó với phần tính phí và lộ trình: bài Directions API cho định tuyến xe máy ở Việt Nam cho thấy cách tính quãng đường và ETA thật để ra số tiền cần thu — chính là số tiền bạn đưa vào vnp_Amount. Và khi đưa lên production, đừng quên đóng gói chuẩn theo bài Docker hóa ứng dụng NestJS cho Production.

Hệ thống của bạn đang gặp vấn đề hiệu năng hay mở rộng tải?

Tôi chuyên xây dựng hạ tầng bản đồ độ trễ thấp, streaming pipeline thời gian thực (Kafka, ClickHouse) và các hệ thống backend tối ưu. Hãy cùng hợp tác để nâng cấp sản phẩm của bạn.

Hợp tác ngay

Tác giả

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Bài viết liên quan