Published: June 2026
Stack: Django · Django REST Framework · Python 3.11+ · eSewa Intent API v2
Table of Contents
- Background & Motivation
- Understanding the eSewa Intent Payment Flow
- Project Structure & Environment Setup
- Step 1 — Booking a Payment Intent
- Step 2 — HMAC-SHA256 Signatures
- Step 3 — Handling the Callback
- Step 4 — Server-Side Status Verification
- Step 5 — Django Routing
- The Bug: Silent Callback Failures in Production
- Integration Checklist
1. Background & Motivation
eSewa is Nepal’s most widely used digital payment wallet. For any product targeting Nepali users, eSewa integration is essentially mandatory.
eSewa offers two integration modes:
| Mode | Mechanism | Best for |
|---|---|---|
| Legacy v1 | HTML form redirect, browser-based | Web apps |
| Intent API v2 | REST API + app deep-link | Mobile apps |
The Intent API v2 eliminates the browser redirect chain entirely. Instead, your backend books a payment intent, gets a deeplink, and the mobile client opens the eSewa app directly. The result is a seamless native-to-native handoff that feels far more polished.
This post documents a full production integration of the Intent API v2 — including a production-only failure that took us hours to root-cause.
2. Understanding the eSewa Intent Payment Flow
Mobile Client Your Backend eSewa Gateway
────────────── ──────────── ─────────────
│ │ │
│── POST /initiate ──>│ │
│ │── POST /intent/book ───>│
│ │<── { booking_id, │
│ │ deeplink, │
│ │ correlation_id } ───│
│<── { deeplink } ────│ │
│ │ │
│─── open deeplink ──────────────────────────> │
│ (user confirms payment in eSewa) │
│ │ │
│ │<── POST callback_url ───│ ← server push
│ │ { signature, ... } │
│ │ │
│ │── POST /intent/status ─>│ ← server verify
│ │<── { status: SUCCESS } ─│
│ │ │
│<── 200 OK ──────────│ │
│ (app polls status │ │
│ via redirect_url) │ │
Three IDs, one transaction
Every payment involves three identifiers that you need to store and later reconstruct:
| Identifier | Origin | Used for |
|---|---|---|
transaction_uuid | Generated by you before booking | Linking the intent to your DB record |
booking_id | Returned by eSewa on /intent/book | Included in the status check signature |
correlation_id | Returned by eSewa on /intent/book | Primary key for status checks |
We persist these as a composite string {transaction_uuid}:{booking_id}:{correlation_id} in a single DB column, making reconstruction deterministic without schema changes.
3. Project Structure & Environment Setup
We isolate everything eSewa-related into a self-contained package. Drop it anywhere in your Django project and register the URLs — nothing else needs to change.
esewa/ # standalone package — drop anywhere in your project
├── __init__.py
├── constants.py # Enums, response codes, signed-field-name constants
├── exceptions.py # Typed domain exceptions
├── service.py # All eSewa API logic (signing, booking, status check)
├── views.py # DRF views — thin HTTP controllers only
└── urls.py # URL patterns to include() in your root router
Every import inside the package uses relative paths (from . import ...), so the package name is completely decoupled from your host project’s module layout.
Environment variables
# .env
ESEWA_INTENT_KEY=<your_base64_hmac_key>
ESEWA_INTENT_PRODUCT_CODE=<your_product_code>
ESEWA_INTENT_BOOK_URL=https://checkout.esewa.com.np/api/client/intent/payment/book
ESEWA_INTENT_STATUS_URL=https://checkout.esewa.com.np/api/client/intent/payment/status
BACKEND_API_URL=https://api.yourdomain.com
[!IMPORTANT] The Intent API uses the same URLs for both sandbox and production. The environment is determined entirely by which
product_codeandESEWA_INTENT_KEYpair you use. Request separate credentials from eSewa for each environment.
4. Step 1 — Booking a Payment Intent
constants.py
from enum import StrEnum
class EsewaResponseCode(StrEnum):
SUCCESS = "IP-200"
CREATED = "IP-201"
class EsewaPaymentStatus(StrEnum):
SUCCESS = "SUCCESS"
FAILED = "FAILED"
CANCELED = "CANCELED"
PENDING = "PENDING"
# Exact field order for signing — must match signed_field_names
BOOKING_SIGNED_FIELDS = "product_code,amount,transaction_uuid"
STATUS_SIGNED_FIELDS = "booking_id,product_code,correlation_id"
exceptions.py
class EsewaIntentError(Exception):
"""Base exception for all eSewa Intent API errors."""
class EsewaBookingError(EsewaIntentError):
"""Raised when the /intent/book call fails or returns a non-success code."""
class EsewaStatusCheckError(EsewaIntentError):
"""Raised when the /intent/status call fails or returns a non-success code."""
class EsewaSignatureError(EsewaIntentError):
"""Raised when HMAC signature verification fails."""
services/esewa_service.py — config & crypto layer
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import os
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING
import requests
from requests import Response, Timeout, HTTPError
from payment_management.constants import (
BOOKING_SIGNED_FIELDS,
STATUS_SIGNED_FIELDS,
EsewaPaymentStatus,
EsewaResponseCode,
)
from payment_management.exceptions import (
EsewaBookingError,
EsewaSignatureError,
EsewaStatusCheckError,
)
if TYPE_CHECKING:
pass
logger = logging.getLogger("payment.esewa")
_ESEWA_REQUEST_TIMEOUT = 20 # seconds
# ─── Config ───────────────────────────────────────────────────────────────────
def _get_env(key: str, default: str = "") -> str:
value = os.getenv(key, default).strip()
if not value:
raise EnvironmentError(f"Required environment variable '{key}' is not set.")
return value
class EsewaConfig:
"""Lazy-loaded, centralised access to eSewa environment configuration."""
@staticmethod
def book_url() -> str:
return os.getenv(
"ESEWA_INTENT_BOOK_URL",
"https://checkout.esewa.com.np/api/client/intent/payment/book",
).strip()
@staticmethod
def status_url() -> str:
return os.getenv(
"ESEWA_INTENT_STATUS_URL",
"https://checkout.esewa.com.np/api/client/intent/payment/status",
).strip()
@staticmethod
def product_code() -> str:
return _get_env("ESEWA_INTENT_PRODUCT_CODE")
@staticmethod
def secret_key() -> str:
return _get_env("ESEWA_INTENT_KEY")
@staticmethod
def backend_url() -> str:
return os.getenv("BACKEND_API_URL", "https://api.yourdomain.com").rstrip("/")
Booking the intent
@dataclass(frozen=True, slots=True)
class EsewaBookingResult:
transaction_uuid: str
booking_id: str
deeplink: str
correlation_id: str
@property
def composite_id(self) -> str:
"""Compact string suitable for storing in a single DB column."""
return f"{self.transaction_uuid}:{self.booking_id}:{self.correlation_id}"
@classmethod
def from_composite(cls, value: str) -> "EsewaBookingResult":
parts = value.split(":")
if len(parts) < 3: # noqa: PLR2004
raise ValueError(f"Cannot reconstruct EsewaBookingResult from '{value}'")
return cls(
transaction_uuid=parts[0],
booking_id=parts[1],
correlation_id=parts[2],
deeplink="", # not persisted — only needed at booking time
)
def book_payment_intent(
*,
amount_npr: int,
customer_id: str,
purpose: str,
app_scheme: str = "yourapp",
) -> EsewaBookingResult:
"""
Create a payment intent on eSewa and return the booking result.
Args:
amount_npr: Amount in Nepalese Rupees (integer).
customer_id: An opaque identifier for the customer (never PII).
purpose: Human-readable description shown in eSewa UI.
app_scheme: Your mobile app's deep-link URL scheme.
Returns:
EsewaBookingResult dataclass with all identifiers and the deeplink.
Raises:
EsewaBookingError: If eSewa returns a non-success response code.
requests.HTTPError: If the HTTP request itself fails (4xx/5xx).
requests.Timeout: If the request exceeds the timeout threshold.
"""
config = EsewaConfig
product_code = config.product_code()
transaction_uuid = str(uuid.uuid4())
signature = _build_signature(
secret=config.secret_key(),
fields={
"product_code": product_code,
"amount": str(amount_npr),
"transaction_uuid": transaction_uuid,
},
field_order=BOOKING_SIGNED_FIELDS,
)
callback_url = (
f"{config.backend_url()}/api/payments/esewa-intent/callback/"
f"?transaction_uuid={transaction_uuid}"
)
redirect_url = f"{app_scheme}://payment-callback/?transaction_uuid={transaction_uuid}"
payload = {
"product_code": product_code,
"amount": amount_npr,
"transaction_uuid": transaction_uuid,
"signed_field_names": BOOKING_SIGNED_FIELDS,
"signature": signature,
"callback_url": callback_url,
"redirect_url": redirect_url,
"properties": {
"customer_id": customer_id,
"remarks": purpose,
},
}
logger.info(
"Booking eSewa intent",
extra={"transaction_uuid": transaction_uuid, "amount_npr": amount_npr},
)
response = _post_to_esewa(config.book_url(), payload)
result = response.json()
if result.get("code") not in {EsewaResponseCode.SUCCESS, EsewaResponseCode.CREATED}:
error_msg = result.get("error_message") or result.get("message") or str(result)
raise EsewaBookingError(f"eSewa booking rejected: {error_msg} (raw={result!r})")
data = result["data"]
booking = EsewaBookingResult(
transaction_uuid=transaction_uuid,
booking_id=data["booking_id"],
deeplink=data["deeplink"],
correlation_id=data["correlation_id"],
)
logger.info(
"eSewa intent booked successfully",
extra={"booking_id": booking.booking_id, "transaction_uuid": transaction_uuid},
)
return booking
Your mobile client receives booking.deeplink and opens it:
// iOS — Swift
guard let url = URL(string: deeplink) else { return }
UIApplication.shared.open(url)
// Android — Kotlin
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(deeplink)))
5. Step 2 — HMAC-SHA256 Signatures
Signatures authenticate both outbound requests (to eSewa) and inbound callbacks (from eSewa). The algorithm is identical in both directions: HMAC-SHA256 over a comma-separated field=value string, Base64-encoded.
def _build_signature(secret: str, fields: dict[str, str], field_order: str) -> str:
"""
Construct the HMAC-SHA256 / Base64 signature required by eSewa.
The message is built from `fields` in the exact order defined by `field_order`
(a comma-separated list of field names). Field order is not negotiable —
a mismatch will produce a different digest and eSewa will reject the request.
Args:
secret: The raw ESEWA_INTENT_KEY string (Base64, not decoded).
fields: Mapping of field name → string value.
field_order: Comma-separated field names defining message construction order.
Returns:
Base64-encoded HMAC-SHA256 digest.
"""
ordered_names = [name.strip() for name in field_order.split(",")]
message_parts = []
for name in ordered_names:
value = fields.get(name)
if value is None:
raise ValueError(f"Field '{name}' listed in field_order but not present in fields dict.")
message_parts.append(f"{name}={value}")
message = ",".join(message_parts)
digest = hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.b64encode(digest).decode("utf-8")
Verifying an inbound callback signature
When eSewa POSTs to your callback URL the body looks like:
{
"transaction_uuid": "550e8400-e29b-41d4-a716-446655440000",
"booking_id": "BK-2024-XXXXXXX",
"correlation_id": "CID-XXXXXXX",
"amount": "500",
"status": "SUCCESS",
"signed_field_names": "transaction_uuid,booking_id,correlation_id,amount,status",
"signature": "abc123base64=="
}
def verify_callback_signature(payload: dict) -> bool:
"""
Verify the HMAC-SHA256 signature on an inbound eSewa callback payload.
Key subtleties:
- The `amount` field may arrive as "500.0" even if you sent 500 (integer).
It must be normalised to "500" before building the message string.
- Use hmac.compare_digest() — never == — to prevent timing-oracle attacks.
- Fields are processed in the exact order declared by `signed_field_names`.
Returns:
True if the signature is valid, False otherwise.
Raises:
EsewaSignatureError: If required fields (`signature`, `signed_field_names`)
are absent from the payload.
"""
raw_signature = payload.get("signature")
field_names = payload.get("signed_field_names")
if not raw_signature or not field_names:
raise EsewaSignatureError(
"Callback payload is missing 'signature' or 'signed_field_names'."
)
message_parts: list[str] = []
for field in (f.strip() for f in field_names.split(",")):
raw_value = payload.get(field)
if raw_value is None:
logger.warning("Signature field '%s' declared but absent in payload.", field)
return False
value = _normalise_amount(raw_value) if field == "amount" else str(raw_value)
message_parts.append(f"{field}={value}")
message = ",".join(message_parts)
expected = _build_signature(
secret=EsewaConfig.secret_key(),
fields=dict(zip(
[f.strip() for f in field_names.split(",")],
[p.split("=", 1)[1] for p in message_parts],
)),
field_order=field_names,
)
valid = hmac.compare_digest(raw_signature, expected)
if not valid:
logger.warning(
"eSewa signature mismatch.",
extra={"message": message, "received": raw_signature},
)
return valid
def _normalise_amount(value: str | int | float) -> str:
"""
Normalise an amount value to a plain integer string.
eSewa may send amount as "500.0" in callback payloads even when the
booking was made with integer 500. Both representations must produce
the same signature message byte string.
"""
try:
parsed = float(value)
return str(int(parsed)) if parsed.is_integer() else str(parsed)
except (TypeError, ValueError):
return str(value)
[!CAUTION] Never use
==for signature comparison. Python’s==on strings short-circuits on the first differing byte, leaking timing information an attacker can exploit to forge signatures.hmac.compare_digest()runs in constant time regardless of where the strings diverge.
[!WARNING] Do not decode the
ESEWA_INTENT_KEYfrom Base64 before passing it to the HMAC function. The key is used as the raw string secret. Decoding it will produce the wrong digest on every request.
6. Step 3 — Handling the Callback
After the user confirms or cancels in the eSewa app, eSewa POSTs to your callback_url. Your endpoint must be:
- Publicly reachable over HTTPS (not
localhost, not an internal VPC address). - Unauthenticated — eSewa does not send any auth headers.
- Fast — acknowledge with
200 OKbefore doing heavy work.
esewa/views.py
from __future__ import annotations
import logging
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from .exceptions import EsewaSignatureError
from .service import verify_callback_signature, resolve_payment_status
logger = logging.getLogger("esewa.views")
@api_view(["POST"])
@authentication_classes([]) # eSewa does not send auth credentials
@permission_classes([AllowAny]) # public webhook receiver
@csrf_exempt # webhook — no browser session involved
def esewa_intent_callback_view(request: Request) -> Response:
"""
Webhook receiver for eSewa Intent payment outcomes.
eSewa POSTs here after the user completes or cancels payment.
We verify the HMAC signature before doing anything with the payload,
then delegate to the service layer for status resolution and fulfilment.
Query param:
transaction_uuid — appended to callback_url at booking time so we can
locate the transaction even before parsing the body.
"""
payload = request.data
logger.info(
"eSewa callback received",
extra={"query_params": dict(request.GET), "payload_keys": list(payload.keys())},
)
try:
if not verify_callback_signature(payload):
return Response(
{"detail": "Signature verification failed."},
status=status.HTTP_400_BAD_REQUEST,
)
except EsewaSignatureError as exc:
logger.warning("Malformed eSewa callback: %s", exc)
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
result, http_status = resolve_payment_status(
correlation_id=payload.get("correlation_id"),
transaction_uuid=request.GET.get("transaction_uuid"),
)
return Response(result, status=http_status)
@api_view(["GET"])
@permission_classes([AllowAny])
def esewa_intent_redirect_view(request: Request) -> Response:
"""
Mobile-side status polling endpoint.
Called by the mobile app's deep-link handler when the user returns
from the eSewa app. Acts as a fallback for cases where the server-side
callback was not delivered (e.g. network issue, IP not whitelisted).
Query params:
correlation_id — from the eSewa deep-link redirect parameters.
transaction_uuid — from the original booking.
"""
logger.info(
"eSewa redirect poll",
extra={
"correlation_id": request.GET.get("correlation_id"),
"transaction_uuid": request.GET.get("transaction_uuid"),
},
)
result, http_status = resolve_payment_status(
correlation_id=request.GET.get("correlation_id"),
transaction_uuid=request.GET.get("transaction_uuid"),
)
return Response(result, status=http_status)
7. Step 4 — Server-Side Status Verification
[!IMPORTANT] Always verify payment status server-side via the eSewa status API. Do not mark a transaction as complete based solely on the callback body — the callback can be spoofed or replayed. The status API call is the authoritative source of truth.
def _check_payment_status(booking_id: str, correlation_id: str) -> EsewaPaymentStatus:
"""
Query the eSewa Intent status API and return the canonical payment status.
Args:
booking_id: From the original booking result.
correlation_id: From the original booking result.
Returns:
EsewaPaymentStatus enum member.
Raises:
EsewaStatusCheckError: If the API returns a non-IP-200 response code.
requests.HTTPError: On 4xx/5xx HTTP errors.
requests.Timeout: If the request exceeds the timeout threshold.
"""
product_code = EsewaConfig.product_code()
signature = _build_signature(
secret=EsewaConfig.secret_key(),
fields={
"booking_id": booking_id,
"product_code": product_code,
"correlation_id": correlation_id,
},
field_order=STATUS_SIGNED_FIELDS,
)
payload = {
"booking_id": booking_id,
"product_code": product_code,
"correlation_id": correlation_id,
"signed_field_names": STATUS_SIGNED_FIELDS,
"signature": signature,
}
logger.debug(
"Calling eSewa status API",
extra={"booking_id": booking_id, "correlation_id": correlation_id},
)
response = _post_to_esewa(EsewaConfig.status_url(), payload)
result = response.json()
if result.get("code") != EsewaResponseCode.SUCCESS:
raise EsewaStatusCheckError(
f"eSewa status API returned code={result.get('code')!r}. "
f"Full response: {result!r}"
)
raw_status = result["data"]["status"]
try:
return EsewaPaymentStatus(raw_status)
except ValueError:
logger.error("Unrecognised eSewa payment status: %s", raw_status)
return EsewaPaymentStatus.PENDING
def resolve_payment_status(
*,
correlation_id: str | None,
transaction_uuid: str | None,
) -> tuple[dict, int]:
"""
Core orchestration: locate the transaction, verify its status with eSewa,
apply fulfilment logic, and return a response tuple.
This function is called by both the server-side callback view and the
mobile-side redirect polling view — ensuring both paths share identical
business logic.
Returns:
A (response_dict, http_status_code) tuple.
"""
if not correlation_id and not transaction_uuid:
return {"detail": "Missing correlation_id and transaction_uuid."}, 400
transaction, is_subscription = _find_transaction(
correlation_id=correlation_id,
transaction_uuid=transaction_uuid,
)
if transaction is None:
logger.warning(
"Transaction not found",
extra={"correlation_id": correlation_id, "transaction_uuid": transaction_uuid},
)
return {"detail": "Transaction not found."}, 404
# ── Idempotency guard ────────────────────────────────────────────────────
# If this transaction was already approved by a prior callback delivery,
# acknowledge success without re-running fulfilment logic.
if _is_already_approved(transaction, is_subscription):
logger.info("Transaction already approved — returning cached success.")
return {"detail": "Payment already confirmed."}, 200
# ── Reconstruct booking identifiers ─────────────────────────────────────
raw_id = _get_raw_id(transaction, is_subscription)
try:
booking_result = EsewaBookingResult.from_composite(raw_id)
except ValueError:
logger.error("Could not reconstruct booking identifiers from '%s'", raw_id)
return {"detail": "Transaction data is malformed."}, 500
# ── Verify with eSewa ────────────────────────────────────────────────────
try:
payment_status = _check_payment_status(
booking_id=booking_result.booking_id,
correlation_id=correlation_id or booking_result.correlation_id,
)
except (EsewaStatusCheckError, HTTPError, Timeout) as exc:
logger.exception("eSewa status check failed: %s", exc)
return {"detail": "Could not verify payment status. Please try again."}, 502
# ── Act on status ────────────────────────────────────────────────────────
if payment_status is EsewaPaymentStatus.SUCCESS:
_mark_approved(transaction, is_subscription)
_run_fulfilment(transaction, is_subscription)
logger.info(
"Transaction approved",
extra={"transaction": str(transaction), "is_subscription": is_subscription},
)
return {"detail": "Payment confirmed."}, 200
if payment_status in {EsewaPaymentStatus.FAILED, EsewaPaymentStatus.CANCELED}:
_mark_failed(transaction, is_subscription)
return {"detail": f"Payment {payment_status.value.lower()}."}, 400
return {"detail": f"Payment status is {payment_status.value}."}, 202
Shared HTTP helper
def _post_to_esewa(url: str, payload: dict) -> Response:
"""
POST JSON to an eSewa API endpoint with consistent timeout and error handling.
Raises:
requests.HTTPError: On 4xx/5xx responses.
requests.Timeout: If no response within _ESEWA_REQUEST_TIMEOUT seconds.
"""
try:
response = requests.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=_ESEWA_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response
except Timeout:
logger.error("eSewa API timed out after %ds — URL: %s", _ESEWA_REQUEST_TIMEOUT, url)
raise
except HTTPError as exc:
logger.error(
"eSewa API returned HTTP %s — URL: %s — Body: %s",
exc.response.status_code,
url,
exc.response.text[:500],
)
raise
8. Step 5 — Django Routing
# payment_management/urls.py
from django.urls import path
from payment_management.views.esewa_views import (
esewa_intent_callback_view,
esewa_intent_redirect_view,
)
app_name = "payment_management"
urlpatterns = [
# Server-side webhook — called by eSewa after payment
path(
"esewa-intent/callback/",
esewa_intent_callback_view,
name="esewa_intent_callback",
),
# Mobile-side polling — called by your app on return from eSewa
path(
"esewa-intent/redirect/",
esewa_intent_redirect_view,
name="esewa_intent_redirect",
),
]
# project/urls.py
urlpatterns = [
...
path("api/payments/", include("payment_management.urls", namespace="payment_management")),
]
Your callback URL at runtime:
https://api.yourdomain.com/api/payments/esewa-intent/callback/?transaction_uuid=<uuid>
Django settings (proxy-aware)
# settings.py
ALLOWED_HOSTS = ["api.yourdomain.com"]
# Required if behind nginx / AWS ALB — lets Django resolve https:// correctly
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
9. The Bug: Silent Callback Failures in Production
This section is the reason we wrote this post.
The symptom
After deploying to production, payments appeared to complete successfully on the user’s side — the eSewa app showed “Payment Successful” — but our backend never received the callback. Transactions sat in pending indefinitely.
Production logs from our callback endpoint:
[nothing]
Manual curl to the same endpoint:
HTTP 200 OK ←← endpoint is alive and reachable
The debugging timeline
| Check | Result |
|---|---|
Callback endpoint responds to manual POST | ✅ Works |
| nginx access log — any request from eSewa IPs | ❌ Zero hits |
| Firewall — port 443 open to public | ✅ Open |
callback_url in booking payload — correct domain | ✅ Correct |
| SSL certificate — valid, no errors | ✅ Valid |
| Sandbox environment — callbacks arriving | ✅ Works |
The divergence between sandbox (working) and production (silent) was the key signal. Everything on our infrastructure was correct.
Root cause
We contacted the eSewa merchant support team and asked them to inspect their callback delivery logs for our domain.
Their response: our production server’s IP address was not present in eSewa’s outbound callback allowlist.
eSewa’s callback dispatcher restricts which destination IPs it will attempt to deliver to. This allowlist is not documented in any public API reference. Our sandbox environment shared an IP that was already on the list. Our production server’s IP was not.
The fix
# Run this on your production server to find your outbound IP
curl -s https://api.ipify.org
# Cross-check
curl -s https://ifconfig.me
Provide this IP to the eSewa merchant support team — via email or their merchant portal — and ask them to whitelist it for callback delivery. In our case, the turnaround was same-day.
Once whitelisted, callbacks began arriving immediately without any code changes.
[!IMPORTANT] If your eSewa callback endpoint never receives requests in production but works perfectly in sandbox, assume IP whitelisting first. Contact eSewa support before spending time investigating your own infrastructure — there is nothing to fix on your end.
The failsafe: mobile-side polling
Because server-side callbacks can fail silently for many reasons (IP not whitelisted, transient network issues, deployment restarts), always implement a client-side fallback:
1. User is returned to your app via redirect_url (deep-link)
2. Mobile app immediately calls:
GET /api/payments/esewa-intent/redirect/?transaction_uuid=<uuid>
3. Backend calls eSewa status API → returns authoritative result
4. App renders success or failure screen
Both the callback endpoint and the redirect endpoint route through the same resolve_payment_status() function, so business logic is never duplicated and idempotency is guaranteed by the same guard clause.
10. Integration Checklist
Before going live
-
ESEWA_INTENT_KEYis the raw Base64 string — pass it as-is to HMAC, never decode it. -
amountis an integer in NPR in the booking payload — no floats, no decimals. - Message string field order matches
signed_field_namesexactly — wrong order = wrong digest. -
amountnormalisation in signature verification:"500.0"→"500". - Callback view is
@csrf_exemptwith@permission_classes([AllowAny]). -
callback_urlis a public HTTPS URL — notlocalhost, not a private IP. -
redirect_urlis your mobile app deep-link scheme (yourapp://...). - Contact eSewa and confirm your production server’s outbound IP is whitelisted.
- Implement idempotency — re-processing an already-approved transaction must be a no-op.
- Always re-verify status via the status API after receiving a callback.
- Store all three identifiers:
transaction_uuid,booking_id,correlation_id. - Mobile app polls your redirect endpoint as a fallback when returning from eSewa.
- Signature comparison uses
hmac.compare_digest()— never==.
eSewa API response codes
| Code | Meaning |
|---|---|
IP-200 | Success |
IP-201 | Intent created (success for /book) |
| anything else | Failure — inspect error_message field |
Payment status values
| Value | Meaning |
|---|---|
SUCCESS | Payment confirmed — fulfil the order |
FAILED | Payment failed — notify user |
CANCELED | User cancelled — allow retry |
PENDING | Not yet resolved — poll again later |