How eSewa Works

Your Backend: buildFormPayload(amount, uuid) โ†’ Generates HMAC-SHA256 signature โ†’ Returns EsewaFormPayload โœ… Save uuid to DB FIRST โ€” before returning to frontend Frontend: POST form fields to payload.formActionUrl() โ†“ User pays on eSewa gateway โ†“ eSewa redirects to your success-url: ?data=BASE64_ENCODED_JSON โš ๏ธ NEVER trust this redirect alone โ€” Base64 can be forged! Your Backend: verifyCallback(data) Step 1: Decode Base64 โ†’ JSON Step 2: Verify HMAC-SHA256 signature (constant-time) Step 3: Call eSewa status API for final confirmation โ†“ โœ… Only mark paid when ALL THREE steps pass
โ„น๏ธ

eSewa uses a form POST model โ€” unlike Khalti which is API-first. Your backend generates a signed payload, returns it to the frontend, and the frontend submits a form directly to eSewa. The backend never makes a direct HTTP call to eSewa during initiation.

Configure

nepalpay:
  esewa:
    secret-key:   ${ESEWA_SECRET_KEY}
    product-code: ${ESEWA_PRODUCT_CODE}
    success-url:  ${ESEWA_SUCCESS_URL}
    failure-url:  ${ESEWA_FAILURE_URL}
    sandbox: true    # false for production

    # Optional retry (applies to checkStatus() inside verifyCallback)
    retry:
      enabled: true
      max-attempts: 3
      initial-delay-ms: 500
      multiplier: 2.0
      max-delay-ms: 5000
Property Required Default Description
secret-key โœ… โ€” HMAC-SHA256 secret key. Sandbox: 8gBm/:&EnhH.1/q
product-code โœ… โ€” Sandbox: EPAYTEST ยท Production: your merchant code
success-url โœ… โ€” eSewa redirects here on successful payment with ?data=BASE64
failure-url โœ… โ€” eSewa redirects here on failed or canceled payment
sandbox โ€” true true = rc-epay.esewa.com.np ยท false = epay.esewa.com.np
timeout-seconds โ€” 10 HTTP timeout for status API calls in seconds

buildFormPayload()

Simple overload โ€” amount only

// Generate a unique UUID for this transaction
String uuid = EsewaClient.generateTransactionUuid();

// โœ… Save uuid to DB BEFORE returning payload to frontend
orderRepo.saveUuid(orderId, uuid);

// eSewa uses NPR directly โ€” NOT paisa like Khalti
EsewaFormPayload payload =
    esewaClient.buildFormPayload(
        new BigDecimal("100.00"),   // NPR 100
        uuid
    );

// Return payload to frontend โ€” they will POST it

Full overload โ€” with charges

EsewaFormPayload payload =
    esewaClient.buildFormPayload(
        new BigDecimal("100.00"),   // base amount NPR
        new BigDecimal("10.00"),    // tax NPR (null = zero)
        uuid,
        new BigDecimal("5.00"),     // service charge NPR (null = zero)
        new BigDecimal("0.00")      // delivery charge NPR (null = zero)
    );
// totalAmount = 100 + 10 + 5 + 0 = 115.00
โš ๏ธ

eSewa uses NPR โ€” not paisa. NPR 100 โ†’ send new BigDecimal("100.00"). Charge components (tax, service, delivery) cannot be negative โ€” NepalPay validates this and throws EsewaException if they are.

EsewaFormPayload fields

Field JSON key Description
amount() amount Base amount as String e.g. "100.00"
taxAmount() tax_amount Tax as String
totalAmount() total_amount Sum of all charges as String
transactionUuid() transaction_uuid Your unique transaction ID
productCode() product_code Your eSewa merchant code
signature() signature HMAC-SHA256 Base64 signature โ€” generated by NepalPay
signedFieldNames() signed_field_names Always: total_amount,transaction_uuid,product_code
formActionUrl() form_action_url The eSewa form URL to POST to (sandbox or production)

Frontend Form Submission

Your frontend must POST all payload fields as a form submission to payload.formActionUrl().

// Angular / TypeScript example
submitEsewaPayment(payload: EsewaFormPayload): void {
    const form = document.createElement('form');
    form.method = 'POST';
    form.action = payload.form_action_url;

    const fields = {
        amount:                 payload.amount,
        tax_amount:             payload.tax_amount,
        total_amount:           payload.total_amount,
        transaction_uuid:       payload.transaction_uuid,
        product_code:           payload.product_code,
        product_service_charge: payload.product_service_charge,
        product_delivery_charge:payload.product_delivery_charge,
        success_url:            payload.success_url,
        failure_url:            payload.failure_url,
        signed_field_names:     payload.signed_field_names,
        signature:              payload.signature
    };

    Object.entries(fields).forEach(([key, value]) => {
        const input = document.createElement('input');
        input.type  = 'hidden';
        input.name  = key;
        input.value = value ?? '';
        form.appendChild(input);
    });

    document.body.appendChild(form);
    form.submit();
}
โ„น๏ธ

The signature is generated entirely on your backend using your HMAC-SHA256 secret key. The HMAC secret never reaches the frontend. Only the resulting signature value is sent as a form field.

verifyCallback()

eSewa redirects to your success-url with a ?data=BASE64_ENCODED_JSON query parameter. verifyCallback() handles all three steps automatically.

@GetMapping("/esewa/callback")
public ResponseEntity<String> callback(
        @RequestParam String data) {

    // verifyCallback does all three steps:
    // 1. Decode Base64 โ†’ JSON
    // 2. Verify HMAC-SHA256 signature (constant-time)
    // 3. Call eSewa status API
    EsewaClient.EsewaVerificationResult result =
        esewaClient.verifyCallback(data);

    if (!result.isPaymentSuccessful()) {
        return ResponseEntity.badRequest()
            .body("Payment not confirmed: " +
                result.statusResponse().status());
    }

    // Load order using saved uuid
    String uuid = result.callbackData().transactionUuid();
    orderRepo.markPaid(uuid);

    return ResponseEntity.ok("Payment confirmed");
}

// Handle failure redirect
@GetMapping("/esewa/failed")
public ResponseEntity<String> failed() {
    return ResponseEntity.ok("Payment was canceled or failed");
}
โš ๏ธ

If the HMAC signature does not match, verifyCallback() throws EsewaException with the message "signature verification FAILED". This increments the nepalpay.esewa.callback.signature.failed Micrometer counter โ€” alert on a spike.

EsewaVerificationResult fields

Method Returns Description
isPaymentSuccessful() boolean true only when HMAC matched AND status is COMPLETE
verified() boolean Same as isPaymentSuccessful()
callbackData() EsewaCallbackData Decoded callback JSON from eSewa
statusResponse() EsewaStatusResponse Response from the eSewa status API

checkStatus()

Call the eSewa status API directly without going through the callback flow. Useful for:

  • Manually checking a payment status
  • Background reconciliation jobs
  • Handling cases where the callback was missed
// uuid = the transaction_uuid you saved during initiation
// totalAmount = the exact total amount string used during initiation
EsewaStatusResponse status =
    esewaClient.checkStatus(uuid, "100.00");

if (status.isPaymentSuccessful()) {
    // status.status() == "COMPLETE"
    orderRepo.markPaid(uuid);
}
โš ๏ธ

eSewa status is COMPLETE โ€” not COMPLETED. This is different from Khalti which uses Completed. NepalPay handles both correctly โ€” just never compare the raw status strings directly in your code. Always use isPaymentSuccessful().

Security Rules

๐Ÿ”ด

Never trust the ?data=BASE64 redirect parameter alone. The Base64 data can be forged. Always call verifyCallback(data) which re-computes the HMAC-SHA256 signature and calls the status API.

โœ…

NepalPay uses constant-time comparison (MessageDigest.isEqual()) in both blocking and reactive eSewa clients โ€” preventing timing attacks on the HMAC signature.

โœ…

Always save uuid to your database before returning the payload to the frontend. Use the saved uuid โ€” not the one from the callback โ€” when calling checkStatus().

# Correct eSewa flow โ€” all steps required:

1. generateTransactionUuid()   โ†’ save to DB
2. buildFormPayload()          โ†’ return to frontend
3. Frontend POSTs to eSewa
4. eSewa redirects ?data=BASE64
5. verifyCallback(data)        โ†’ all 3 sub-steps automatic
6. isPaymentSuccessful()       โ†’ then mark paid

Reactive Usage

Use EsewaReactiveClient from the reactive starter. buildFormPayload() is synchronous (no HTTP). verifyCallback() and checkStatus() return Mono.

Build payload โ€” synchronous

// Synchronous โ€” no Mono needed
public EsewaFormPayload startEsewa(
        String orderId, BigDecimal amount) {

    String uuid = EsewaReactiveClient.generateTransactionUuid();
    orderRepo.saveUuid(orderId, uuid); // save first!

    return esewaReactiveClient.buildFormPayload(amount, uuid);
}

Verify callback โ€” reactive

public Mono<Boolean> verifyEsewa(String encodedData) {
    return esewaReactiveClient.verifyCallback(encodedData)
        .map(EsewaReactiveClient.EsewaVerificationResult
                ::isPaymentSuccessful);
}

Check status directly โ€” reactive

public Mono<Boolean> checkEsewaStatus(
        String uuid, String totalAmount) {
    return esewaReactiveClient.checkStatus(uuid, totalAmount)
        .map(EsewaStatusResponse::isPaymentSuccessful);
}
โ„น๏ธ

The reactive client uses Mono.fromCallable() for the synchronous HMAC and signature verification steps, so all exceptions are emitted as reactive error signals โ€” never thrown outside the pipeline.

eSewa Status Values

Status string isPaymentSuccessful() Meaning
COMPLETE โœ… true Payment fully completed โ€” safe to fulfil order
INCOMPLETE false Payment was not completed
anything else false Unknown status โ€” do not mark as paid
๐Ÿ”ด

eSewa uses COMPLETE (not COMPLETED). Never compare raw status strings in your code. Always use isPaymentSuccessful().

Sandbox Credentials

FieldSandbox Value
eSewa ID9806800001
PasswordNepal@123
MPIN1122
Token123456
Secret Key (secret-key) 8gBm/:&EnhH.1/q
Product Code (product-code) EPAYTEST
Form Action URL https://rc-epay.esewa.com.np/api/epay/main/v2/form
Status API https://rc.esewa.com.np/api/epay/transaction/status/
โš ๏ธ

Never hardcode sandbox credentials. Even though these are public sandbox values, always use environment variables so you have a consistent pattern when switching to production.