Command Palette

Search for a command to run...

Blog

Integrating ConnectIPS with Go — RSA Tokens, Validation

Integrating ConnectIPS with Go — RSA Tokens, Validation

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.


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:

  1. Build a signed form — RSA-sign a fixed field string, drop the signature into a TOKEN field, and POST the form to the gateway.
  2. Redirect — the customer lands on ConnectIPS, logs into their bank, and pays.
  3. Return — ConnectIPS sends the browser back to your success URL with ?TXNID=<your-txnid> appended. That redirect is not proof of payment.
  4. Validate — your server calls the validatetxn API (signed, Basic-authed) and only treats the order as paid when it returns status: "SUCCESS".
Value
Gateway (redirect){base}/connectipswebgw/loginpage
Validation API{base}/connectipswebws/api/creditor/validatetxn
UAT basehttps://uat.connectips.com
Production basehttps://www.connectips.com (confirm with NCHL)
SigningSHA256withRSA, private key from .pfx, Base64
Amount unitpaisa (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:

  1. 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).
  2. 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.
  3. 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.

terminal
go get golang.org/x/crypto/pkcs12
connectips/signing.go
package 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
}

Configuration

Load the keystore once at startup and hold the parsed key on the config.

connectips/config.go
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
}
.env
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/failed

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

FieldMeaning
MERCHANTIDYour numeric merchant id
APPIDYour app id, e.g. MER-550-APP-1
APPNAMEYour app name
TXNIDYour unique transaction id (≤ 20 chars)
TXNDATEOrigination date, DD-MM-YYYY
TXNCRNCYCurrency — NPR
TXNAMTAmount in paisa
REFERENCEIDYour reference/extra info
REMARKSShort remark
PARTICULARSAdditional remark
TOKENThe Base64 signature
connectips/payment.go
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:

connectips/autosubmit.go
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 referenceId you send here is the original TXNID, not the original REFERENCEID. Yes, really.
connectips/validate.go
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):

validatetxn response
{
  "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.

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

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

payments/settle.go
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 a time.Ticker that re-runs ValidateTxn (or gettxndetail) on pending orders recovers real, paid transactions.
  • 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.
  • 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 fields MERCHANTID,APPID,REFERENCEID,TXNAMT.
  • Paisa everywhere. Sign and send the same integer paisa value. Rs 5.00 is 500, never 5.
  • Date format. TXNDATE is DD-MM-YYYY — the Go layout 02-01-2006. June 22nd is 22-06-2026.
  • referenceId = the original TXNID in the validatetxn body, not your REFERENCEID.
  • 401 on 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.

Command Palette

Search for a command to run...