Integrating PayHere payments into a Next.js + Express stack
Signing checkouts on the server, verifying the notify callback properly, and why the redirect back to your site should never mark an order as paid.
PayHere is the payment gateway most Sri Lankan businesses end up using, and the integration is straightforward once you understand one thing: the browser is never the source of truth. The customer's redirect back to your site proves nothing. PayHere's server-to-server notification does. Everything below follows from that.
How the flow works
- 01The customer clicks Pay. The Next.js frontend asks the Express API to create an order.
- 02The API stores the order as
pendingand returns the checkout parameters, including a hash signed with your merchant secret. - 03The frontend opens PayHere's checkout (the popup SDK, or a form POST to the checkout URL).
- 04After payment, PayHere POSTs the result to your
notify_url. That is where the order is marked paid. - 05The frontend shows the result by asking your API for the order's status, not by trusting the popup's callback.
Signing the checkout on the server
The checkout hash is the uppercase MD5 of the merchant ID, order ID, amount (always two decimals), currency, and the uppercase MD5 of your merchant secret. The secret never leaves the server, and the amount always comes from your database, never from the request body.
import crypto from "node:crypto";
import express from "express";
import { Order } from "../models/order.js";
import { requireUser } from "../middleware/auth.js";
const router = express.Router();
const md5 = (s) => crypto.createHash("md5").update(s).digest("hex").toUpperCase();
const { PAYHERE_MERCHANT_ID: MERCHANT_ID, PAYHERE_SECRET, API_URL } = process.env;
const SECRET_HASH = md5(PAYHERE_SECRET);
router.post("/checkout", requireUser, async (req, res) => {
const order = await Order.createFromCart(req.user.id); // prices from the DB
const amount = order.total.toFixed(2);
const currency = "LKR";
res.json({
merchant_id: MERCHANT_ID,
order_id: order.id,
items: order.summary,
amount,
currency,
hash: md5(MERCHANT_ID + order.id + amount + currency + SECRET_HASH),
notify_url: `${API_URL}/api/payments/notify`,
first_name: req.user.firstName,
last_name: req.user.lastName,
email: req.user.email,
phone: req.user.phone,
address: req.user.address,
city: req.user.city,
country: "Sri Lanka",
});
});Verifying the notification
PayHere sends the notification as a form-encoded POST, so that route needs express.urlencoded(). It includes an md5sig built the same way as the checkout hash, but with the status code added. Recompute it, compare, and only then touch the order:
router.post("/notify", express.urlencoded({ extended: false }), async (req, res) => {
const { merchant_id, order_id, payhere_amount, payhere_currency, status_code, md5sig, payment_id } = req.body;
const expected = md5(merchant_id + order_id + payhere_amount + payhere_currency + status_code + SECRET_HASH);
if (merchant_id !== MERCHANT_ID || md5sig !== expected) return res.sendStatus(400);
const order = await Order.findById(order_id);
if (!order || order.total.toFixed(2) !== payhere_amount) return res.sendStatus(400);
// 2 = success, 0 = pending, -1 = cancelled, -2 = failed, -3 = charged back
if (status_code === "2" && order.status !== "paid") {
order.status = "paid";
order.paymentId = payment_id;
} else if (status_code === "-1" || status_code === "-2") {
order.status = "failed";
} else if (status_code === "-3") {
order.status = "charged_back";
}
await order.save();
res.sendStatus(200);
});
export default router;Opening checkout from Next.js
The popup SDK keeps the customer on your site. Load it with next/script, fetch the signed parameters, and start the payment. The onCompleted callback only means the popup closed after a payment attempt, so it sends the customer to an order page that reads the real status from the API.
"use client";
import Script from "next/script";
import { useRouter } from "next/navigation";
import { useState } from "react";
declare global {
interface Window { payhere: any }
}
export function PayButton() {
const router = useRouter();
const [error, setError] = useState("");
const pay = async () => {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/payments/checkout`, {
method: "POST",
credentials: "include",
});
if (!res.ok) return setError("Couldn't start the payment. Please try again.");
const params = await res.json();
window.payhere.onCompleted = (orderId: string) => router.push(`/orders/${orderId}`);
window.payhere.onDismissed = () => setError("Payment cancelled.");
window.payhere.onError = (message: string) => setError(message);
window.payhere.startPayment({
sandbox: process.env.NEXT_PUBLIC_PAYHERE_SANDBOX === "true",
return_url: undefined, // not used by the popup
cancel_url: undefined,
...params,
});
};
return (
<>
<Script src="https://www.payhere.lk/lib/payhere.js" strategy="lazyOnload" />
<button onClick={pay}>Pay with PayHere</button>
{error && <p role="alert">{error}</p>}
</>
);
}The order page polls /api/orders/:id for a few seconds until the status leaves pending. The notification usually lands before the popup has finished closing, so in practice the customer rarely sees the spinner.
Before going live
- Add your live domain in the PayHere merchant portal and switch to the live merchant ID and secret. Sandbox credentials don't work on the live gateway.
- Make the notify handler idempotent. PayHere may retry, and a second notification must never send a second confirmation email.
- Log every raw notification before you process it. When a customer says they paid and the order says otherwise, that log settles it in a minute.
- Test the unhappy paths in the sandbox too: cancelled payments, failed cards, and closing the popup halfway through.