django-nepali-payment
Accept payments in Nepal from your Django app with a single, consistent API.
We handle Khalti, eSewa, Fonepay (Dynamic QR, Static QR, status
verification, tax refund) and ConnectIPS, so you don't have to learn four
different HTTP APIs, four different signing schemes, and four different
callback formats. You get one PaymentManager, one PaymentResult, done.
The library is deliberately lightweight. No WebSockets, no async event loops,
no background daemons. Just plain HTTPS requests through a pooled
requests.Session, which is why it runs happily even on shared hosting.
pip install django-nepali-payment
# or with uv
uv add django-nepali-payment
Requirements: Python 3.11+, Django 4.2+ (the library itself is not tied to a
specific Django major). It bundles requests for transport and cryptography
for ConnectIPS RSA signing.
Contents
- What you get
- How it's built
- Quick start · Tune the HTTP client
- Initiate a payment — eSewa, Khalti, Fonepay
- Fonepay Static QR
- ConnectIPS
- Verify a payment
- Errors · Handling callbacks safely
- Fonepay QR status monitoring
- Why polling
- See it in action · Development · CI
What you get
- One shape for every gateway. Every call returns a
PaymentResult, so reading a success, a failure, or some provider data looks the same whether it came from Khalti or Fonepay. - Signatures that just work. We reproduced the exact signing from each
provider's reference implementation, so you don't have to debug hex vs base64
yourself:
- eSewa: HMAC-SHA256 (base64)
- Fonepay: HMAC-SHA512 (lowercase hex)
- Khalti:
Authorization: key <secret>header - ConnectIPS: SHA256withRSA over your merchant certificate
- One flag for sandbox vs production. Flip
PaymentModeand you're done. - Shared-hosting-safe Fonepay monitoring. Settlement is checked with a lightweight polling thread rather than a WebSocket, so it works where long connections are forbidden.
- Fonepay extras built in. Tax refunds and Static QR out of the box.
- Connection reuse. One
requests.Sessionis shared across calls, with sensible timeouts. - Optional retries.
ApiServicecan retry transient failures (connection drops and HTTP 429/5xx) with exponential backoff — opt in per client.
How it's built
The library has three small layers, all of which you can use directly:
PaymentManager— the entry point. Pick a gateway, mode and secret key once, then callinitiate_payment/verify_payment(plus Fonepay tax refund and static QR) without thinking about which provider you chose.- Gateway services — one per provider (Khalti, eSewa, Fonepay, ConnectIPS).
Every service extends
BasePaymentService, which stores the things all gateways share (the HTTP client, the sandbox-vs-production base URL, and a "turn any error into a failed result" helper). Each service only implements its own initiate/verify logic and signing. - Request/response models —
PaymentResultis the universal answer type. Each gateway also has typed request models. The request models use Pythondataclass(slots=True)and serialize themselves withdataclasses.asdict, so they are small in memory and safe to pass straight to a provider.
Under the hood everything is synchronous HTTPS through a pooled
requests.Session — no event loops, WebSockets or daemons.
Quick start
Create a manager once for your gateway and mode, then reuse it:
from nepali_payment import PaymentManager, PaymentMethod, PaymentMode, PaymentResult
manager = PaymentManager(
payment_method=PaymentMethod.ESEWA, # or KHALTI, FONEPAY
payment_mode=PaymentMode.SANDBOX, # switch to PRODUCTION when ready
secret_key="your-secret-key",
)
Every call returns the same PaymentResult, with these attributes:
| Field | What it holds |
|---|---|
success |
Did the operation succeed? |
message |
Human-readable message (no secrets are logged) |
data |
Gateway-specific response object (may be None) |
status |
Reserved; currently unused (always 200) |
error_code |
Reserved; currently unused (always None) |
PaymentResult also behaves like a bool inside an if, and exposes
raise_for_status() to turn a failure into a raised PaymentError when you'd
rather not handle it inline:
result = manager.initiate_payment(PaymentResult, request).raise_for_status()
print(result.data) # only reached if the payment actually started
Tuning the HTTP client
By default the manager creates its own ApiService — a pooled
requests.Session with a 15s timeout. You can inject your own to change the
timeout, enable retries, or share one session across several managers:
from nepali_payment import PaymentManager, PaymentMethod, PaymentMode
from nepali_payment.http import ApiService
api = ApiService(
timeout=30.0, # default request timeout (seconds)
retries=2, # retry transient failures 0..N times (default 0)
retry_backoff=1.0, # initial backoff seconds; doubles each attempt
)
manager = PaymentManager(
PaymentMethod.KHALTI,
PaymentMode.SANDBOX,
secret_key="your-secret-key",
api=api,
)
The ApiService is shared and reuse-cached, so many calls never open a new
connection. Call api.close() (or manager._api_service().close()) to release
pooled connections when your app shuts down.
Retries are off by default because re-sending a payment initiation could double-charge. Enable them only for endpoints that are safe to repeat.
Initiate a payment
Each gateway has its own request model, but the call is always
manager.initiate_payment(PaymentResult, request).
eSewa
from nepali_payment.models.esewa import PaymentRequest as EsewaRequest
request = EsewaRequest(
amount="100",
total_amount="113", # amount + tax + charges
transaction_uuid="order-123",
product_code="EPAYTEST", # your eSewa product code
signed_field_names="total_amount,transaction_uuid,product_code",
)
result = manager.initiate_payment(PaymentResult, request)
if result.success:
redirect_url = result.data.payment_url # send the customer here
eSewa redirects the customer to a hosted payment form.
Khalti
from nepali_payment.models.khalti import PaymentRequest as KhaltiRequest
request = KhaltiRequest(
return_url="https://yoursite.com/callback",
website_url="https://yoursite.com",
amount=1000, # in paisa (NPR 10.00)
purchase_order_id="order-123",
purchase_order_name="My Order",
)
result = manager.initiate_payment(PaymentResult, request)
if result.success:
redirect_url = result.data.payment_url # send the customer here
pidx = result.data.pidx # keep for verification later
Khalti amounts are in paisa (1 NPR = 100 paisa).
Fonepay (Dynamic QR)
from nepali_payment.models.fonepay import QrRequest
request = QrRequest(
amount="100",
remarks1="Order 123",
remarks2="Main",
prn="order-123",
merchant_code="NBQM",
username="merchant-user",
password="merchant-password",
)
result = manager.initiate_payment(PaymentResult, request)
if result.success:
qr_data = result.data.qr_message # show this to the customer
The dynamic QR bakes the amount into the QR. Fonepay settlement happens asynchronously, so you'll usually pair this with the status monitor.
Fonepay Static QR
A static QR is a single, fixed merchant QR (think: printed at a point of sale). The customer types in the amount when they scan, so you don't need an amount up front. Fetch the QR payload once per station and render it:
from nepali_payment.models.fonepay import StaticQrRequest
request = StaticQrRequest(
prn="merchant-station",
merchant_code="NBQM",
username="merchant-user",
password="merchant-password",
)
result = manager.process_static_qr(PaymentResult, request)
if result.success:
static_qr = result.data.qr_message # render/print this once for this station
process_static_qris Fonepay-only, just like the tax refund. Calling it on another gateway raisesValidationError. For settlement checks, useverify_paymentor theFonepayPaymentMonitorwith the payment's PRN.
ConnectIPS
ConnectIPS is a form POST gateway. You render a hidden form and hand the
customer over to ConnectIPS, who redirects them back once the payment is done.
It signs with SHA256withRSA using a merchant certificate (a .pfx, .p12
or .pem key), so instead of a secret key the manager takes a config object:
import os
from nepali_payment.enums import PaymentMethod, PaymentMode
from nepali_payment.services.connectips import ConnectIpsConfig
config = ConnectIpsConfig(
merchant_id="YOUR_MERCHANT_ID",
app_id="YOUR_APP_ID",
app_name="Demo App",
app_password="Your App Password",
cert_path=os.environ["CONNECTIPS_CERT_PATH"], # .pfx / .p12 / .pem
cert_password=os.environ.get("CONNECTIPS_CERT_PASSWORD", ""),
success_url="https://yoursite.com/payments/success",
failure_url="https://yoursite.com/payments/failure",
)
manager = PaymentManager(
payment_method=PaymentMethod.CONNECTIPS,
payment_mode=PaymentMode.SANDBOX, # switch to PRODUCTION when ready
secret_key="", # unused for ConnectIPS
config=config,
)
Initiate the payment. You get back the hidden form fields to render, plus the URL to POST them to:
from nepali_payment.models.connectips import PaymentRequest
request = PaymentRequest(
order_id="order-123",
amount=12500, # NPR amount
description="Order 123",
)
result = manager.initiate_payment(PaymentResult, request)
if result.success:
form = result.data.form_fields # hidden inputs to render
action = result.data.target_url # the ConnectIPS login form URL
When ConnectIPS redirects back to your callback, verify using the query parameters it sent (plus the amount you expected):
import json
callback = {
"TXNID": "order-123",
"STATUS": "SUCCESS", # optional; FAILED/CANCELLED short-circuits
"expectedAmount": 12500,
}
result = manager.verify_payment(PaymentResult, json.dumps(callback))
if result.success:
trans_ref_id = result.data.trans_ref_id
ConnectIPS needs the
cryptographypackage, which is installed automatically with the library. The certificate is loaded inside the service and never logged.
Verify a payment
The verification call is also the same across gateways — only the argument differs:
# Khalti: pass the pidx
result = manager.verify_payment(PaymentResult, pidx)
# Fonepay: pass a JSON string with prn + merchant credentials
import json
payload = json.dumps({
"prn": "order-123",
"merchantCode": "NBQM",
"username": "merchant-user",
"password": "merchant-password",
})
result = manager.verify_payment(PaymentResult, payload)
# eSewa: pass the base64 response echoed back from the payment form
result = manager.verify_payment(PaymentResult, base64_response)
Errors
There are two kinds of failures, and the library keeps them separate:
1. Gateway failures return a failed PaymentResult — no exception is
raised, so you can treat rejections, non-2xx responses and network hiccups as
data:
result = manager.initiate_payment(PaymentResult, request)
if result.success:
# proceed with checkout
...
else:
logger.warning("Payment failed: %s", result.message) # no secrets logged
If you'd rather stop and handle it with a raised error, chain
raise_for_status():
result = manager.initiate_payment(PaymentResult, request).raise_for_status()
# this line only runs if the payment actually started
eSewa is the one exception to "failures don't raise": it raises
PaymentErrorinstead of returning a failed result, matching how eSewa's own API reports errors. Catch it (or catchPaymentError) when working with eSewa directly.
2. Developer-facing mistakes raise typed exceptions. An empty secret, an
unsupported gateway, a None/empty/invalid request — these are bugs in your
call, so they fail loudly:
from nepali_payment.exceptions import (
PaymentError, ValidationError, AuthenticationError,
NetworkError, ProviderError, UnsupportedGatewayError,
)
The rule of thumb: if a real payment could fail and you should check success,
it comes back as a PaymentResult; if it's a mistake in your code, it raises.
Handling callbacks safely
When Khalti, eSewa or ConnectIPS redirects the customer back to your site, you verify the payment before marking it complete. A few practical rules:
- Verify server-side, don't trust the query string. The
pidx(Khalti) ordata(eSewa) in the URL is only an identifier — always round-trip it throughmanager.verify_payment, never assume it means "paid". For ConnectIPS, pass the callback parameters and the amount you expected. - Reconcile the amount. Confirm the verified amount matches your order's
amount before marking it paid. Khalti and eSewa report the paid amount in
result.data.total_amount, ConnectIPS inresult.data.txn_amount. Theexamples/app does exactly this — a settled payment for the wrong amount is stored asfailed, neverpaid. - Keep the callback handler idempotent. A provider may redeliver a callback, or a user may refresh. Guard your handler so re-verifying the same order is a no-op rather than a duplicate charge or state flip.
- Check the order belongs to your user. If callbacks are unauthenticated
(
@csrf_exempt), binding the order to the current request/session still matters in real apps before you release goods.
Fonepay QR status monitoring
Fonepay's QR flow is asynchronous. The customer scans, pays, and you need to find out when it settles. Here it's plain HTTP polling so it works on shared hosting.
Attach a few handlers and start a poller per payment:
from datetime import timedelta
from nepali_payment import FonepayPaymentMonitor, PaymentCredentials
monitor = FonepayPaymentMonitor( # defaults shown; tweak as you like
timeout=timedelta(minutes=15), # overall session lifetime
interval=timedelta(seconds=5), # delay between polls
)
@monitor.on("status") # fires on every poll
def on_status(args):
print("status:", args.prn, args.payment_status)
@monitor.on("verified") # payment settled
def on_verified(args):
print("verified:", args.prn, args.success)
monitor.stop(args.prn) # done with this one
@monitor.on("timeout") # no terminal state in time
def on_timeout(args):
print("timeout:", args.prn)
@monitor.on("error") # poller/network error
def on_error(args):
print("error:", args.prn, args.error_message)
monitor.start(
"order-123",
PaymentCredentials(
secret_key="...",
merchant_code="NBQM",
username="u",
password="p",
sandbox_mode=True,
),
)
You can also manage sessions directly:
monitor.is_monitoring("order-123") # is a poller active?
monitor.stop("order-123") # stop one poller
monitor.dispose() # stop all and release the session
In Django, don't
sleep()in the request path. Start the monitor in a background task (Celery), a management command, or your task runner so the HTTP response returns immediately.
Why polling instead of WebSockets?
Shared hosting usually forbids persistent connections, daemon processes and
async event loops. The polling monitor needs none of those. It just issues
plain HTTPS requests on a background thread through the shared
requests.Session. The result is a library that's dependency-light,
synchronous, and deploys cleanly on shared hosting and typical Django setups
alike.
See it in action
There's a complete, runnable Django project in examples/. It
wires every gateway into real views, an Order model, provider callbacks and a
Fonepay background monitor — the way you'd actually use the library for real:
cd examples
uv sync
cp .env.sample .env
uv run python manage.py makemigrations payments
uv run python manage.py migrate
uv run python manage.py runserver # open http://127.0.0.1:8000/
uv run python manage.py monitor_fonepay # poll Fonepay settlements in a worker
See examples/README.md for the full walkthrough. It
defaults to sandbox mode and reads credentials from environment variables, so
you can poke around the UI and the request/response shapes safely before going
live.
Development
We use uv for the environment and tooling:
# install the package and dev deps in a venv
uv sync --extra dev
# lint (library, tests and the example app)
uv run ruff check nepali_payment tests examples/payments
# format
uv run ruff format --check nepali_payment tests examples/payments
# run all tests (live sandbox tests are skipped without credentials)
uv run pytest
# run tests with a coverage report (the suite sits around 97%)
uv run pytest --cov=nepali_payment
# live sandbox smoke tests (hits real provider endpoints)
NEPALI_PAYMENT_ESEWA_SECRET=... \
NEPALI_PAYMENT_KHALTI_SECRET=... \
NEPALI_PAYMENT_FONEPAY_SECRET=... NEPALI_PAYMENT_FONEPAY_MERCHANT=... \
NEPALI_PAYMENT_FONEPAY_USERNAME=... NEPALI_PAYMENT_FONEPAY_PASSWORD=... \
uv run pytest tests/test_live_api.py -v
CI
GitHub Actions workflows live in .github/workflows/:
ci.ymllints, formats, and runs a Python × Django test matrix with an 80% coverage gate, plus a package build check, on every push/PR.live-api.ymlruns real sandbox smoke tests, either manually or on a schedule; skipped unless provider credentials are configured as repo secrets.release.ymlbuilds and publishes to PyPI ondjango-nepali-payment/v*tags.