Published: June 2026
Stack: Django · Django REST Framework · Python 3.11+ · eSewa Intent API v2


Table of Contents

  1. Background & Motivation
  2. Understanding the eSewa Intent Payment Flow
  3. Project Structure & Environment Setup
  4. Step 1 — Booking a Payment Intent
  5. Step 2 — HMAC-SHA256 Signatures
  6. Step 3 — Handling the Callback
  7. Step 4 — Server-Side Status Verification
  8. Step 5 — Django Routing
  9. The Bug: Silent Callback Failures in Production
  10. 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:

ModeMechanismBest for
Legacy v1HTML form redirect, browser-basedWeb apps
Intent API v2REST API + app deep-linkMobile 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:

IdentifierOriginUsed for
transaction_uuidGenerated by you before bookingLinking the intent to your DB record
booking_idReturned by eSewa on /intent/bookIncluded in the status check signature
correlation_idReturned by eSewa on /intent/bookPrimary 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_code and ESEWA_INTENT_KEY pair 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_KEY from 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 OK before 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

CheckResult
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_KEY is the raw Base64 string — pass it as-is to HMAC, never decode it.
  • amount is an integer in NPR in the booking payload — no floats, no decimals.
  • Message string field order matches signed_field_names exactly — wrong order = wrong digest.
  • amount normalisation in signature verification: "500.0""500".
  • Callback view is @csrf_exempt with @permission_classes([AllowAny]).
  • callback_url is a public HTTPS URL — not localhost, not a private IP.
  • redirect_url is 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

CodeMeaning
IP-200Success
IP-201Intent created (success for /book)
anything elseFailure — inspect error_message field

Payment status values

ValueMeaning
SUCCESSPayment confirmed — fulfil the order
FAILEDPayment failed — notify user
CANCELEDUser cancelled — allow retry
PENDINGNot yet resolved — poll again later