Bỏ qua menu, vào nội dung chính

Tích hợp VNPay vào website Next.js - hướng dẫn step-by-step 2026

Chuyên mục:  Hướng dẫn
Ngày đăng:  16/05/2026
Thời lượng:  12 phút đọc

VNPay là cổng thanh toán phổ biến nhất Việt Nam - hỗ trợ ATM 40+ ngân hàng, QR code, thẻ tín dụng. Bài này đi qua đăng ký merchant, tạo signature HMAC SHA-512, xử lý IPN callback, test sandbox và go-live production. Bao gồm code mẫu Next.js 14 App Router.

1. Khi nào dùng VNPay (so với Momo/ZaloPay/Stripe)

  • Bạn target khách Việt Nam trong nước - VNPay phủ 40+ ngân hàng (Vietcombank, BIDV, Techcombank, MB, ACB, VPBank, etc.)
  • Cần thẻ ATM nội địa (không phải thẻ tín dụng) - VNPay là leader.
  • Đơn hàng > 500k - VNPay hỗ trợ tốt, Momo phù hợp đơn nhỏ hơn.
  • Yêu cầu integrate đa kênh: web + app + POS - VNPay có SDK đầy đủ.

VNPay phí 1.6-2.2% mỗi giao dịch (tuỳ ngân hàng + volume) - đắt hơn Momo (1.1-1.8%) nhưng ổn định + phổ thông hơn. Stripe không cho merchant VN, không phải lựa chọn cho doanh nghiệp trong nước.

2. Bước 1: Đăng ký merchant sandbox

Truy cập sandbox.vnpayment.vn → Đăng ký tài khoản test → Nhận TmnCode (mã merchant) + HashSecret (secret key dùng ký HMAC). Sandbox cấp ngay trong 5 phút, không cần giấy tờ. Sau khi code xong + test đủ 5 scenario, mới xin go-live production cần GPKD + STK doanh nghiệp.

3. Bước 2: Cài đặt env + cấu hình

Trong Next.js project, thêm .env.local:

envVNPAY_TMN_CODE=YOUR_TMN_CODE_FROM_SANDBOX
VNPAY_HASH_SECRET=YOUR_HASH_SECRET_FROM_SANDBOX
VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_RETURN_URL=https://yoursite.vn/payment/return
VNPAY_IPN_URL=https://yoursite.vn/api/vnpay/ipn

VNPAY_URL cho production: vnpayment.vn/paymentv2/vpcpay.html (bỏ "sandbox.").

4. Bước 3: Tạo URL payment

Tạo file src/app/api/vnpay/create/route.ts:

tsimport crypto from 'crypto';
import { NextResponse } from 'next/server';

function sortObject(obj: Record<string, string>) {
  return Object.keys(obj).sort().reduce<Record<string, string>>((acc, k) => {
    acc[k] = encodeURIComponent(obj[k]).replace(/%20/g, '+');
    return acc;
  }, {});
}

export async function POST(req: Request) {
  const { amount, orderId, orderInfo } = await req.json();
  const date = new Date();
  const pad = (n: number) => String(n).padStart(2, '0');
  const createDate = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;

  const params: Record<string, string> = {
    vnp_Version: '2.1.0',
    vnp_Command: 'pay',
    vnp_TmnCode: process.env.VNPAY_TMN_CODE!,
    vnp_Amount: String(amount * 100),  // VNPay yêu cầu * 100
    vnp_CurrCode: 'VND',
    vnp_TxnRef: orderId,
    vnp_OrderInfo: orderInfo,
    vnp_OrderType: 'other',
    vnp_Locale: 'vn',
    vnp_ReturnUrl: process.env.VNPAY_RETURN_URL!,
    vnp_IpAddr: req.headers.get('x-forwarded-for') || '127.0.0.1',
    vnp_CreateDate: createDate,
  };

  const sorted = sortObject(params);
  const signData = Object.entries(sorted).map(([k, v]) => `${k}=${v}`).join('&');
  const hmac = crypto.createHmac('sha512', process.env.VNPAY_HASH_SECRET!);
  const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');

  const paymentUrl = `${process.env.VNPAY_URL}?${signData}&vnp_SecureHash=${signed}`;
  return NextResponse.json({ url: paymentUrl });
}

5. Bước 4: Handle Return URL (frontend redirect)

Sau khi user thanh toán xong, VNPay redirect về VNPAY_RETURN_URL với query params chứa kết quả. Tạo src/app/payment/return/page.tsx:

tsx// Verify signature lần nữa ở client để hiện success/fail page
export default function ReturnPage({ searchParams }: { searchParams: Record<string, string> }) {
  const responseCode = searchParams.vnp_ResponseCode;
  const ok = responseCode === '00';
  return (
    <div>
      {ok ? <h1>Thanh toán thành công</h1> : <h1>Thanh toán thất bại</h1>}
      <p>Mã giao dịch: {searchParams.vnp_TxnRef}</p>
      <p>Số tiền: {Number(searchParams.vnp_Amount) / 100} VND</p>
    </div>
  );
}

6. Bước 5: Handle IPN Callback (verify thật)

Tạo src/app/api/vnpay/ipn/route.ts:

tsexport async function GET(req: Request) {
  const url = new URL(req.url);
  const params: Record<string, string> = {};
  url.searchParams.forEach((v, k) => { if (k !== 'vnp_SecureHash' && k !== 'vnp_SecureHashType') params[k] = v; });
  const secureHash = url.searchParams.get('vnp_SecureHash');

  const sorted = Object.keys(params).sort().reduce<Record<string, string>>((a, k) => { a[k] = encodeURIComponent(params[k]).replace(/%20/g, '+'); return a; }, {});
  const signData = Object.entries(sorted).map(([k, v]) => `${k}=${v}`).join('&');
  const hmac = crypto.createHmac('sha512', process.env.VNPAY_HASH_SECRET!);
  const computed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');

  if (computed !== secureHash) {
    return new Response(JSON.stringify({ RspCode: '97', Message: 'Invalid signature' }));
  }

  const orderId = params.vnp_TxnRef;
  const responseCode = params.vnp_ResponseCode;

  if (responseCode === '00') {
    // Update DB: orderId → status = 'paid'
    // Send email confirmation
  }

  return new Response(JSON.stringify({ RspCode: '00', Message: 'Confirm Success' }));
}

7. Bước 6: Test đủ 5 scenario sandbox

  1. Success: thẻ NCB 9704198526191432198 - số CVV bất kỳ, OTP 123456.
  2. Insufficient funds: thẻ NCB 9704195798459170488 - sẽ báo không đủ tiền.
  3. Card not registered: thẻ không kích hoạt Internet Banking.
  4. Wrong OTP: nhập OTP sai 3 lần.
  5. User cancel: bấm "Hủy" giữa flow.

Mỗi case test đủ flow: Return URL hiện đúng status + IPN callback fire đúng + DB update đúng. Log mọi request VNPay vào file để debug nếu cần.

8. Bước 7: Xin go-live production

Gửi email cho VNPay (sales@vnpay.vn) kèm: GPKD doanh nghiệp + STK ngân hàng + screenshot website + 5 test case sandbox đã pass. Mất 5-7 ngày làm việc duyệt. Sau khi duyệt, đổi VNPAY_URL bỏ "sandbox." + đổi TmnCode + HashSecret production.

9. Các bug phổ biến + cách xử lý

  • "Sai chữ ký" - kiểm tra sort key alphabetic + encodeURIComponent + replace %20 → +.
  • "Giao dịch không hợp lệ" - TmnCode sai hoặc môi trường (sandbox vs production) không khớp.
  • Amount lệch - quên × 100 (VNPay tính theo đồng × 100).
  • IPN không fire - VNPay không gọi được URL của bạn (firewall, IP whitelist, port).
  • Timezone lệch - vnp_CreateDate phải GMT+7, không phải UTC.

Cần Alodev tích hợp VNPay vào website doanh nghiệp? Dịch vụ trọn gói 15-25tr bao gồm code + test + go-live + 3 tháng support. Liên hệ tại /lien-he.

  • VNPay
  • Thanh toán
  • Next.js
  • Hướng dẫn

Cần tư vấn?
Founder trả lời trong 24 giờ.

Mô tả bài toán, ALODEV tư vấn đúng nghiệp vụ, miễn phí.