Integrating PayOS with Node.js: Clean Webhook Handling to Never Lose an Order

Development tutorial - IT technology blog
Development tutorial - IT technology blog

When the System Goes on Strike at 2 AM

The clock struck exactly 2 AM, and just as I was about to close my eyes, Slack notifications started exploding. A customer sent a rather heated message: “I successfully transferred 500k, the money was deducted, but why hasn’t my account been upgraded to Pro?”. Checking the logs, I was horrified: the old bank-scanning script was delayed due to bank maintenance, so the data didn’t arrive in time to activate the service.

At that moment, I realized: if I kept using those “shoddy” bank log scanning scripts or manual checks, I would lose customers sooner or later. Startups need a solution with an official, stable API and, most importantly, 0 transaction fees. PayOS is the most promising name right now. In this article, I will show you how to implement PayOS with Node.js and handle Webhooks properly to absolutely avoid missing any orders.

Why PayOS instead of Stripe or PayPal?

Choosing the wrong payment gateway from the start will cost you a week of refactoring later. Let’s look at the actual numbers to see the difference.

1. Manual Bank Transfer

  • Problem: Customers have to take screenshots, and you have to reconcile manually.
  • Risk: Extremely hard to scale. If you have 50 orders a day, you’ll spend the whole day just checking your banking app.

2. International Payment Gateways (Stripe, PayPal)

  • Cost: Transaction fees are typically 2.9% + $0.3. For a 1,000,000 VNĐ order, you lose nearly 40,000 VNĐ in fees.
  • Procedures: Withdrawing money to a Vietnamese bank takes 3-7 days and incurs additional exchange rate fees.

3. PayOS (VietQR Payment Gateway)

  • Cost: Zero transaction fees (0đ). Funds are settled directly into your bank account instantly.
  • Experience: Customers just need to scan the QR code; no need to manually enter account numbers or transfer descriptions.

Bottom line: If your project serves Vietnamese users, especially when building multi-tenant SaaS, PayOS is the optimal choice to save on operational costs.

A Real-World Implementation Roadmap

I once had to tear down and rebuild a system because the payment logic was scattered everywhere. The lesson learned: Decouple payment logic into a separate module, a practice similar to implementing repository pattern to keep the codebase clean.

Step 1: Project Initialization

Install the official library from PayOS. Don’t rewrite the checksum hashing functions yourself unless you want to deal with potential security vulnerabilities.

npm install @payos/node dotenv express

Get the Client ID, API Key, and Checksum Key from the PayOS Dashboard and add them to your .env file:

PAYOS_CLIENT_ID=your_id
PAYOS_API_KEY=your_key
PAYOS_CHECKSUM_KEY=your_checksum_key

Step 2: Instance Configuration

Create a payos.js file to manage the connection. This keeps the code clean and easier to maintain.

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;

Step 3: Creating a Payment Link

When a customer clicks the payment button, you call the API to get the QR link. One crucial note: orderCode must be a Number (integer). If your ID is a String, use a hash function to convert it to a number.

app.post("/create-payment-link", async (req, res) => {
  const { amount, orderId } = req.body;

  const body = {
    orderCode: Number(orderId),
    amount: amount,
    description: `Payment for order ${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: "Unable to create payment link" });
  }
});

Webhook Handling: Don’t Let Fraudsters Bypass Your System

Many developers only wait for the customer to click “Return to website” before updating the order. This is a fatal mistake. A customer might pay and then close the tab immediately. Webhooks are where the most accurate logic should be handled.

Verify Signature

Malicious actors could send fake requests to your Webhook URL to steal services. PayOS provides a checksum mechanism to ensure data only comes from their servers.

app.post("/payos-webhook", async (req, res) => {
  const webhookData = req.body;

  try {
    // Verify if the data is actually sent from PayOS
    const verifiedData = payos.verifyPaymentWebhookData(webhookData);

    if (webhookData.code === "00") {
       // CHECK: Has this order been processed before?
       const order = await Order.findOne({ id: verifiedData.orderCode });
       
       if (order && order.status !== 'PAID') {
          await order.update({ status: 'PAID', paidAt: new Date() });
          console.log(`Order ${verifiedData.orderCode} paid successfully.`);
       }
    }

    return res.json({ success: true });
  } catch (error) {
    return res.status(400).json({ message: "Invalid signature" });
  }
});

Idempotency (Preventing Duplicate Processing)

Sometimes due to network issues, PayOS might send a Webhook 2-3 times for the same order. If you don’t check the order status in the Database before updating, your system might credit money or send activation emails multiple times. Always ensure idempotency in REST APIs by checking if (order.status !== 'PAID') before executing business logic.

Quick Debugging Tips with Ngrok

Instead of pushing code to a real server to test Webhooks, use Ngrok to create a tunnel to your local machine.

  1. Run your Node.js app on port 3000.
  2. Type the command: ngrok http 3000.
  3. Copy the URL provided by Ngrok and paste it into the Webhook section of the PayOS Dashboard.

Now, every time you scan a test QR code, the logs will appear immediately in your local terminal. This saves hours of waiting for deployments.

Conclusion

Payment integration isn’t just about writing code that works; it’s about building a secure process. Always verify signatures, handle duplicates, and keep detailed logs for every transaction, perhaps using professional error management techniques to handle edge cases. Once the system is running smoothly, you’ll no longer have to worry about complaining messages in the middle of the night. Good luck with your implementation!

Share: