
A production-grade guide to wiring ConnectIPS — NCHL's account-to-account payment rail for Nepal — into a Go backend. SHA256withRSA token signing straight from a PKCS12 keystore, the redirect flow, server-to-server transaction validation, and the idempotency and amount checks most guides leave out.
ConnectIPS is the other half of taking money in Nepal. Where Fonepay is the QR-and-wallet network, ConnectIPS is the rail that debits a customer's bank account directly — and almost every Nepali checkout ends up supporting both. The integration is small, but it trips people up because it's nothing like an HMAC gateway: you sign a very specific string with an RSA private key from a .pfx keystore, get the field order or the amount unit slightly wrong, and the gateway rejects your token with no useful error.
Go is a good fit for it. crypto/rsa signs natively, and unlike most languages you can read the PKCS12 keystore directly — one small dependency for the .pfx, the rest is standard library, and context.Context keeps the gateway timeout in the type signature.
There's a companion guide — Integrating ConnectIPS with NestJS — covering the exact same steps in TypeScript. Same signed strings, same field orders, same production rules.
ConnectIPS field sets and the production host vary by API version and by what NCHL provisions for your merchant. Every signed string and endpoint below is cross-checked against NCHL's official docs and multiple community SDKs (PHP, Go, Node) — but treat your onboarding packet as the source of truth and validate on UAT before going live.
What ConnectIPS actually is
ConnectIPS is run by NCHL (Nepal Clearing House) — the same outfit behind the country's interbank clearing. It lets a customer pay straight from a linked bank account (account-to-account / IBFT), and it's everywhere: utility bills, government payments, e-commerce checkouts. For a merchant it's a hosted redirect: you POST a signed form to ConnectIPS, the customer authenticates with their bank login, and you confirm the result with a server-side API call.
The defining trait — and the thing that makes it feel unlike Fonepay or eSewa — is that every request is signed with RSA, not a shared secret. NCHL gives you a PKCS12 keystore (CREDITOR.pfx) holding your private key; you sign with it, and ConnectIPS verifies with the matching public key it already has on file.
You don't self-serve credentials. You onboard through NCHL (directly or via a member bank), and they issue your Merchant ID, App ID (like MER-550-APP-1), App Name, a web-service password, and the CREDITOR.pfx keystore (with its own password). Your success and failure return URLs must be pre-registered with their integration team.
How the flow works
ConnectIPS is one flow with a server-side confirmation chaser:
- Build a signed form — RSA-sign a fixed field string, drop the signature into a
TOKENfield, and POST the form to the gateway. - Redirect — the customer lands on ConnectIPS, logs into their bank, and pays.
- Return — ConnectIPS sends the browser back to your success URL with
?TXNID=<your-txnid>appended. That redirect is not proof of payment. - Validate — your server calls the
validatetxnAPI (signed, Basic-authed) and only treats the order as paid when it returnsstatus: "SUCCESS".
| Value | |
|---|---|
| Gateway (redirect) | {base}/connectipswebgw/loginpage |
| Validation API | {base}/connectipswebws/api/creditor/validatetxn |
| UAT base | https://uat.connectips.com |
| Production base | https://www.connectips.com (confirm with NCHL) |
| Signing | SHA256withRSA, private key from .pfx, Base64 |
| Amount unit | paisa (rupees × 100) |
The signing primitive: SHA256withRSA
Everything ConnectIPS verifies is a Base64 SHA256withRSA signature over a comma-joined KEY=value string. Three rules decide whether it works:
- Field order is exact, the separator is a comma, and there are no spaces (the official doc shows stray spaces — ignore them; every working client omits them).
- The amount is in paisa, and the same value must appear in the signed string and in the form/JSON field. Mix rupees and paisa and the signature is "valid" but rejected.
- Sign the raw string, then Base64 the signature — never the other way around.
Go reads the .pfx directly with golang.org/x/crypto/pkcs12, so there's no openssl conversion step — load the keystore at startup and keep the *rsa.PrivateKey on your config.
go get golang.org/x/crypto/pkcs12package connectips
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"golang.org/x/crypto/pkcs12"
)
// LoadKey extracts the RSA private key from a PKCS12 (.pfx) keystore.
func LoadKey(pfx []byte, password string) (*rsa.PrivateKey, error) {
key, _, err := pkcs12.Decode(pfx, password)
if err != nil {
return nil, fmt.Errorf("decode pfx: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("keystore key is not RSA")
}
return rsaKey, nil
}
// Sign returns the Base64 SHA256withRSA signature of message — a ConnectIPS token.
func Sign(key *rsa.PrivateKey, message string) (string, error) {
digest := sha256.Sum256([]byte(message))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}ConnectIPS hands you two secrets: the keystore password (unlocks the private key in CREDITOR.pfx, used by LoadKey at startup) and the web-service password (HTTP Basic auth for validatetxn). They are different values used at different steps. Mixing them up is the most common reason validation returns 401.
Configuration
Load the keystore once at startup and hold the parsed key on the config.
package connectips
import (
"crypto/rsa"
"os"
)
type Config struct {
BaseURL string // https://uat.connectips.com
MerchantID string // e.g. 550
AppID string // MER-550-APP-1
AppName string
Password string // web-service password (Basic auth)
SuccessURL string // pre-registered with NCHL
FailureURL string
Key *rsa.PrivateKey // loaded from the .pfx
}
func FromEnv() (Config, error) {
pfx, err := os.ReadFile(os.Getenv("CONNECTIPS_PFX_PATH"))
if err != nil {
return Config{}, err
}
key, err := LoadKey(pfx, os.Getenv("CONNECTIPS_KEYSTORE_PASSWORD"))
if err != nil {
return Config{}, err
}
return Config{
BaseURL: os.Getenv("CONNECTIPS_URL"),
MerchantID: os.Getenv("CONNECTIPS_MERCHANT_ID"),
AppID: os.Getenv("CONNECTIPS_APP_ID"),
AppName: os.Getenv("CONNECTIPS_APP_NAME"),
Password: os.Getenv("CONNECTIPS_PASSWORD"),
SuccessURL: os.Getenv("CONNECTIPS_SUCCESS_URL"),
FailureURL: os.Getenv("CONNECTIPS_FAILURE_URL"),
Key: key,
}, nil
}CONNECTIPS_URL=https://uat.connectips.com
CONNECTIPS_MERCHANT_ID=550
CONNECTIPS_APP_ID=MER-550-APP-1
CONNECTIPS_APP_NAME=YourApp
CONNECTIPS_PASSWORD=your_webservice_password
CONNECTIPS_PFX_PATH=./secrets/CREDITOR.pfx
CONNECTIPS_KEYSTORE_PASSWORD=your_keystore_password
CONNECTIPS_SUCCESS_URL=https://your-app.example.com/payments/connectips/return
CONNECTIPS_FAILURE_URL=https://your-app.example.com/payments/connectips/failedStep 1: Build the signed payment form
The redirect TOKEN signs eleven fields in this exact order, ending with the literal TOKEN=TOKEN placeholder. TXNDATE is DD-MM-YYYY (Go layout 02-01-2006), TXNCRNCY is NPR, and most fields cap at 20 characters.
| Field | Meaning |
|---|---|
MERCHANTID | Your numeric merchant id |
APPID | Your app id, e.g. MER-550-APP-1 |
APPNAME | Your app name |
TXNID | Your unique transaction id (≤ 20 chars) |
TXNDATE | Origination date, DD-MM-YYYY |
TXNCRNCY | Currency — NPR |
TXNAMT | Amount in paisa |
REFERENCEID | Your reference/extra info |
REMARKS | Short remark |
PARTICULARS | Additional remark |
TOKEN | The Base64 signature |
package connectips
import (
"fmt"
"time"
)
type PaymentForm struct {
Action string
Fields map[string]string
Order []string // stable field order for form rendering
}
func (c Config) BuildPaymentForm(txnID string, amountPaisa int, referenceID, remarks, particulars string) (PaymentForm, error) {
txnDate := time.Now().Format("02-01-2006") // DD-MM-YYYY
f := map[string]string{
"MERCHANTID": c.MerchantID,
"APPID": c.AppID,
"APPNAME": c.AppName,
"TXNID": txnID,
"TXNDATE": txnDate,
"TXNCRNCY": "NPR",
"TXNAMT": fmt.Sprintf("%d", amountPaisa),
"REFERENCEID": referenceID,
"REMARKS": remarks,
"PARTICULARS": particulars,
}
// EXACT order, comma-joined, NO spaces, literal TOKEN=TOKEN suffix.
message := fmt.Sprintf(
"MERCHANTID=%s,APPID=%s,APPNAME=%s,TXNID=%s,TXNDATE=%s,TXNCRNCY=%s,"+
"TXNAMT=%s,REFERENCEID=%s,REMARKS=%s,PARTICULARS=%s,TOKEN=TOKEN",
f["MERCHANTID"], f["APPID"], f["APPNAME"], f["TXNID"], f["TXNDATE"],
f["TXNCRNCY"], f["TXNAMT"], f["REFERENCEID"], f["REMARKS"], f["PARTICULARS"],
)
token, err := Sign(c.Key, message)
if err != nil {
return PaymentForm{}, err
}
f["TOKEN"] = token
return PaymentForm{
Action: c.BaseURL + "/connectipswebgw/loginpage",
Fields: f,
Order: []string{
"MERCHANTID", "APPID", "APPNAME", "TXNID", "TXNDATE", "TXNCRNCY",
"TXNAMT", "REFERENCEID", "REMARKS", "PARTICULARS", "TOKEN",
},
}, nil
}ConnectIPS expects a form POST, not a GET redirect, so write a tiny auto-submitting form straight to the response:
package connectips
import (
"html"
"net/http"
"strings"
)
// WriteAutoSubmitForm sends a self-submitting form that bounces the browser to
// ConnectIPS. html.EscapeString guards every value against injection.
func (f PaymentForm) WriteAutoSubmitForm(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var b strings.Builder
b.WriteString(`<!doctype html><html><body onload="document.forms[0].submit()">`)
b.WriteString(`<form method="POST" action="` + html.EscapeString(f.Action) + `">`)
for _, k := range f.Order {
b.WriteString(`<input type="hidden" name="` + html.EscapeString(k) +
`" value="` + html.EscapeString(f.Fields[k]) + `">`)
}
b.WriteString(`</form></body></html>`)
_, _ = w.Write([]byte(b.String()))
}Step 2: Handle the return, then validate server-to-server
ConnectIPS redirects back to your success URL with ?TXNID=<txnid> — and nothing else trustworthy. The truth comes from validatetxn: a JSON POST, HTTP Basic auth (App ID as the username, the web-service password), with its own RSA token signing four fields.
Two gotchas the docs bury:
- The validation token signs
MERCHANTID,APPID,REFERENCEID,TXNAMT(uppercase keys) — but the JSON body uses camelCase (merchantId,appId, …). - The
referenceIdyou send here is the originalTXNID, not the originalREFERENCEID. Yes, really.
package connectips
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
)
type Validation struct {
Status string `json:"status"` // "SUCCESS" when paid
StatusDesc string `json:"statusDesc"`
TxnAmt string `json:"txnAmt"` // echoed back as a string
}
func (c Config) ValidateTxn(ctx context.Context, txnID string, amountPaisa int) (*Validation, error) {
// 4-field token, UPPERCASE keys, no TOKEN=TOKEN suffix.
token, err := Sign(c.Key, fmt.Sprintf(
"MERCHANTID=%s,APPID=%s,REFERENCEID=%s,TXNAMT=%d",
c.MerchantID, c.AppID, txnID, amountPaisa,
))
if err != nil {
return nil, err
}
mid, _ := strconv.Atoi(c.MerchantID)
// camelCase body; referenceId carries the original TXNID.
body, _ := json.Marshal(map[string]any{
"merchantId": mid,
"appId": c.AppID,
"referenceId": txnID,
"txnAmt": amountPaisa,
"token": token,
})
url := c.BaseURL + "/connectipswebws/api/creditor/validatetxn"
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
auth := base64.StdEncoding.EncodeToString([]byte(c.AppID + ":" + c.Password))
req.Header.Set("Authorization", "Basic "+auth)
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("validatetxn failed: HTTP %d", res.StatusCode)
}
var v Validation
if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
return nil, err
}
return &v, nil
}A successful response looks like this (note txnAmt comes back as a string, and statusDesc really is misspelled):
{
"merchantId": 550,
"appId": "MER-550-APP-1",
"referenceId": "txn-123",
"txnAmt": "500",
"token": null,
"status": "SUCCESS",
"statusDesc": "TRANSACTION SUCESSFULL"
}Wiring it into net/http
Two handlers: start a payment (writes the auto-submit form) and handle the return (validates, then redirects). The amount is never read from the request — it comes from your database.
package main
import (
"net/http"
"os"
"yourapp/connectips"
)
// 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 {
ByID(id string) (Order, error)
ByTxnID(txnID string) (Order, error)
MarkPaid(id string) (bool, error)
MarkFailed(id string) error
Fulfil(id string) error
}
func StartPayment(cfg connectips.Config, store OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
order, err := store.ByID(r.PathValue("orderId"))
if err != nil {
http.Error(w, "unknown order", http.StatusNotFound)
return
}
form, err := cfg.BuildPaymentForm(
order.TxnID, order.ExpectedAmountPaisa, // from the DB, not the request
order.ID, "Order "+order.ID, "Checkout",
)
if err != nil {
http.Error(w, "could not start payment", http.StatusInternalServerError)
return
}
form.WriteAutoSubmitForm(w)
}
}
func HandleReturn(cfg connectips.Config, store OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status, _ := Settle(r.Context(), cfg, store, r.URL.Query().Get("TXNID"))
base := os.Getenv("CLIENT_URL")
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 — and keep paisa consistent
The browser can change any number you send it, so derive the amount server-side from your own data, store it as paisa, and reuse that exact value everywhere: in the signed token, in TXNAMT, and in the validatetxn call. The single most common ConnectIPS bug is signing rupees in one place and sending paisa in another.
package payments
import (
"context"
"crypto/rand"
"encoding/hex"
)
// newTxnID returns a unique transaction id (<= 20 chars).
func newTxnID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return "T" + hex.EncodeToString(b)
}
func (s *Service) CreatePayment(ctx context.Context, orderID string) (txnID string, amountPaisa int, err error) {
order, err := s.orders.ByID(ctx, orderID)
if err != nil {
return "", 0, err
}
// Re-price from line items in YOUR database; ignore any client total.
rupees := 0
for _, li := range order.LineItems {
rupees += li.UnitPrice * li.Qty
}
amountPaisa = rupees * 100 // paisa, integer
txnID = newTxnID()
if err := s.orders.InitPayment(ctx, orderID, txnID, amountPaisa); err != nil {
return "", 0, err
}
return txnID, amountPaisa, nil
}Fulfil exactly once
ConnectIPS can land on your return URL more than once, and the customer can refresh it. Drive a PENDING → PAID/FAILED state machine and gate fulfilment behind one atomic transition so only the first caller wins. The validatetxn call — never the redirect — is the source of truth.
package payments
import (
"context"
"fmt"
"strconv"
"time"
"yourapp/connectips"
)
func Settle(ctx context.Context, cfg connectips.Config, store OrderStore, txnID string) (string, error) {
order, err := store.ByTxnID(txnID)
if err != nil {
return "", err
}
if order.Status != "PENDING" { // refresh / retry safe
return order.Status, nil
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Source of truth — not the redirect query.
v, err := cfg.ValidateTxn(ctx, txnID, order.ExpectedAmountPaisa)
if err != nil {
return "", err
}
if v.Status != "SUCCESS" {
_ = store.MarkFailed(order.ID)
return "FAILED", nil
}
// Re-check the validated amount against what we stored.
if v.TxnAmt != strconv.Itoa(order.ExpectedAmountPaisa) {
return "", fmt.Errorf("amount mismatch: got %s, expected %d", v.TxnAmt, order.ExpectedAmountPaisa)
}
// Atomic: only the first caller flips PENDING -> PAID and fulfils.
if won, _ := store.MarkPaid(order.ID); won {
_ = store.Fulfil(order.ID) // runs once, ever
}
return "PAID", nil
}The rest of the checklist
- Guard the keystore. Never commit
CREDITOR.pfx; mount it as a secret with locked-down file permissions. It holds your signing key — leaking it lets anyone forge your tokens. - Reconcile stragglers. If a customer pays but your return never fires, the order sits
PENDING. A goroutine on atime.Tickerthat re-runsValidateTxn(orgettxndetail) on pending orders recovers real, paid transactions. - Always pass a
contextwith a timeout (every call above takes one) so a stalled gateway can't hang a request or leak a goroutine. - Register both return URLs (success and failure) with NCHL — unregistered URLs are rejected.
Testing in UAT
Point CONNECTIPS_URL at https://uat.connectips.com and use the test bundle NCHL provides — CREDITOR.pfx plus a Merchant ID, App ID, App Name, and web-service password. Drop the .pfx at CONNECTIPS_PFX_PATH, set the keystore password, register your success/failure URLs with their integration team, and run a full redirect → return → validatetxn round-trip before switching to the production host.
Troubleshooting
Almost every ConnectIPS bug is the token or the amount. When the gateway rejects you, check, in order:
- Field order and spacing. The redirect token is the 11 fields in the exact order above, comma-joined, no spaces, ending with the literal
TOKEN=TOKEN. The validation token is the 4 fieldsMERCHANTID,APPID,REFERENCEID,TXNAMT. - Paisa everywhere. Sign and send the same integer paisa value. Rs 5.00 is
500, never5. - Date format.
TXNDATEisDD-MM-YYYY— the Go layout02-01-2006. June 22nd is22-06-2026. referenceId= the originalTXNIDin thevalidatetxnbody, not yourREFERENCEID.401on validatetxn → you used the keystore password (or wrong App ID) instead of the web-service password for Basic auth.- Sign-then-encode. Hash and sign the raw string, then Base64 the signature. Signing an already-encoded string fails silently.
Conclusion
ConnectIPS looks intimidating because of the keystore, but the shape is simple: sign a fixed string with your RSA key, POST a form, and confirm with one server-side call. What separates a demo from something you'd put real money through is everything around that core — deriving amounts as paisa from your own database, treating validatetxn as the only authority, making fulfilment idempotent, and guarding the private key like the credential it is.
Go makes the crypto pleasant — pkcs12.Decode plus rsa.SignPKCS1v15 is a few lines, no openssl conversion dance — so the whole ConnectIPS client is a handful of small files you can vendor into a service and forget about.
Prefer TypeScript? The same integration, built on NestJS, lives in Integrating ConnectIPS with NestJS.
ConnectIPS contracts differ by API version and acquiring bank. Use this as the map, not the territory — always diff it against the spec and credentials NCHL gives you, and validate on UAT before you ship.