PayBridgeNP

Developers

eSewa and Khalti in Laravel

eSewa and Khalti in a Laravel app, step by step. Attempts that survive retries, and payments confirmed before orders are marked paid.

By PayBridgeNP Team11 min read

Three things make an eSewa or Khalti integration in Laravel safe: the payment starts on your server, every attempt is kept so a retry never loses an earlier payment, and each payment is confirmed with the provider before the order is marked paid, never from the redirect alone. This guide builds all three, then shows the shorter path through one SDK.

Last verified: 25 October 2026, against eSewa's ePay docs, Khalti's ePayment docs, the PayBridgeNP PHP SDK 5.5.0, and Laravel 11 and 12. The code is a starting point: add your own validation, logging and error pages.

Before you start

  • Store amounts in paisa (an integer, 10000 for Rs 100). Khalti takes paisa; eSewa takes rupees as a string like 100.00. Convert at the edge of each provider.
  • Every payment attempt gets its own row. A customer who retries must not wipe out the first attempt, because the first one can still complete. eSewa also expects a unique transaction_uuid for every attempt, so a retry needs a new one.
  • Start payments with a POST, behind Laravel's CSRF protection and your login, never with a plain link.
  • Mark an order paid exactly once, with a database update that only succeeds while the order is still pending.
  • Test credentials: eSewa test credentials and Khalti test credentials.
// config/services.php
// Live URLs: https://epay.esewa.com.np and https://khalti.com/api/v2
'esewa' => [
    'product_code' => env('ESEWA_PRODUCT_CODE', 'EPAYTEST'),
    'secret' => env('ESEWA_SECRET_KEY'),
    'base_url' => env('ESEWA_BASE_URL', 'https://rc-epay.esewa.com.np'),
],
'khalti' => [
    'secret' => env('KHALTI_SECRET_KEY'),
    'base_url' => env('KHALTI_BASE_URL', 'https://dev.khalti.com/api/v2'),
],

The tables

Orders carry user_id, total_paisa, status (default pending) and two nullable columns, paid_via and provider_ref. Payment attempts get their own table:

// database/migrations/xxxx_create_payment_attempts_table.php
Schema::create('payment_attempts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained();
    $table->string('provider'); // esewa, khalti or paybridgenp
    // eSewa uuid, Khalti pidx or checkout session ID
    $table->string('reference')->nullable()->unique();
    $table->uuid('idempotency_key')->nullable(); // checkout sessions
    $table->unsignedBigInteger('amount_paisa'); // what THIS attempt asked
    $table->string('provider_ref')->nullable(); // provider's transaction ID
    $table->timestamps();
});
// app/Models/Order.php
class Order extends Model
{
    protected $guarded = [];

    // Integers, so the strict comparisons below hold on every database.
    protected $casts = ['user_id' => 'integer', 'total_paisa' => 'integer'];

    public function attempts()
    {
        return $this->hasMany(PaymentAttempt::class);
    }

    public function payableBy(User $user): bool
    {
        return $this->user_id === $user->id && $this->status === 'pending';
    }

    /** Pending to paid exactly once, and only for the amount paid. */
    public static function markPaid(
        int $id,
        int $paidPaisa,
        string $via,
        ?string $ref,
    ): bool {
        return static::whereKey($id)
            ->where('status', 'pending')
            ->where('total_paisa', $paidPaisa)
            ->update([
                'status' => 'paid',
                'paid_via' => $via,
                'provider_ref' => $ref,
            ]) === 1;
    }
}

// app/Models/PaymentAttempt.php
class PaymentAttempt extends Model
{
    protected $guarded = [];

    protected $casts = ['order_id' => 'integer', 'amount_paisa' => 'integer'];

    public function order()
    {
        return $this->belongsTo(Order::class);
    }
}

Because markPaid only updates a row that is still pending and still costs what was paid, two confirmations arriving at once cannot both mark the order paid, and an order whose total changed cannot be marked paid for the old amount. Only the call that returns true should send the receipt or release the goods.

eSewa in Laravel

An eSewa ePay payment end to end: your server signs the form, the browser posts it to eSewa, the customer pays, eSewa redirects back with Base64 data you verify, your server calls the status check, and only COMPLETE means fulfil, once

eSewa's ePay v2 works by posting a signed HTML form to eSewa. The customer pays on eSewa's page and comes back to your success_url with a signed data parameter. You then confirm with eSewa's status API.

Step 1: create the attempt and the signed form

// app/Http/Controllers/EsewaController.php
use App\Models\Order;
use App\Models\PaymentAttempt;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

public function pay(Request $request, Order $order)
{
    abort_unless($order->payableBy($request->user()), 404);

    $attempt = $order->attempts()->create([
        'provider' => 'esewa',
        // eSewa allows letters, numbers and hyphens only
        'reference' => $order->id . '-' . Str::random(10),
        'amount_paisa' => $order->total_paisa,
    ]);

    $total = self::rupees($attempt->amount_paisa); // "100.00"
    $code = config('services.esewa.product_code');
    $signature = self::sign(
        "total_amount={$total},"
        . "transaction_uuid={$attempt->reference},"
        . "product_code={$code}"
    );
    $back = route('esewa.return');

    return view('esewa.redirect', [
        'action' => config('services.esewa.base_url')
            . '/api/epay/main/v2/form',
        'fields' => [
            'amount' => $total,
            'tax_amount' => '0',
            'total_amount' => $total,
            'transaction_uuid' => $attempt->reference,
            'product_code' => $code,
            'product_service_charge' => '0',
            'product_delivery_charge' => '0',
            'success_url' => $back,
            'failure_url' => $back,
            'signed_field_names' =>
                'total_amount,transaction_uuid,product_code',
            'signature' => $signature,
        ],
    ]);
}

private static function rupees(int $paisa): string
{
    return number_format($paisa / 100, 2, '.', '');
}

private static function sign(string $message): string
{
    $secret = config('services.esewa.secret');

    return base64_encode(hash_hmac('sha256', $message, $secret, true));
}
{{-- resources/views/esewa/redirect.blade.php --}}
<form id="esewa" method="POST" action="{{ $action }}">
    @foreach ($fields as $name => $value)
        <input type="hidden" name="{{ $name }}" value="{{ $value }}">
    @endforeach
    <noscript><button type="submit">Continue to eSewa</button></noscript>
</form>
<script>document.getElementById('esewa').submit();</script>

No @csrf in this form: it goes to eSewa, not to your app.

Step 2: handle the return, then confirm with eSewa

public function return(Request $request)
{
    $encoded = $request->query('data');
    if (! $encoded) {
        // Cancelled, failed or still pending on eSewa's page.
        return redirect()->route('cart.index');
    }

    $data = json_decode(base64_decode($encoded), true) ?: [];

    // 1. Check eSewa's signature on what came back. Insist on the
    //    response's own signed field set, so signed *request* fields
    //    cannot be replayed here.
    $fields = 'transaction_code,status,total_amount,'
        . 'transaction_uuid,product_code,signed_field_names';
    abort_unless(($data['signed_field_names'] ?? null) === $fields, 400);
    $message = collect(explode(',', $fields))
        ->map(fn ($f) => $f . '=' . ($data[$f] ?? ''))
        ->implode(',');
    $given = (string) ($data['signature'] ?? '');
    abort_unless(hash_equals(self::sign($message), $given), 400);

    // 2. Find YOUR attempt by its uuid. Never trust the URL's amount.
    $attempt = PaymentAttempt::where('provider', 'esewa')
        ->where('reference', $data['transaction_uuid'] ?? null)
        ->firstOrFail();

    // 3. Ask eSewa directly, with the values this attempt sent.
    $status = Http::timeout(20)
        ->get(config('services.esewa.base_url')
            . '/api/epay/transaction/status/', [
            'product_code' => config('services.esewa.product_code'),
            'total_amount' => self::rupees($attempt->amount_paisa),
            'transaction_uuid' => $attempt->reference,
        ])
        ->json();

    // 4. Paid only if eSewa says COMPLETE for this attempt's amount.
    $reported = str_replace(',', '', (string) ($status['total_amount'] ?? 0));
    $paidPaisa = (int) round((float) $reported * 100);
    $ref = $status['ref_id'] ?? null;

    if (($status['status'] ?? null) === 'COMPLETE'
        && $paidPaisa === $attempt->amount_paisa) {
        $attempt->update(['provider_ref' => $ref]);
        Order::markPaid(
            $attempt->order_id,
            $attempt->amount_paisa,
            'esewa',
            $ref,
        );
    }

    return redirect()->route('orders.show', $attempt->order_id);
}

The usual eSewa traps: a status check that answers NOT_FOUND because the amount is formatted differently from the form (100 against 100.00), a reused transaction_uuid, and test values left in a live deployment. eSewa and Khalti error codes lists every status.

Khalti in Laravel

A Khalti ePayment payment end to end: your server calls initiate and stores the pidx, the customer pays on Khalti's page and returns with the pidx, your server calls lookup, and only Completed means fulfil, once

Khalti is plain JSON over HTTPS, which suits Laravel's HTTP client.

Step 1: create the attempt and redirect

// app/Http/Controllers/KhaltiController.php
use App\Models\Order;
use App\Models\PaymentAttempt;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

public function pay(Request $request, Order $order)
{
    abort_unless($order->payableBy($request->user()), 404);

    $response = self::khalti()
        ->post('/epayment/initiate/', [
            'return_url' => route('khalti.return'),
            'website_url' => config('app.url'), // required, never blank
            'amount' => $order->total_paisa, // in paisa
            'purchase_order_id' => $order->id . '-' . Str::random(8),
            'purchase_order_name' => 'Order ' . $order->id,
        ])
        ->throw()
        ->json();

    $order->attempts()->create([
        'provider' => 'khalti',
        'reference' => $response['pidx'],
        'amount_paisa' => $order->total_paisa,
    ]);

    return redirect()->away($response['payment_url']);
}

private static function khalti(): PendingRequest
{
    return Http::baseUrl(config('services.khalti.base_url'))
        ->withHeaders([
            'Authorization' => 'Key ' . config('services.khalti.secret'),
        ])
        ->timeout(20);
}

Step 2: handle the return, then look it up

public function return(Request $request)
{
    // Only a pidx YOU stored can confirm an order.
    $attempt = PaymentAttempt::where('provider', 'khalti')
        ->where('reference', (string) $request->query('pidx'))
        ->firstOrFail();

    $lookup = self::khalti()
        ->post('/epayment/lookup/', ['pidx' => $attempt->reference])
        ->json();

    $paid = ($lookup['status'] ?? null) === 'Completed'
        && empty($lookup['refunded'])
        && (int) ($lookup['total_amount'] ?? 0) === $attempt->amount_paisa;

    if ($paid) {
        $ref = $lookup['transaction_id'] ?? null;
        $attempt->update(['provider_ref' => $ref]);
        Order::markPaid(
            $attempt->order_id,
            $attempt->amount_paisa,
            'khalti',
            $ref,
        );
    }

    return redirect()->route('orders.show', $attempt->order_id);
}

If Khalti's answer to the initiate call never arrives, the customer never receives a payment link, so nothing can be paid on that attempt and starting again is safe. Pending and Initiated mean "not finished yet": leave the order pending and check again later. If the lookup itself times out, the customer may still have paid, so treat it as unknown. Every status is explained in eSewa and Khalti error codes.

Routes and the pay buttons

// routes/web.php
use App\Http\Controllers\EsewaController as Esewa;
use App\Http\Controllers\KhaltiController as Khalti;

Route::middleware('auth')->group(function () {
    Route::post('/orders/{order}/pay/esewa', [Esewa::class, 'pay'])
        ->name('esewa.pay');
    Route::post('/orders/{order}/pay/khalti', [Khalti::class, 'pay'])
        ->name('khalti.pay');
});

// The providers send the customer back here with a GET.
Route::get('/payments/esewa/return', [Esewa::class, 'return'])
    ->name('esewa.return');
Route::get('/payments/khalti/return', [Khalti::class, 'return'])
    ->name('khalti.return');
<form method="POST" action="{{ route('esewa.pay', $order) }}">
    @csrf
    <button type="submit">Pay with eSewa</button>
</form>

The return routes only ever confirm a payment with the provider, so they do not need the customer to be signed in.

What this does not handle yet

The code above covers the core flow, and it is still only the start. In production you will also want:

  • A way to catch payments whose customer never came back. If the browser closes on eSewa's page, your return route never runs. Schedule a job that looks up every attempt that has not been confirmed or ruled out, even after its order is paid, so a second payment from an old attempt is caught and refunded.
  • Refunds, which work differently at each provider.
  • Fonepay, which works nothing like either: a dynamic QR and a live confirmation connection instead of a redirect.
  • One place to see everything, instead of two merchant portals.

The shorter path: one PHP SDK for eSewa, Khalti and Fonepay

With PayBridgeNP you connect your own eSewa, Khalti and Fonepay merchant accounts once, and your Laravel app talks to one API. The PHP SDK is on Packagist:

composer require paybridge-np/sdk
// config/services.php
'paybridgenp' => [
    // sk_test_... in sandbox
    'key'    => env('PAYBRIDGENP_API_KEY'),
    'secret' => env('PAYBRIDGENP_WEBHOOK_SECRET'),
    // true only with an sk_live_ key
    'live'   => env('PAYBRIDGENP_LIVE', false),
],
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use PayBridgeNP\PayBridgeNP;

public function pay(Request $request, Order $order)
{
    abort_unless($order->payableBy($request->user()), 404);

    $paybridgenp = new PayBridgeNP([
        'api_key' => config('services.paybridgenp.key'),
    ]);

    // Record the attempt BEFORE creating the session, with its own
    // idempotency key. A second tap, a retry or a reload finds the same
    // attempt (same amount, under the row lock), so its key returns the
    // same session instead of creating a second one.
    $attempt = DB::transaction(function () use ($order) {
        Order::whereKey($order->id)->lockForUpdate()->first();

        return $order->attempts()
            ->where('provider', 'paybridgenp')
            ->where('amount_paisa', $order->total_paisa)
            ->first()
            ?? $order->attempts()->create([
                'provider' => 'paybridgenp',
                'idempotency_key' => (string) Str::uuid(),
                'amount_paisa' => $order->total_paisa,
            ]);
    });

    $session = $paybridgenp->checkout->create([
        'amount' => $attempt->amount_paisa,
        'returnUrl' => route('orders.show', $order),
        'cancelUrl' => route('cart.index'),
        'metadata' => ['orderId' => (string) $order->id],
    ], $attempt->idempotency_key);

    // The session ID is the link between this order and the webhook.
    $attempt->update(['reference' => $session['id']]);

    return redirect()->away($session['checkout_url']);
}

The customer chooses eSewa, Khalti or Fonepay on a hosted page. Your server then gets one signed payment.succeeded webhook, whichever they used:

use App\Models\Order;
use App\Models\PaymentAttempt;
use Illuminate\Http\Request;
use PayBridgeNP\Exceptions\SignatureVerificationException;
use PayBridgeNP\PayBridgeNP;

public function webhook(Request $request)
{
    try {
        $event = PayBridgeNP::webhooks()->constructEvent(
            $request->getContent(),
            $request->header('X-PayBridgeNP-Signature'),
            config('services.paybridgenp.secret'),
        );
    } catch (SignatureVerificationException) {
        return response()->json(['error' => 'Invalid signature'], 400);
    }

    // A sandbox event must never mark a real order paid, nor the reverse.
    $live = (bool) config('services.paybridgenp.live');
    if (($event['livemode'] ?? null) !== $live) {
        return response()->json(['ignored' => 'wrong mode']);
    }

    if ($event['type'] === 'payment.succeeded') {
        $data = $event['data'];
        $attempt = PaymentAttempt::where('provider', 'paybridgenp')
            ->where('reference', $data['session_id'] ?? '')
            ->first();

        if ($attempt
            && (int) $data['amount'] === $attempt->amount_paisa
            && $data['currency'] === 'NPR') {
            Order::markPaid(
                $attempt->order_id,
                (int) $data['amount'],
                'paybridgenp',
                $data['provider_ref'] ?? null,
            );
        }
    }

    return response()->json(['received' => true]);
}

Register the route, and because webhooks come from outside your app, exclude it from CSRF checks. In Laravel 11 and 12 the exclusion goes in bootstrap/app.php:

// routes/web.php
Route::post('/webhooks/paybridgenp', [PaymentController::class, 'webhook']);
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['webhooks/paybridgenp']);
})

The signature check rejects anything not signed with your webhook secret, and messages more than five minutes old. The same event can still arrive more than once, for example when a delivery is retried, which is why the handler finds the attempt by the session ID it stored and marks the order paid only through markPaid. Sandbox keys are free on every plan, and in test mode eSewa and Khalti work without any provider keys of your own. Payments settle straight into your own merchant accounts; PayBridgeNP never holds the money.

Next Steps


Ready to accept payments in Nepal?

Connect eSewa, Khalti, and Fonepay with a single API. Free to start, with no fund-holding and no new bank accounts.