Skip to main content

django-nepali-payment

Nepal payments, one API. Khalti, eSewa, Fonepay, ConnectIPS.

CI PyPI version Python versions Django PyPI downloads License Last commit

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; call initiate_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: PaymentResult is the universal answer type; per-gateway typed requests are dataclass(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_qr is Fonepay-only (like the tax refund). On other gateways it raises ValidationError. For settlement, use verify_payment or the FonepayPaymentMonitor with 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

cryptography installs 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 PaymentError instead 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) or data (eSewa) is only an identifier; always round-trip through manager.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 in result.data.txn_amount. The examples/ app stores a wrong-amount settlement as failed, never paid.
  • 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.

  1. Fork the repo
  2. Create a feature branch (git checkout -b my-branch)
  3. Make your changes
  4. 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)

Source distribution for django-nepali-payment 1.0.0
File Size Uploaded
django_nepali_payment-1.0.0.tar.gz 41.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-nepali-payment 1.0.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page