0%
PRAXIUM LABS

Namaste! 🇳🇵

You found our hidden gem! Something incredible is brewing in the heart of the Himalayas. We might have something special here for you soon.

Stay curious. Jay Nepal!

Share

Khalti Integration in React / Next.js: Production Setup (2026)

Khalti Integration in React / Next.js: Production Setup (2026)

TL;DR. Khalti KPG v2 in a Next.js app: API route initiates the payment, returns the payment_url, frontend redirects user to Khalti, Khalti redirects back to your /payment/return route with pidx, that route calls the lookup endpoint and marks the order. The code below is ready to copy into a TypeScript Next.js App Router project.

Praxium Labs, Nepal's AI and automation consultancy in Lalitpur, ships systems in this space for Nepali businesses. Khalti is the most common payment gateway choice for Nepali SaaS and education startups built on modern stacks. This guide is the integration walkthrough for a Next.js team starting today.

Setup

  • Khalti merchant account — sign up at khalti.com merchant portal
  • Live secret key: from merchant dashboard. Store in .env.local as KHALTI_SECRET (never commit)
  • Test secret key: from dev.khalti.com for UAT
  • Webhook / return URL: https://yoursite.com/payment/return

Initiation API route (App Router)

app/api/payment/initiate/route.ts

import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const { orderId, amountPaisa, customer } = await req.json();
  const body = {
    return_url: 'https://yoursite.com/payment/return',
    website_url: 'https://yoursite.com',
    amount: amountPaisa,        // amount in paisa, e.g. 100000 = NPR 1000
    purchase_order_id: orderId,
    purchase_order_name: 'Order ' + orderId,
    customer_info: customer,
  };
  const r = await fetch('https://khalti.com/api/v2/epayment/initiate/', {
    method: 'POST',
    headers: {
      Authorization: 'Key ' + process.env.KHALTI_SECRET!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  if (!r.ok) return NextResponse.json({ error: 'init failed' }, { status: 500 });
  const data = await r.json();
  return NextResponse.json({ paymentUrl: data.payment_url, pidx: data.pidx });
}

Frontend pay button

components/PayWithKhalti.tsx

'use client';
import { useState } from 'react';

export default function PayWithKhalti({ orderId, amountPaisa }: { orderId: string; amountPaisa: number }) {
  const [loading, setLoading] = useState(false);
  async function pay() {
    setLoading(true);
    const res = await fetch('/api/payment/initiate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId, amountPaisa, customer: { name: 'Customer', email: '', phone: '' } }),
    });
    const data = await res.json();
    if (data.paymentUrl) window.location.href = data.paymentUrl;
    else alert('Payment initiation failed');
    setLoading(false);
  }
  return (
    <button onClick={pay} disabled={loading}>{loading ? 'Loading…' : 'Pay with Khalti'}</button>
  );
}

Return-URL handler with lookup verification

app/payment/return/page.tsx

import { redirect } from 'next/navigation';

async function lookup(pidx: string) {
  const r = await fetch('https://khalti.com/api/v2/epayment/lookup/', {
    method: 'POST',
    headers: {
      Authorization: 'Key ' + process.env.KHALTI_SECRET!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ pidx }),
    cache: 'no-store',
  });
  return r.json();
}

export default async function Page({ searchParams }: { searchParams: Promise<{ pidx?: string }> }) {
  const sp = await searchParams;
  if (!sp.pidx) return <p>No payment reference.</p>;

  const result = await lookup(sp.pidx);
  if (result.status === 'Completed') {
    // markOrderPaid(...) — idempotent! use pidx as unique key in your DB
    return <p>Payment successful. Order is being processed.</p>;
  }
  if (result.status === 'Pending') {
    return <p>Payment pending. We will email you when confirmed.</p>;
  }
  return <p>Payment {result.status.toLowerCase()}. Please try again or contact us.</p>;
}

Idempotency in the DB

The lookup may be called multiple times (browser refresh, race conditions). Mark the order paid only once. SQL pattern:

Postgres example

-- ensure pidx column has a unique constraint on the orders table
ALTER TABLE orders ADD CONSTRAINT orders_pidx_unique UNIQUE (khalti_pidx);

-- when handling a successful lookup
INSERT INTO payment_events (order_id, pidx, status, amount_paisa, processed_at)
VALUES ($1, $2, 'Completed', $3, NOW())
ON CONFLICT (pidx) DO NOTHING
RETURNING id;
-- if this returned a row, then transition the order; otherwise no-op (already processed)

Error states to handle

  • Pending: bank transfer hasn't cleared. Show "we'll email when confirmed"; re-lookup every 10 min for 1 hour
  • User canceled: show friendly cancel message; preserve cart
  • Failed: friendly error; allow retry with different method
  • Refunded: if you query an old transaction; surface accurately
  • Amount mismatch: alarming — log + alert ops + do not auto-process

Common production gotchas

  • Sandbox vs production keys: Khalti gives separate keys for sandbox and live. A surprising number of Nepali sites accidentally ship sandbox keys to production. Use environment variables (KHALTI_SECRET_KEY_LIVE) and verify on deploy
  • Webhook verification: always verify the lookup endpoint on your server before marking an order paid. Client-side success callback is not authoritative — it can be spoofed
  • Idempotency: Khalti may call your verify endpoint twice if its network hiccups. Treat orders by pidx + status, not by timestamp
  • CORS: Khalti's checkout uses a redirect flow now, not popup — CORS rarely an issue, but if you embed the legacy popup, set frame-ancestors in CSP
  • Retry-after handling: Khalti rate-limits aggressively in sandbox; back off exponentially in tests

Refund handling

Khalti supports partial and full refunds via the merchant dashboard or API. For programmatic refunds: call POST /api/v2/epayment/refund/ with the original pidx and the refund amount. The funds typically return to the customer wallet within 24 hours. Always store the refund pidx alongside your order record so support staff can trace disputes. For broader payment-stack design choices, see our multi-gateway selector guide.

Observability for payment flows

Payments are the highest-stakes pages in your app. Treat them accordingly:

  • Structured logs on every initiate / lookup call — log pidx, order id, amount, status, latency. Search by pidx becomes the standard debug move
  • Synthetic monitoring — run a sandbox-payment every 5 minutes from an external prober (Better Stack, UptimeRobot). Catches gateway-side outages before customers report them
  • Alerts on success-rate drop — calculate rolling 5-minute payment-success rate; page on-call when it drops below 95%
  • Capture verification mismatches separately — they should be zero in steady state; any nonzero is investigation-grade
  • Customer-facing status page — when Khalti is degraded, surface a banner rather than letting customers think your site is broken
  • Daily reconciliation report — compare yesterday's gateway settlements to your orders; investigate every discrepancy

Multi-region resilience

If your Next.js app runs on Vercel or similar edge platforms, ensure the payment-initiation and lookup routes pin to a single region close to Khalti's infrastructure (Mumbai works well for Nepal). Edge functions can introduce subtle race conditions when the same pidx is touched from different regions simultaneously. Database-level uniqueness on pidx (as in the SQL pattern above) is your last line of defence; do not rely solely on application-level locking.

Frequently asked questions

Why use server-side lookup instead of trusting the redirect query params?

The redirect URL is trivially forgeable. A malicious user can hit your /payment/return route with status=Completed and a made-up pidx. Server-side lookup with your authenticated key is the only trustworthy verification.

Is there a Khalti webhook?

KPG v2 does not push transaction-completion webhooks. Pattern is always: redirect → lookup. For async transactions (Pending), poll lookup every 10 minutes for an hour, then mark expired.

How do I test in development?

Use the dev.khalti.com test environment with test credentials. Khalti provides test payment flows that simulate success, cancel, and pending without real money.

Can I support both eSewa and Khalti in this codebase?

Yes — add a second /api/payment/initiate-esewa route with the eSewa-specific logic (see eSewa code post). Common return-URL handler can branch on a query param indicating which gateway.

What about the deprecated Khalti checkout JS widget?

KPG v2 is server-side; the legacy JavaScript widget (KPG v1) is being phased out. New integrations should use v2 with the redirect / lookup pattern shown here.

How long do test transactions stay in sandbox?

Khalti sandbox transactions persist indefinitely but are visible only to your sandbox account. Production transactions retain forever for audit.

What's the typical settlement time?

T+1 for amounts under NPR 10 lakh; T+2 for larger settlements. Bank holidays delay; build your accounting flows to expect 2-3 working day windows.

Who can build this in Nepal?

Praxium Labs — Nepal's AI and automation consultancy, based in Lalitpur — designs and builds the systems described in this guide for Nepali businesses and for international teams hiring from Nepal. Start a project or see all services.