How Fonepay Works

โ„น๏ธ

Fonepay uses a URL redirect model. Your backend builds a signed URL. The frontend navigates the user to it. No server-to-server HTTP calls are made from your backend during initiation.

Your Backend: buildRedirectParams() โ†’ HMAC-SHA512 signature (hex lowercase) of field string โ†’ Returns FonepayRedirectParams with redirectUrl โœ… Save PRN to DB FIRST โ€” before returning redirectUrl Frontend: window.location.href = params.redirectUrl() โ†“ User pays on Fonepay (QR, bank, wallet) โ†“ Fonepay redirects to your returnUrl: ?PRN=FP-ORD-001&PID=MERCHANT&PS=success&DV=HMAC_SHA512_HEX โš ๏ธ NEVER trust PS=success alone โ€” DV can be forged! Your Backend: verifyCallback(callback) โ†’ Re-compute HMAC-SHA512, compare with DV (constant-time) โ†’ Check PS = "success" โ€” ONLY after HMAC passes โ†“ โœ… Mark as paid only when BOTH signature + status pass

Key Differences from Other Gateways

Feature Khalti eSewa Fonepay
Flow API POST Form POST URL redirect (GET)
Signature API key header HMAC-SHA256 Base64 HMAC-SHA512 hex
Amount unit Paisa (long) NPR (BigDecimal) NPR (double)
Server HTTP calls Yes Yes (status API) None
Retry Yes Yes N/A โ€” no HTTP calls
Reactive starter KhaltiReactiveClient EsewaReactiveClient Use blocking FonepayClient
โ„น๏ธ

Because Fonepay makes zero server-side HTTP calls, the blocking FonepayClient works perfectly inside WebFlux reactive applications โ€” it never touches the event loop. No reactive wrapper is needed.

Configure

nepalpay:
  fonepay:
    merchant-code: ${FONEPAY_MERCHANT_CODE}    # your PID
    secret-key:    ${FONEPAY_SECRET_KEY}
    return-url:    ${FONEPAY_RETURN_URL}
    sandbox: true  # false for production
    # No retry config โ€” Fonepay makes no HTTP calls
Property Required Default Description
merchant-code โœ… โ€” Your Fonepay PID (merchant code).
secret-key โœ… โ€” HMAC-SHA512 secret key. Bean created when present.
return-url โœ… โ€” Fonepay redirects here after payment with all callback params.
sandbox โ€” true true = dev.fonepay.com ยท false = fonepay.com
Mode Gateway URL
Sandbox https://dev.fonepay.com/api/merchantRequest
Production https://fonepay.com/api/merchantRequest

buildRedirectParams()

// PRN must be 3โ€“25 chars, unique per transaction
// AFTER โ€” Use UUID-based PRN to avoid collision from truncation
// Distinct orderIds sharing the same first 22 chars would produce
// the same truncated PRN โ€” risking callback misattribution.
import java.util.UUID;
String prn = "FP-" + UUID.randomUUID()
        .toString()
        .replace("-", "")
        .substring(0, 22);   // 3 + 22 = 25 chars โ€” within Fonepay limit

// โœ… Save the generated PRN mapped to orderId BEFORE returning
orderRepo.savePrn(orderId, prn);

// โœ… Save PRN to DB BEFORE returning redirectUrl
orderRepo.savePrn(orderId, prn);

FonepayRedirectParams params = fonepayClient.buildRedirectParams(
    FonepayPaymentRequest.builder()
        .prn(prn)
        .amount(100.0)         // โš ๏ธ NPR directly โ€” NOT paisa
        .remarks1("Pro Plan")  // shown to user, max 160 chars
        .remarks2("MyApp")     // optional, max 50 chars
        .build()
);

// Return redirectUrl to frontend
return params.redirectUrl();
โš ๏ธ

Amount is NPR as a double โ€” not paisa. NPR 100 โ†’ send 100.0. PRN must be between 3 and 25 characters. remarks1 is required (max 160 chars). remarks2 is optional (max 50 chars).

FonepayPaymentRequest builder fields

Method Required Description
.prn(String) Required Product Reference Number โ€” unique, 3โ€“25 chars, save to DB
.amount(double) Required Amount in NPR as double โ€” NOT paisa
.remarks1(String) Required Payment description shown to user, max 160 chars
.remarks2(String) Optional Additional remarks, max 50 chars

FonepayRedirectParams fields

Method Description
redirectUrl() Full signed URL โ€” redirect the user to this
prn() Your Product Reference Number
dv() HMAC-SHA512 signature (hex lowercase)
amt() Amount string (whole numbers have no decimal โ€” 100.0 โ†’ "100")
ru() Return URL

Frontend Redirect

Unlike eSewa and ConnectIPS which use form POST, Fonepay uses a simple URL redirect.

// JavaScript / TypeScript
startFonepayPayment(redirectUrl: string): void {
    // Simple redirect โ€” no form needed
    window.location.href = redirectUrl;
}

// Angular example
initiatePayment(orderId: string, amount: number): void {
    this.paymentService
        .startFonepay(orderId, amount)
        .subscribe(res => {
            window.location.href = res.redirectUrl;
        });
}

verifyCallback()

Fonepay redirects to your returnUrl with all payment parameters as GET query parameters. Always verify the DV signature before trusting PS=success.

@GetMapping("/fonepay/callback")
public ResponseEntity<Map<String, Object>> callback(
        @RequestParam String PRN,
        @RequestParam String PID,
        @RequestParam String PS,
        @RequestParam String RC,
        @RequestParam String UID,
        @RequestParam String BC,
        @RequestParam String INI,
        @RequestParam(name = "P_AMT") String pAmt,
        @RequestParam(name = "R_AMT") String rAmt,
        @RequestParam(name = "DV")    String dv) {

    FonepayCallbackResponse callback =
        FonepayCallbackResponse.of(
            PRN, PID, PS, RC, UID, BC, INI, pAmt, rAmt, dv);

    // โœ… ALWAYS verify signature first โ€” redirect params can be faked
    // verifyCallback re-computes HMAC-SHA512 and compares with DV
    // THEN checks PS=success โ€” only after HMAC passes
    FonepayClient.FonepayVerificationResult result =
        fonepayClient.verifyCallback(callback);

    if (!result.isPaymentSuccessful()) {
        return ResponseEntity.ok(Map.of(
            "verified", false,
            "message",  "Payment not successful"
        ));
    }

    // Load order using saved PRN
    orderRepo.markPaid(PRN);
    return ResponseEntity.ok(Map.of(
        "verified", true,
        "message",  "Payment confirmed"
    ));
}
โš ๏ธ

verifyCallback() throws FonepayException when the HMAC-SHA512 signature does not match โ€” treat this as a potential fraud attempt and log it. Never call callback.paymentStatus() directly โ€” it bypasses HMAC verification. There is no isPaymentSuccessful() on FonepayCallbackResponse by design.

FonepayVerificationResult fields

Method Returns Description
isPaymentSuccessful() boolean true only when HMAC matched AND PS=success
verified() boolean Same as isPaymentSuccessful()
callbackResponse() FonepayCallbackResponse The original callback parameters

Security Rules

๐Ÿ”ด

Never trust PS=success alone. The DV signature is the only real proof. Always call verifyCallback() which re-computes HMAC-SHA512 first.

๐Ÿ”ด

There is no FonepayCallbackResponse.isPaymentSuccessful() method. This was intentionally removed in v1.1.1 because it allowed bypassing HMAC verification. The only safe path is fonepayClient.verifyCallback(callback).

โœ…

NepalPay uses constant-time comparison (MessageDigest.isEqual()) when comparing the expected and received DV signatures โ€” preventing timing attacks on the HMAC.

โœ…

Save the PRN to your database before returning the redirect URL to the frontend. Use the saved PRN to look up the order on callback โ€” never trust the PRN from the redirect params alone.

// โœ… Correct flow
String prn = "FP-" + orderId;
orderRepo.savePrn(orderId, prn);       // SAVE FIRST
String url = fonepayClient
    .buildRedirectParams(request)
    .redirectUrl();                     // return to frontend

// โœ… Correct verification โ€” always verify signature first
FonepayClient.FonepayVerificationResult result =
    fonepayClient.verifyCallback(callback);
// Throws FonepayException if DV mismatch โ€” log as fraud attempt
if (result.isPaymentSuccessful()) {
    orderRepo.markPaid(PRN);
}

// โŒ NEVER do this
if ("success".equals(ps)) {
    orderRepo.markPaid(PRN); // DV not checked โ€” dangerous!
}

Reactive Apps โ€” Use Blocking Client

Fonepay makes zero server-side HTTP calls. The blocking FonepayClient is safe to use inside WebFlux applications โ€” it never touches the event loop because all it does is HMAC signing (pure CPU, no I/O).

Direct usage in reactive service

@Service
@RequiredArgsConstructor
public class FonepayService {

    // Blocking client โ€” inject directly in reactive apps
    private final FonepayClient fonepayClient;
    private final OrderRepository orderRepo;

    public String buildRedirect(String orderId, double amountNPR) {
        String prn = "FP-" + orderId;
        orderRepo.savePrn(orderId, prn);

        return fonepayClient.buildRedirectParams(
            FonepayPaymentRequest.builder()
                .prn(prn)
                .amount(amountNPR)
                .remarks1("Pro Plan")
                .build()
        ).redirectUrl();
    }

    // Wrap in Mono if needed for reactive pipeline composition
    // subscribeOn(boundedElastic) offloads HMAC work off event-loop
    public Mono<Boolean> verifyFonepay(
            FonepayCallbackResponse callback) {
        return Mono.fromCallable(() ->
            fonepayClient.verifyCallback(callback)
                         .isPaymentSuccessful())
            .subscribeOn(
                reactor.core.scheduler.Schedulers.boundedElastic());
    }
}
โ„น๏ธ

If you use both the reactive starter (for Khalti, eSewa, ConnectIPS) and need Fonepay, add the blocking nepal-pay-spring-boot-3-starter alongside the reactive starter. Both starters share the same nepalpay.* YAML configuration โ€” no duplicate config needed.

Callback Parameters Reference

Fonepay redirects to your returnUrl with these GET query parameters:

Param Description Example
PRN Your original Product Reference Number FP-ORD-001
PID Fonepay merchant code TEST_MERCHANT
PS Payment status โ€” verify DV first! success or failed
RC Response code 200
UID Fonepay unique transaction ID uid-001
BC Bank code of the paying bank GBIME
INI Transaction initiator 9800000001
P_AMT Paid amount in NPR 100
R_AMT Refund amount 0
DV HMAC-SHA512 signature โ€” VERIFY THIS FIRST A1B2C3... (UPPERCASE hex)

Signature algorithm

# Request signature field order (MANDATORY per Fonepay spec):
# PID,MD,PRN,AMT,CRN,DT,R1,R2,RU

# Response verification field order (MANDATORY):
# PRN,PID,PS,RC,UID,BC,INI,P_AMT,R_AMT

# HMAC-SHA512(secretKey, message, UTF-8) โ†’ hex lowercase
# DV from Fonepay is UPPERCASE hex
# NepalPay normalizes BOTH to UPPERCASE before constant-time comparison