How to add PayHere checkout to a Next.js site

A step-by-step guide to taking card and mobile wallet payments with PayHere, without card details ever touching your server.

NNEXA Dev TeamDevelopers at NEXA4 min read

PayHere is one of the most widely used payment gateways in Sri Lanka. It accepts local and international cards plus popular mobile wallets, and it hosts the payment page for you. That means card details are entered on PayHere, not on your site.

In this guide we connect PayHere to a Next.js front end with a small back end. We use Laravel, but any server language works. The back end has two jobs: create a secure hash before checkout, and verify the payment notification afterwards.

What you need

  • A PayHere merchant account. Start with a sandbox account so you can test without real money.
  • Your Merchant ID and Merchant Secret, from the PayHere dashboard.
  • A Next.js site and a small back end (Laravel, Node.js or similar).
  • A public URL for your back end. PayHere cannot send notifications to localhost.

How the payment flow works

  1. Your customer clicks "Pay" on your site.
  2. Your back end creates an order and a hash for it.
  3. The browser opens PayHere checkout with the order details and the hash.
  4. The customer pays on PayHere.
  5. PayHere sends the result to your notify URL, server to server.
  6. Your back end checks the signature and marks the order as paid.

Tip

The return URL is only where the customer lands after paying. PayHere does not send any payment status to it, so never mark an order as paid from the return page. Only trust the notify URL.

Step 1: Create the hash on your server

PayHere checks every checkout request with a hash made from your Merchant ID, the order ID, the amount, the currency and your Merchant Secret. Because it uses your secret, it must be created on the server.

// app/Http/Controllers/PaymentController.php
$hash = strtoupper(
    md5(
        $merchantId .
        $orderId .
        number_format($amount, 2, '.', '') .
        $currency .
        strtoupper(md5($merchantSecret))
    )
);

Watch the amount: it must always have two decimal places, for example 2500.00. A small formatting difference here is the most common reason a hash is rejected.

Step 2: Open checkout from Next.js

PayHere offers a JavaScript SDK that opens checkout in a popup on top of your page. Load the script once, ask your API for the order and its hash, then start the payment.

// app/checkout/PayButton.tsx
'use client';
import Script from 'next/script';

export default function PayButton({ orderId }: { orderId: string }) {
  async function pay() {
    const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/payments/${orderId}`);
    const order = await res.json();

    window.payhere.startPayment({
      sandbox: true,
      merchant_id: order.merchantId,
      return_url: undefined,
      cancel_url: undefined,
      notify_url: `${process.env.NEXT_PUBLIC_API_URL}/payhere/notify`,
      order_id: order.id,
      items: order.title,
      amount: order.amount,
      currency: 'LKR',
      hash: order.hash,
      first_name: order.firstName,
      last_name: order.lastName,
      email: order.email,
      phone: order.phone,
      address: order.address,
      city: order.city,
      country: 'Sri Lanka',
    });
  }

  return (
    <>
      <Script src="https://www.payhere.lk/lib/payhere.js" />
      <button onClick={pay}>Pay now</button>
    </>
  );
}

The SDK also gives you three callbacks: payhere.onCompleted, payhere.onDismissed and payhere.onError. Use them to update the screen, for example to show "Checking your payment". Do not use them to confirm the order.

Keep sandbox: true while testing, and switch it to false only after your live merchant account is approved.

Step 3: Verify the payment on your notify URL

After each payment, PayHere sends a POST request to your notify URL with the order ID, amount, currency, a status code and a signature called md5sig. Rebuild the signature on your server and compare the two.

// routes/api.php
Route::post('/payhere/notify', function (Request $request) {
    $localSig = strtoupper(md5(
        $request->merchant_id .
        $request->order_id .
        $request->payhere_amount .
        $request->payhere_currency .
        $request->status_code .
        strtoupper(md5(config('services.payhere.secret')))
    ));

    if ($localSig === $request->md5sig && (int) $request->status_code === 2) {
        Order::where('id', $request->order_id)
            ->where('status', '!=', 'paid')
            ->update(['status' => 'paid']);
    }

    return response('OK');
});

A status code of 2 means the payment succeeded. 0 means pending, -1 means the customer cancelled, -2 means it failed and -3 means a chargeback.

Before you go live

  • Keep your Merchant Secret in your .env file and never commit it to Git.
  • Routes in routes/api.php are not CSRF protected in Laravel. If your notify route lives in web routes, exclude it from CSRF checks.
  • Make the notify handler safe to run twice. Only update orders that are not already paid.
  • Test a successful, a cancelled and a failed payment in the sandbox.
  • Switch to your live Merchant ID, live secret and sandbox: false.

That's all it takes to accept payments safely. If you'd like us to set up PayHere on your site or store, send us an inquiry and a developer will reply within 24 hours.

  • Next JS
  • Laravel
  • PayHere
  • Payments
N

Written by NEXA Dev Team

The developers who design, build and integrate our clients' websites and apps. Have a question? Send us an inquiry.

Free first consultation

Have a project in mind? Let's build it together.

Every project gets a custom quote. Tell us what you need and we'll reply within 24 hours.