
August 16, 2026
11 min read
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.
/api/v2/epayment/initiate/, stores pidx against the order, finds that order on return, verifies via /api/v2/epayment/lookup/, and validates signed webhooks. Never mark an order paid from the browser redirect alone.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.
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.
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 Khaltikhalti_pidx— returned by initiate; primary key for return URL handlingpayment_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.
What are common Khalti integration pitfalls in Laravel apps?
These issues recur across Nepal e-commerce builds. Most never appear in vendor docs.
| Pitfall | Symptom | Fix |
|---|---|---|
| Paisa vs NPR | Rs 10 charged instead of Rs 1,000 | Multiply by 100, cast to int |
| No pidx stored | Cannot find order on return URL | Save pidx at initiate; index the column |
| Trusting redirect | Fake paid orders | Always call /lookup/ server-side |
| Unsigned webhooks | Spoofed payment events | Validate HMAC with hash_equals() |
| Duplicate webhook | Double shipment or credit | Idempotent job; check payment_status |
| HTTP return_url | Initiate rejected in production | Force 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: Keyheaders. eSewa mixes form posts and JSON depending on product version. Khalti maps cleanly to Laravel's HTTP client. - Order lookup: Khalti gives you
pidximmediately. 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.
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:
- Test all payment paths: Success, failure, user abort, and timeout. Confirm each updates
payment_statuscorrectly. - Prove order lookup: Hit the return URL with a valid
pidxand confirm the correct order loads. - Log webhooks: Alert on signature failures. Track retry counts in your queue monitor.
- Add retry logic: Wrap lookup calls with backoff for transient network blips between your VPS and Khalti.
- Reconcile nightly: Build an Artisan command comparing paid orders against Khalti settlement exports.
- Harden security: Review secure Laravel OWASP Top 10 in practice and rate-limit the callback route.
- 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_pidxat 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_statusinside 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
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.

