
A production-grade guide to wiring Fonepay — Nepal's interbank QR and payment network — into a Go backend on the standard library alone. Constant-time HMAC-SHA512 signing, dynamic QR with real-time confirmation, the web-redirect flow, and the server-side verification, idempotency, and amount checks most guides leave out.
Almost every Nepali product eventually has to take money, and in Nepal that means Fonepay. The integration itself is small — a few signed HTTP calls — but the tutorials I keep finding stop at "generate the hash and redirect." They skip the part that actually matters: never trusting the amount the browser hands you, verifying the outcome server-to-server, and making sure a refreshed tab can't fulfil an order twice.
Go is a great fit for this. Constant-time comparison, HMAC, and XML parsing are all in the standard library, and context.Context makes the gateway timeout part of the type signature instead of an afterthought. The whole client below is a handful of files with zero dependencies — nothing to go get.
There's a companion guide — Integrating Fonepay with NestJS — covering the exact same flows in TypeScript. Same contracts, same field orders, same production rules.
Fonepay has no public developer portal and the spec ships with your merchant credentials, so exact field sets vary by API version. Every signing string and endpoint below is cross-checked against Fonepay's own Java/Spring sample, the official dynamic-QR demo, and community SDKs — but treat your merchant contract as the source of truth and diff it against this before going live.
What Fonepay actually is
Fonepay is a vertical of F1Soft and Nepal's largest payment network, licensed by Nepal Rastra Bank as a Payment System Operator. It's an interoperable EMV QR + interbank (IBFT) rail reaching ~64 banks and wallets, so a single Fonepay QR is scannable from nearly every mobile-banking app in the country. Transactions are NPR (the QR also accepts Indian UPI), with a typical customer-side daily cap of NPR 200,000.
You don't self-serve API keys. You enrol as a merchant through a supporting acquiring bank, and Fonepay (or the bank) issues your merchant code, secret key, and a username/password. There are separate dev and live credentials — but the sandbox only exposes a single "test bank", so the full set of payment methods only lights up in production.
The two integration paths
Fonepay gives you two completely separate APIs on two different hosts. Pick by where the customer is paying.
| Dynamic QR | Web Redirect (PG) | |
|---|---|---|
| Host | merchantapi.fonepay.com | clientapi.fonepay.com |
| Customer pays by | Scanning a QR in any banking app | Being redirected to Fonepay's page |
| Best for | In-app checkout, POS, "scan to pay" | Classic web checkout button |
| Confirmation | WebSocket push + status poll | Browser redirect + server verify |
| Response format | JSON | XML (verification call) |
Both are signed the same way, so let's build that primitive once.
The signing primitive: HMAC-SHA512
Every Fonepay request carries a dataValidation / DV field: an HMAC-SHA512 of specific fields joined by commas (no spaces), keyed by your secret. Two rules decide whether it works:
- Field order is exact and non-negotiable. A reordered field produces a valid-looking hash that Fonepay rejects.
- Sign the raw values, then URL-encode for transport. Hash first, encode second — never the other way around.
crypto/hmac ships a constant-time comparison (hmac.Equal) so you don't have to think about timing leaks — and it returns false on a length mismatch instead of panicking, which is the trap you have to guard by hand in most other languages.
package fonepay
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
)
// Sign returns the lowercase-hex HMAC-SHA512 of message, keyed by secret.
func Sign(secret, message string) string {
mac := hmac.New(sha512.New, []byte(secret))
mac.Write([]byte(message))
return hex.EncodeToString(mac.Sum(nil))
}
// SafeHexEqual compares two hex signatures in constant time.
// Never use `a == b` on the raw strings — that short-circuits and leaks timing.
func SafeHexEqual(a, b string) bool {
ba, err1 := hex.DecodeString(a)
bb, err2 := hex.DecodeString(b)
if err1 != nil || err2 != nil || len(ba) == 0 {
return false
}
return hmac.Equal(ba, bb) // false (constant-time) if lengths differ
}Fonepay's own samples disagree on case — the Java helper emits uppercase, the community packages emit lowercase. Because SafeHexEqual decodes both sides to bytes before comparing, it's immune to the difference. That's exactly why we compare decoded bytes instead of strings.
Configuration
One config struct, fed from the environment. The secret never leaves the server and never enters git.
package fonepay
import "os"
type Config struct {
PGBaseURL string // https://dev-clientapi.fonepay.com
QRBaseURL string // https://uat-new-merchant-api.fonepay.com/api
MerchantCode string // PID
Secret string // never sent in any payload
Username string
Password string
ReturnURL string // public HTTPS callback
}
func FromEnv() Config {
return Config{
PGBaseURL: os.Getenv("FONEPAY_PG_URL"),
QRBaseURL: os.Getenv("FONEPAY_QR_URL"),
MerchantCode: os.Getenv("FONEPAY_MERCHANT_CODE"),
Secret: os.Getenv("FONEPAY_SECRET"),
Username: os.Getenv("FONEPAY_USERNAME"),
Password: os.Getenv("FONEPAY_PASSWORD"),
ReturnURL: os.Getenv("FONEPAY_RETURN_URL"),
}
}# Sandbox hosts — swap for the live hosts in production.
FONEPAY_PG_URL=https://dev-clientapi.fonepay.com
FONEPAY_QR_URL=https://uat-new-merchant-api.fonepay.com/api
FONEPAY_MERCHANT_CODE=NBQM
FONEPAY_SECRET=your_merchant_secret_key
FONEPAY_USERNAME=your_username
FONEPAY_PASSWORD=your_password
FONEPAY_RETURN_URL=https://your-tunnel.example.com/payments/fonepay/returnSources split between uat-new-merchant-api.fonepay.com and dev-merchantapi.fonepay.com for the QR sandbox. Use whichever your onboarding packet names; the live host (merchantapi.fonepay.com) is consistent everywhere.
Flow A: Dynamic QR
The shape: you ask Fonepay for a QR, render the returned string as an image, and the customer scans it. Confirmation arrives two ways — a real-time WebSocket push (great UX) and a status endpoint (your source of truth).
1. Generate the QR
POST /merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload. The dataValidation signs amount,prn,merchantCode,remarks1,remarks2 — note the PRN sits before the merchant code. Always pass a context.Context so a stalled gateway can't pin a goroutine forever.
package fonepay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type QRResult struct {
QRMessage string `json:"qrMessage"` // encode THIS as the QR image
WebSocketURL string `json:"thirdpartyQrWebSocketUrl"`
StatusCode int `json:"statusCode"`
}
func (c Config) CreateDynamicQR(ctx context.Context, prn string, amount int, remarks1, remarks2 string) (*QRResult, error) {
// EXACT order: amount, prn, merchantCode, remarks1, remarks2
msg := fmt.Sprintf("%d,%s,%s,%s,%s", amount, prn, c.MerchantCode, remarks1, remarks2)
body, _ := json.Marshal(map[string]any{
"amount": amount,
"remarks1": remarks1,
"remarks2": remarks2,
"prn": prn,
"merchantCode": c.MerchantCode,
"dataValidation": Sign(c.Secret, msg),
"username": c.Username,
"password": c.Password,
})
url := c.QRBaseURL + "/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload"
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("qr download failed: HTTP %d", res.StatusCode)
}
var out QRResult
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}Render QRMessage as a scannable image on the front end (any QR library will do — the payload is just a string).
2. Confirm in real time, verify for real
The download response carries thirdpartyQrWebSocketUrl. The browser opens that socket for an instant "Paid!" the moment the customer's bank confirms — but the browser is untrusted, so the server still polls the status endpoint before it believes anything. That keeps your Go backend dependency-free: no WebSocket client needed server-side, just one more signed POST.
thirdPartyDynamicQrGetStatus signs prn,merchantCode:
package fonepay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type QRStatus struct {
PaymentStatus string `json:"paymentStatus"` // "success" | "pending" | "failed"
FonepayTraceID int64 `json:"fonepayTraceId"`
PRN string `json:"prn"`
}
func (c Config) GetQRStatus(ctx context.Context, prn string) (*QRStatus, error) {
// EXACT order: prn, merchantCode
dv := Sign(c.Secret, fmt.Sprintf("%s,%s", prn, c.MerchantCode))
body, _ := json.Marshal(map[string]any{
"prn": prn,
"merchantCode": c.MerchantCode,
"dataValidation": dv,
"username": c.Username,
"password": c.Password,
})
url := c.QRBaseURL + "/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrGetStatus"
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qr status failed: HTTP %d", res.StatusCode)
}
var out QRStatus
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}Anyone can connect to a socket and tell your front end "paid!". The push event is a hint to refresh the UI; settlement is whatever GetQRStatus returns from your server. Same rule as every gateway: the client tells you when to check, never what the answer is.
Flow B: Web Redirect (PG)
Here the customer leaves your site for Fonepay's hosted page and comes back. You build one signed URL, redirect, then verify on return.
1. Build the signed redirect URL
BuildPaymentURL signs PID,MD,PRN,AMT,CRN,DT,R1,R2,RU. Mind the details: MD is always P, CRN is NPR, and DT is MM/DD/YYYY — US month-first, not the day-first format your instinct reaches for in Nepal. Go's reference date makes that self-documenting: 01/02/2006.
package fonepay
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
func (c Config) BuildPaymentURL(prn string, amount int, remarks1, remarks2 string) string {
dt := time.Now().Format("01/02/2006") // MM/DD/YYYY
amt := strconv.Itoa(amount)
// Sign the RAW values in this EXACT order, THEN encode for transport.
dv := Sign(c.Secret, strings.Join([]string{
c.MerchantCode, "P", prn, amt, "NPR", dt, remarks1, remarks2, c.ReturnURL,
}, ","))
q := url.Values{}
for k, v := range map[string]string{
"PID": c.MerchantCode, "MD": "P", "PRN": prn, "AMT": amt, "CRN": "NPR",
"DT": dt, "R1": remarks1, "R2": remarks2, "RU": c.ReturnURL, "DV": dv,
} {
q.Set(k, v)
}
return fmt.Sprintf("%s/api/merchantRequest?%s", c.PGBaseURL, q.Encode())
}Redirect the customer to that URL and they land on Fonepay's payment page.
2. Handle the return, then verify server-to-server
Fonepay's official flow redirects back to your RU with just PRN, BID, and UID. Those query params are not proof of payment — they only tell you which transaction to go verify. The truth comes from a server-to-server call whose DV signs PID,AMT,PRN,BID,UID, and the response is XML — which encoding/xml unmarshals straight into a struct, no parser dependency. The amount you pass in is your expected amount, read from your database, never from the redirect query.
package fonepay
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
)
type Verification struct {
XMLName xml.Name `xml:"response"`
Amount float64 `xml:"amount"`
BankCode string `xml:"bankCode"`
Message string `xml:"message"`
ResponseCode string `xml:"response_code"` // "successful" on success
Success bool `xml:"success"`
UniqueID string `xml:"uniqueId"`
}
func (c Config) VerifyTransaction(ctx context.Context, prn, bid, uid string, amount int) (*Verification, error) {
amt := strconv.Itoa(amount)
// EXACT order: PID,AMT,PRN,BID,UID
dv := Sign(c.Secret, fmt.Sprintf("%s,%s,%s,%s,%s", c.MerchantCode, amt, prn, bid, uid))
q := url.Values{}
for k, v := range map[string]string{
"PRN": prn, "PID": c.MerchantCode, "BID": bid, "AMT": amt, "UID": uid, "DV": dv,
} {
q.Set(k, v)
}
endpoint := fmt.Sprintf("%s/api/merchantRequest/verificationMerchant?%s", c.PGBaseURL, q.Encode())
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("verification failed: HTTP %d", res.StatusCode)
}
raw, _ := io.ReadAll(res.Body)
var v Verification
if err := xml.Unmarshal(raw, &v); err != nil {
return nil, err
}
return &v, nil
}The XML you're unmarshalling looks like this:
<response>
<amount>100</amount>
<txnAmount>100</txnAmount>
<bankCode>BANK</bankCode>
<initiator>98XXXXXXXX</initiator>
<message>payment success</message>
<response_code>successful</response_code>
<statusCode>1</statusCode>
<success>true</success>
<uniqueId>202406201234567</uniqueId>
</response>Some Fonepay contracts instead redirect to RU with a fuller param set (PRN, PID, PS, RC, UID, BC, INI, P_AMT, R_AMT, DV) where the response DV signs PRN,PID,PS,RC,UID,BC,INI,P_AMT,R_AMT. If that's yours, check that signature with SafeHexEqual as a first gate — but still run the server-to-server VerifyTransaction above before you fulfil. The redirect is convenience; the verification call is authority.
Wiring it into net/http
A standard handler. Notice what it does not do: it never reads an amount from the request at verification time — the amount always comes from your database.
package main
import (
"context"
"net/http"
"os"
"time"
"yourapp/fonepay"
)
// OrderStore is your DB layer. MarkPaid must be a single atomic statement —
// e.g. UPDATE orders SET status='PAID' WHERE id=$1 AND status='PENDING' —
// returning true only for the first caller that wins the row.
type OrderStore interface {
ByPRN(prn string) (Order, error)
MarkPaid(id, gatewayRef string) (bool, error)
MarkFailed(id string) error
Fulfil(id string) error
}
func HandleReturn(fp fonepay.Config, store OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
order, err := store.ByPRN(q.Get("PRN"))
if err != nil {
http.Error(w, "unknown payment", http.StatusNotFound)
return
}
base := os.Getenv("CLIENT_URL")
if order.Status != "PENDING" { // refresh / retry safe
redirect(w, r, base, order.Status)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Source of truth — not the redirect query.
v, err := fp.VerifyTransaction(ctx, order.PRN, q.Get("BID"), q.Get("UID"), order.ExpectedAmount)
if err != nil || !v.Success || v.ResponseCode != "successful" {
_ = store.MarkFailed(order.ID)
redirect(w, r, base, "FAILED")
return
}
// Re-check the paid amount against what we stored (reject under- AND overpay).
if int(v.Amount) != order.ExpectedAmount {
http.Error(w, "amount mismatch", http.StatusBadRequest)
return
}
// Atomic: only the first caller flips PENDING -> PAID and fulfils.
if won, _ := store.MarkPaid(order.ID, v.UniqueID); won {
_ = store.Fulfil(order.ID) // runs once, ever
}
redirect(w, r, base, "PAID")
}
}
func redirect(w http.ResponseWriter, r *http.Request, base, status string) {
dest := base + "/payment/failed"
if status == "PAID" {
dest = base + "/payment/success"
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}Getting it right in production
This is the part the copy-paste tutorials skip, and it's the only part that protects your revenue.
Never trust the client's amount
The browser can change any number you send it. So you derive the amount twice from your own data: once when you create the payment, and again when you verify it. A PRN needs to be unique per attempt — crypto/rand gives you that without a UUID dependency.
package payments
import (
"context"
"crypto/rand"
"encoding/hex"
)
// newPRN returns a unique reference number — no external uuid package needed.
func newPRN() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// CreatePayment re-prices the order from YOUR data and persists the PRN +
// expected amount as the source of truth. The client never sends an amount.
func (s *Service) CreatePayment(ctx context.Context, orderID string) (prn string, amount int, err error) {
order, err := s.orders.ByID(ctx, orderID)
if err != nil {
return "", 0, err
}
// Re-derive from line items in your DB; ignore any client-sent total.
expected := 0
for _, li := range order.LineItems {
expected += li.UnitPrice * li.Qty
}
prn = newPRN()
if err := s.orders.InitPayment(ctx, orderID, prn, expected); err != nil {
return "", 0, err
}
return prn, expected, nil
}Fulfil exactly once
Fonepay can hit your return URL more than once, the customer can refresh it, and your QR poller can fire at the same time. Without a guard, that's a double-shipped order. The fix is the state machine in the handler above plus one atomic transition — MarkPaid must be a single statement so only the first caller wins:
UPDATE orders
SET status = 'PAID', gateway_ref = $2
WHERE id = $1 AND status = 'PENDING';
-- rows affected == 1 -> you won the race, fulfil now
-- rows affected == 0 -> someone already did, do nothingReconcile the stragglers
Networks drop. A payment can succeed at the bank while your callback never arrives, leaving an order stuck PENDING. Run a goroutine on a time.Ticker (or a cron) that re-checks pending orders against GetQRStatus (QR) or VerifyTransaction (redirect) and resolves them. Without it, you'll quietly lose real, paid orders.
The rest of the checklist
- Secrets live in env or a secret manager — never in client code, never committed.
- Your return URL must be public HTTPS. For local dev, tunnel it:
ngrok http 3000orcloudflared. - Always pass a
contextwith a timeout (every call above takes one) so a stalled gateway can't hang a request or leak a goroutine. - Compare signatures with
SafeHexEqual, never with==.
Testing in the sandbox
Point your config at the dev hosts (dev-clientapi.fonepay.com, uat-new-merchant-api.fonepay.com) and use the credentials from your onboarding packet. Fonepay's public Spring sample ships working dev keys — merchant code NBQM — handy for a first redirect smoke test. Remember the sandbox only offers a single test bank; the full bank/wallet list only appears with live credentials.
Troubleshooting
Nearly every Fonepay bug is the signature. When Fonepay rejects a request, check, in order:
- Field order. It must match the signing string exactly —
amount,prn,merchantCode,...for QR,PID,MD,PRN,...for redirect. One swapped field, silent failure. - Encode-after-sign. Hash the raw values; URL-encode only for transport.
url.Values.Encode()runs afterSign, never before. - Date format. Redirect
DTisMM/DD/YYYY— the Go layout01/02/2006. June 20th is06/20/2026, not20/06/2026. - Hex case.
SafeHexEqualdecodes to bytes, so case never bites you — but don't fall back to==on the strings. - Wrong host. QR lives on
merchantapi, redirect onclientapi. They are not interchangeable.
Conclusion
The Fonepay integration is genuinely small — sign a string, make a call, verify the result. What separates a demo from something you'd put real money through is everything around that core: deriving amounts from your own database, treating the gateway's server-to-server response as the only authority, and making fulfilment idempotent so no refresh or retry can charge or ship twice.
Go makes the security-critical bits pleasant — constant-time comparison and XML parsing in the standard library, timeouts in the type signature — so the whole Fonepay client is a few dependency-free files you can vendor into a service and forget about. Which is exactly what you want a payment rail to be.
Prefer TypeScript? The same integration, built on NestJS, lives in Integrating Fonepay with NestJS.
Fonepay's contract can differ by API version and acquiring bank. Use this as the map, not the territory — always diff it against the spec that came with your credentials before you ship.