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.
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