Apr 26, 2026 / 8 min read
Integrating eSewa v2: form POSTs, signatures and money in paisa
How hosted payment gateways work, why you must verify payments server to server, and the details that make eSewa v2 payments succeed or quietly fail.
Hosted payment gateways look simple from the outside. You send the user to the gateway, they come back, and you mark the order paid. Most of the bugs live in the details between those steps: how you send the user, what comes back, how you confirm it, and how you store the money along the way. Get any of them wrong and every successful payment can look like a failure. Worse, a failed one can look like a success.
In this post I'll explain how hosted gateways work in general. Then I'll cover what matters with eSewa v2, Nepal's most widely used wallet: form POST checkouts, HMAC signatures that must match byte for byte, money stored in paisa, a redirect quirk that breaks URLs, and careful verification. I'll finish with a real case from a tournament app where every successful eSewa payment failed verification.
How a hosted payment flow works
Almost every hosted gateway (eSewa, Khalti, Stripe Checkout, PayPal) follows the same four steps:
- 1.Intent. Your server creates a payment record (amount, product, your own transaction ID). It then asks the gateway, or works out locally, what the client needs to start checkout.
- 2.Checkout. The client hands the user to the gateway, either by redirecting to a URL or by submitting a form.
- 3.Callback. The gateway sends the user back to a success or failure URL you gave it, usually with some data attached.
- 4.Verification. Your server asks the gateway directly whether the transaction really went through, for the amount you expected. Only then do you mark it paid.
Teams are tempted to skip step four, because step three already "says" the payment worked. Don't skip it.
Don't trust the callback on its own
Anything that reaches your server through the user's device can be replayed, edited or faked. A success URL with status=COMPLETE only proves that someone loaded that URL. Your verification should:
- call the gateway's status API from your server, with your credentials
- check the transaction belongs to the record you created (your own transaction ID, the gateway's reference)
- compare the verified amount with the amount you stored, not the amount the client reports
- check the signature on any signed data the gateway returns
It also helps to save the gateway's own reference when you create the intent. Khalti, for example, returns a pidx when you start a payment. If you save it, verification can look up the payment by your saved value instead of whatever ID the client sends back.
Money as whole numbers of the smallest unit
Floating-point numbers can't store most decimal amounts exactly. 0.1 + 0.2 isn't 0.3 in binary floating point, and rupee amounts with paisa have the same problem. The usual fix is to store money as a whole number of the smallest unit everywhere inside your system. Here that's paisa (1 rupee = 100 paisa):
type Enrollment struct {
EntryFee int64 // paisa
}Only convert to a decimal string at the edges: when you talk to a gateway that expects rupees, and when you show amounts to users. Every conversion should go through one function. Picture this: the API stores paisa, the admin dashboard stores rupees, and the app multiplies by 100 in one place but not another. Sooner or later a fee is off by a factor of a hundred.
Signatures must match byte for byte
eSewa v2 signs each request with an HMAC, a hash made with a shared secret key. It's computed over a string built from fields, something like total_amount=100.00,transaction_uuid=...,product_code=.... An HMAC signs bytes, not values. So 100, 100.0 and 100.00 are the same amount but three different signatures.
That means every place that builds the amount string has to build the exact same string: the checkout form, the signature, and the status-check query. The safest setup is one formatter that all of them use:
// One formatter, every call site.
func paisaToRupeeString(p int64) string {
return fmt.Sprintf("%.2f", float64(p)/100)
}If two places format the amount on their own, a mismatch shows up as a signature failure. It's very hard to track down, because the values look the same when you log them.
Form POSTs, not redirects
Some gateways accept a GET redirect with the parameters in the query string. eSewa v2 expects the client to POST a form to its /api/epay/main/v2/form endpoint. A GET with a query string gets rejected.
On the web, that's a hidden form submitted on page load. A mobile app using a WebView does the same thing: load an HTML page with the form and submit it from script. The server can stay generic by returning a method and a set of fields instead of just a URL:
type PaymentIntent struct {
URL string
Method string // "GET" or "POST"
FormFields map[string]string // signed fields for POST gateways
}The client then redirects for GET gateways and auto-submits a form for POST gateways. It doesn't need to know which gateway it's talking to.
Redirect quirks: keep your IDs in the path
eSewa sends the user back to your success_url by adding ?data=<base64 payload> to the end. If your success URL already has a query string, like /verify?payment_id=42, you end up with a URL that has two question marks:
/verify?payment_id=42?data=eyJ0cmFuc2FjdGlvbl9jb2RlIjoi...Most URL parsers now read the payment ID as 42?data=..., and the data parameter disappears. The sturdy fix is to keep your own IDs in the path. Then the gateway can add whatever query it likes:
/verify/42?data=eyJ0cmFuc2FjdGlvbl9jb2RlIjoi...The data value is base64-encoded JSON with the transaction code, status and a signature. Decode it and check the signature. Then still confirm with the status API.
Careful verification
Treat gateway responses with the same caution as user input:
- Use the right method. eSewa's status check is a GET with query parameters. A POST gets back an HTML 404 page, which then fails JSON decoding in confusing ways.
- Decode numbers flexibly. A field documented as a string may arrive as a JSON number. In Go,
json.Numberaccepts both. - Limit the read. Wrap the body in
io.LimitReader, so a broken endpoint can't make you hold unlimited data in memory. - Keep the evidence. When decoding fails, put a short excerpt of the raw body in the error. Then the log shows an HTML error page instead of "invalid character '<'".
Where I ran into this
I worked as a freelancer on Nepal Ludo League, a paid online Ludo tournament app with a Go API and a Flutter client. I owned the payment path for tournament entry. A teammate had written the original eSewa integration. It didn't yet work end to end with eSewa v2, and in testing every successful eSewa payment failed verification with HTTP 500.
Several separate problems were stacked on top of each other:
- The intent used a GET redirect where v2 needs a form POST.
- The status check used POST and got an HTML 404 back.
total_amountarrived as a JSON number but was typed as a string.- The success URL already had
?payment_id=, so eSewa's added?data=broke it.
The status check switched to GET, with json.Number for the amount:
// before
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(reqBody.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// ...
TotalAmount string `json:"total_amount"`
// after
verifyURL := fmt.Sprintf("%s/api/epay/transaction/status/?%s", g.baseURL, q.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
// ...
TotalAmount json.Number `json:"total_amount"`The payment ID moved from the query string into the path:
// before
successURL := fmt.Sprintf("%s/tournaments/%s/entry/verify?payment_id=%s", base, tournamentID, paymentID)
// after
successURL := fmt.Sprintf("%s/tournaments/%s/entry/verify/%s", base, tournamentID, paymentID)The gateway now returns a method and signed form fields. On mobile, the Flutter WebView auto-submits a hidden form. It decodes eSewa's base64 data payload before passing it to the verify endpoint. On the web, I added a same-tab flow with a payment-return screen.
I also moved all money in the system to whole paisa, across the Go API, the admin dashboard and the Flutter UI. One paisaToRupeeString helper feeds the checkout form, the HMAC and the status query.
Verification got stricter too:
- Khalti's
pidxis saved on the server when the intent is created. - Amount mismatches are logged with the expected and verified values in paisa.
- Gateway responses are read through a 64 KiB limit.
- Decode errors include the first 500 bytes of the raw body.
Test fixtures use 10000 paisa for NPR 100.
Last, retrying an entry after a cancelled or pending payment used to check first and then write, which could race. It became one atomic statement:
INSERT INTO enrollments (tournament_id, user_id, status, payment_id)
VALUES ($1, $2, 'pending', $3)
ON CONFLICT (tournament_id, user_id) DO UPDATE
SET status = 'pending', payment_id = EXCLUDED.payment_id
WHERE enrollments.status <> 'confirmed';The WHERE on the update means a retry never resets a confirmed entry. Abandoned attempts can still be retried safely.
Checklist
- Verify every payment server to server, and compare against the amount you stored.
- Save the gateway's reference when you create the intent, instead of trusting the client's copy.
- Keep money in whole numbers of the smallest unit, and convert only at the gateway and display edges.
- Build every signed string with one formatter.
- Check whether the gateway wants a redirect or a form POST, and let the server tell the client which.
- Keep your IDs in the URL path when the gateway adds its own query.
- Decode gateway responses with flexible number types, limited reads and raw-body excerpts in errors.
- Make payment retries a single atomic upsert that never downgrades a confirmed record.
Takeaways
- The redirect back from a gateway is a hint. The status check is the proof.
- Floats and money don't mix. Use paisa in the core and rupees at the edges.
- HMACs sign bytes, so equal values formatted differently give different signatures.
- Small gateway quirks, like an added query string, can fail every payment.
- Careful decoding turns a mystery 500 into a log line that explains itself.