Khi hệ thống ‘đình công’ lúc 2 giờ sáng
Kim đồng hồ chỉ đúng 2 giờ sáng, mình vừa định chợp mắt thì Slack nổ thông báo liên hồi. Một khách hàng nhắn tin khá gắt: “Mình chuyển khoản thành công 500k rồi, tiền trừ rồi mà sao tài khoản vẫn chưa lên Pro?”. Kiểm tra log, mình mới tá hỏa: script quét mã QR cũ bị delay do phía ngân hàng bảo trì, dẫn đến dữ liệu không về kịp để kích hoạt dịch vụ.
Lúc đó mình hiểu rằng: Nếu cứ dùng mấy script quét log ngân hàng “lỏ” hoặc check tay, sớm muộn gì mình cũng mất khách. Anh em Startup cần một giải pháp có API chính thống, ổn định và quan trọng nhất là phí giao dịch 0 đồng. PayOS là cái tên sáng giá nhất hiện nay. Trong bài này, mình sẽ cùng anh em triển khai PayOS vào Node.js và xử lý Webhook sao cho chuẩn chỉ, tránh tuyệt đối việc sót đơn.
Tại sao lại là PayOS mà không phải Stripe hay PayPal?
Chọn sai cổng thanh toán ngay từ đầu sẽ khiến bạn tốn cả tuần để refactor sau này. Hãy nhìn vào con số thực tế để thấy sự khác biệt.
1. Chuyển khoản thủ công (Manual Bank Transfer)
- Vấn đề: Khách phải chụp ảnh màn hình, bạn phải đối soát tay.
- Rủi ro: Cực kỳ khó scale. Nếu một ngày có 50 đơn, bạn sẽ dành cả ngày chỉ để check app ngân hàng.
2. Cổng thanh toán quốc tế (Stripe, PayPal)
- Chi phí: Phí giao dịch thường là 2.9% + $0.3. Với đơn hàng 1 triệu VNĐ, bạn mất gần 40.000đ tiền phí.
- Thủ tục: Rút tiền về ngân hàng Việt Nam mất 3-7 ngày và chịu thêm phí chênh lệch tỷ giá.
3. PayOS (VietQR Payment Gateway)
- Chi phí: Miễn phí giao dịch (0đ). Tiền về thẳng tài khoản ngân hàng của bạn ngay lập tức.
- Trải nghiệm: Khách chỉ cần quét mã QR là xong, không cần nhập số tài khoản hay nội dung chuyển khoản thủ công.
Chốt lại: Nếu dự án của bạn phục vụ người dùng Việt, PayOS là lựa chọn tối ưu nhất để tiết kiệm chi phí vận hành.
Lộ trình triển khai thực tế
Mình từng phải đập đi xây lại một hệ thống chỉ vì code xử lý tiền nong nằm rải rác khắp nơi. Bài học rút ra là: Hãy tách biệt logic thanh toán thành một module riêng.
Bước 1: Khởi tạo dự án
Cài đặt thư viện chính thức từ PayOS. Đừng tự viết lại hàm hash checksum nếu bạn không muốn đau đầu với các lỗi bảo mật tiềm ẩn.
npm install @payos/node dotenv express
Lấy các thông số Client ID, API Key, và Checksum Key từ Dashboard PayOS rồi đưa vào file .env:
PAYOS_CLIENT_ID=your_id
PAYOS_API_KEY=your_key
PAYOS_CHECKSUM_KEY=your_checksum_key
Bước 2: Cấu hình Instance
Tạo file payos.js để quản lý kết nối. Cách này giúp code gọn gàng và dễ bảo trì hơn.
const PayOS = require("@payos/node");
require('dotenv').config();
const payos = new PayOS(
process.env.PAYOS_CLIENT_ID,
process.env.PAYOS_API_KEY,
process.env.PAYOS_CHECKSUM_KEY
);
module.exports = payos;
Bước 3: Tạo link thanh toán
Khi khách bấm nút thanh toán, bạn gọi API để lấy link QR. Một lưu ý cực quan trọng: orderCode phải là kiểu Number (số nguyên). Nếu ID của bạn là String, hãy dùng một hàm hash để convert nó sang số.
app.post("/create-payment-link", async (req, res) => {
const { amount, orderId } = req.body;
const body = {
orderCode: Number(orderId),
amount: amount,
description: `Thanh toan don hang ${orderId}`,
returnUrl: "https://your-app.com/success",
cancelUrl: "https://your-app.com/cancel",
};
try {
const paymentLinkRes = await payos.createPaymentLink(body);
return res.json({ url: paymentLinkRes.checkoutUrl });
} catch (error) {
return res.status(500).json({ message: "Không thể tạo link thanh toán" });
}
});
Xử lý Webhook: Đừng để kẻ gian “vượt rào”
Nhiều bạn chỉ đợi khách bấm “Quay lại website” rồi mới cập nhật đơn hàng. Đây là sai lầm chết người. Khách có thể thanh toán xong rồi tắt tab luôn. Webhook mới là nơi xử lý logic chính xác nhất.
Xác thực chữ ký (Verify Signature)
Kẻ gian có thể gửi request giả mạo đến URL Webhook của bạn để chiếm đoạt dịch vụ. PayOS cung cấp cơ chế checksum để đảm bảo data chỉ đến từ server của họ.
app.post("/payos-webhook", async (req, res) => {
const webhookData = req.body;
try {
// Xác thực data có đúng từ PayOS gửi đến không
const verifiedData = payos.verifyPaymentWebhookData(webhookData);
if (webhookData.code === "00") {
// KIỂM TRA: Đơn hàng này đã được xử lý trước đó chưa?
const order = await Order.findOne({ id: verifiedData.orderCode });
if (order && order.status !== 'PAID') {
await order.update({ status: 'PAID', paidAt: new Date() });
console.log(`Đơn hàng ${verifiedData.orderCode} đã thanh toán thành công.`);
}
}
return res.json({ success: true });
} catch (error) {
return res.status(400).json({ message: "Chữ ký không hợp lệ" });
}
});
Tính Idempotency (Chống xử lý trùng)
Đôi khi do lỗi mạng, PayOS có thể gửi Webhook 2-3 lần cho cùng một đơn hàng. Nếu bạn không kiểm tra trạng thái đơn hàng trong Database trước khi cập nhật, hệ thống có thể cộng tiền hoặc gửi email kích hoạt dịch vụ nhiều lần. Luôn kiểm tra if (order.status !== 'PAID') trước khi thực hiện logic nghiệp vụ.
Mẹo Debug cực nhanh với Ngrok
Thay vì đẩy code lên server thật để test Webhook, anh em hãy dùng Ngrok để tạo tunnel về máy local.
- Chạy app Node.js tại port 3000.
- Gõ lệnh:
ngrok http 3000. - Lấy URL Ngrok cung cấp dán vào mục Webhook trên Dashboard PayOS.
Lúc này, mỗi khi bạn quét mã QR test, log sẽ hiện ngay lập tức tại terminal máy cá nhân. Cách này giúp tiết kiệm hàng giờ đồng hồ chờ đợi deploy.
Lời kết
Tích hợp thanh toán không chỉ là viết code cho chạy được, mà là xây dựng một quy trình an toàn. Luôn xác thực chữ ký, xử lý trùng lặp và ghi log chi tiết cho từng giao dịch. Khi hệ thống đã chạy trơn tru, bạn sẽ không còn phải lo lắng về những tin nhắn phàn nàn lúc nửa đêm nữa. Chúc anh em triển khai thành công!

