Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Khalti Integration Guide for Laravel Apps

By Kokil Thapa | Last reviewed: September 2026

A customer pays through Khalti and lands back on your site with a pidx in the URL. Your job is to find the order, confirm money actually arrived, and only then fulfil it. This Khalti Integration Guide for Laravel Apps walks through that flow on Laravel 12 with PHP 8.3+, using Khalti ePayment API v2. It covers what basic tutorials skip: order lookup, server-side verification, and webhook hardening. For the wider payment picture, see my overview of Laravel payment integrations.

How do you configure environment variables for Khalti in Laravel?

Hardcoded keys and mixed test/live environments cause more production incidents than API bugs. On real client projects I audit, the first fix is always separating credentials cleanly.

Khalti sandbox runs at https://dev.khalti.com. Production uses https://khalti.com. Swap them once and every lookup returns confusing errors. Keep both URL and secret in .env:

# .env — local / staging
KHALTI_GATEWAY_URL=https://dev.khalti.com
KHALTI_SECRET_KEY=test_secret_key_from_merchant_dashboard
KHALTI_WEBHOOK_SECRET=whsec_test_from_dashboard

# .env — production
KHALTI_GATEWAY_URL=https://khalti.com
KHALTI_SECRET_KEY=live_secret_key_from_merchant_dashboard
KHALTI_WEBHOOK_SECRET=whsec_live_from_dashboard

Expose values through a dedicated config file. This keeps controllers thin and lets you run php artisan config:cache on deploy:

<?php
// config/khalti.php
return [
    'gateway_url'    => env('KHALTI_GATEWAY_URL', 'https://dev.khalti.com'),
    'secret_key'     => env('KHALTI_SECRET_KEY'),
    'webhook_secret' => env('KHALTI_WEBHOOK_SECRET'),
    'timeout'        => (int) env('KHALTI_TIMEOUT', 30),
];

Confirm .env stays out of Git. Rotate keys after developer offboarding. Teams I work with on e-commerce development in Nepal also mirror these vars in staging before any UAT run.

Khalti Credential FlowSandboxdev.khalti.comtest_secret_keyProductionkhalti.comlive_secret_keyLaravel Configconfig/khalti.phpReads .env onlyNever commit .env • Rotate keys quarterly
Khalti Integration Guide for Laravel Apps: separate sandbox and live credentials before writing payment code.

How do you initiate and verify Khalti payments in Laravel?

Every Khalti checkout has two server-side steps: initiate and verify. Many devs stop after redirect because the UI looks done. That gap is where revenue leaks.

Step 1: Initiate payment and store pidx

Call POST /api/v2/epayment/initiate/ with your secret key. Khalti returns a payment_url and a pidx. Save pidx on the order row before redirecting the user:

<?php
// app/Services/KhaltiService.php
use Illuminate\Support\Facades\Http;

public function initiatePayment(float $amountNpr, string $orderId, string $returnUrl): array
{
    $response = Http::withHeaders([
        'Authorization' => 'Key ' . config('khalti.secret_key'),
        'Content-Type'  => 'application/json',
    ])
    ->timeout(config('khalti.timeout'))
    ->post(config('khalti.gateway_url') . '/api/v2/epayment/initiate/', [
        'amount'              => (int) round($amountNpr * 100),
        'purchase_order_id'   => $orderId,
        'purchase_order_name' => 'Order #' . $orderId,
        'return_url'          => $returnUrl,
        'website_url'         => config('app.url'),
    ]);

    if (!$response->successful()) {
        throw new \RuntimeException('Khalti initiate failed: ' . $response->body());
    }

    return $response->json();
}

Khalti expects paisa, not rupees. Rs 1,000 equals 100000 paisa. Passing 1000 charges Rs 10. Cast to integer after multiplication to dodge float rounding.

Step 2: Verify with the lookup endpoint

After checkout, Khalti sends the user to your return_url with pidx. Treat that as a hint only. Confirm status through the API:

public function verifyPayment(string $pidx): array
{
    $response = Http::withHeaders([
        'Authorization' => 'Key ' . config('khalti.secret_key'),
    ])
    ->timeout(config('khalti.timeout'))
    ->get(config('khalti.gateway_url') . '/api/v2/epayment/lookup/', [
        'pidx' => $pidx,
    ]);

    $data = $response->json();

    if (($data['status'] ?? '') !== 'Completed') {
        throw new \RuntimeException('Payment not completed: ' . ($data['status'] ?? 'unknown'));
    }

    return $data;
}

The lookup response is your source of truth. Match returned amount against the order total before flipping status to paid. For service-layer patterns, see Laravel API best practices and the official Khalti ePayment documentation.

Khalti Payment SequenceBrowserLaravelDatabaseKhalti API1. Pay click2. Pending order3. POST initiate4. pidx + URL5. Redirect6. Return pidx7. GET lookup8. Mark paid
Mandatory server-side lookup before updating order status in any Khalti Integration Guide for Laravel Apps workflow.

How do you find an order after a Khalti payment callback?

Developers migrating from legacy PHP often searched orders with URLs like view.php?id=4821 or article.php?article=order-ref. Google still surfaces those patterns when engineers look up how to find an order after payment. Laravel replaces that with explicit database lookups tied to Khalti identifiers.

Store three fields on your orders table at initiation time:

  • purchase_order_id — your internal order number sent to Khalti
  • khalti_pidx — returned by initiate; primary key for return URL handling
  • payment_status — pending, paid, failed; prevents double fulfilment

Migration example:

Schema::table('orders', function (Blueprint $table) {
    $table->string('khalti_pidx')->nullable()->index();
    $table->string('purchase_order_id')->unique();
    $table->string('payment_status')->default('pending');
});

On the return route, read pidx from the query string and find the order:

<?php
// app/Http/Controllers/KhaltiCallbackController.php
public function handleReturn(Request $request, KhaltiService $khalti)
{
    $pidx = $request->query('pidx');

    if (!$pidx) {
        return redirect()->route('checkout.failed')->with('error', 'Missing payment reference.');
    }

    $order = Order::where('khalti_pidx', $pidx)->firstOrFail();

    if ($order->payment_status === 'paid') {
        return redirect()->route('orders.show', $order)->with('info', 'Already confirmed.');
    }

    $verification = $khalti->verifyPayment($pidx);

    if ((int) $verification['total_amount'] !== (int) round($order->total * 100)) {
        abort(422, 'Amount mismatch.');
    }

    DB::transaction(function () use ($order, $verification) {
        $order->update([
            'payment_status' => 'paid',
            'khalti_transaction_id' => $verification['transaction_id'] ?? null,
            'paid_at' => now(),
        ]);
    });

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

If pidx is missing but Khalti sends purchase_order_id, fall back carefully:

$order = Order::where('purchase_order_id', $request->query('purchase_order_id'))->firstOrFail();

Still run lookup with the stored khalti_pidx. Never trust query parameters alone. Wrap updates in a transaction as shown in Laravel database transactions and deadlocks. On legal-tech portals I have built, this pattern replaced old ?tid= ticket URLs with auditable order records.

How do you handle Khalti webhooks securely in Laravel?

Redirects fail when users close the tab. Webhooks close that gap. They are also public endpoints anyone can hit, so signature validation is mandatory.

Khalti signs payloads with HMAC-SHA256. Verify before processing:

<?php
// app/Http/Controllers/Webhook/KhaltiWebhookController.php
public function __invoke(Request $request)
{
    $signature = $request->header('X-Khalti-Signature');
    $payload   = $request->getContent();
    $expected  = hash_hmac('sha256', $payload, config('khalti.webhook_secret'));

    if (!$signature || !hash_equals($expected, $signature)) {
        Log::warning('Khalti webhook rejected', ['ip' => $request->ip()]);
        abort(403);
    }

    $event = json_decode($payload, true);
    $pidx  = $event['pidx'] ?? null;

    if (!$pidx || ($event['status'] ?? '') !== 'Completed') {
        return response()->json(['message' => 'Ignored']);
    }

    ProcessKhaltiPaymentJob::dispatch($pidx, $event);

    return response()->json(['message' => 'OK']);
}

Exclude CSRF middleware on the webhook route. Khalti cannot send a Laravel token:

Route::post('/webhooks/khalti', KhaltiWebhookController::class)
    ->withoutMiddleware([VerifyCsrfToken::class]);

Return HTTP 200 quickly. Push heavy work to a queue. Khalti retries on failure, so make the job idempotent. Check payment_status before updating. Read webhooks for Nepal payment gateways and Laravel webhooks send and receive reliably for retry patterns that survive deploy restarts.

Find Order + VerifyReceive pidx on returnFind order by pidxNot CompletedShow retry pageCompletedMatch amountAPI timeoutQueue retry jobFulfil only after Completed + amount match
How to find an order and verify Khalti status before marking it paid in Laravel.

What are common Khalti integration pitfalls in Laravel apps?

These issues recur across Nepal e-commerce builds. Most never appear in vendor docs.

PitfallSymptomFix
Paisa vs NPRRs 10 charged instead of Rs 1,000Multiply by 100, cast to int
No pidx storedCannot find order on return URLSave pidx at initiate; index the column
Trusting redirectFake paid ordersAlways call /lookup/ server-side
Unsigned webhooksSpoofed payment eventsValidate HMAC with hash_equals()
Duplicate webhookDouble shipment or creditIdempotent job; check payment_status
HTTP return_urlInitiate rejected in productionForce HTTPS via APP_URL or secure()

Shared hosting in Nepal sometimes blocks outbound HTTPS on non-standard ports. Test initiate and lookup from the production server, not only from your laptop. Compare gateway options in e-commerce payment gateway options for Nepal compared and the Nepal digital payment landscape 2026 overview.

Amount reconciliation matters for multi-item carts. Use the Nepal forex rates tool when displaying USD alongside NPR on international storefronts like Quick And Easy Nepalese Grocery.

How does Khalti compare to eSewa for Laravel integration?

Most Nepal-facing stores offer both wallets. Architect one payment interface with two drivers instead of duplicating controller logic.

  • API shape: Khalti uses JSON REST with Authorization: Key headers. eSewa mixes form posts and JSON depending on product version. Khalti maps cleanly to Laravel's HTTP client.
  • Order lookup: Khalti gives you pidx immediately. eSewa references vary by integration type. Store gateway-specific IDs in separate nullable columns.
  • Verification: Both need server-side confirmation. Khalti /lookup/ is synchronous JSON. eSewa paths differ; see eSewa integration guide for PHP apps.
  • Webhooks: Khalti ships signed webhooks. eSewa webhook availability depends on merchant tier.
  • Sandbox: Khalti test mode mirrors production error codes closely. Budget extra UAT time for eSewa edge cases.

For shared abstractions and side-by-side code, read Khalti and eSewa Nepal payment integration. Payment work on Mijar Law Associates followed the same dual-gateway pattern.

Khalti vs eSewa in LaravelKhaltiJSON REST + pidxSigned webhooks/lookup/ verifyStrong sandboxPaisa amountseSewaMixed form/JSONWebhooks varyProduct-specific verifyExtra UAT timeReference IDs differOne PaymentGateway interface, two drivers
Khalti and eSewa differ in verify flow and webhooks; abstract both behind one Laravel service.

What should you check before going live with Khalti in production?

Switching KHALTI_GATEWAY_URL to production is the last step, not the first. Run this checklist on staging that mirrors prod:

  1. Test all payment paths: Success, failure, user abort, and timeout. Confirm each updates payment_status correctly.
  2. Prove order lookup: Hit the return URL with a valid pidx and confirm the correct order loads.
  3. Log webhooks: Alert on signature failures. Track retry counts in your queue monitor.
  4. Add retry logic: Wrap lookup calls with backoff for transient network blips between your VPS and Khalti.
  5. Reconcile nightly: Build an Artisan command comparing paid orders against Khalti settlement exports.
  6. Harden security: Review secure Laravel OWASP Top 10 in practice and rate-limit the callback route.
  7. Deploy safely: Use zero-downtime releases so webhook endpoints stay up during symlink swaps. See zero-downtime deployment for Laravel with Deployer.

Follow the Laravel HTTP client docs for timeout and retry configuration. Cache config after deploy so Khalti keys load from the live .env, not a stale build artefact.

Key Takeaways

  • Store khalti_pidx at initiation so you can find the order when the user returns.
  • Convert NPR to paisa with (int) round($amount * 100) before every initiate call.
  • Call /api/v2/epayment/lookup/ server-side; never trust redirect query params alone.
  • Validate webhook HMAC signatures with hash_equals() and process work in queued jobs.
  • Make payment updates idempotent by checking payment_status inside a DB transaction.
  • Offer Khalti and eSewa through one gateway interface to keep controllers clean.

People Also Ask

How do I find an order after Khalti redirects back to my Laravel app?

Read pidx from the return URL query string. Look up Order::where('khalti_pidx', $pidx)->firstOrFail(). You should have saved that value when initiate succeeded. If the order is already paid, show a confirmation page instead of re-processing.

Does Khalti send the purchase order ID on the return URL?

Khalti primarily returns pidx. You sent purchase_order_id during initiate, and it appears in dashboard reports. Design your schema around pidx for callback handling. Use purchase_order_id only as a secondary lookup after verification.

Can I mark an order paid when the user lands on the success page?

No. The success redirect proves the user finished the Khalti UI, not that funds cleared. Always verify through the lookup API or a signed webhook before changing payment_status to paid.

Which Laravel version works with Khalti ePayment v2?

Laravel 12 and 13 both work. Use the built-in HTTP client with PHP 8.3 or higher. No official Khalti Composer package is required; a small service class is enough for most stores.

Ship Khalti payments you can trust

This Khalti Integration Guide for Laravel Apps gives you initiate, lookup, order finding, and webhook validation in one flow. Payments are the highest-risk feature on any Nepal storefront. Get them wrong and you lose money and trust at once. Need hands-on help wiring Khalti into a live Laravel build? Browse development services or contact us about your project. For direct questions on an existing integration, you can also reach out here.

Frequently Asked Questions

No, Khalti does not maintain an official Laravel SDK. In my experience building payment flows for Nepal Gift Card and other client projects, you should use the official REST API directly via Laravel's HTTP Client or Guzzle. This avoids dependency on unmaintained third-party packages that often lag behind API changes and lack support for newer verification endpoints required in production environments.

Standard merchant rate is 1.5% plus NPR 10 per successful transaction.

Test keys work only against sandbox.khalti.com and accept dummy credentials for development. Live keys connect to khalti.com and process real NPR transactions. Never commit live secret keys to version control; store them in your .env file and validate environment-specific configuration during deployment to prevent accidental production charges during staging tests.

Always verify payments server-side using the verification endpoint at api.khalti.com/api/v2/epayment/lookup/. Pass the pidx received from the frontend redirect along with your live secret key. Never trust client-side success callbacks alone, as these can be manipulated. In production Laravel applications, I implement this check inside a dedicated service class before updating order status or delivering digital goods.

Yes, using the Khalti Web Checkout widget embedded via JavaScript. The widget handles the payment UI in a modal overlay while keeping users on your domain. However, server-side verification remains mandatory after the widget returns a success token. On eCommerce projects like Petals Nepal, this approach reduced cart abandonment compared to full-page redirects while maintaining security through backend validation.

Typically three to five business days after submitting complete KYC documents.

Implement retry logic with exponential backoff using Laravel's HTTP Client retry method, capped at three attempts. If verification fails repeatedly, mark the order as pending and queue a background job to recheck later. Display a clear message to users rather than generic errors. On legal-tech portals handling service payments, this pattern prevents lost bookings during intermittent gateway outages common with Nepali payment providers.

Yes, Khalti requires HTTPS for all live API calls and webhook callbacks. Let's Encrypt certificates via Certbot are sufficient and free. Without valid SSL, the payment widget will fail to load and API requests will be rejected. Ensure your Laravel APP_URL matches the certificate domain exactly, including www prefix consistency, to avoid mixed-content warnings that break the checkout flow.

Store the pidx or transaction_id in your database with a unique constraint. Before processing any payment confirmation, check if that identifier already exists. Use database transactions to ensure atomicity between recording the payment and updating order status. This idempotency pattern prevents double-charging or duplicate service activations, which I have encountered on subscription platforms where network retries caused multiple callback deliveries for single transactions.

Both cover similar user bases, but Khalti offers cleaner API documentation and faster sandbox testing. eSewa has broader rural reach and bank transfer options. Many Nepal-facing stores integrate both. Choose based on your customer demographics rather than technical superiority. For urban digital products like Nepal Gift Card, Khalti alone sufficed; for florist delivery services targeting diverse regions, dual integration proved necessary.

Amounts must be sent in paisa as integers, not decimal rupees.

Use Laravel's Log facade within your payment service to record request payloads, response codes, and verification results. Structure logs with context arrays containing order IDs and pidx values for traceability. Avoid logging raw secret keys or sensitive customer data. On production systems, I configure separate log channels for payment events to isolate them from application noise, making troubleshooting failed transactions significantly faster during support incidents.

Only if they possess a Khalti wallet, which requires a Nepali mobile number and KYC. International cards cannot fund Khalti wallets directly. For stores serving diaspora customers, such as Quick And Easy Nepalese Grocery targeting Australian Nepalis, you must offer Stripe or PayPal alongside Khalti. Clearly indicate payment method restrictions on the checkout page to prevent confusion and abandoned carts from users expecting global card acceptance.

Use ngrok or Cloudflare Tunnel to expose your local Laravel server to the internet. Configure the tunnel URL as your webhook endpoint in the Khalti dashboard sandbox settings. Verify that your route accepts POST requests and returns appropriate HTTP status codes. Remember that webhook signatures must be validated using your test secret key. This setup allows end-to-end testing of asynchronous payment confirmations without deploying to staging servers.

Most failures stem from using test keys against live endpoints, sending amounts in rupees instead of paisa, or mismatched environment variables after deployment. Another frequent issue is incorrect Content-Type headers when calling the lookup endpoint. Always verify your .env contains the correct key pair for the target environment. Run artisan config:cache after deployments to prevent stale cached values from overriding updated credentials in production releases.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: