django-nepali-payment
Nepal payments, one API. Khalti, eSewa, Fonepay, ConnectIPS.
Accept Nepal payments from Django with one consistent API for Khalti, eSewa,
Fonepay (Dynamic QR, Static QR, status, tax refund) and ConnectIPS. No four
HTTP APIs, no four signing schemes, no four callback formats. One
PaymentManager, one PaymentResult.
Lightweight: plain HTTPS through a pooled requests.Session. No WebSockets, no
async loops, no daemons, and runs on shared hosting.
pip install django-nepali-payment
# or with uv
uv add django-nepali-payment
Requirements: Python 3.11+, Django 4.2+, requests, cryptography
(for ConnectIPS signing).
Highlights
Why you'll love it.
- One shape for every gateway: every call returns a
PaymentResult. - Signatures that just work: reproduced from each provider's reference:
- eSewa: HMAC-SHA256 (base64)
- Fonepay: HMAC-SHA512 (lowercase hex)
- Khalti:
Authorization: key <secret>header - ConnectIPS: SHA256withRSA over your merchant certificate
- Sandbox ↔ production in one flag: flip
PaymentMode. - Shared-hosting-safe polling: Fonepay settlement checked with a lightweight polling thread, not a WebSocket.
- Fonepay extras built in: tax refunds and Static QR.
- Connection reuse: one shared
requests.Session, sensible timeouts. - Optional retries: exponential backoff on transient failures; opt in.
Architecture
Simple by design. Three layers, total.
Three small layers, all usable directly:
PaymentManager: the entry point. Pick gateway, mode, secret once; callinitiate_payment/verify_payment(plus Fonepay refund / static QR).- Gateway services: one per provider, extending
BasePaymentService(shared HTTP client, sandbox/prod URL, error handling). Each implements only its own initiate/verify logic and signing. - Models:
PaymentResultis the universal answer type; per-gateway typed requests aredataclass(slots=True)and self-serialize.
Quick start
Your first payment in seconds.
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 a PaymentResult:
| Field | What it holds |
|---|---|
success |
Did the operation succeed? |
message |
Human-readable message (no secrets logged) |
data |
Gateway-specific response object (may be None) |
status |
Reserved; unused (always 200) |
error_code |
Reserved; unused (always None) |
It's truthy like a bool, and exposes raise_for_status() to raise on failure:
result = manager.initiate_payment(PaymentResult, request).raise_for_status()
print(result.data) # only reached if the payment actually started
Custom HTTP client
from nepali_payment.http import ApiService
api = ApiService(
timeout=30.0, # seconds; default 15
retries=2, # retry transient failures (default 0)
retry_backoff=1.0, # initial backoff; doubles each attempt
)
manager = PaymentManager(
PaymentMethod.KHALTI, PaymentMode.SANDBOX,
secret_key="your-secret-key",
api=api,
)
Call api.close() (or manager._api_service().close()) to release pooled
connections on shutdown.
Retries default off: re-sending a payment initiation could double-charge. Enable only for endpoints safe to repeat.
Initiate a payment
Same call, every gateway.
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",
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
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, # 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
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 to the customer
The dynamic QR bakes the amount in; settlement is async, so pair it with the status monitor.
Fonepay Static QR
Set it once, scan forever.
One fixed merchant QR; the customer types the amount at scan. Fetch once per station:
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 once for this station
process_static_qris Fonepay-only (like the tax refund). On other gateways it raisesValidationError. For settlement, useverify_paymentor theFonepayPaymentMonitorwith the payment's PRN.
ConnectIPS
Bank-grade signing, dead-simple form.
A form POST gateway signed with SHA256withRSA using a merchant
certificate (.pfx, .p12 or .pem). Instead of a secret key it 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,
secret_key="", # unused for ConnectIPS
config=config,
)
Initiate to get the hidden form fields:
from nepali_payment.models.connectips import PaymentRequest
request = PaymentRequest(
order_id="order-123",
amount=12500, # NPR
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 # ConnectIPS login form URL
Verify on the callback with the query params plus the expected amount:
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
cryptographyinstalls automatically. The cert is loaded inside the service and never logged.
Verify a payment
One call to confirm it's real.
Same call everywhere, only the argument differs:
# Khalti: pass the pidx
result = manager.verify_payment(PaymentResult, pidx)
# Fonepay: 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: base64 response echoed back from the payment form
result = manager.verify_payment(PaymentResult, base64_response)
Errors
Failures are data. Bugs are loud.
Two kinds, kept separate:
1. Gateway failures return a failed PaymentResult: no exception, so
rejections, non-2xx responses and network hiccups are data:
result = manager.initiate_payment(PaymentResult, request)
if result.success:
... # proceed with checkout
else:
logger.warning("Payment failed: %s", result.message) # no secrets logged
Prefer raising? 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 exception: it raises
PaymentErrorinstead of returning a failed result, matching eSewa's own API. Catch it when working with eSewa.
2. Developer mistakes raise typed exceptions. An empty secret, an
unsupported gateway, a None/empty/invalid request, these fail loudly:
from nepali_payment.exceptions import (
PaymentError, ValidationError, AuthenticationError,
NetworkError, ProviderError, UnsupportedGatewayError,
)
Rule of thumb: if a real payment could fail and you should check success, it
comes back as a PaymentResult; if it's a bug in your call, it raises.
Handling callbacks safely
Never trust the query string. Always verify.
- Verify server-side, don't trust the query string. The
pidx(Khalti) ordata(eSewa) is only an identifier; always round-trip throughmanager.verify_payment. For ConnectIPS, pass the params and the expected amount. - Reconcile the amount against your order before marking paid: Khalti/eSewa
report it in
result.data.total_amount, ConnectIPS inresult.data.txn_amount. Theexamples/app stores a wrong-amount settlement asfailed, neverpaid. - Keep the handler idempotent. Providers may redeliver; a user may refresh. Re-verifying the same order should be a no-op.
- Check the order belongs to your user. If callbacks are
@csrf_exempt, still bind the order to the request/session before releasing goods.
Fonepay QR status monitoring
Know the moment it settles.
Settlement is async, so poll for it with plain HTTP polling, shared-hosting safe:
from datetime import timedelta
from nepali_payment import FonepayPaymentMonitor, PaymentCredentials
monitor = FonepayPaymentMonitor(
timeout=timedelta(minutes=15), # overall session lifetime
interval=timedelta(seconds=5), # delay between polls
)
@monitor.on("status") # every poll
def on_status(args):
print("status:", args.prn, args.payment_status)
@monitor.on("verified") # settled
def on_verified(args):
print("verified:", args.prn, args.success)
monitor.stop(args.prn)
@monitor.on("timeout")
def on_timeout(args):
print("timeout:", args.prn)
@monitor.on("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,
))
Session management:
monitor.is_monitoring("order-123") # is a poller active?
monitor.stop("order-123") # stop one
monitor.dispose() # stop all, release session
In Django, don't
sleep()in the request path. Start the monitor in a background task (Celery), a management command, or a task runner so the HTTP response returns immediately.
Why polling, not WebSockets?
Shared hosting said no. We said fine.
Shared hosting forbids persistent connections, daemons and async loops. The
monitoring thread needs none of these; it is plain HTTPS on the shared
requests.Session. Dependency-light, synchronous, deploys cleanly.
See it in action
Wired up, ready to run.
A runnable Django app in examples/ wires every gateway into real
views, an Order model, provider callbacks and a Fonepay background monitor:
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 settlements in a worker
Defaults to sandbox; credentials come from env vars. See
examples/README.md for the full walkthrough.
Development
One command to get going.
Tooling via uv:
uv sync --extra dev # venv + dev deps
uv run ruff check nepali_payment tests examples/payments # lint
uv run ruff format --check nepali_payment tests examples/payments
uv run pytest # tests (~97% coverage with --cov)
NEPALI_PAYMENT_ESEWA_SECRET=... ... uv run pytest tests/test_live_api.py -v
Contributing
PRs welcome, big or small.
Contributions are welcome. Open an issue or submit a PR.
- Fork the repo
- Create a feature branch (
git checkout -b my-branch) - Make your changes
- Submit a pull request
Metadata
Release files for django-nepali-payment 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_nepali_payment-1.0.0.tar.gz | 41.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_nepali_payment-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 78.4 kB
Release files / django_nepali_payment-1.0.0.tar.gz
| Download URL | django_nepali_payment-1.0.0.tar.gz |
|---|---|
| Size | 41.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
75f495fae5fc9eeac31e1eef1a97939699040d11f8036583d814211cbe87de14
|
|
BLAKE2b-256 checksum How to use checksums |
89c26f1c327b8563719b181aa6eb07951e3a6bcfac688699a41bcd5b5b542690
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.
Transparency logRelease files / django_nepali_payment-1.0.0-py3-none-any.whl
| Download URL | django_nepali_payment-1.0.0-py3-none-any.whl |
|---|---|
| Size | 37.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
02dda604ca337e084b147e34a90876df4d95dd2e068634d2908b8476582b1c67
|
|
BLAKE2b-256 checksum How to use checksums |
b3c928e5991324980652f166feefca384c413b3cd4ea0327b4406412f92bb6f1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.
Transparency log