Framework-agnostic Khalti SDK for modern ePayment integrations in PHP.
If this package has been useful to you, GitHub Sponsors is a simple way to support ongoing maintenance, improvements, and future releases.
- Modern resource API:
payments(),verification(),legacyPayments(),transactions() - ePayment KPG-2 create/status flow with strict backend verification
- First-class payload models (
CustomerInfo,AmountBreakdownItem,ProductDetail) - Typed models and value objects (
MoneyPaisa,OrderVerificationResult) - Polling helper:
waitForCompletion() - Idempotency-friendly verification model for safe order fulfillment
- Retry policy for transient failures (
429,5xx, transport) - Framework agnostic core with pluggable
TransportInterface
- PHP
8.2+ext-curlis optional. It is only required when using the built-inCurlTransport.
composer require sudiptpa/khalti-sdk-phpuse Khalti\Config\ClientConfig;
use Khalti\Khalti;
$khalti = Khalti::client(new ClientConfig(
secretKey: $_ENV['KHALTI_SECRET_KEY'],
));If ext-curl is not installed, default transport throws a clear exception telling you to install ext-curl or pass your own transport.
use Khalti\Exception\TransportException;
use Khalti\Http\HttpRequest;
use Khalti\Http\HttpResponse;
use Khalti\Transport\TransportInterface;
final class MyTransport implements TransportInterface
{
public function send(HttpRequest $request, int $timeoutSeconds): HttpResponse
{
// Send request with your preferred HTTP client.
throw new TransportException('Implement transport send logic.');
}
}
$khalti = Khalti::client(
new ClientConfig(secretKey: $_ENV['KHALTI_SECRET_KEY']),
new MyTransport(),
);use GuzzleHttp\Client;
use Http\Adapter\Guzzle7\Client as GuzzleAdapter;
use Khalti\Config\ClientConfig;
use Khalti\Exception\TransportException;
use Khalti\Http\HttpRequest;
use Khalti\Http\HttpResponse;
use Khalti\Khalti;
use Khalti\Transport\TransportInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Throwable;
final class Psr18Transport implements TransportInterface
{
public function __construct(
private readonly \Psr\Http\Client\ClientInterface $client,
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
) {
}
public function send(HttpRequest $request, int $timeoutSeconds): HttpResponse
{
try {
$psrRequest = $this->requestFactory
->createRequest($request->method, $request->url);
foreach ($request->headers as $name => $value) {
$psrRequest = $psrRequest->withHeader($name, $value);
}
if ($request->body !== '') {
$psrRequest = $psrRequest->withBody($this->streamFactory->createStream($request->body));
}
$psrResponse = $this->client->sendRequest($psrRequest);
} catch (Throwable $e) {
throw new TransportException('PSR-18 transport failed.', 0, $e);
}
$headers = [];
foreach ($psrResponse->getHeaders() as $name => $values) {
$headers[strtolower($name)] = implode(', ', $values);
}
return new HttpResponse(
$psrResponse->getStatusCode(),
(string) $psrResponse->getBody(),
$headers
);
}
}
$psr18 = new GuzzleAdapter(new Client());
$khalti = Khalti::client(
new ClientConfig(secretKey: $_ENV['KHALTI_SECRET_KEY']),
new Psr18Transport($psr18, $requestFactory, $streamFactory),
);Note: This PSR-18 example requires user-land packages (for example guzzlehttp/guzzle, php-http/guzzle7-adapter, and PSR-17 factories). They are optional and not required by this SDK.
use Khalti\Transport\CurlTransport;
$khalti = Khalti::client(
new ClientConfig(secretKey: $_ENV['KHALTI_SECRET_KEY']),
new CurlTransport(),
);use Illuminate\Support\Facades\Http;
use Khalti\Exception\TransportException;
use Khalti\Http\HttpRequest;
use Khalti\Http\HttpResponse;
use Khalti\Transport\TransportInterface;
use Throwable;
final class LaravelTransport implements TransportInterface
{
public function send(HttpRequest $request, int $timeoutSeconds): HttpResponse
{
try {
$response = Http::timeout($timeoutSeconds)
->withHeaders($request->headers)
->send($request->method, $request->url, ['body' => $request->body]);
} catch (Throwable $e) {
throw new TransportException('Laravel HTTP transport failed.', 0, $e);
}
$headers = [];
foreach ($response->headers() as $name => $values) {
$headers[strtolower($name)] = implode(', ', $values);
}
return new HttpResponse($response->status(), $response->body(), $headers);
}
}use Khalti\Model\AmountBreakdownItem;
use Khalti\Model\CustomerInfo;
use Khalti\Model\EpaymentInitiateRequest;
use Khalti\Model\ProductDetail;
$request = EpaymentInitiateRequest::make(
returnUrl: 'https://example.com/payments/khalti/return',
websiteUrl: 'https://example.com',
amount: 1000,
purchaseOrderId: 'ORD-1001',
purchaseOrderName: 'Pro Subscription',
)
->setCustomerInfo(new CustomerInfo(
name: 'Sujip Thapa',
email: '[email protected]',
phone: '9800000000',
))
->addAmountBreakdownItem(new AmountBreakdownItem('Subtotal', 900))
->addAmountBreakdownItem(new AmountBreakdownItem('Tax', 100))
->addProductDetail(new ProductDetail(
identity: 'SKU-1001',
name: 'Pro Subscription',
totalPrice: 1000,
quantity: 1,
unitPrice: 1000,
));
$session = $khalti->payments()->initiate($request); // alias: create($request)
$status = $khalti->payments()->lookup($session->pidx); // alias: status($session->pidx)Khalti ePayment does not provide a dedicated payment webhook for this checkout flow.
You must manually verify payment on your backend before order fulfillment.
Never trust return query params alone.
use Khalti\ValueObject\MoneyPaisa;
use Khalti\Verification\VerificationContext;
$payload = $khalti->verification()->parseReturnQuery($_GET);
$result = $khalti->verification()->verify(
payload: $payload,
context: new VerificationContext(
orderId: 'ORD-1001',
pidx: $payload->pidx,
expectedAmount: MoneyPaisa::of(1000),
receivedAtUnix: time(),
)
);
if (! $result->fulfillable) {
// pending/failed/refunded/duplicate
return;
}
// fulfill onceUse IdempotencyStoreInterface in your app:
$idempotencyStore = new App\Payments\Khalti\RedisIdempotencyStore($redis);
$result = $khalti->verification()->verify(
payload: $payload,
context: new VerificationContext(
orderId: $orderId,
pidx: $payload->pidx,
expectedAmount: MoneyPaisa::of($expectedAmount),
),
idempotencyStore: $idempotencyStore,
);If the same payment return is received again, status becomes duplicate and fulfillment is blocked.
$status = $khalti->payments()->waitForCompletion(
pidx: $session->pidx,
timeoutSeconds: 30,
intervalSeconds: 2,
);$config = new ClientConfig(
secretKey: $_ENV['KHALTI_SECRET_KEY'],
maxRetries: 2,
retryBackoffMs: 200,
retryMaxBackoffMs: 1200,
retryHttpStatusCodes: [429, 500, 502, 503, 504],
);$list = $khalti->transactions()->all(page: 1, pageSize: 20);
$detail = $khalti->transactions()->find('txn_idx');
foreach ($list->records as $row) {
echo $row->idx . ' => ' . $row->state . PHP_EOL;
}$verify = $khalti->legacyPayments()->verify($token, 1000);
$status = $khalti->legacyPayments()->status($token, 1000);RequestNormalizerInterfaceResponseNormalizerInterfaceIdempotencyStoreInterfaceMismatchCounterInterfaceClockInterface
These are optional and do not add runtime dependencies.
AuthenticationException(401/403): wrong secret key or wrong environment key.- Check sandbox vs production key mismatch.
- Stored order amount in paisa differs from Khalti lookup amount.
- Tax/fee math done at UI but not stored in backend order snapshot.
- Verifying wrong order against wrong
pidx.
- Parse query with
parseReturnQuery(). - Verify with
VerificationContext(orderId,pidx,expectedAmount). - Enforce idempotency before fulfillment.
- Fulfill only when
OrderVerificationResult::isPaid()andfulfillable === true.
composer test
composer stan
composer lintSee ARCHITECTURE.md.
MIT