Command Palette

Search for a command to run...

Blog

Integrating Fonepay with Go — QR, Web Redirect

Integrating Fonepay with Go — QR, Web Redirect

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.


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 QRWeb Redirect (PG)
Hostmerchantapi.fonepay.comclientapi.fonepay.com
Customer pays byScanning a QR in any banking appBeing redirected to Fonepay's page
Best forIn-app checkout, POS, "scan to pay"Classic web checkout button
ConfirmationWebSocket push + status pollBrowser redirect + server verify
Response formatJSONXML (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:

  1. Field order is exact and non-negotiable. A reordered field produces a valid-looking hash that Fonepay rejects.
  2. 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.

fonepay/signing.go
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
}

Configuration

One config struct, fed from the environment. The secret never leaves the server and never enters git.

fonepay/config.go
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"),
	}
}
.env
# 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/return

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.

fonepay/qr.go
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:

fonepay/qr_status.go
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
}

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.

fonepay/redirect.go
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.

fonepay/verify.go
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:

verificationMerchant response
<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>

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.

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

payments/create.go
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:

exactly-once transition
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 nothing

Reconcile 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 3000 or cloudflared.
  • Always pass a context with 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 after Sign, never before.
  • Date format. Redirect DT is MM/DD/YYYY — the Go layout 01/02/2006. June 20th is 06/20/2026, not 20/06/2026.
  • Hex case. SafeHexEqual decodes to bytes, so case never bites you — but don't fall back to == on the strings.
  • Wrong host. QR lives on merchantapi, redirect on clientapi. 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.

Command Palette

Search for a command to run...