Skip to content
~/ashwin-shresthaLoading

000%

All posts

Verifying payments server-side: Khalti and Razorpay

Whatever the payment gateway, the rule is the same: the browser tells you a payment happened, the gateway's server tells you whether it really did.

Payment gateways differ in names and endpoints, but they share one security model. The customer's browser is untrusted. If your server marks an order as paid because a URL was visited or a JavaScript callback fired, anyone who can open developer tools can mark their own order as paid. Both Khalti and Razorpay give you a way to check with their servers, and using it is not optional.

The details below follow each gateway's documentation at the time of writing. Check the current docs for exact endpoints and field names before you ship.

The shape of every integration

  1. Your server creates a payment or order with the gateway, with the amount you computed.
  2. The customer pays on the gateway's page or widget.
  3. The customer returns to your site and the gateway sends identifiers along with them.
  4. Your server verifies the payment directly with the gateway, then fulfils the order.

Steps 1 and 4 happen on your server. Step 3 is only a hint about where to look.

Khalti: initiate, then look up

With Khalti's ePayment API, your server calls an initiate endpoint with the amount, a return URL and your own order reference. It responds with a pidx (the payment identifier) and a payment_url to send the customer to. Authentication is a server-side secret key sent in the Authorization header, which must never reach the browser.

$response = Http::withHeaders([
    'Authorization' => 'Key ' . config('services.khalti.secret'),
])->post(config('services.khalti.base_url') . '/epayment/initiate/', [
    'return_url'          => route('checkout.return'),
    'website_url'         => config('app.url'),
    'amount'              => $order->total_in_paisa,   // paisa, not rupees
    'purchase_order_id'   => $order->reference,
    'purchase_order_name' => "Order {$order->reference}",
]);

$order->update(['gateway_ref' => $response->json('pidx')]);

return redirect($response->json('payment_url'));

When the customer returns, the query string carries the pidx and a status. Do not trust that status. Call the lookup endpoint and read the result yourself:

$lookup = Http::withHeaders(['Authorization' => 'Key ' . config('services.khalti.secret')])
    ->post(config('services.khalti.base_url') . '/epayment/lookup/', ['pidx' => $pidx]);

$paid = $lookup->json('status') === 'Completed'
    && (int) $lookup->json('total_amount') === $order->total_in_paisa;

Notice the second condition. Compare the verified amount with the amount you expected, otherwise a customer could pay for a cheap order and have it applied to an expensive one.

Razorpay: verify the signature

Razorpay's flow starts with creating an order on your server, then opening Checkout in the browser with that order id. On success the browser receives three values: razorpay_order_id, razorpay_payment_id and razorpay_signature. The signature is an HMAC-SHA256 of order_id|payment_id using your key secret, so you can recompute it and compare:

$expected = hash_hmac('sha256', $orderId . '|' . $paymentId, config('services.razorpay.secret'));

if (! hash_equals($expected, $signature)) {
    abort(400, 'Invalid signature');
}

Use hash_equals for the comparison so it takes the same time whether or not the strings match. For anything that must survive the customer closing the tab, also handle Razorpay's server-to-server webhooks, which are signed with a separate webhook secret.

Shared rules that prevent real losses

  • Compute amounts on the server. Never accept a price from the request.
  • Store the gateway reference (pidx, order id) against your order before sending the customer away, so you can look it up later.
  • Make fulfilment idempotent. Verification can run twice (a refresh, a retry, a webhook plus a redirect). Update the order only if it is still pending.
  • Handle the unhappy states. Pending, failed, expired and refunded payments should each end in a clear order status, not a dead end.
  • Keep secrets server-side and out of version control. Use separate test and live keys per environment.
  • Log every verification with the order reference and the gateway's response status, never with secret keys.

A test plan that catches the common bugs

  1. Pay successfully and confirm exactly one fulfilment.
  2. Pay, then reload the return URL several times: still one fulfilment.
  3. Start a payment and abandon it: the order stays pending and can be retried.
  4. Tamper with the returned status or signature: the server rejects it.
  5. Pay a different amount than the order total, if the sandbox allows it: the server rejects it.

If those five pass, you have covered most of the ways a payment integration goes wrong in production.