<SYSTEM>This document contains comprehensive information about Ranjan Yadav's professional profile, portfolio, and blog content. It includes personal details, work experience, projects, achievements, certifications, and all published blog posts. This data is formatted for consumption by Large Language Models (LLMs) to provide accurate and up-to-date information about Ranjan Yadav's background, skills, and expertise.</SYSTEM>

# Ranjan Yadav

> Personal dev portfolio and blog.

## About

- Backend-focused engineer — Go and NestJS, building scalable, real-time systems with an eye for clean architecture.
- I reach for the frontend (React, Next.js, Angular) when a project needs it; the backend is where I prefer to be. Off the clock, I read to unwind.

### Personal Information

- First Name: Ranjan
- Last Name: Yadav
- Display Name: Ranjan Yadav
- Location: Kathmandu, Nepal
- Website: https://ranjanyadav.com.np

### Social Links

- [GitHub](https://github.com/ranjanydv)
- [X](https://x.com/theranzanydv)
- [LinkedIn](https://linkedin.com/in/theranzanydv)

### Tech Stack

- [Go](https://go.dev)
- [NestJS](https://nestjs.com)
- [Serverless](https://aws.amazon.com/serverless/)
- [Next.js](https://nextjs.org)
- [Kubernetes](https://kubernetes.io)
- [TypeScript](https://www.typescriptlang.org)
- [PostgreSQL](https://www.postgresql.org)
- [Redis](https://redis.io)
- [RabbitMQ](https://www.rabbitmq.com/)
- [MongoDB](https://www.mongodb.com)
- [Node.js](https://nodejs.org)
- [Express](https://expressjs.com)
- [Firebase](https://firebase.google.com)
- [Angular](https://angular.dev)
- [Tailwind CSS](https://tailwindcss.com)
- [Flutter](https://flutter.dev)
- [Docker](https://www.docker.com)
- [Linux](https://www.linux.org)
- [Omarchy](https://omarchy.org/)

## Experience

### Software Engineer | Nomor LLC

Duration: 09.2024 - Present

Skills: Go, NestJS, PostgreSQL, Redis

Backend services and systems engineering with Go and NestJS.

### MEAN Stack Developer | NSW IT Support

Duration: 05.2023 - 09.2024

Skills: Angular, Node.js, REST API, CI/CD

Built and maintained EvergrowCRM (SaaS) — Angular front end, Node.js APIs, and CI/CD.

### London Metropolitan University — BSc (Hons) Computing | Education

Duration: 03.2020 - 05.2023

Skills: N/A

BSc (Hons) Computing (Computer Science), First-Class Honors — via Islington College, Kathmandu.

## Projects

### NEPSE Trading Analysis & Learning Platform

Project URL: https://bullhouseinvestment.com

Skills: Go, NestJS, PostgreSQL, Redis

Trading-analysis and learning platform for Nepal's stock market — a Go backend for NEPSE market data, TradingView charts, and high-parallelism compute, plus a NestJS marketplace (courses, subscriptions, live streaming).

### ShopIt

Project URL: https://shopitnepal.com

Skills: NestJS, Next.js, PostgreSQL, Redis

Quick-commerce platform for sub-10-minute grocery delivery — customer storefront, packer admin, and rider API, with zone-based routing and real-time order tracking.

### SkoolSewa — School Management

Project URL: https://skoolsewa.com

Skills: NestJS, Prisma, PostgreSQL, Next.js

School management for Nepali schools — students, staff, fees, attendance, exams, and ID-card generation.

### School Accounting System

Project URL: https://skoolsewa.com

Skills: NestJS, Prisma, PostgreSQL, React

Double-entry accounting for Nepali schools — ledgers, fee invoices, and reports (trial balance, P&L) with Bikram Sambat dates.

## Awards



## Certifications



## Blog

---
title: "Syncing My AI Coding Agent's Memory Across Devices with Supabase"
description: "How I built a Supabase-backed sync layer for my AI coding agent's memory, skills, and configs — the schema, the auth trick, optimistic concurrency, tombstoned deletes, and the real bugs a second device exposed."
last_updated: "August 3, 2026"
source: "https://ranjanyadav.com.np/blog/sync-ai-agent-memory-supabase"
---

# Syncing My AI Coding Agent's Memory Across Devices with Supabase

How I built a Supabase-backed sync layer for my AI coding agent's memory, skills, and configs — the schema, the auth trick, optimistic concurrency, tombstoned deletes, and the real bugs a second device exposed.

I work off two machines — a Linux desktop and a Mac mini — and I use Claude Code as my day-to-day coding agent on both. The problem is that everything an agent like this learns over time — memory files, custom skills, hooks, configuration — lives in a local config directory on whichever machine you happened to be sitting at when it learned it. Switch machines, and the agent forgets everything. It has no idea it's the same "you."

I spent a day building a proper fix: a Supabase-backed sync layer that keeps memory, skills, and configs consistent across every device, in near real time, with conflict handling instead of silent overwrites. This is the write-up — architecture, the decisions that mattered, and the bugs that only showed up once a second real machine was in the loop.

***

## The problem, precisely

An AI coding agent's local state isn't one file — it's a scattered tree: free-form memory notes it writes about your projects, custom skills you've installed, hook scripts, credentials-adjacent config, session state. Some of it is safe to sync everywhere. Some of it (logs, caches, plugin installs, session transcripts) is either too large, too disposable, or actively shouldn't leave the machine.

So the goal wasn't "sync a folder." It was: sync the *right* subset, incrementally, with real conflict detection, in a way that survives two machines with genuinely different filesystem layouts.

I ended up syncing seven categories of state — the memory notes the agent writes per project, my agent brains' own memory, those brains' configuration, globally installed skills, a canonical skills store, global config, and a folder of personal system docs — 502 files in total. Deliberately excluded: installed plugins (over a gigabyte, and trivially reinstallable), session transcripts (multiple gigabytes of conversation history), logs, caches, and anything that looked like a credentials file. A secret scan runs before every push and aborts on any hit, so even an accidental `.env` sitting in a synced directory can't leave the machine.

***

## Why not just Git, Dropbox, or iCloud

The honest first question was whether I needed a database at all. A `git` repo of dotfiles would get me versioning almost for free. But it fails on the two things that actually mattered here: **structured conflict resolution** (git merge conflicts on machine-generated memory files are useless — there's no human paged in to resolve them) and **partial, field-level sync** (I needed to track a version number and an executable bit per file, not just blob content). Dropbox/iCloud-style file sync gives you neither — it's last-write-wins, full stop, and that is exactly the failure mode that quietly corrupts memory: two machines both learn something in the same session window, and one throws the other's knowledge away without telling anyone.

A small Postgres schema behind a purpose-built API gave me both for a modest amount of extra setup.

***

## Architecture at a glance

The shape of the system:

* A **private Postgres schema**, not exposed through PostgREST directly. Four thin `public`-schema wrapper functions are the *entire* external API surface — push a file, pull changes since a cursor, and two small helpers. Hit the underlying tables directly with the anon key and you get a 404; call an RPC without a valid session and you get a 401.
* A lightweight **CLI client** that scans the local filesystem, diffs against last-known state, and talks to those four functions over plain PostgREST — no bulk SQL, just batched HTTP.
* **Row-level security** keyed to the authenticated user, verified by literally creating a second auth user and confirming zero rows are visible to it anywhere.
* A **session hook** that makes the whole thing invisible: pull on session start, push on session stop.

```sql title="schema (simplified)"
create schema claude_sync;
create sequence claude_sync.change_seq;

create table claude_sync.files (
  id             uuid primary key default gen_random_uuid(),
  owner_id       uuid not null references auth.users(id),
  bucket         text not null,
  rel_path       text not null,
  content_b64    text,
  content_sha256 text not null,
  symlink_target text,
  version        int not null default 1,
  exec_bit       boolean not null default false,
  deleted        boolean not null default false,
  seq            bigint not null default nextval('claude_sync.change_seq'),
  updated_at     timestamptz not null default now()
);

create table claude_sync.file_versions (
  file_id     uuid references claude_sync.files(id),
  version     int not null,
  content_b64 text,
  created_at  timestamptz not null default now()
);

-- the only publicly exposed entry points (plus two small helpers):
-- claude_sync_push_file(...), claude_sync_pull_changes(...)
```

***

## Authentication without a real identity

Every device needs to authenticate as *something*. The tempting shortcut is tying sync to a personal account — but that couples a general-purpose infrastructure tool to a specific login, which is exactly the kind of thing that becomes a headache later (shared devices, revocation, "wait, which account is this synced under?").

So the sync identity is a single, disposable, non-personal auth user, deliberately unrelated to any real account. Creating it cleanly was more fiddly than expected: Supabase's signup API rejects obviously-fake domains outright, and the "clever" workaround of using an unregistered-but-real-looking domain backfired — it actually tried to *send a confirmation email* to a domain I don't own, and got throttled for it.

The fix was to skip the signup flow entirely: generate a password locally, hash it with `htpasswd`'s bcrypt mode, normalize the hash prefix (Go's bcrypt implementation only accepts `2a`/`2b`, and some tools emit `2y`), and insert directly into `auth.users` with `email_confirmed_at` already set. No email ever gets dispatched; only the hash is ever visible.

That path has its own trap: hand-inserting into `auth.users` breaks sign-in with an opaque `500 Database error querying schema` unless every one of the token columns (`confirmation_token`, `recovery_token`, the `email_change*`/`phone_change*` family) is set to an empty string rather than left `NULL`. The auth server scans them straight into non-nullable Go strings, and a `NULL` blows that up before you ever see a useful error.

Each device then holds its own **rotating refresh token** on disk (locked-down permissions, no plaintext password after the initial login), which means a compromised or retired device can be revoked individually without touching the others.

***

## Optimistic concurrency, not last-write-wins

This was the non-negotiable design choice. Every push carries the `base_version` the client last saw for that file. If the server's current version has moved on *and* the content actually differs, the push is rejected outright with a `conflict` response — it never silently picks a winner. The client backs up its local copy, tells you to pull first, and lets you decide. I verified this by intentionally racing two edits against the same file; the loser gets stopped, not overwritten.

Reads work off a single global, ever-increasing sequence number. Each device just remembers the last sequence it has seen and asks for everything after it — a trivial, cheap "what's new" query that scales fine regardless of total history size.

Deletes are **tombstones**, never hard deletes. A removed file gets `deleted = true` and its row stays. That's what makes deletion *propagate* — a hard delete has nothing left to sync — and it means the full version history for a file survives even after it's gone.

***

## Inside the sync function: scan, push, pull

The CLI itself is a single script with a handful of subcommands — `login`, `status`, `push`, `pull`, `sync` (push then pull), `bootstrap` (first run on a brand-new machine), and `export`/`import` (a full dump-and-restore pair for migrating between projects). Everything else described so far — the schema, the conflict handling, the tombstones — exists to serve three functions: `scan`, `push`, and `pull`. The interesting decisions live at this level, not in the schema, so it's worth walking through what they actually do.

### Scan: turning a filesystem into a diffable list

Every push and every status check starts the same way — walk each synced bucket, and for each file compute a content hash, a size, and whether the executable bit is set:

```js title="scan (trimmed)"
const add = (bucket, rel, file) => {
  if (file.link !== null) {
    rows.push({
      bucket,
      rel_path: rel,
      content: null,
      symlink_target: file.link,
      content_sha256: sha256("link:" + file.link),
    })
    return
  }
  const st = statSync(file.path)
  if (st.size > MAX_BYTES) return // skip anything over 1MB
  const content = readFileSync(file.path, "utf8")
  if (content.includes("\0")) return // binary — a NUL byte can't appear in real text
  rows.push({
    bucket,
    rel_path: rel,
    content,
    content_sha256: sha256(content),
    exec_bit: (st.mode & 0o111) !== 0,
    frontmatter: rel.endsWith(".md") ? parseFrontmatter(content) : null,
  })
}
```

Two choices worth calling out. Binary detection isn't an extension whitelist — it's just "does this file contain a NUL byte." Real text never does; almost everything else does, so a two-line check quietly handles images and compiled binaries without a list to keep up to date. And for markdown memory files specifically, `scan` parses just the YAML frontmatter — name, description, type — into its own column server-side, which is what makes it possible to search across every memory ever written, from any device, without pulling full file contents first.

### The gate before anything leaves the machine

Before a single row is sent anywhere, every file's content is tested against a short list of recognizable secret formats:

```js title="secret gate"
const SECRETS = [
  [/\bsk-ant-[A-Za-z0-9_-]{20,}/, "anthropic-key"],
  [/\bghp_[A-Za-z0-9]{36}/, "github-pat"],
  [/\bsbp_[a-f0-9]{40}/, "supabase-pat"],
  [/\bAKIA[0-9A-Z]{16}\b/, "aws-key"],
  [/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/, "private-key"],
]
```

A single match anywhere in the changeset aborts the *entire* push, not just that one file, and prints exactly which bucket and path tripped it. It's a circuit breaker, not a linter — better to fail loudly and force a fix at the source (usually: that file shouldn't be in a synced directory at all) than push 501 clean files and quietly swallow the 502nd.

### Push: diff, batch, and let the server say no

`push` calls `scan`, then diffs the result in memory against a small local state file that remembers the last known `{ version, sha, exec }` for every file this device has ever synced:

```js title="push (trimmed)"
for (const r of rows) {
  const known = state.files[key(r.bucket, r.rel_path)]
  if (known && known.sha === r.content_sha256 && known.exec === r.exec_bit)
    continue // unchanged
  changed.push({ ...r, base_version: known?.version ?? null })
}
// anything the state file remembers that scan() no longer sees locally = a delete
for (const k of Object.keys(state.files)) {
  if (!seen.has(k))
    deletes.push({ ...tombstoneFor(k), base_version: state.files[k].version })
}
```

A local cache plus a server-side cursor means running `push` on an unmodified machine costs one round trip that returns nothing — not a full re-upload. Real changes and deletes get batched into requests capped around 400KB and applied one row at a time, server-side, by `push_file`:

```sql title="push_file (trimmed)"
if not found then
  insert into claude_sync.files (...) returning * into cur;
  return jsonb_build_object('status', 'created', 'version', cur.version);
end if;

if cur.content_sha256 = p_sha256 and cur.deleted = p_deleted then
  return jsonb_build_object('status', 'unchanged', 'version', cur.version);
end if;

if p_base_version is not null and p_base_version <> cur.version then
  return jsonb_build_object('status', 'conflict', 'server_version', cur.version, 'client_base', p_base_version);
end if;

update claude_sync.files
   set content_b64 = p_content_b64, version = cur.version + 1,
       seq = nextval('claude_sync.change_seq')
 where id = cur.id
returning * into cur;
return jsonb_build_object('status', 'updated', 'version', cur.version);
```

Four possible outcomes per file, and the client only updates its local cache for the three that aren't `conflict`. A conflict deliberately leaves the cache untouched — the next push offers the same `base_version` again until you actually pull and resolve it. What that looks like from the terminal:

```text
1 CONFLICT(S) — remote changed since this device last synced:
  global-config/hooks/pre-commit.sh  (server v4)
run: claude-sync pull   then re-push (remote wins on pull; your local copy is backed up)
```

### Pull: apply changes without trusting the filesystem

`pull` asks for everything with `seq > cursor`, then walks the results applying each one locally — and this is where most of the edge-case handling described earlier actually lives. Before writing anything, it precomputes every local path that a *live* (non-deleted) row in this batch will claim, specifically so a tombstone for an old, since-superseded row can never delete a file a live row in the same batch just wrote:

```js title="pull (trimmed)"
const claimed = new Set(
  active.filter((r) => !r.deleted).map((r) => localPath(r.bucket, r.rel_path))
)

for (const r of active) {
  const abs = localPath(r.bucket, r.rel_path)

  if (r.deleted) {
    if (!claimed.has(abs) && existsSync(abs)) {
      rmSync(abs)
      pruneEmptyDirs(abs, bucketRoot(r.bucket))
    }
    continue
  }

  // edited locally since last sync AND changed remotely — don't guess, surface it
  const known = state.files[key(r.bucket, r.rel_path)]
  if (
    known &&
    localSha(abs) !== known.sha &&
    localSha(abs) !== r.content_sha256
  ) {
    localEdits.push(`${r.bucket}/${r.rel_path}`)
    continue
  }

  if (isDirectory(abs)) backupThenRemove(abs) // a dir here, a file/symlink at the source
  if (r.symlink_target) symlinkSync(r.symlink_target, abs)
  else {
    writeFileSync(abs, r.content ?? "")
    chmodSync(abs, r.exec_bit ? 0o755 : 0o644)
  }

  state.files[key(r.bucket, r.rel_path)] = {
    version: r.version,
    sha: r.content_sha256,
    exec: r.exec_bit,
  }
  state.lastSeq = Math.max(state.lastSeq, r.seq)
}
```

Every branch there exists because of a real failure mode: the claimed-path check stops a stale tombstone from erasing a live file, the local-edit check stops a pull from silently overwriting work that hasn't been pushed yet, the directory check stops a crash when the same path is a symlink on one machine and a real directory on another, and the explicit `chmodSync` is the fix for hook scripts that used to arrive non-executable. None of it is exotic — all of it was necessary.

The server side of pull, by comparison, is close to the simplest function in the whole system:

```sql title="pull_changes"
select id, bucket, rel_path, version, exec_bit, deleted, seq,
       symlink_target, content_sha256,
       convert_from(decode(content_b64, 'base64'), 'UTF8') as content
  from claude_sync.files
 where owner_id = auth.uid() and seq > p_since
   and (p_buckets is null or bucket = any(p_buckets))
 order by seq;
```

The real work is entirely in what the client does with the rows it gets back. And every insert or update on `files` fires a trigger that appends an immutable copy into `file_versions` before the row is ever returned to anyone — so even a bad push is recoverable by walking history, not by hoping the client cached the right thing.

***

## The hardest bug: path portability

This is the one that actually took the most thought, and it's specific to how coding agents like this one organize per-project memory: each project's memory directory is named after the absolute path to that project, with every `/` replaced by `-`.

That scheme is **lossy**. A directory named `-home-me-dev-self-ranjan-portfolio` could just as easily be `/home/me/dev/self/ranjan-portfolio` as `/home/me/dev/self-ranjan/portfolio` — a real hyphen in a folder name is indistinguishable from the separator once it's been flattened. Trying to reconstruct the original path by splitting on `-` is a guessing game you will eventually lose.

The fix was to never invert the slug at all. Instead, build the map **forward** on each machine independently: walk the local project root, slugify every real directory exactly the way the agent does, and index `slug → true relative path`. The *true* path is what gets stored and synced — not the slug — so each device just re-derives its own slug from the true path using its own root, and the two never have to agree on directory layout.

```js title="building the reverse map (forward, not inverted)"
const map = new Map()

walk(projectRoot, (absPath) => {
  const slug = absPath.replaceAll("/", "-") // exactly how the agent names it
  map.set(slug, path.relative(projectRoot, absPath))
})
```

One more edge case: a project that existed on the device where a memory was written might not exist locally yet. Rather than drop those memories, they fall back to a raw `_slug:<original>` key so they still round-trip — this alone rescued a handful of files that belonged to a repo that had since been removed from disk.

***

## What a firewall taught me about my own traffic

Midway through testing, a push started dying partway through, returning an HTML error page where JSON should have been. The cause: a WAF sitting in front of the database's REST layer was reading synced shell scripts and SQL snippets in the request body and flagging them as an injection attempt — not unreasonably, since raw source code and injection payloads look similar to a pattern matcher.

The fix wasn't a security workaround, just an encoding change: send file content as base64 (`content_b64`) and decode it server-side. That makes the request body opaque to a WAF doing text pattern matching, without changing anything about who can read the data — reads were never affected, only the shape of push bodies.

***

## Testing with one device lies to you

Everything above worked cleanly with a single machine talking to itself. The moment a *second*, genuinely different machine joined — different home directory layout, different OS — it surfaced a run of real bugs that one-machine testing had no way to catch, because a single client only ever validates against its own assumptions.

A few worth calling out:

**A naive path split broke on nested project keys.** The pull logic split each stored path on the *first* `/` to separate "project key" from "file within it." That's correct for a flat key, but wrong the moment a project key itself contains a slash (a project nested a few directories deep). Files landed in the wrong location entirely — and worse, it looked completely fine, because the tool re-read its own wrong output and reported zero drift. The fix was an explicit `//` delimiter between the project key and the filename, which no legitimate path can ever contain naturally; old rows written before the fix still needed the legacy split path preserved so their history and tombstones kept resolving correctly.

**A stale tombstone could erase a live file.** When a memory got re-organized under a new key, the old row's eventual tombstone and the new row's live content could resolve to the same local path. Applied strictly in sequence order, the higher-numbered tombstone landed *after* the live write and deleted a file that had just been correctly created. The fix: precompute every path claimed by a currently-live row first, and let a live claim always beat a tombstone for the same path, regardless of ordering.

**The same path was a symlink on one machine and a real directory on the other.** One synced skill had been hand-copied as a directory on one machine, while the setup script on the other had created it as a symlink. A plain file read on a symlink-to-a-directory throws `EISDIR`, which the pull path hadn't accounted for. Fixed by checking the link status explicitly before touching it, copying the whole tree aside as a backup, and only then replacing it.

**File permissions weren't part of the sync payload at all.** Push captured content but not the executable bit, so a pulled hook script arrived on the second machine as a plain, non-executable file — and the hook that was supposed to *run* sync silently did nothing, forever, with no error. Fixed by threading an `exec_bit` column through push and pull, and treating a permissions-only change as a real change worth syncing even when the bytes are identical.

**Sync direction mattered more than expected.** The original flow was pull-then-push, which is backwards: it meant a fresh pull could silently overwrite an edit you'd just made locally before that edit ever got a chance to push. It now pushes first, then pulls — and pull refuses to blindly take the remote version of a file that's been edited locally *and* changed remotely since the last sync; it flags that case explicitly instead of guessing.

**One platform's assumptions don't hold on the other.** The locking primitive the hook relied on to prevent two sessions racing each other doesn't exist on macOS at all. The guard was written as "take the lock, or exit quietly" — the intended behavior when another session already holds it. With the lock command missing, that first half failed on every single invocation, the "exit quietly" fired, and the hook returned success without doing anything, forever. A guard designed to fail silently will fail silently for reasons you never intended. Replaced with an atomic directory-creation lock plus a staleness timeout, which works identically on both platforms. A related date-formatting flag used for log timestamps also isn't supported by the BSD version of `date` bundled with macOS — different flag, same underlying idea, needed per-platform handling.

**Deleting a file left an empty shell behind.** Pull removed files correctly but never cleaned up directories that were now empty, so a deleted skill left a hollow directory tree lying around on other devices — invisible to the agent, but very visible to anyone poking at the filesystem. Pull now walks upward from every removed file, pruning empty directories until it hits the top of that sync category.

Every single one of these was invisible with one machine and obvious within minutes of a second, real machine joining. If you're building anything that claims to sync across environments, the lesson is blunt: one environment cannot validate a sync system, full stop — you need at least two, and they need to actually differ.

***

## The hook: invisible by design

The entire point of building this was to never think about it. The integration point is two lifecycle hooks: pull when a session starts, push when it stops.

```json title="hook registration (excerpt)"
{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "command": "claude-sync-hook.sh pull" }] }],
    "Stop": [{ "hooks": [{ "command": "claude-sync-hook.sh push" }] }]
  }
}
```

A few properties made this actually disappear rather than becoming background friction:

* It's fully detached and adds roughly 2ms of session startup latency — the sync itself runs out-of-band.
* It's lock-guarded, so two sessions starting at once can't race each other into a corrupted push.
* When nothing local has changed, it exits before making any network call at all — a no-op costs nothing.
* Its own log file self-truncates instead of growing forever.

***

## Bootstrapping a machine that has nothing

There's a chicken-and-egg at the bottom of all this: the sync client can't sync itself. It lives outside every bucket it manages, so a brand-new machine needs the script and its config copied across by hand — scp, USB, whatever's easiest — before it can pull a single byte. Two files deliberately don't come along: the session token and the local state cache. Both are per-device, and copying them makes two machines claim one identity and share one change cursor, which means each quietly skips the other's updates.

Only one thing in that config is machine-specific: the roots. The server stores every path *relative* to them, which is the whole reason the same memory lands in the right place on a filesystem laid out differently — one machine keeps its repos in `~/development`, the other in `~/dev`, and neither has to know that about the other.

After that, `bootstrap` is just `pull` with the cursor reset to zero and a backup directory forced on:

```js title="bootstrap"
state.lastSeq = 0
saveState(state)
await pull({ since: 0, backupDir })
```

The forced backup is the part that matters. A machine that's "new" to sync usually isn't empty — mine had months of hand-copied skills sitting at exactly the paths the pull was about to write to. Every local file that differs from the remote gets copied into a timestamped directory before it's replaced, and that path gets printed at the end. Nothing is destroyed silently, even on a first run that touches 502 files.

The honest cost of living outside the buckets is that the client can't *update* itself either. Change it on one machine and the others keep running the old version until you copy it over — which is exactly the kind of silent drift the rest of this system exists to prevent. I've been bitten by it once already.

***

## Migrating without losing a byte

The first version of this lived in a throwaway project — good enough to prove the design, not something to build a habit on top of. Moving to a real, permanent project meant a full export from the old database and import into the new one: 502 live rows re-created, then a verification pass confirming zero drift between source and destination before the old project was decommissioned. Cutover, not migration-in-place — safer to prove correctness on a copy first than to mutate the thing you're relying on.

***

## What's still open

Two honest loose ends: a security advisory flagging that leaked-password protection isn't enabled at the project level yet (a dashboard toggle, not a code fix), and no retry handling for network failures — a dropped Wi-Fi connection mid-push is a "when," not an "if," and the client still treats every sync as though it will succeed.

***

## The actual takeaway

The interesting problems here weren't the ones I expected going in. I expected the hard part to be "design a sync protocol." It wasn't — optimistic concurrency and a sequence cursor are well-worn patterns. The hard part was everywhere the *real world* disagreed with my assumptions: a WAF that reads code as an attack, an auth table that silently rejects `NULL` in the wrong column, a path-encoding scheme that's lossy in a way you won't notice until two directories collide, a locking primitive that doesn't exist on half your fleet.

Single-machine testing will tell you your protocol is correct. It will tell you nothing about whether your system survives contact with an environment you didn't build it in. That gap is where almost every bug above was hiding, and it's the reason the second device — annoying as it was to debug against — was worth setting up before calling this done.


Last updated on August 3, 2026

---
title: "Install PostgreSQL 18 on Ubuntu — PGDG Repository, Security, First Steps"
description: "A production-focused guide to installing PostgreSQL 18 on Ubuntu 24.04 using the official PGDG apt repository — covering the repo setup, post-install security, remote access, and the configuration decisions most tutorials gloss over."
last_updated: "June 26, 2026"
source: "https://ranjanyadav.com.np/blog/install-postgresql-18-ubuntu-vps"
---

# Install PostgreSQL 18 on Ubuntu — PGDG Repository, Security, First Steps

A production-focused guide to installing PostgreSQL 18 on Ubuntu 24.04 using the official PGDG apt repository — covering the repo setup, post-install security, remote access, and the configuration decisions most tutorials gloss over.

Ubuntu's default apt repository ships PostgreSQL 14 on Noble. If you `apt install postgresql` and walk away, you get a version that's two major releases behind, missing features like improved BRIN indexes, incremental backups, and the performance work that landed in 15 through 18. The fix is a single script from the PostgreSQL Global Development Group — but the script is only the start. Most guides stop at "service is running." This one covers what you actually need to make that database safe to leave running on a VPS: authentication modes, remote access with real constraints, a dedicated application user, and the configuration knobs that matter.

<Callout className="bg-info/10 inset-ring-info/35" title="Setting up a fresh VPS?">
  If you haven't deployed your server yet, the companion guide — [**Deploy a Node.js App on Ubuntu with Nginx, PM2, and Let's Encrypt**](/blog/deploy-nodejs-vps-nginx) — covers the full server setup from SSH access to SSL. PostgreSQL fits naturally after that foundation.
</Callout>

<Callout title="Read this before you copy a line">
  This guide targets **Ubuntu 24.04 LTS (Noble Numbat)**. The PGDG script auto-detects your release, so the same steps work on 22.04 (Jammy) — but the cluster path changes from `18/main` to match your OS. Verify with `lsb_release -cs` before you start.
</Callout>

***

## What you are actually installing

PostgreSQL's release cadence is one major version per year. Ubuntu LTS ships whatever was stable at freeze time and backports security fixes but not features. The **PGDG apt repository** — maintained by the PostgreSQL project itself — carries every major version from 11 through 18 for all active Ubuntu releases, updated the day a new version ships.

When you add PGDG, `apt` gets a new source alongside Ubuntu's default packages. PGDG's PostgreSQL packages take precedence by pin priority, and the package manager handles upgrades normally from that point on. You are not bypassing apt — you are extending it.

***

## Prerequisites

* Ubuntu 24.04 VPS with SSH access
* A user account with sudo privileges

Verify your release codename before anything else — it determines which repository line the script adds:

```bash title="terminal"
lsb_release -cs
```

Expected output on Noble:

```text
noble
```

***

## Step 1: Update the Server

```bash title="terminal"
sudo apt update && sudo apt upgrade -y
```

***

## Step 2: Add the PGDG Repository

Install the packages needed to configure the repository:

```bash title="terminal"
sudo apt install -y postgresql-common ca-certificates
```

Run the official PGDG setup script. It detects your Ubuntu release, adds the correct signing key, and writes the apt source — no manual keyring handling:

```bash title="terminal"
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
```

Update the package index to include the new repository:

```bash title="terminal"
sudo apt update
```

***

## Step 3: Install PostgreSQL 18

```bash title="terminal"
sudo apt install -y postgresql-18
```

This installs the server, initialises the default cluster at `/var/lib/postgresql/18/main`, and starts the service. The installer also creates a system user named `postgres` that owns the cluster.

***

## Step 4: Verify the Installation

Check that the cluster is online:

```bash title="terminal"
pg_lsclusters
```

Expected output:

```text
Ver  Cluster  Port  Status  Owner     Data directory
18   main     5432  online  postgres  /var/lib/postgresql/18/main
```

Confirm the server version:

```bash title="terminal"
sudo -u postgres psql -c "SELECT version();"
```

Check the service status:

```bash title="terminal"
sudo systemctl status postgresql@18-main
```

***

## Step 5: Set a Password for the postgres Superuser

The `postgres` OS user can connect to the database cluster without a password by default — that is fine for a local socket, but you need a database password before exposing anything to a network.

```bash title="terminal"
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'your-strong-password';"
```

<Callout className="bg-destructive/10 inset-ring-destructive/35" title="Do this before Step 6">
  `postgres` is a full superuser — it can read every table, drop every database, and create new superusers. Never use it for application queries, and never leave it passwordless on a machine with network access.
</Callout>

***

## Step 6: Understand Authentication Before Changing It

PostgreSQL's authentication is controlled by two files:

| File              | What it controls                                                             |
| ----------------- | ---------------------------------------------------------------------------- |
| `postgresql.conf` | Server settings — port, listen address, memory, logging                      |
| `pg_hba.conf`     | Client authentication rules — who can connect, from where, using what method |

Both live at `/etc/postgresql/18/main/`. `pg_hba.conf` is the one most guides skip past, and it is the most important for security.

The default `pg_hba.conf` on Ubuntu allows:

* Local socket connections from the `postgres` OS user via `peer` auth — this is how `sudo -u postgres psql` works without a password.
* Local socket connections for all other users via `scram-sha-256`.
* No remote connections at all.

That default is deliberately conservative. Do not open remote access unless you actually need it.

***

## Step 7: Create a Database and User

You should never connect your application as `postgres`. Create a dedicated user and database with only the permissions the application needs.

```bash title="terminal"
sudo -u postgres psql
```

```sql title="psql"
CREATE USER myuser WITH ENCRYPTED PASSWORD 'strong-password';
CREATE DATABASE mydb OWNER myuser;
GRANT ALL PRIVILEGES ON DATABASE mydb TO myuser;
\q
```

Test that the new user can connect:

```bash title="terminal"
psql -h 127.0.0.1 -U myuser -d mydb
```

<Callout title="Why -h 127.0.0.1 instead of just psql?">
  Without `-h`, `psql` uses a Unix socket and triggers `peer` auth — which checks that your OS username matches the database username. By passing `-h 127.0.0.1`, you force a TCP connection and trigger password auth instead. That is the same path your application will take.
</Callout>

***

## Step 8: Enable Remote Access (Only If Needed)

By default, PostgreSQL only listens on the loopback interface. If your application server is on the **same machine**, you do not need this — stay on `localhost`. Only proceed if you have a separate app server or need to connect from your local machine.

### 1. Allow listening on all interfaces

```bash title="terminal"
sudo nano /etc/postgresql/18/main/postgresql.conf
```

Find the `listen_addresses` line and update it:

```text title="/etc/postgresql/18/main/postgresql.conf"
listen_addresses = '*'
```

### 2. Add a remote authentication rule

```bash title="terminal"
sudo nano /etc/postgresql/18/main/pg_hba.conf
```

To allow a specific IP only:

```text title="/etc/postgresql/18/main/pg_hba.conf"
host  all  all  203.0.113.10/32  scram-sha-256
```

To allow a subnet (for example, a private network between your servers):

```text title="/etc/postgresql/18/main/pg_hba.conf"
host  all  all  10.0.0.0/24  scram-sha-256
```

### 3. Restart PostgreSQL

```bash title="terminal"
sudo systemctl restart postgresql@18-main
```

### 4. Open the firewall for that IP only

```bash title="terminal"
sudo ufw allow from 203.0.113.10 to any port 5432
```

<Callout className="bg-destructive/10 inset-ring-destructive/35" title="Never open 5432 to 0.0.0.0/0">
  A PostgreSQL port open to the internet is one of the most scanned targets in existence. Even with a strong password, you are exposed to brute force and any zero-days in the PostgreSQL authentication code. Restrict `pg_hba.conf` to the exact IP or CIDR of your app server, and match that with a UFW rule. If you need access from your laptop for debugging, use an SSH tunnel — `ssh -L 5432:localhost:5432 user@yourserver` — rather than punching a firewall hole.
</Callout>

***

## Getting it right in production

This is the part the copy-paste tutorials skip.

### Use scram-sha-256 everywhere, never md5

Ubuntu 24.04's PGDG packages default to `scram-sha-256` for new clusters — that is correct. If you see `md5` in your `pg_hba.conf`, replace it. MD5 password hashing in PostgreSQL predates modern cryptography standards: it is unsalted, fast to brute-force, and has been deprecated since PostgreSQL 14.

Check your current authentication method:

```bash title="terminal"
grep "^host\|^local" /etc/postgresql/18/main/pg_hba.conf
```

Every line should end with `scram-sha-256`, not `md5`.

### Grant only what the application needs

`GRANT ALL PRIVILEGES ON DATABASE` gives the user the right to connect and create objects within the database. It does **not** automatically grant read/write access to existing tables — that is a separate step.

For a typical web application with full read/write on all tables:

```sql title="psql"
\c mydb
GRANT USAGE ON SCHEMA public TO myuser;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO myuser;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO myuser;

-- Make these grants apply to future tables too.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO myuser;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO myuser;
```

### Set a connection limit

PostgreSQL uses one process per connection. Without a connection limit, a misbehaving application can exhaust the server's `max_connections` and lock out everything else, including administrative access.

Set a per-user connection limit when you create the user:

```sql title="psql"
ALTER USER myuser CONNECTION LIMIT 50;
```

And review `postgresql.conf`'s `max_connections` (default: 100). Each connection consumes \~5–10 MB of RAM at idle — size accordingly, or use a connection pooler like PgBouncer in front of PostgreSQL for high-concurrency workloads.

### Enable query logging for slow queries

One line in `postgresql.conf` gives you visibility into what is making your application slow:

```text title="/etc/postgresql/18/main/postgresql.conf"
log_min_duration_statement = 1000   # log queries taking over 1 second
```

Reload after changing:

```bash title="terminal"
sudo systemctl reload postgresql@18-main
```

Slow query logs land at `/var/log/postgresql/postgresql-18-main.log`.

***

## Troubleshooting

### `psql: error: connection to server ... failed: FATAL: password authentication failed`

The user's database password is wrong, or `pg_hba.conf` is routing the connection to a different auth method than you expect. Check which rule matches:

```bash title="terminal"
sudo grep "^host\|^local" /etc/postgresql/18/main/pg_hba.conf
```

Rules are evaluated top-to-bottom; the first match wins.

### `psql: error: connection to server ... failed: Connection refused`

Either PostgreSQL is not running, or it is not listening on the address you are connecting to:

```bash title="terminal"
sudo systemctl status postgresql@18-main
sudo ss -tlnp | grep 5432
```

If the second command shows `127.0.0.1:5432` and you are connecting from a remote host, you have not set `listen_addresses = '*'` and restarted.

### `FATAL: remaining connection slots are reserved for non-replication superuser connections`

You have hit `max_connections`. Either raise the limit in `postgresql.conf` (restart required), kill idle connections, or add PgBouncer. Short-term:

```sql title="psql — connect as postgres"
SELECT pid, usename, application_name, state, query_start
FROM pg_stat_activity
WHERE state = 'idle'
ORDER BY query_start;

SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle';
```

### `pg_lsclusters` shows status `down`

The cluster failed to start. Check the log:

```bash title="terminal"
sudo journalctl -u postgresql@18-main --no-pager -n 50
sudo tail -n 50 /var/log/postgresql/postgresql-18-main.log
```

The most common cause on a fresh install is a port conflict — something else is on 5432.

***

## Useful Commands

| Task                        | Command                                      |
| --------------------------- | -------------------------------------------- |
| Start PostgreSQL            | `sudo systemctl start postgresql@18-main`    |
| Stop PostgreSQL             | `sudo systemctl stop postgresql@18-main`     |
| Restart PostgreSQL          | `sudo systemctl restart postgresql@18-main`  |
| Reload config (no downtime) | `sudo systemctl reload postgresql@18-main`   |
| Check status                | `sudo systemctl status postgresql@18-main`   |
| List clusters               | `pg_lsclusters`                              |
| Connect as postgres         | `sudo -u postgres psql`                      |
| Connect as app user (TCP)   | `psql -h 127.0.0.1 -U myuser -d mydb`        |
| View active connections     | `SELECT * FROM pg_stat_activity;`            |
| Config directory            | `/etc/postgresql/18/main/`                   |
| Data directory              | `/var/lib/postgresql/18/main/`               |
| Log file                    | `/var/log/postgresql/postgresql-18-main.log` |

***

## Conclusion

The PGDG script does the hard work — one command and you have a current, maintained PostgreSQL 18 instead of whatever Ubuntu decided to freeze years ago. What turns a running database into one you would actually trust with production data is everything after that: `scram-sha-256` authentication everywhere, a dedicated application user with scoped grants, connection limits that cannot bring the server down, and remote access locked to the specific IP that needs it rather than the whole internet.

PostgreSQL is conservative by default and loud when you get it wrong — lean on that. The logs are clear, `pg_stat_activity` shows you exactly what is connected, and `pg_hba.conf` gives you precise control over who can reach what. Get those three things right and the database becomes the boring, reliable part of your stack — which is the only acceptable outcome.


Last updated on June 26, 2026

---
title: "Integrating ConnectIPS with Go — RSA Tokens, Validation"
description: "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."
last_updated: "June 22, 2026"
source: "https://ranjanyadav.com.np/blog/connectips-payment-gateway-golang"
---

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

<Callout className="bg-info/10 inset-ring-info/35" title="On a Node backend instead?">
  There's a companion guide — [**Integrating ConnectIPS with NestJS**](/blog/connectips-payment-gateway-nodejs) — covering the exact same steps in TypeScript. Same signed strings, same field orders, same production rules.
</Callout>

<Callout title="Read this before you copy a line">
  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.
</Callout>

***

## What ConnectIPS actually is

ConnectIPS is run by [NCHL](https://www.nchl.com.np/) (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 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:

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.

```bash title="terminal"
go get golang.org/x/crypto/pkcs12
```

```go title="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
}
```

<Callout title="Two passwords — don't conflate them">
  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`.
</Callout>

***

## Configuration

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

```go title="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 title=".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.

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

```go title="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:

```go title="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.

```go title="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):

```json title="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.

```go title="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.

```go title="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.

```go title="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**](/blog/connectips-payment-gateway-nodejs).

<Callout title="A standing caveat">
  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.
</Callout>


Last updated on June 22, 2026

---
title: "Integrating ConnectIPS with NestJS — RSA Tokens, Validation"
description: "A production-grade guide to wiring ConnectIPS — NCHL's account-to-account payment rail for Nepal — into a NestJS (TypeScript) backend. SHA256withRSA token signing from a PKCS12 keystore, the redirect flow, server-to-server transaction validation, and the idempotency and amount checks most guides leave out."
last_updated: "June 21, 2026"
source: "https://ranjanyadav.com.np/blog/connectips-payment-gateway-nodejs"
---

# Integrating ConnectIPS with NestJS — RSA Tokens, Validation

A production-grade guide to wiring ConnectIPS — NCHL's account-to-account payment rail for Nepal — into a NestJS (TypeScript) backend. SHA256withRSA token signing 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.

This is the version I wish I'd had: idiomatic NestJS on Node 24, `node:crypto` only, the exact signed strings, and the production hardening — amount re-derivation, server-to-server validation, idempotent fulfilment — written down instead of assumed.

<Callout className="bg-info/10 inset-ring-info/35" title="Writing a Go backend instead?">
  There's a companion guide — [**Integrating ConnectIPS with Go**](/blog/connectips-payment-gateway-golang) — that ports every step here to Go. Same signed strings, same field orders, same production rules.
</Callout>

<Callout title="Read this before you copy a line">
  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.
</Callout>

***

## What ConnectIPS actually is

ConnectIPS is run by [NCHL](https://www.nchl.com.np/) (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 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:

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.

Node's `crypto` signs with RSA natively. The one wrinkle is the keystore: `crypto` can't read a `.pfx` directly, so convert it to a PEM private key once at deploy time and load that — which also keeps the signer dependency-free.

```bash title="convert the keystore once"
openssl pkcs12 -in CREDITOR.pfx -nocerts -nodes \
  -passin pass:"$KEYSTORE_PASSWORD" -out creditor-key.pem
```

```ts title="connectips/signing.ts"
import { createPrivateKey, createSign, type KeyObject } from "node:crypto"
import { readFileSync } from "node:fs"

import { connectips } from "./config"

// Load the PEM private key once. The keystore password unlocked it during the
// openssl conversion above — it is NOT needed at runtime.
let cachedKey: KeyObject | undefined
function privateKey(): KeyObject {
  cachedKey ??= createPrivateKey(readFileSync(connectips.keyPath))
  return cachedKey
}

/** SHA256withRSA over `message`, Base64-encoded — a ConnectIPS TOKEN. */
export function signToken(message: string): string {
  return createSign("RSA-SHA256")
    .update(message, "utf8")
    .sign(privateKey(), "base64")
}
```

<Callout title="Two passwords — don't conflate them">
  ConnectIPS hands you *two* secrets: the **keystore password** (unlocks the private key in `CREDITOR.pfx`) 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`.
</Callout>

***

## Configuration

One typed config object from the environment. The keystore and password never touch git.

```ts title="connectips/config.ts"
export const connectips = {
  baseUrl: process.env.CONNECTIPS_URL!, // https://uat.connectips.com
  merchantId: process.env.CONNECTIPS_MERCHANT_ID!, // e.g. 550
  appId: process.env.CONNECTIPS_APP_ID!, // e.g. MER-550-APP-1
  appName: process.env.CONNECTIPS_APP_NAME!,
  password: process.env.CONNECTIPS_PASSWORD!, // web-service password (Basic auth)
  keyPath: process.env.CONNECTIPS_KEY_PATH!, // path to creditor-key.pem
  successUrl: process.env.CONNECTIPS_SUCCESS_URL!, // pre-registered with NCHL
  failureUrl: process.env.CONNECTIPS_FAILURE_URL!,
} as const
```

```env title=".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_KEY_PATH=./secrets/creditor-key.pem
CONNECTIPS_SUCCESS_URL=https://your-app.example.com/payments/connectips/return
CONNECTIPS_FAILURE_URL=https://your-app.example.com/payments/connectips/failed
```

Node 24 reads `.env` natively — `node --env-file=.env server.js`.

***

## 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`, `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                    |

```ts title="connectips/payment.ts"
import { connectips } from "./config"
import { signToken } from "./signing"

export type PaymentForm = {
  action: string
  fields: Record<string, string>
}

export function buildPaymentForm(input: {
  txnId: string // unique per attempt, ≤ 20 chars
  amountPaisa: number // rupees × 100, derived server-side
  referenceId: string
  remarks: string
  particulars: string
}): PaymentForm {
  const d = new Date()
  const txnDate = `${String(d.getDate()).padStart(2, "0")}-${String(
    d.getMonth() + 1
  ).padStart(2, "0")}-${d.getFullYear()}` // DD-MM-YYYY

  const fields: Record<string, string> = {
    MERCHANTID: connectips.merchantId,
    APPID: connectips.appId,
    APPNAME: connectips.appName,
    TXNID: input.txnId,
    TXNDATE: txnDate,
    TXNCRNCY: "NPR",
    TXNAMT: String(input.amountPaisa),
    REFERENCEID: input.referenceId,
    REMARKS: input.remarks,
    PARTICULARS: input.particulars,
  }

  // EXACT order, comma-joined, NO spaces, literal `TOKEN=TOKEN` suffix.
  const message =
    `MERCHANTID=${fields.MERCHANTID},APPID=${fields.APPID},APPNAME=${fields.APPNAME},` +
    `TXNID=${fields.TXNID},TXNDATE=${fields.TXNDATE},TXNCRNCY=${fields.TXNCRNCY},` +
    `TXNAMT=${fields.TXNAMT},REFERENCEID=${fields.REFERENCEID},REMARKS=${fields.REMARKS},` +
    `PARTICULARS=${fields.PARTICULARS},TOKEN=TOKEN`

  return {
    action: `${connectips.baseUrl}/connectipswebgw/loginpage`,
    fields: { ...fields, TOKEN: signToken(message) },
  }
}
```

ConnectIPS expects a **form POST**, not a GET redirect, so the cleanest handoff is a tiny auto-submitting HTML form your server returns:

```ts title="connectips/auto-submit.ts"
import type { PaymentForm } from "./payment"

const escape = (s: string) =>
  s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)

/** A self-submitting form that bounces the browser to ConnectIPS. */
export function renderAutoSubmitForm(form: PaymentForm): string {
  const inputs = Object.entries(form.fields)
    .map(([k, v]) => `<input type="hidden" name="${k}" value="${escape(v)}">`)
    .join("")

  return `<!doctype html><html><body onload="document.forms[0].submit()">
<form method="POST" action="${form.action}">${inputs}</form>
</body></html>`
}
```

***

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

```ts title="connectips/validate.ts"
import { connectips } from "./config"
import { signToken } from "./signing"

export type Validation = {
  status: string // "SUCCESS" when paid
  statusDesc: string
  txnAmt: string // echoed back as a string
}

export async function validateTxn(input: {
  txnId: string // the TXNID you sent (goes in `referenceId`)
  amountPaisa: number // your EXPECTED amount, from your DB
}): Promise<Validation> {
  // 4-field token, UPPERCASE keys, no `TOKEN=TOKEN` suffix.
  const token = signToken(
    `MERCHANTID=${connectips.merchantId},APPID=${connectips.appId},` +
      `REFERENCEID=${input.txnId},TXNAMT=${input.amountPaisa}`
  )

  const auth = Buffer.from(
    `${connectips.appId}:${connectips.password}`
  ).toString("base64")

  const res = await fetch(
    `${connectips.baseUrl}/connectipswebws/api/creditor/validatetxn`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Basic ${auth}`,
      },
      // camelCase body. `referenceId` is the original TXNID.
      body: JSON.stringify({
        merchantId: Number(connectips.merchantId),
        appId: connectips.appId,
        referenceId: input.txnId,
        txnAmt: input.amountPaisa,
        token,
      }),
      signal: AbortSignal.timeout(30_000),
    }
  )

  // fetch does NOT reject on 4xx/5xx — check it yourself.
  if (!res.ok) throw new Error(`validatetxn failed: HTTP ${res.status}`)
  return (await res.json()) as Validation
}
```

A successful response looks like this (note `txnAmt` comes back as a string, and `statusDesc` really is misspelled):

```json title="validatetxn response"
{
  "merchantId": 550,
  "appId": "MER-550-APP-1",
  "referenceId": "txn-123",
  "txnAmt": "500",
  "token": null,
  "status": "SUCCESS",
  "statusDesc": "TRANSACTION SUCESSFULL"
}
```

***

## Wiring it into NestJS

A thin controller over the helpers, with `OrdersService` injected. The amount is never read from the request at validation time — it comes from your database.

```ts title="payments.controller.ts"
import {
  Controller,
  Get,
  Header,
  Param,
  Post,
  Query,
  Redirect,
} from "@nestjs/common"

import { renderAutoSubmitForm } from "./connectips/auto-submit"
import { buildPaymentForm } from "./connectips/payment"
import { OrdersService } from "./orders/orders.service"
import { settlePayment } from "./payments/settle" // see "Getting it right in production"

@Controller("payments/connectips")
export class PaymentsController {
  constructor(private readonly orders: OrdersService) {}

  // Start a payment — returns an auto-submitting form that bounces to ConnectIPS.
  @Post(":orderId")
  @Header("Content-Type", "text/html")
  async start(@Param("orderId") orderId: string) {
    const order = await this.orders.findByIdOrThrow(orderId)
    const form = buildPaymentForm({
      txnId: order.txnId,
      amountPaisa: order.expectedAmountPaisa, // from the DB, not the request
      referenceId: order.id,
      remarks: `Order ${order.id}`,
      particulars: "Checkout",
    })
    return renderAutoSubmitForm(form)
  }

  // ConnectIPS redirects the customer back here with ?TXNID=...
  @Get("return")
  @Redirect()
  async onReturn(@Query("TXNID") txnId: string) {
    const status = await settlePayment(txnId)
    const base = process.env.CLIENT_URL
    return {
      url:
        status === "PAID"
          ? `${base}/payment/success`
          : `${base}/payment/failed`,
    }
  }
}
```

Register it in a module:

```ts title="payments.module.ts"
import { Module } from "@nestjs/common"

import { OrdersService } from "./orders/orders.service"
import { PaymentsController } from "./payments.controller"

@Module({
  controllers: [PaymentsController],
  providers: [OrdersService],
})
export class PaymentsModule {}
```

***

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

```ts title="payments/create.ts"
import { randomBytes } from "node:crypto"

export async function createPayment(orderId: string) {
  const order = await db.orders.findByIdOrThrow(orderId)

  // Re-price from line items in YOUR database; ignore any client total.
  const rupees = order.lineItems.reduce(
    (sum, li) => sum + li.unitPrice * li.qty,
    0
  )
  const expectedAmountPaisa = Math.round(rupees * 100) // paisa, integer

  const txnId = `T${Date.now()}${randomBytes(3).toString("hex")}`.slice(0, 20)
  await db.orders.update(orderId, {
    txnId,
    expectedAmountPaisa, // persisted source of truth
    status: "PENDING",
  })

  return { txnId, expectedAmountPaisa }
}
```

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

```ts title="payments/settle.ts"
import { validateTxn } from "../connectips/validate"

export async function settlePayment(
  txnId: string
): Promise<"PAID" | "FAILED" | "PENDING"> {
  const order = await db.orders.findByTxnIdOrThrow(txnId)
  if (order.status !== "PENDING") return order.status // refresh / retry safe

  const v = await validateTxn({ txnId, amountPaisa: order.expectedAmountPaisa })

  if (v.status !== "SUCCESS") {
    await db.orders.updateWhere(
      { id: order.id, status: "PENDING" },
      { status: "FAILED" }
    )
    return "FAILED"
  }

  // Re-check the validated amount against what we stored (reject under/overpay).
  if (Number(v.txnAmt) !== order.expectedAmountPaisa) {
    throw new Error(
      `Amount mismatch: got ${v.txnAmt}, expected ${order.expectedAmountPaisa}`
    )
  }

  // Atomic: only the first caller flips PENDING -> PAID and fulfils.
  const flipped = await db.orders.updateWhere(
    { id: order.id, status: "PENDING" },
    { status: "PAID" }
  )
  if (flipped.rowCount === 1) await fulfilOrder(order.id) // runs once, ever

  return "PAID"
}
```

### The rest of the checklist

* **Guard the keystore.** Never commit `CREDITOR.pfx` or `creditor-key.pem`; mount them as secrets with locked-down file permissions. The PEM 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 cron that re-runs `validatetxn` (or `gettxndetail`) on pending orders recovers real, paid transactions.
* **`fetch` doesn't throw on 4xx/5xx** — check `res.ok` (the helper does) and use `AbortSignal.timeout()`.
* **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. Convert the `.pfx` to PEM with the `openssl` command above, 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`. 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.

Get the signed strings byte-exact, keep the two passwords straight, verify server-side, and ConnectIPS becomes the dependable bank-account half of your checkout.

Running a Go backend? The same integration, ported to Go, lives in [**Integrating ConnectIPS with Go**](/blog/connectips-payment-gateway-golang).

<Callout title="A standing caveat">
  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.
</Callout>


Last updated on June 21, 2026

---
title: "Integrating Fonepay with Go — QR, Web Redirect"
description: "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."
last_updated: "June 20, 2026"
source: "https://ranjanyadav.com.np/blog/fonepay-payment-gateway-golang"
---

# 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`.

<Callout className="bg-info/10 inset-ring-info/35" title="On a Node backend instead?">
  There's a companion guide — [**Integrating Fonepay with NestJS**](/blog/fonepay-payment-gateway-nodejs) — covering the exact same flows in TypeScript. Same contracts, same field orders, same production rules.
</Callout>

<Callout title="Read this before you copy a line">
  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.
</Callout>

***

## What Fonepay actually is

Fonepay is a vertical of [F1Soft](https://f1soft.com/business/fonepay) 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:

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.

```go title="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
}
```

<Callout title="Hex casing is a real footgun">
  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.
</Callout>

***

## Configuration

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

```go title="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 title=".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
```

<Callout title="Heads up: the dev QR host is ambiguous">
  Sources 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.
</Callout>

***

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

```go title="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`**:

```go title="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
}
```

<Callout title="Why not just trust the WebSocket?">
  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.
</Callout>

***

## 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`.

```go title="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.

```go title="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:

```xml title="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>
```

<Callout title="Two callback conventions exist — know which you have">
  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.
</Callout>

***

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

```go title="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.

```go title="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:

```sql title="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**](/blog/fonepay-payment-gateway-nodejs).

<Callout title="A standing caveat">
  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.
</Callout>


Last updated on June 20, 2026

---
title: "Integrating Fonepay with NestJS — QR, Web Redirect"
description: "A production-grade guide to wiring Fonepay — Nepal's interbank QR and payment network — into a NestJS (TypeScript) backend. HMAC-SHA512 signing done right, dynamic QR with real-time WebSocket confirmation, the web-redirect flow, and the server-side verification, idempotency, and amount checks most guides leave out."
last_updated: "June 20, 2026"
source: "https://ranjanyadav.com.np/blog/fonepay-payment-gateway-nodejs"
---

# Integrating Fonepay with NestJS — QR, Web Redirect

A production-grade guide to wiring Fonepay — Nepal's interbank QR and payment network — into a NestJS (TypeScript) backend. HMAC-SHA512 signing done right, dynamic QR with real-time WebSocket 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.

This is the version I wish I'd had: idiomatic NestJS on Node 24, zero crypto dependencies, both flows (dynamic QR and web redirect), and the production hardening written down instead of assumed.

<Callout className="bg-info/10 inset-ring-info/35" title="Writing a Go backend instead?">
  There's a companion guide — [**Integrating Fonepay with Go**](/blog/fonepay-payment-gateway-golang) — that ports every flow here to the Go standard library. Same contracts, same field orders, same production rules.
</Callout>

<Callout title="Read this before you copy a line">
  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.
</Callout>

***

## What Fonepay actually is

Fonepay is a vertical of [F1Soft](https://f1soft.com/business/fonepay) 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:

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.

`node:crypto` covers all of it; there's nothing to install.

```ts title="fonepay/signing.ts"
import { createHmac, timingSafeEqual } from "node:crypto"

/** HMAC-SHA512 of `message`, keyed by the merchant secret, as lowercase hex. */
export function sign(secret: string, message: string): string {
  return createHmac("sha512", secret).update(message, "utf8").digest("hex")
}

/**
 * Constant-time comparison of two hex strings.
 *
 * Do NOT use `a === b` or `a.toUpperCase() === b.toUpperCase()` — string
 * equality short-circuits on the first differing byte and leaks, via timing,
 * how many leading characters matched. That's enough to forge a signature
 * byte by byte.
 */
export function safeHexEqual(a: string, b: string): boolean {
  if (typeof a !== "string" || typeof b !== "string") return false

  const bufA = Buffer.from(a, "hex")
  const bufB = Buffer.from(b, "hex")

  // timingSafeEqual throws RangeError on a length mismatch. Length isn't
  // secret, so short-circuiting here is safe — and saves you a 500.
  if (bufA.length === 0 || bufA.length !== bufB.length) return false

  return timingSafeEqual(bufA, bufB)
}
```

<Callout title="Hex casing is a real footgun">
  Fonepay's own samples disagree on case — the Java helper emits uppercase, the Node 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 buffers instead of strings.
</Callout>

***

## Configuration

One typed config object, fed from the environment. Secrets never leave the server, and `.env` never enters git.

```ts title="fonepay/config.ts"
export const fonepay = {
  // Web Redirect (PG) — clientapi
  pgBaseUrl: process.env.FONEPAY_PG_URL!, // https://dev-clientapi.fonepay.com
  // Dynamic QR — merchantapi
  qrBaseUrl: process.env.FONEPAY_QR_URL!, // https://uat-new-merchant-api.fonepay.com/api

  merchantCode: process.env.FONEPAY_MERCHANT_CODE!, // PID
  secret: process.env.FONEPAY_SECRET!, // never sent in any payload
  username: process.env.FONEPAY_USERNAME!,
  password: process.env.FONEPAY_PASSWORD!,

  returnUrl: process.env.FONEPAY_RETURN_URL!, // public HTTPS callback
} as const
```

```env title=".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
```

Node 24 reads `.env` natively — `node --env-file=.env server.js`. No `dotenv`.

<Callout title="Heads up: the dev QR host is ambiguous">
  Sources 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.
</Callout>

***

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

```ts title="fonepay/qr.ts"
import { fonepay } from "./config"
import { sign } from "./signing"

type QrResult = {
  qrMessage: string // encode THIS as the QR image
  thirdpartyQrWebSocketUrl: string // open for real-time push
  statusCode: number
}

export async function createDynamicQr(input: {
  prn: string // unique per attempt — you generate it
  amount: number // derived server-side, never from the client
  remarks1: string
  remarks2: string
}): Promise<QrResult> {
  const { prn, amount, remarks1, remarks2 } = input

  // EXACT order: amount, prn, merchantCode, remarks1, remarks2
  const dataValidation = sign(
    fonepay.secret,
    `${amount},${prn},${fonepay.merchantCode},${remarks1},${remarks2}`
  )

  const res = await fetch(
    `${fonepay.qrBaseUrl}/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        amount,
        remarks1,
        remarks2,
        prn,
        merchantCode: fonepay.merchantCode,
        dataValidation,
        username: fonepay.username,
        password: fonepay.password,
      }),
      signal: AbortSignal.timeout(15_000),
    }
  )

  // fetch does NOT reject on 4xx/5xx — you must check this yourself.
  if (!res.ok) throw new Error(`QR download failed: HTTP ${res.status}`)
  return (await res.json()) as QrResult
}
```

Turn `qrMessage` into a scannable image with the one dependency this guide needs:

```bash title="terminal"
npm install qrcode
```

```ts title="fonepay/render-qr.ts"
import QRCode from "qrcode"

export const toDataUrl = (qrMessage: string) => QRCode.toDataURL(qrMessage)
```

### 2. Confirm in real time, verify for real

The download response includes `thirdpartyQrWebSocketUrl`. The **browser** opens it for instant "Paid!" feedback the moment the customer's bank confirms — but the browser is untrusted, so the **server** still polls the status endpoint before it believes anything.

```ts title="fonepay/qr-status.ts"
import { fonepay } from "./config"
import { sign } from "./signing"

type QrStatus = {
  paymentStatus: "success" | "pending" | "failed"
  fonepayTraceId: number
  prn: string
}

export async function getQrStatus(prn: string): Promise<QrStatus> {
  // EXACT order: prn, merchantCode
  const dataValidation = sign(fonepay.secret, `${prn},${fonepay.merchantCode}`)

  const res = await fetch(
    `${fonepay.qrBaseUrl}/merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrGetStatus`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        prn,
        merchantCode: fonepay.merchantCode,
        dataValidation,
        username: fonepay.username,
        password: fonepay.password,
      }),
      signal: AbortSignal.timeout(15_000),
    }
  )

  if (!res.ok) throw new Error(`QR status failed: HTTP ${res.status}`)
  return (await res.json()) as QrStatus
}
```

On the client, the WebSocket is purely for UX:

```ts title="client/qr-listener.ts"
// Browser side — instant feedback only. The server decides if the order is paid.
const ws = new WebSocket(thirdpartyQrWebSocketUrl)
ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data)
  const tx =
    typeof msg.transactionStatus === "string"
      ? JSON.parse(msg.transactionStatus)
      : msg.transactionStatus
  if (tx?.paymentSuccess) {
    // Show a spinner, then poll your own backend, which calls getQrStatus().
  }
}
```

<Callout title="Why not just trust the WebSocket?">
  Anyone can open a WebSocket and send `{ paymentSuccess: true }` to your client. 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.
</Callout>

***

## 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

The request `DV` 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.

| Field | Meaning                      |
| ----- | ---------------------------- |
| `PID` | Merchant code                |
| `MD`  | Payment mode — always `P`    |
| `PRN` | Your unique reference number |
| `AMT` | Amount                       |
| `CRN` | Currency — `NPR`             |
| `DT`  | Request date, `MM/DD/YYYY`   |
| `R1`  | Remark 1 (required)          |
| `R2`  | Remark 2 (optional)          |
| `RU`  | Return URL                   |
| `DV`  | The HMAC-SHA512 signature    |

```ts title="fonepay/redirect.ts"
import { fonepay } from "./config"
import { sign } from "./signing"

export function buildPaymentUrl(input: {
  prn: string
  amount: number
  remarks1: string
  remarks2?: string
}): string {
  const today = new Date()
  const dt = `${String(today.getMonth() + 1).padStart(2, "0")}/${String(
    today.getDate()
  ).padStart(2, "0")}/${today.getFullYear()}` // MM/DD/YYYY

  const params = {
    PID: fonepay.merchantCode,
    MD: "P",
    PRN: input.prn,
    AMT: String(input.amount),
    CRN: "NPR",
    DT: dt,
    R1: input.remarks1,
    R2: input.remarks2 ?? "",
    RU: fonepay.returnUrl,
  }

  // Sign the RAW values in this exact order...
  const DV = sign(
    fonepay.secret,
    [
      params.PID,
      params.MD,
      params.PRN,
      params.AMT,
      params.CRN,
      params.DT,
      params.R1,
      params.R2,
      params.RU,
    ].join(",")
  )

  // ...then let URLSearchParams handle encoding. Query-string order is
  // irrelevant to Fonepay — only the SIGNING order above matters.
  const qs = new URLSearchParams({ ...params, DV })
  return `${fonepay.pgBaseUrl}/api/merchantRequest?${qs}`
}
```

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

```ts title="fonepay/verify.ts"
import { fonepay } from "./config"
import { sign } from "./signing"

export type Verification = {
  success: boolean
  responseCode: string // "successful" on success
  amount: number
  uniqueId: string
}

export async function verifyTransaction(input: {
  prn: string
  bid: string // BID from the return query
  uid: string // UID from the return query
  amount: number // your EXPECTED amount, from your DB
}): Promise<Verification> {
  const { prn, bid, uid, amount } = input

  // EXACT order: PID, AMT, PRN, BID, UID
  const DV = sign(
    fonepay.secret,
    `${fonepay.merchantCode},${amount},${prn},${bid},${uid}`
  )

  const qs = new URLSearchParams({
    PRN: prn,
    PID: fonepay.merchantCode,
    BID: bid,
    AMT: String(amount),
    UID: uid,
    DV,
  })

  const res = await fetch(
    `${fonepay.pgBaseUrl}/api/merchantRequest/verificationMerchant?${qs}`,
    { signal: AbortSignal.timeout(30_000) }
  )
  if (!res.ok) throw new Error(`Verification failed: HTTP ${res.status}`)

  const xml = await res.text()
  const field = (name: string) =>
    xml.match(new RegExp(`<${name}>(.*?)</${name}>`, "s"))?.[1]?.trim() ?? ""

  return {
    success: field("success") === "true",
    responseCode: field("response_code"),
    amount: Number(field("amount")) || 0,
    uniqueId: field("uniqueId"),
  }
}
```

The XML you're parsing looks like this:

```xml title="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>
```

<Callout title="Two callback conventions exist — know which you have">
  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, verify 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.
</Callout>

The flat regex extraction keeps this dependency-free for Fonepay's simple, fixed response. For anything richer, reach for `fast-xml-parser` rather than hand-rolling.

***

## Wiring it into NestJS

A thin controller over the helpers above, with `OrdersService` injected. Notice what it does *not* do: it never reads an amount from the request at verification time — the amount always comes from your database.

```ts title="payments.controller.ts"
import { Controller, Get, Param, Post, Query, Redirect } from "@nestjs/common"

import { createDynamicQr } from "./fonepay/qr"
import { buildPaymentUrl } from "./fonepay/redirect"
import { OrdersService } from "./orders/orders.service"
import { settlePayment } from "./payments/settle" // see "Getting it right in production"

@Controller("payments/fonepay")
export class PaymentsController {
  constructor(private readonly orders: OrdersService) {}

  // Start a QR payment.
  @Post("qr/:orderId")
  async startQr(@Param("orderId") orderId: string) {
    const order = await this.orders.findByIdOrThrow(orderId)
    const qr = await createDynamicQr({
      prn: order.prn,
      amount: order.expectedAmount, // from the DB, not the request
      remarks1: order.id,
      remarks2: "checkout",
    })
    return { qrMessage: qr.qrMessage, ws: qr.thirdpartyQrWebSocketUrl }
  }

  // Start a web-redirect payment.
  @Post("web/:orderId")
  async startWeb(@Param("orderId") orderId: string) {
    const order = await this.orders.findByIdOrThrow(orderId)
    return {
      url: buildPaymentUrl({
        prn: order.prn,
        amount: order.expectedAmount,
        remarks1: order.id,
      }),
    }
  }

  // Fonepay redirects the customer back here. @Redirect lets the return value
  // override the destination at runtime — no need to grab the raw res object.
  @Get("return")
  @Redirect()
  async onReturn(
    @Query("PRN") prn: string,
    @Query("BID") bid = "",
    @Query("UID") uid = ""
  ) {
    const status = await settlePayment(prn, { bid, uid })
    const base = process.env.CLIENT_URL
    return {
      url:
        status === "PAID"
          ? `${base}/payment/success`
          : `${base}/payment/failed`,
    }
  }
}
```

Register it in a module — `OrdersService` is provided here (or imported from its own module):

```ts title="payments.module.ts"
import { Module } from "@nestjs/common"

import { OrdersService } from "./orders/orders.service"
import { PaymentsController } from "./payments.controller"

@Module({
  controllers: [PaymentsController],
  providers: [OrdersService],
})
export class PaymentsModule {}
```

***

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

```ts title="payments/create.ts"
import { randomUUID } from "node:crypto"

export async function createOrderPayment(orderId: string) {
  const order = await db.orders.findByIdOrThrow(orderId)

  // Re-price from line items in YOUR database. Ignore any client total.
  const expectedAmount = order.lineItems.reduce(
    (sum, li) => sum + li.unitPrice * li.qty,
    0
  )

  const prn = randomUUID() // unique per attempt
  await db.orders.update(orderId, {
    prn,
    expectedAmount, // the persisted source of truth
    currency: "NPR",
    status: "PENDING",
  })

  return { prn, expectedAmount }
}
```

### 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 a state machine plus one **atomic** transition: only the first caller that flips `PENDING` wins.

```ts title="payments/settle.ts"
import { verifyTransaction } from "../fonepay/verify"

export async function settlePayment(
  prn: string,
  ref: { bid: string; uid: string }
): Promise<"PAID" | "FAILED" | "PENDING"> {
  const order = await db.orders.findByPrnOrThrow(prn)

  // Already decided? No-op. This is what makes a refresh / retry safe.
  if (order.status !== "PENDING") return order.status

  // The gateway is the source of truth — not the redirect query string.
  const v = await verifyTransaction({
    prn,
    bid: ref.bid,
    uid: ref.uid,
    amount: order.expectedAmount,
  })

  if (!v.success || v.responseCode !== "successful") {
    await db.orders.updateWhere(
      { id: order.id, status: "PENDING" },
      { status: "FAILED" }
    )
    return "FAILED"
  }

  // Re-check the paid amount against what we stored. Reject under- AND overpay.
  if (v.amount !== order.expectedAmount) {
    throw new Error(
      `Amount mismatch: paid ${v.amount}, expected ${order.expectedAmount}`
    )
  }

  // Atomic: flip PENDING -> PAID only if still PENDING. Exactly one winner.
  const flipped = await db.orders.updateWhere(
    { id: order.id, status: "PENDING" },
    { status: "PAID", gatewayRef: v.uniqueId }
  )
  if (flipped.rowCount === 1) await fulfilOrder(order.id) // runs once, ever

  return "PAID"
}
```

### Reconcile the stragglers

Networks drop. A payment can succeed at the bank while your callback never arrives, leaving an order stuck `PENDING`. Run 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: `npx ngrok http 3000` or `cloudflared`.
* **`fetch` doesn't throw on 4xx/5xx.** Always check `res.ok` (every snippet above does). Use `AbortSignal.timeout()` so a stalled gateway can't hang your request.
* **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. Signing the encoded string is the classic mistake.
* **Date format.** Redirect `DT` is `MM/DD/YYYY`. June 20th is `06/20/2026`, not `20/06/2026`.
* **Hex case.** If you must string-compare, do it case-insensitively — but really, just use `safeHexEqual`.
* **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.

Build the signing primitive once, keep the secret server-side, verify everything, and Fonepay becomes the boring, reliable part of checkout — which is exactly what you want a payment rail to be.

Running a Go backend? The same integration, ported to the standard library, lives in [**Integrating Fonepay with Go**](/blog/fonepay-payment-gateway-golang).

<Callout title="A standing caveat">
  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.
</Callout>


Last updated on June 20, 2026

---
title: "Deploy a Node.js App on an Ubuntu VPS with Nginx, PM2, and Let's Encrypt"
description: "A modern step-by-step guide for deploying a Node.js application on an Ubuntu VPS using Nginx as a reverse proxy, PM2 for process management, pnpm for package management, and Let's Encrypt for free SSL."
last_updated: "June 8, 2026"
source: "https://ranjanyadav.com.np/blog/deploy-nodejs-vps-nginx"
---

# Deploy a Node.js App on an Ubuntu VPS with Nginx, PM2, and Let's Encrypt

A modern step-by-step guide for deploying a Node.js application on an Ubuntu VPS using Nginx as a reverse proxy, PM2 for process management, pnpm for package management, and Let's Encrypt for free SSL.

## Overview

This guide walks through deploying a Node.js application on an Ubuntu VPS using:

* Node.js 24 (via NVM)
* pnpm
* PM2
* Nginx reverse proxy
* Let's Encrypt SSL certificates
* UFW firewall

At the end, your application will:

* Run continuously in the background
* Automatically restart after crashes or server reboots
* Be accessible through your domain
* Serve traffic securely over HTTPS

***

## Prerequisites

Before you begin, make sure you have:

* An Ubuntu VPS with SSH access
* A registered domain name
* DNS A records pointing to your VPS IP address
* A Git repository containing your application
* A user account with sudo privileges

***

## Step 1: Connect to Your Server

```bash
ssh username@server_ip
```

Example:

```bash
ssh ubuntu@203.0.113.10
```

Verify your OS version:

```bash
lsb_release -a
```

***

## Step 2: Update the Server

```bash
sudo apt update
sudo apt install -y curl git build-essential
```

***

## Step 3: Create a Deploy User (Recommended)

Avoid running applications as root.

```bash
sudo adduser deploy
sudo usermod -aG sudo deploy
```

Switch to the new user:

```bash
su - deploy
```

All subsequent Node.js, pnpm, and PM2 commands should be run as this user.

***

## Step 4: Remove Apache (Optional)

If Apache is already installed and you plan to use Nginx:

```bash
sudo apt purge apache2* -y
sudo apt autoremove -y
```

***

## Step 5: Install Node.js with NVM

NVM (Node Version Manager) allows you to manage Node.js versions without relying on system package repositories.

### Install NVM

```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
```

Load NVM into the current shell:

```bash
\. "$HOME/.nvm/nvm.sh"
```

### Install Node.js

```bash
nvm install 24
```

Verify the installation:

```bash
node -v
```

Expected output:

```text
v24.x.x
```

### Enable pnpm

```bash
corepack enable pnpm
```

Verify pnpm:

```bash
pnpm -v
```

***

## Step 6: Install Nginx

```bash
sudo apt install -y nginx
```

Enable and start Nginx:

```bash
sudo systemctl enable nginx
sudo systemctl start nginx
```

Verify installation:

```bash
sudo systemctl status nginx
nginx -v
```

***

## Step 7: Configure the Firewall

Install UFW (Uncomplicated Firewall):

```bash
sudo apt install -y ufw
```

Allow SSH, HTTP, and HTTPS traffic:

```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
```

Verify:

```bash
sudo ufw status
```

***

## Step 8: Clone Your Application

```bash
sudo mkdir -p /var/www
sudo chown $USER:$USER /var/www

cd /var/www
```

Clone your repository:

```bash
git clone <repository-url> my-app
```

Enter the project directory:

```bash
cd my-app
```

Install dependencies:

```bash
pnpm install
```

Build the project if required:

```bash
pnpm build
```

***

## Step 9: Configure Environment Variables

Create your environment file:

```bash
nano .env
```

Example:

```env
PORT=3000
DATABASE_URL=postgresql://...
JWT_SECRET=your-secret
```

***

## Step 10: Install PM2

Install PM2 globally:

```bash
pnpm add -g pm2
```

Verify:

```bash
pm2 --version
```

***

## Step 11: Start the Application

Applications using:

```bash
pnpm start
```

can be started with:

```bash
pm2 start pnpm --name "my-app" -- start
```

For custom entry files:

```bash
pm2 start server.js --name "my-app"
```

View running processes:

```bash
pm2 list
```

View logs:

```bash
pm2 logs my-app
```

***

## Step 12: Configure PM2 Startup

Generate startup configuration:

```bash
pm2 startup
```

PM2 will output a command.

Run the generated command exactly as shown.

Save the current process list:

```bash
pm2 save
```

> **Important**
>
> If you installed Node.js using NVM, run `pm2 startup` as the same user that installed Node.js. PM2 will automatically generate the correct Node.js path.

***

## Step 13: Configure Nginx Reverse Proxy

Create a new site configuration:

```bash
sudo nano /etc/nginx/sites-available/my-app
```

Paste the following:

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream nodejs_backend {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    listen [::]:80;

    server_name domain.com www.domain.com;

    location / {
        proxy_pass http://nodejs_backend;

        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_cache_bypass $http_upgrade;
    }
}
```

Replace:

* `domain.com`
* `www.domain.com`
* `3000` with your application's port

### Why Use an Upstream Block?

Instead of hardcoding:

```nginx
proxy_pass http://127.0.0.1:3000;
```

define the backend once:

```nginx
upstream nodejs_backend {
    server 127.0.0.1:3000;
}
```

Then reference it everywhere:

```nginx
proxy_pass http://nodejs_backend;
```

If the application port changes later, only the upstream definition needs updating.

***

## Step 14: Enable the Site

Enable the site:

```bash
sudo ln -s /etc/nginx/sites-available/my-app            /etc/nginx/sites-enabled/
```

Validate the configuration:

```bash
sudo nginx -t
```

Reload Nginx:

```bash
sudo systemctl reload nginx
```

***

## Step 15: Verify the Application

Confirm your application is listening:

```bash
ss -tulpn | grep 3000
```

Visit:

```text
http://yourdomain.com
```

Your application should now be accessible through Nginx.

***

## Step 16: Install SSL with Let's Encrypt

Install Certbot:

```bash
sudo apt install -y certbot python3-certbot-nginx
```

Generate certificates:

```bash
sudo certbot --nginx   -d domain.com   -d www.domain.com
```

Choose:

```text
Redirect HTTP to HTTPS
```

***

## Step 17: Enable Automatic SSL Renewal

Let's Encrypt certificates expire every 90 days, so renewal needs to happen without manual intervention.

### Verify the Renewal Timer

Installing `certbot` via apt also installs a systemd timer that checks twice a day whether any certificate needs renewing. Confirm it's active:

```bash
sudo systemctl status certbot.timer
```

If it shows `inactive`, enable it:

```bash
sudo systemctl enable --now certbot.timer
```

### Test the Renewal Process

Simulate a renewal without touching your live certificate:

```bash
sudo certbot renew --dry-run
```

A successful dry run means the certificate will renew automatically before it expires.

### Reload Nginx After Renewal

The Nginx plugin normally reloads Nginx for you after a real renewal, but adding an explicit deploy hook guarantees it:

```bash
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```

```bash
#!/bin/bash
systemctl reload nginx
```

Make it executable:

```bash
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```

Certbot runs every script in `renewal-hooks/deploy` after a successful renewal.

### Check Renewal Logs

```bash
sudo cat /var/log/letsencrypt/letsencrypt.log
```

***

## Step 18: Deploy Future Updates

Pull the latest code:

```bash
git pull
```

Install dependencies:

```bash
pnpm install
```

Build:

```bash
pnpm build
```

Restart the application:

```bash
pm2 restart my-app
```

***

## Useful PM2 Commands

```bash
pm2 list
pm2 logs my-app
pm2 restart my-app
pm2 stop my-app
pm2 delete my-app
pm2 monit
```

***

## Troubleshooting

### View PM2 Logs

```bash
pm2 logs my-app
```

### View Nginx Logs

```bash
sudo tail -f /var/log/nginx/error.log
```

### Validate Nginx Configuration

```bash
sudo nginx -t
```

### Verify Port Binding

```bash
ss -tulpn
```

***

## Conclusion

You now have a production-ready Node.js deployment powered by:

* Node.js 24 via NVM
* pnpm
* PM2
* Nginx reverse proxy
* Let's Encrypt SSL
* Automatic startup after server reboots

This setup works well for Express.js, NestJS, Next.js standalone deployments, Fastify, Hono, and most modern Node.js applications.

<Callout className="bg-info/10 inset-ring-info/35" title="Need a database?">
  If your application requires PostgreSQL, the companion guide — [**Install PostgreSQL 18 on Ubuntu — PGDG Repository, Security, First Steps**](/blog/install-postgresql-18-ubuntu-vps) — covers adding the PGDG repository, post-install security, remote access, and the configuration decisions most tutorials skip.
</Callout>


Last updated on June 8, 2026

---
title: "Hello — I'm Ranjan"
description: "A quick introduction to who I am, what I build, and why this blog exists."
last_updated: "June 7, 2026"
source: "https://ranjanyadav.com.np/blog/welcome"
---

# Hello — I'm Ranjan

A quick introduction to who I am, what I build, and why this blog exists.

Backend engineer based in Kathmandu, Nepal. I design systems, write Go services, and occasionally touch the frontend when there's no one else to blame.

***

## Who I Am

```go title="engineer.go"
type Engineer struct {
	Name      string
	Location  string
	Role      string
	Focus     []string
	Interests []string
	Currently []string
}

var me = Engineer{
	Name:     "Ranjan Yadav",
	Location: "Kathmandu, Nepal 🇳🇵",
	Role:     "Software Engineer @ Nomor LLC",
	Focus: []string{
		"Backend Systems",
		"Distributed APIs",
		"DevOps & Deployment",
	},
	Interests: []string{
		"High-performance Go services",
		"System design",
		"Scalable SaaS products",
	},
	Currently: []string{
		"Building a NEPSE trading & learning platform",
		"Shipping quick-commerce infrastructure at Nomor",
	},
}
```

***

## Experience

```yaml title="experience.yaml"
experience:
  - company: Nomor LLC
    role: Software Engineer
    type: Full-time
    since: Sep 2024
    focus: Backend services and systems engineering
    stack: [Go, NestJS, PostgreSQL, Redis]

  - company: NSW IT Support
    role: MEAN Stack Developer
    type: Full-time
    period: May 2023 – Sep 2024
    focus: Built and maintained EvergrowCRM (SaaS)
    stack: [Angular, Node.js, REST API, CI/CD]

education:
  - institution: London Metropolitan University
    degree: BSc (Hons) Computing
    grade: First Class Honours
    via: Islington College, Kathmandu
    period: Mar 2020 – May 2023
```

***

## Tech Stack

```toml title="stack.toml"
[primary]
languages  = ["Go", "TypeScript"]
frameworks = ["NestJS", "Next.js"]

[backend]
stack = ["Go", "NestJS", "Express"]

[frontend]
ui        = ["Next.js", "React", "Tailwind CSS"]
animation = ["Motion"]

[data]
databases = ["PostgreSQL", "MongoDB"]
cache     = ["Redis"]
messaging = ["RabbitMQ"]

[devops]
tools = ["Docker", "Kubernetes", "Linux", "Nginx"]
```

***

## Projects

```hcl title="projects.hcl"
project "nepse" {
  name        = "NEPSE Trading Analysis & Learning Platform"
  year        = 2026
  description = "Trading analysis for Nepal's stock market. TradingView charts, high-parallelism Go backend, NestJS marketplace with live streaming."
  stack       = ["Go", "NestJS", "PostgreSQL", "Redis"]
  url         = "https://bullhouseinvestment.com"
}

project "shopit" {
  name        = "ShopIt — Quick Commerce"
  year        = 2025
  description = "Sub-10-minute grocery delivery with zone-based routing, real-time order tracking, and a rider API."
  stack       = ["NestJS", "Next.js", "PostgreSQL", "Redis"]
  url         = "https://shopitnepal.com"
}

project "skoolsewa" {
  name        = "SkoolSewa — School Management"
  year        = 2024
  description = "Students, staff, fees, attendance, exams, and ID-card generation built for Nepali schools."
  stack       = ["NestJS", "Prisma", "PostgreSQL", "Next.js"]
  url         = "https://skoolsewa.com"
}

project "school_accounting" {
  name        = "School Accounting System"
  year        = 2024
  description = "Double-entry accounting with Bikram Sambat dates — ledgers, fee invoices, trial balance, and P&L reports."
  stack       = ["NestJS", "Prisma", "PostgreSQL", "React"]
  url         = "https://skoolsewa.com"
}
```

***

## Why This Blog

I write to remember. Most of what I learn lives in production — the setups I'll forget in six months, the patterns that held under load, and the ones that quietly didn't. This is where I write them down before they leak out of my head: backend systems, the occasional sharp edge, and notes from shipping software out of Kathmandu.

```bash title="blog.log"
$ git log --oneline --all
8f3c1da feat: zero-downtime deploys with Docker and GitHub Actions
b2e94f1 fix: N+1 queries hiding in NestJS eager relations
7a10cc3 docs: how I structure Go services for scale
e5d6b07 perf: Redis-backed job queues with Asynq
f9d823b feat: Bikram Sambat date handling in PostgreSQL
3c72a19 chore: things I wish I knew before Kubernetes
c3f9a1e init: showing up and writing things down
```

<Callout title="Subscribe">
  New posts land in the [RSS feed](/blog/rss) — no newsletter, no popups, just the log, appended.
</Callout>


Last updated on June 7, 2026