This is the full developer documentation for Nepal Payments
# Introduction
> Nepal Payments is an Agent Skill plus tested adapters for eSewa, Khalti, Fonepay, connectIPS, CyberSource, NPS and IME Pay in TypeScript, Python, Java, Kotlin, PHP and Go.
Nepal Payments is an [Agent Skill](https://agentskills.io) for integrating Nepali payment gateways into any backend, web app or mobile app. Ask a coding agent to “add eSewa and Khalti checkout to this app” and it follows a tested recipe: server-side signing, server-to-server verification, exactly-once fulfilment and reconciliation.
The skill ships working adapters in 6 languages, covered by 414 tests that include each provider’s own signature test vectors. The adapters also work on their own, without any agent.
## Providers
[Section titled “Providers”](#providers)
[**eSewa**ePay v2 web checkout, Intent app-to-app](/docs/providers/esewa/)[**Khalti**KPG-2 web checkout, refunds](/docs/providers/khalti/)[**Fonepay**Web redirect, Dynamic QR over WebSocket](/docs/providers/fonepay/)[**connectIPS**Direct bank debit, RSA-signed with your .pfx](/docs/providers/connectips/)[**CyberSource**Secure Acceptance cards via Nepali banks](/docs/providers/cybersource/)[**NPS OnePG**About 29 banks, wallets and cards in one API](/docs/providers/nps/)[**IME Pay**Wallet checkout for existing merchants](/docs/providers/imepay/)
HBL (2C2P PACO), Prabhu Pay, Moru, CellPay and Namaste Pay are documented in [Other gateways](/docs/providers/others/) without a bundled adapter.
## Two ways to use it
[Section titled “Two ways to use it”](#two-ways-to-use-it)
[With a coding agent](/docs/install/)Install the skill in Claude Code, Codex, Cursor, Gemini CLI or Copilot and describe what you need.
[Adapters only](/docs/adapters/)Copy the adapter folder for your language into your server and call initiate() and verify().
## What the skill enforces
[Section titled “What the skill enforces”](#what-the-skill-enforces)
* Secrets and signing stay on the server. Clients only send an order id.
* A redirect or callback is never proof of payment. The provider’s status API decides.
* Verification uses the amount and ids from your database, not from the callback.
* State changes go through one tested function, `decideTransition`, which handles early return-URL hits, late payments after an attempt was closed, missing or mismatched amounts and duplicate payments.
* A reconciliation job settles payments where the customer closed the tab.
The reasoning behind each rule is in [Production architecture](/docs/guides/architecture/).
# Install the skill
> Install the Nepal Payments Agent Skill in Claude Code from the plugin marketplace, in any Agent Skills compatible agent with the skills CLI, or by copying the folder.
The skill is a plain folder, `skills/nepal-payments/`, in the [GitHub repo](https://github.com/broisnees/nepal-payments-skill). Pick whichever install path fits your agent.
* Claude Code
Install it from the plugin marketplace. Run these inside Claude Code:
```text
/plugin marketplace add broisnees/nepal-payments-skill
/plugin install nepal-payments@nepal-payments-skill
```
* Any agent (skills CLI)
The [skills CLI](https://github.com/vercel-labs/skills) installs into any agent that supports Agent Skills, including Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot.
```bash
npx skills add broisnees/nepal-payments-skill # this project only
npx skills add broisnees/nepal-payments-skill -g # every project
```
* Manual
Copy the folder into your agent’s skills directory. For Claude Code:
```bash
git clone https://github.com/broisnees/nepal-payments-skill
cp -r nepal-payments-skill/skills/nepal-payments ~/.claude/skills/
```
## What gets installed
[Section titled “What gets installed”](#what-gets-installed)
```text
skills/nepal-payments/
├── SKILL.md workflow, non-negotiable rules, provider table, traps
├── references/ one file per provider + architecture, web, mobile, env, testing
└── scripts/ tested adapters: ts, python, java, kotlin, php, go
```
The agent reads `SKILL.md` when a task matches, then opens only the references it needs. Every page under [Providers](/docs/providers/esewa/) and [Guides](/docs/guides/architecture/) on this site is generated from those same reference files.
## Next steps
[Section titled “Next steps”](#next-steps)
1. Put your sandbox credentials in environment variables. The [environment variables](/docs/guides/env/) page lists each one, and [Testing and go-live](/docs/guides/testing/) has the public sandbox values.
2. Ask your agent for the integration you want. [Use it with your agent](/docs/usage/) has prompts that work well.
3. Run the bundled tests and the sandbox checks before going live.
# Use the adapters directly
> Copy the Nepal Payments adapter for TypeScript, Python, Java, Kotlin, PHP or Go into your server and call initiate() and verify() without any agent.
Each language has the same shape: build the configured providers from environment variables, call `initiate()` to start a payment, then call `verify()` from your return handler with the values you stored. All amounts are integer **paisa** (1 NPR = 100 paisa).
## 1. Copy the adapter
[Section titled “1. Copy the adapter”](#1-copy-the-adapter)
| Language | Copy | Notes |
| ---------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TypeScript | `scripts/ts/src/` | Keep `core.ts`, `index.ts` and the providers you need. Files import each other with `.ts` extensions; rewrite to `.js` for NodeNext or extensionless for bundlers if your tsconfig lacks `allowImportingTsExtensions`. |
| Python | `scripts/python/nepal_payments/` | Stdlib only. Add `cryptography` for connectIPS. |
| Java | `scripts/java/src/main/java/np/payments/` | JDK 17+, no dependencies, blocking and thread-safe. |
| Kotlin | `scripts/kotlin/src/main/kotlin/np/payments/` | kotlin-stdlib only, `suspend` API. Server side only, never inside an Android app. |
| PHP | `scripts/php/src/` | Namespace `NepalPayments`. Register it in your PSR-4 autoload. |
| Go | `scripts/go/payments/` | Stdlib only. Copy into your module, for example `internal/payments/`, and fix the import path. |
## 2. Configure
[Section titled “2. Configure”](#2-configure)
Set the environment variables for each provider you want. A provider is enabled when its required variables are present. The full list is in [Environment variables](/docs/guides/env/).
```bash
ESEWA_ENV=sandbox
ESEWA_PRODUCT_CODE=EPAYTEST
ESEWA_SECRET_KEY='8gBm/:&EnhH.1/q'
KHALTI_SECRET_KEY=... # from test-admin.khalti.com
KHALTI_WEBSITE_URL=https://shop.example.com
```
## 3. Initiate and verify
[Section titled “3. Initiate and verify”](#3-initiate-and-verify)
* TypeScript
```ts
import { createPayments, paymentsConfigFromEnv, newOrderId } from "./payments/index.ts";
const payments = createPayments(paymentsConfigFromEnv());
const init = await payments.get("khalti").initiate({
orderId: newOrderId(), amountPaisa: 150000, description: "Order #42",
returnUrl: "https://shop.example.com/payments/khalti/return",
});
// later, in the return handler: verify with values from YOUR database
const result = await payments.get("khalti").verify({
orderId: attempt.id, amountPaisa: attempt.amountPaisa, providerRef: attempt.providerRef,
});
```
* Python
```python
from nepal_payments import create_payments, config_from_env, new_order_id
payments = create_payments(config_from_env())
init = payments["khalti"].initiate(
order_id=new_order_id(), amount_paisa=150000, description="Order #42",
return_url="https://shop.example.com/payments/khalti/return",
)
# later, in the return handler: verify with values from YOUR database
result = payments["khalti"].verify(attempt.id, attempt.amount_paisa, provider_ref=attempt.provider_ref)
```
* Go
```go
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
init, err := p.Khalti.Initiate(ctx, payments.InitiateInput{
OrderID: payments.NewOrderID(), AmountPaisa: 150000, Description: "Order #42",
ReturnURL: "https://shop.example.com/payments/khalti/return",
})
// later, in the return handler: verify with values from YOUR database
res, err := p.Khalti.Verify(ctx, payments.VerifyInput{
OrderID: a.ID, AmountPaisa: a.AmountPaisa, ProviderRef: a.ProviderRef,
})
```
* Java
```java
Payments payments = Payments.create(Payments.configFromEnv());
InitiateResult init = payments.khalti().initiate(InitiateInput.of(
Payments.newOrderId(), 150_000, "Order #42", "https://shop.example.com/payments/khalti/return"));
// later, in the return handler: verify with values from YOUR database
VerifyResult result = payments.khalti().verify(
new VerifyInput(attempt.id(), attempt.amountPaisa(), attempt.providerRef(), null));
```
* Kotlin
```kotlin
val payments = Payments.create(paymentsConfigFromEnv())
val init = payments.khalti.initiate(InitiateInput(
orderId = newOrderId(), amountPaisa = 150_000, description = "Order #42",
returnUrl = "https://shop.example.com/payments/khalti/return",
))
// later, in the return handler (suspend): verify with values from YOUR database
val result = payments.khalti.verify(VerifyInput(attempt.id, attempt.amountPaisa, providerRef = attempt.providerRef))
```
* PHP
```php
use NepalPayments\{InitiateInput, Payments, VerifyInput};
$payments = Payments::create(Payments::configFromEnv());
$init = $payments->khalti()->initiate(new InitiateInput(
orderId: Payments::newOrderId(), amountPaisa: 150000, description: 'Order #42',
returnUrl: 'https://shop.example.com/payments/khalti/return',
));
// later, in the return handler: verify with values from YOUR database
$result = $payments->khalti()->verify(new VerifyInput($attempt->id, $attempt->amountPaisa, $attempt->providerRef));
```
`initiate()` returns either a **redirect** (send the browser to `url`) or a **form** (render an auto-submitting POST; every port has a `renderAutoSubmitForm` helper). Store the `providerRef` it returns on the attempt: Khalti’s `pidx`, NPS’s `ProcessId` and IME Pay’s token are needed by `verify()`.
Never fulfil from the callback alone
Pass the amount and ids from your database into `verify()`, then apply the result with `decideTransition` and a guarded `UPDATE`. Fulfil only when the new status is `paid`. [Production architecture](/docs/guides/architecture/) has the table schema, the return handler and the reconciliation job.
## 4. Wire the routes
[Section titled “4. Wire the routes”](#4-wire-the-routes)
Every framework needs the same five routes. [Web frameworks](/docs/guides/web/) has complete handlers for Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor, plus notes for Go `net/http`, chi and Gin. Mobile apps reuse those routes; see [Mobile apps](/docs/guides/mobile/).
# Providers
> Compare eSewa, Khalti, Fonepay, connectIPS, CyberSource, NPS and IME Pay, then follow the step-by-step guide for each.
Every provider implements the same `initiate` and `verify` interface in all six languages. Each one has a step-by-step integration guide and a full reference.
| Provider | Type | Signing | Proof of payment | Docs |
| --------------- | ---------- | ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------- |
| **eSewa** | Wallet | HMAC-SHA256 (base64) | Status API | [Integrate](/docs/providers/esewa/) · [Reference](/docs/providers/esewa/reference/) |
| **Khalti** | Wallet | Key header (secret key) | Lookup by `pidx` | [Integrate](/docs/providers/khalti/) · [Reference](/docs/providers/khalti/reference/) |
| **Fonepay** | Bank QR | HMAC-SHA512 (hex) | txnVerification | [Integrate](/docs/providers/fonepay/) · [Reference](/docs/providers/fonepay/reference/) |
| **connectIPS** | Bank | RSA-SHA256 (base64) | `validatetxn` | [Integrate](/docs/providers/connectips/) · [Reference](/docs/providers/connectips/reference/) |
| **CyberSource** | Cards | HMAC-SHA256 (base64) | Signed response POST | [Integrate](/docs/providers/cybersource/) · [Reference](/docs/providers/cybersource/reference/) |
| **NPS OnePG** | Aggregator | HMAC-SHA512 (hex) | `CheckTransactionStatus` | [Integrate](/docs/providers/nps/) · [Reference](/docs/providers/nps/reference/) |
| **IME Pay** | Wallet | Basic auth (base64) | `Recheck` | [Integrate](/docs/providers/imepay/) · [Reference](/docs/providers/imepay/reference/) |
## Which to start with
[Section titled “Which to start with”](#which-to-start-with)
* **eSewa and Khalti** cover the largest wallets.
* **NPS or Fonepay** reach most banks through one integration.
* **connectIPS** debits bank accounts directly.
* **CyberSource** only if a bank is acquiring cards for you.
* **IME Pay** only for existing IME Pay merchants; new builds use Khalti.
HBL (2C2P PACO), Prabhu Pay, Moru, CellPay and Namaste Pay have notes in [Other gateways](/docs/providers/others/) but no bundled adapter.
# Page not found
> The page moved or never existed. The docs start here.
# Docs for AI tools
> Feed the Nepal Payments docs to ChatGPT, Claude, Cursor or any LLM with llms.txt, llms-full.txt and a Markdown version of every page.
These docs are built to be read by coding agents and chat assistants as well as people.
[llms.txt](/llms.txt)An index of the docs in the llms.txt format, with links to every set.
[llms-full.txt](/llms-full.txt)Every page in one Markdown file, including the integration code for all six languages.
[llms-small.txt](/llms-small.txt)A compact version with non-essential content removed, for small context windows.
## Markdown for any page
[Section titled “Markdown for any page”](#markdown-for-any-page)
Add `.md` to a page’s path to get it as plain Markdown, for example [`/docs/providers/esewa.md`](/docs/providers/esewa.md) for the eSewa integration guide. The **Settings** menu in the header has the same actions for the page you’re on:
* **Copy page as Markdown** puts the page on your clipboard, ready to paste into a chat.
* **View as Markdown** opens the raw page.
* **Open in ChatGPT** and **Open in Claude** start a chat that reads the page.
## Prefer the skill for coding
[Section titled “Prefer the skill for coding”](#prefer-the-skill-for-coding)
For writing the integration itself, [install the skill](/docs/install/) instead. The agent then reads the same references on demand, plus the tested adapters it copies into your project.
# Adapter API
> The PaymentAdapter interface shared by every Nepal Payments adapter, its inputs and results, the normalized payment statuses and the decideTransition state machine.
Every provider implements the same interface in all six languages. This page uses the TypeScript names. Python uses `snake_case` (`amount_paisa`, `order_id_from_callback`) and Go uses exported names (`AmountPaisa`, `OrderIDFromCallback`, with a `context.Context` first argument on network calls). Java, Kotlin and PHP keep the TypeScript names.
## PaymentAdapter
[Section titled “PaymentAdapter”](#paymentadapter)
```ts
interface PaymentAdapter {
readonly id: ProviderId; // "esewa" | "khalti" | "fonepay" | "connectips" | "cybersource" | "nps" | "imepay"
initiate(input: InitiateInput): Promise;
/** Server-to-server confirmation. Never trust callback params alone. */
verify(input: VerifyInput): Promise;
/** Extract YOUR order id from return-URL params so you can load the attempt. */
orderIdFromCallback(params: Record): string | null;
}
```
Providers add their own methods on top, for example eSewa’s `intentBook` / `intentVerify`, Khalti refunds, Fonepay Dynamic QR and NPS’s instrument list. Each provider page documents them.
## Inputs
[Section titled “Inputs”](#inputs)
```ts
interface InitiateInput {
orderId: string; // unique per payment ATTEMPT, [A-Za-z0-9-]; newOrderId() fits every provider
amountPaisa: number; // integer paisa, computed server-side
description: string;
returnUrl: string; // your server route
failureUrl?: string; // defaults to returnUrl
customer?: { name?: string; email?: string; phone?: string };
}
interface VerifyInput {
orderId: string;
amountPaisa: number; // the amount YOU stored for this attempt
providerRef?: string | null; // from initiate(): Khalti pidx, NPS ProcessId, IME Pay token
callback?: Record; // raw params the provider sent to your return URL
}
```
## Results
[Section titled “Results”](#results)
```ts
type InitiateResult =
| { kind: "redirect"; url: string; providerRef?: string; expiresAt?: string; raw?: unknown }
| { kind: "form"; action: string; method: "POST"; fields: Record;
enctype?: "multipart/form-data"; providerRef?: string };
interface VerifyResult {
provider: ProviderId;
status: PaymentStatus;
orderId: string;
amountPaisa: number | null; // what the PROVIDER says was paid
transactionId: string | null; // the provider's id; support and refunds need it
raw: unknown; // untouched provider response for audit logs
}
```
## Payment statuses
[Section titled “Payment statuses”](#payment-statuses)
Every provider status maps onto exactly one of these:
| Status | Meaning | Fulfil? |
| --------------------------------- | ---------------------------------------------------------------- | --------- |
| `paid` | Money captured | Yes, once |
| `pending` | Not settled yet; re-check later | No |
| `failed` | Declined or errored; retry with a new order id | No |
| `canceled` | The payer backed out | No |
| `expired` | The session timed out before payment | No |
| `refunded` / `partially_refunded` | Money returned | No |
| `not_found` | The provider has no record (never started, or wrong environment) | No |
| `unknown` | Anything unmapped; treat like pending and alert | No |
Attempts also have an `initiated` status: created, but the provider hasn’t been heard from yet.
## decideTransition
[Section titled “decideTransition”](#decidetransition)
A pure function that turns a `verify()` result into the next stored status. Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when `next === "paid"`.
```ts
decideTransition(prev, result, storedAmountPaisa, attemptAgeMs, sessionWindowMs = 3_600_000): {
next: AttemptStatus | null; // null = write nothing
reviewRequired: boolean; // amount mismatch, or "paid" without an amount: alert a human
latePaid: boolean; // paid after the attempt was closed: recover and alert
reason: string;
}
```
| Language | Call |
| ------------------ | --------------------------------------------------------------------------------------- |
| TypeScript, Kotlin | `decideTransition(...)` |
| Python | `decide_transition(prev, status, amount_paisa, stored_amount_paisa, attempt_age_s)` |
| Java, PHP | `StateMachine.decideTransition(...)` / `StateMachine::decideTransition(...)` |
| Go | `payments.DecideTransition(prev, result, storedAmountPaisa, attemptAge, sessionWindow)` |
## Errors
[Section titled “Errors”](#errors)
Adapters throw `PaymentError` (`PaymentException` in Java, Kotlin and PHP) with a `provider`, a machine-readable `code` and, where it applies, an `httpStatus`. Common codes:
| Code | When |
| ------------------------------------- | --------------------------------------------------------- |
| `invalid_amount` | The amount is not a positive integer in paisa |
| `invalid_order_id` | The order id breaks the provider’s charset or length rule |
| `invalid_signature` | A callback signature doesn’t verify |
| `callback_mismatch` | The callback or status is for a different order |
| `malformed_callback` | The callback data can’t be decoded (HTTP 400) |
| `network_error` | The provider couldn’t be reached after retries |
| `unauthorized` | The provider rejected your credentials (401) |
| `http_` / `_` | The provider returned an error body |
## Helpers
[Section titled “Helpers”](#helpers)
* `newOrderId(prefix = "P")`: at most 20 characters of `[0-9A-Z]`, which fits every provider’s id rules.
* `paisaToRupees(12345)` returns `"123.45"`; `rupeesToPaisa("1,000.0")` returns `100000`. Money stays integer paisa everywhere else.
* `renderAutoSubmitForm(result)`: the HTML for a `kind: "form"` result.
* `paymentsConfigFromEnv()`: builds the config from [environment variables](/docs/guides/env/).
# FAQ
> Common questions about the Nepal Payments skill and adapters, covering agents, providers, mobile apps, sandboxes and going live.
## What is an Agent Skill?
A folder of instructions, references and scripts that a coding agent loads when a task matches. This one teaches the agent how Nepali payment gateways actually behave, so it follows a tested recipe instead of guessing signatures and callback formats.
## Which agents does it work with?
Any agent that supports the Agent Skills format: Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot and others. Claude Code can also install it from the plugin marketplace.
## Can I use the adapters without an agent?
Yes. Copy the folder for your language into your server code. The adapters have no runtime dependencies beyond each language's standard library (connectIPS in Python also needs the cryptography package).
## Which provider should I start with?
eSewa and Khalti cover the largest wallets. Add NPS or Fonepay to reach most banks through one integration, connectIPS for direct bank debit, and CyberSource only if a bank is acquiring cards for you.
## Is it affiliated with eSewa, Khalti or the other providers?
No. It is an independent open-source project under the MIT license. The sandbox credentials it uses are the public ones from each provider's documentation.
## Does it handle mobile apps?
Yes. The mobile guide covers Flutter, React Native, Expo, Android and iOS, including eSewa Intent app-to-app payments, with all signing kept on your server.
## How do I test without merchant accounts?
[Section titled “How do I test without merchant accounts?”](#how-do-i-test-without-merchant-accounts)
eSewa and Fonepay have public sandbox credentials, and Khalti gives you a test key at test-admin.khalti.com. connectIPS, CyberSource, NPS and IME Pay need credentials from the provider or your bank. [Testing and go-live](/docs/guides/testing/) lists every sandbox value and test login.
## A provider changed its API. What now?
[Section titled “A provider changed its API. What now?”](#a-provider-changed-its-api-what-now)
Open an issue or pull request on [GitHub](https://github.com/broisnees/nepal-payments-skill) with the source: an official doc, an SDK commit or a captured sandbox response. New adapters need the provider’s published test vectors as tests before the implementation.
# Production architecture
> Payment attempts, a tested state machine, exactly-once fulfilment and reconciliation: what keeps a Nepali payment integration correct under real traffic.
> Source: [`references/architecture.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/architecture.md). This is the same file the agent reads.
The provider-specific files cover the wire format. This file covers what makes an integration survive real traffic: duplicate callbacks, users closing the tab mid-payment, flaky provider APIs, and people tampering with redirect URLs.
## 1. Non-negotiable rules
[Section titled “1. Non-negotiable rules”](#1-non-negotiable-rules)
1. **Secrets never leave the server.** Signing (eSewa, Fonepay, connectIPS, CyberSource, NPS) and API keys (Khalti, IME Pay) live in backend env vars or a secret manager, never in a web bundle, mobile app, or git repo. Mobile and web clients ask *your* backend to start a payment.
2. **The redirect is not proof of payment.** Query params on `success_url` / `return_url`, and “success” callbacks from mobile SDKs, can be forged, replayed, or arrive for a payment that later reverses. Fulfil only after a **server-to-server status check** (eSewa status API, Khalti lookup, Fonepay verification, connectIPS validatetxn, NPS CheckTransactionStatus, IME Pay Confirm/Recheck) or, for CyberSource, a **verified HMAC signature** on the response.
3. **Check amount and order id, not only status.** Compare the provider-reported amount to what *you* stored for that order. A valid “COMPLETE” for a Rs 10 order must not unlock a Rs 10,000 one.
4. **One order id per payment attempt.** Providers reject or misreport reused ids (`transaction_uuid`, `purchase_order_id`, `PRN`, `TXNID`, `MerchantTxnId`, `reference_number`). Use a fresh id each time the user clicks Pay, linked to your order.
5. **Fulfil exactly once.** Return-URL hits, webhooks, and the reconciliation job can all arrive for the same attempt, sometimes at the same moment. Use a conditional state transition (below) so only one wins.
6. **Money is integer paisa in your system.** Convert to the provider’s format (rupee string, paisa integer, decimal string) at the adapter edge only. Floats will eventually produce `109.99999999` and a signature mismatch.
7. **Keep sandbox and live completely separate**: separate env vars, base URLs and keys. A sandbox key against a live URL usually returns 401 or `NOT_FOUND`, which looks like a lost payment.
## 2. Data model
[Section titled “2. Data model”](#2-data-model)
Minimal Postgres schema; adapt types for MySQL/SQLite. One `orders` row per thing being bought, many `payment_attempts` per order.
```sql
CREATE TYPE payment_status AS ENUM (
'initiated', 'pending', 'paid', 'failed', 'canceled', 'expired',
'refunded', 'partially_refunded', 'not_found', 'unknown'
);
CREATE TABLE payment_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders(id),
provider text NOT NULL, -- 'esewa' | 'khalti' | ...
provider_order_id text NOT NULL, -- the id YOU sent (transaction_uuid / purchase_order_id / PRN / TXNID)
provider_ref text, -- session id from initiate() that verify() needs (Khalti pidx, NPS ProcessId, IME TokenId)
provider_txn_id text, -- final transaction id from verify() (eSewa ref_id, Khalti transaction_id, ...)
amount_paisa bigint NOT NULL CHECK (amount_paisa > 0),
currency char(3) NOT NULL DEFAULT 'NPR',
status payment_status NOT NULL DEFAULT 'initiated',
review_required boolean NOT NULL DEFAULT false, -- amount mismatch / no amount / unsettled at cutoff: human decides
raw_initiate jsonb,
raw_verify jsonb,
verified_at timestamptz,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, provider_order_id)
);
CREATE INDEX payment_attempts_reconcile_idx
ON payment_attempts (status, created_at)
WHERE status IN ('initiated', 'pending', 'unknown') AND NOT review_required;
```
State machine (anything else is a bug):
Payment attempt state machine
* `not_found` is **never stored** while the attempt is younger than the provider’s session window (\~60 min): treat it as “not paid yet” and keep the row open. After the window, store `expired`.
* A provider **“paid” always wins over an earlier `failed`/`canceled`/`expired`** (late payment, reconcile race, user replaying an old payment page): move to `paid`, fulfil, and alert so someone looks at why it closed early.
* `paid` is terminal for automation. Never move it back to `pending` or `failed` because a later check returned something odd; alert a human instead.
* A “paid” with an amount that differs from yours, **or with no amount at all**, goes to `unknown` with `review_required = true`. Never fulfil it automatically.
`decideTransition()` in [`scripts/ts/src/core.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/core.ts) (`decide_transition()` in Python) implements exactly these rules and is unit-tested. Use it rather than re-deriving them.
## 3. The flow
[Section titled “3. The flow”](#3-the-flow)
Payment flow across client, backend and provider
The amount always comes from **your database**, never from the client request.
## 4. Return-URL / callback handler
[Section titled “4. Return-URL / callback handler”](#4-return-url--callback-handler)
Every provider handler has the same shape. TypeScript with any SQL client:
```ts
import { decideTransition } from "./payments/index.ts"; // pure, unit-tested state machine (core.ts)
const SESSION_WINDOW_MS = 60 * 60_000; // longest provider session (Khalti pidx ~60 min, eSewa ~50 min)
// /payments/:provider/return[/:attemptId] (eSewa/Khalti/Fonepay/connectIPS/NPS: GET; CyberSource/IME Pay: POST)
export async function handleReturn(provider: ProviderId, params: Record, pathAttemptId?: string) {
// 1. Find YOUR attempt: from the callback, else from the URL path (eSewa failure redirects carry no data).
const providerOrderId = adapters[provider].orderIdFromCallback(params) ?? pathAttemptId;
const attempt = providerOrderId && await db.oneOrNone(
"SELECT * FROM payment_attempts WHERE provider = $1 AND provider_order_id = $2",
[provider, providerOrderId],
);
if (!attempt) return { http: 404 };
if (attempt.review_required) return { http: 302, location: `/orders/${attempt.order_id}` };
// 2. Ask the provider, server-to-server, with the values WE stored.
const result = await adapters[provider].verify({
orderId: attempt.provider_order_id,
amountPaisa: Number(attempt.amount_paisa),
providerRef: attempt.provider_ref, // from YOUR db, never from the callback
callback: params,
});
// 3. Decide. decideTransition() encodes every rule:
// - only open attempts move; a provider "paid" after we closed it (failed/canceled/expired) is a LATE PAYMENT
// and is recovered instead of silently dropped
// - "paid" must carry an amount equal to ours, else -> unknown + review_required (never auto-fulfil)
// - not_found inside the session window -> write nothing (anyone can hit a return URL early); after it -> expired
const d = decideTransition(attempt.status, result, Number(attempt.amount_paisa),
Date.now() - attempt.created_at.getTime(), SESSION_WINDOW_MS);
if (!d.next) return { http: 302, location: `/orders/${attempt.order_id}` };
if (d.reviewRequired || d.latePaid) await alert(d.reason, { attempt, result });
// 4. Conditional transition guarded by the status we read: only one concurrent caller wins.
const updated = await db.oneOrNone(
`UPDATE payment_attempts
SET status = $1, review_required = review_required OR $2,
provider_txn_id = COALESCE($3, provider_txn_id), raw_verify = $4, verified_at = now(), updated_at = now()
WHERE id = $5 AND status = $6
RETURNING *`,
[d.next, d.reviewRequired, result.transactionId, JSON.stringify(result.raw), attempt.id, attempt.status],
);
// updated === null → someone else changed it first; their write stands (re-run the handler if you need the latest).
// 5. Fulfil only if THIS call made the transition to paid.
if (updated?.status === "paid") await fulfilOrder(attempt.order_id, attempt.id); // idempotent; alerts on double payment
return { http: 302, location: `/orders/${attempt.order_id}` };
}
```
Notes:
* Guarding the UPDATE with `status = ` (optimistic concurrency) makes every rule above race-safe, including late-paid recovery: if the reconciler commits `expired` while a return handler holds a “paid” result, the handler’s UPDATE misses and the next run (refresh, webhook, reconciler) sees `expired` + “paid” and recovers it.
* Make `fulfilOrder` idempotent too: `UPDATE orders SET status='paid', paid_by_attempt=$2 WHERE id=$1 AND status <> 'paid'`. Send emails and grant access only if that update touched a row. If the order was already paid by a **different** attempt, the customer paid twice: alert and refund that attempt.
* Refuse `POST /payments` for orders that are already paid, and refuse `/start` for attempts that are closed or past the session window. Never replay an old signed form; create a new attempt.
* When `verify` throws (network, 5xx), leave the attempt as it is and show “We’re confirming your payment”. The reconciliation job will settle it. Never mark it failed on a timeout.
* Respond quickly. Users refresh return pages, so the handler must be safe to run many times.
* For mobile, end the handler by redirecting to an app deep link (`myapp://payment/result?order=...`) or a page the WebView watches for. See [`mobile.md`](/docs/guides/mobile/).
## 5. Webhooks and server notifications
[Section titled “5. Webhooks and server notifications”](#5-webhooks-and-server-notifications)
| Provider | Server-to-server notification |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| eSewa | ePay v2: no webhook; status API from the return URL + reconciliation. Intent: signed JSON POST to `callback_url` (verify signature, then Intent status API). |
| Khalti | No webhook in KPG-2 checkout. Lookup from return URL + reconciliation. |
| Fonepay | Web PG: none; verify after redirect. Dynamic QR: websocket push / status poll. |
| connectIPS | No webhook; validatetxn after redirect + reconciliation. |
| CyberSource | Merchant POST URL (configured in the Secure Acceptance profile) gets a signed POST for every decision. Most reliable signal. |
| NPS | Notification URL receives an unsigned GET with `MerchantTxnId` + `GatewayTxnId`. Verify, then reply `received` (or `already received`) **only once a final status is recorded**; while it’s still pending, return a non-2xx so NPS keeps notifying. |
| IME Pay | No webhook; Confirm/Recheck. |
Treat every webhook as a hint, never as the truth: verify the signature if there is one, then call `verify()` and run the same conditional update as the return handler.
## 6. Reconciliation job
[Section titled “6. Reconciliation job”](#6-reconciliation-job)
Users close tabs, mobile apps get killed, return URLs time out. Without this job, some customers pay and never get their order. Run every 1–5 minutes:
```sql
SELECT * FROM payment_attempts
WHERE status IN ('initiated', 'pending', 'unknown')
AND NOT review_required
AND created_at < now() - interval '2 minutes'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
```
Hold the row locks only while you pick rows (or use a short `claimed_until` lease column). Call providers **outside** the transaction, then apply results with the guarded UPDATE from §4.
For each row, call `verify()` with bounded concurrency (5–10) and run the same transition as section 4. Rules:
* `not_found` within the provider’s session window (for example Khalti’s pidx expiry, about 60 min) means the user never paid yet, so leave it. After the window, mark it `expired`.
* `pending` / eSewa `AMBIGUOUS` / NPS `Pending` / IME Pay non-zero codes: keep checking with backoff. At the cutoff (e.g. 3 days) set `review_required = true` and alert. Don’t just stop polling silently; these can still settle.
* Alert on any `unknown` status, because the provider added a value you don’t map.
* Daily: download the provider’s settlement report from the merchant dashboard and compare its totals against `paid` rows.
## 7. Refunds
[Section titled “7. Refunds”](#7-refunds)
Refund APIs vary (Khalti has a merchant refund API; CyberSource refunds through the REST API; eSewa, Fonepay and connectIPS refunds usually go through the merchant portal or a support request). Model refunds as their own rows (`refunds` table with amount, status, provider ref) and update the attempt to `refunded` / `partially_refunded` only after the provider confirms.
## 8. Security checklist
[Section titled “8. Security checklist”](#8-security-checklist)
* [ ] Secrets in env / secret manager; never committed; separate sandbox and live keys; rotation documented.
* [ ] Amount computed server-side from the order; the client only sends `orderId` + `provider`.
* [ ] Server-side status check (or signature verification for CyberSource) before fulfilment.
* [ ] Amount + order id + status compared against stored values.
* [ ] Unique provider order id per attempt, enforced by `UNIQUE(provider, provider_order_id)`.
* [ ] Conditional `UPDATE ... WHERE status IN (...)` for state transitions; idempotent fulfilment.
* [ ] Constant-time signature comparison (`safeEqual`).
* [ ] Return / callback URLs are HTTPS, fixed on the server, and not built from client input (open-redirect risk).
* [ ] Callback endpoints rate-limited; log and drop invalid signatures.
* [ ] No secrets or full card data in logs. Log ids and statuses; mask phone numbers and emails.
* [ ] Card payments use hosted pages (CyberSource Secure Acceptance / Unified Checkout) so card data never touches your servers (PCI SAQ A scope).
* [ ] `fetch` timeouts on every provider call (the adapters default to 15 s).
## 9. Observability
[Section titled “9. Observability”](#9-observability)
Log a structured line at each step: `payment.initiate`, `payment.return`, `payment.verify`, `payment.transition`, `payment.reconcile`, carrying `attempt_id`, `provider`, `provider_order_id`, `status`, `latency_ms`. Alert on:
* verify error rate > 5% over 10 min (provider outage or key problem)
* any amount mismatch or invalid signature
* attempts stuck in `pending`/`unknown` for more than 1 h
* `paid` attempts whose order is not fulfilled after 5 min
## 10. Go-live checklist
[Section titled “10. Go-live checklist”](#10-go-live-checklist)
* [ ] Live credentials from the provider (eSewa/Khalti merchant onboarding, the bank for Fonepay/CyberSource, NCHL via the bank for connectIPS).
* [ ] Live base URLs set through config, not code edits.
* [ ] Return URLs registered or allowlisted with the provider where required (Fonepay RU, CyberSource profile URLs, connectIPS success/failure URLs, IME Pay).
* [ ] One real low-value live payment per provider, verified end to end, then refunded.
* [ ] Reconciliation job running and alerting.
* [ ] Customer support runbook: how to look up an attempt by provider ref, and how to issue a refund.
# Environment variables
> Every environment variable the adapters read, per provider, and which ones enable each gateway.
> Source: [`references/env.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/env.md). This is the same file the agent reads.
`paymentsConfigFromEnv()` (TS) and `config_from_env()` (Python) enable a provider when its required variables are set. `*_ENV` is `sandbox` (default) or `production` (`live` also accepted).
| Provider | Required | Optional |
| ---------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| eSewa | `ESEWA_PRODUCT_CODE`, `ESEWA_SECRET_KEY` | `ESEWA_ENV`, `ESEWA_INTENT_PRODUCT_CODE`, `ESEWA_INTENT_ACCESS_KEY`, `ESEWA_INTENT_BASE_URL` (production Intent host from eSewa) |
| Khalti | `KHALTI_SECRET_KEY`, `KHALTI_WEBSITE_URL` | `KHALTI_ENV`; `KHALTI_PUBLIC_KEY` for mobile SDK builds only |
| Fonepay | `FONEPAY_MERCHANT_CODE`, `FONEPAY_SECRET_KEY` | `FONEPAY_ENV`, `FONEPAY_USERNAME`, `FONEPAY_PASSWORD` (needed for txnVerification + Dynamic QR) |
| connectIPS | `CONNECTIPS_MERCHANT_ID`, `CONNECTIPS_APP_ID`, `CONNECTIPS_PRIVATE_KEY_PEM` | `CONNECTIPS_ENV`, `CONNECTIPS_APP_NAME`, `CONNECTIPS_APP_PASSWORD` |
| CyberSource SA | `CYBERSOURCE_PROFILE_ID`, `CYBERSOURCE_ACCESS_KEY`, `CYBERSOURCE_SECRET_KEY` | `CYBERSOURCE_ENV`, `CYBERSOURCE_CURRENCY` (NPR) |
| CyberSource REST (SDK) | `CYBERSOURCE_MERCHANT_ID`, `CYBERSOURCE_KEY_ID`, `CYBERSOURCE_SHARED_SECRET` | |
| NPS | `NPS_MERCHANT_ID`, `NPS_SECRET_KEY` | `NPS_ENV`, `NPS_MERCHANT_NAME`, `NPS_API_USERNAME`, `NPS_API_PASSWORD` |
| IME Pay | `IMEPAY_MERCHANT_CODE`, `IMEPAY_USERNAME` | `IMEPAY_ENV`, `IMEPAY_PASSWORD`, `IMEPAY_MODULE` |
`.env.example` (sandbox values that are public in provider docs; everything else blank):
```dotenv
APP_PUBLIC_URL=https://shop.example.com # used to build return URLs; must be reachable by the payer's browser
ESEWA_ENV=sandbox
ESEWA_PRODUCT_CODE=EPAYTEST
ESEWA_SECRET_KEY=8gBm/:&EnhH.1/q
ESEWA_INTENT_PRODUCT_CODE=INTENT
ESEWA_INTENT_ACCESS_KEY=LB0REg8HUSw3MTYrI1s6JTE8Kyc6JyAqJiA3MQ==
KHALTI_ENV=sandbox
KHALTI_SECRET_KEY= # test-admin.khalti.com → Keys (login OTP 987654)
KHALTI_WEBSITE_URL=https://shop.example.com
FONEPAY_ENV=sandbox
FONEPAY_MERCHANT_CODE=NBQM # public dev sample from Fonepay's spec
FONEPAY_SECRET_KEY=a7e3512f5032480a83137793cb2021dc
FONEPAY_USERNAME=
FONEPAY_PASSWORD=
CONNECTIPS_ENV=sandbox
CONNECTIPS_MERCHANT_ID=
CONNECTIPS_APP_ID=
CONNECTIPS_APP_NAME=
CONNECTIPS_APP_PASSWORD=
CONNECTIPS_PRIVATE_KEY_PEM= # PKCS#8 PEM; "\n" escapes allowed
CYBERSOURCE_ENV=sandbox
CYBERSOURCE_PROFILE_ID=
CYBERSOURCE_ACCESS_KEY=
CYBERSOURCE_SECRET_KEY=
NPS_ENV=sandbox
NPS_MERCHANT_ID=
NPS_MERCHANT_NAME=
NPS_API_USERNAME=
NPS_API_PASSWORD=
NPS_SECRET_KEY=
IMEPAY_ENV=sandbox
IMEPAY_MERCHANT_CODE=
IMEPAY_USERNAME=
IMEPAY_PASSWORD=
IMEPAY_MODULE=
```
Rules:
* Commit `.env.example` only. Production secrets live in the platform’s secret store (Vercel/Render/Fly env, `wrangler secret put`, AWS/GCP Secret Manager, Kubernetes secrets).
* Never expose these to the client. In Next.js, no `NEXT_PUBLIC_` prefix; in Vite, no `VITE_` prefix; in Expo, no `EXPO_PUBLIC_` prefix. The only value a client may hold is the Khalti **public** key for its mobile SDK.
* Fail fast at boot when a provider is half-configured in production (e.g. `*_ENV=production` with a sandbox product code like `EPAYTEST`).
# Mobile apps
> Take Nepali payments from Flutter, React Native, Expo, Android and iOS apps without putting secrets on the device.
> Source: [`references/mobile.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/mobile.md). This is the same file the agent reads.
## 1. The rule
[Section titled “1. The rule”](#1-the-rule)
The app holds **no secrets** and **never decides** that a payment succeeded. It asks your backend to start a payment, shows the provider’s UI, then asks your backend for the verified result. The secret key, merchant secret, `.pfx` or CyberSource secret would be extracted from an APK/IPA within minutes.
## 2. Choosing an approach
[Section titled “2. Choosing an approach”](#2-choosing-an-approach)
| Approach | Use for | Pros | Cons |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------- |
| **Native SDK** | eSewa, Khalti | Best UX, app-to-app switching to the wallet | SDK quirks; still must verify on server |
| **WebView** (in-app) | Fonepay, connectIPS, NPS, IME Pay, CyberSource, or any provider without an SDK | One code path for every provider; you control the return | Some bank 3DS / wallet deep links need extra handling (§8) |
| **System browser / Custom Tabs / ASWebAuthenticationSession** + deep link | Card payments (3DS), or when WebViews are blocked or unreliable | Real browser: 3DS, password managers and wallet apps all work | Needs universal/app links configured |
Pragmatic default: native SDK for Khalti/eSewa if you want app switching, and the **WebView pattern for everything else** using the same backend endpoints.
## 3. Backend endpoints the app needs
[Section titled “3. Backend endpoints the app needs”](#3-backend-endpoints-the-app-needs)
```plaintext
POST /payments {orderId, provider} -> {attemptId, startUrl, providerRef?, url?}
GET /payments/:attemptId/start -> 302 to provider, OR auto-submit HTML form (form-POST providers)
GET|POST /payments/:provider/return -> verifies server-side, then 302 to
https://YOUR_DOMAIN/payments/done?attempt=&status=
GET /payments/:attemptId -> {status, amountPaisa} (app polls this)
POST /payments/:attemptId/verify -> re-runs adapter.verify() and the conditional update; returns status
```
`/start` is what makes the WebView approach universal: eSewa, connectIPS, Fonepay and CyberSource need a form POST or a signed redirect, so the server renders `renderAutoSubmitForm()` (from `core.ts`) or issues a 302, and the app just loads one URL. The secret never leaves the server.
The `/payments/done` URL is the app’s **finish line**: the WebView intercepts it, or the OS opens the app through an app link.
## 4. WebView pattern
[Section titled “4. WebView pattern”](#4-webview-pattern)
### React Native (`react-native-webview` 14.x)
[Section titled “React Native (react-native-webview 14.x)”](#react-native-react-native-webview-14x)
```tsx
import { WebView } from "react-native-webview";
const DONE = "https://api.example.com/payments/done";
export function PaymentScreen({ route, navigation }) {
const { startUrl, attemptId } = route.params; // from POST /payments
const finished = React.useRef(false);
const finish = async () => {
if (finished.current) return;
finished.current = true;
const status = await confirmPayment(attemptId); // §7 — asks YOUR backend
navigation.replace("PaymentResult", { status });
};
return (
{
if (req.url.startsWith(DONE)) { finish(); return false; }
// Let wallet app deep links leave the WebView (see §8)
if (!req.url.startsWith("http")) { Linking.openURL(req.url).catch(() => {}); return false; }
return true;
}}
onNavigationStateChange={(s) => { if (s.url.startsWith(DONE)) finish(); }} // Android fallback for redirects
startInLoadingState
javaScriptEnabled
domStorageEnabled
/>
);
}
```
Handle the hardware back button / close icon by calling `finish()` too: the user may have paid and then backed out, so always ask the server.
### Flutter (`webview_flutter` 4.x)
[Section titled “Flutter (webview_flutter 4.x)”](#flutter-webview_flutter-4x)
```dart
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class PaymentPage extends StatefulWidget {
const PaymentPage({super.key, required this.startUrl, required this.attemptId});
final String startUrl, attemptId;
@override State createState() => _PaymentPageState();
}
class _PaymentPageState extends State {
static const done = 'https://api.example.com/payments/done';
late final WebViewController _c;
bool _finished = false;
@override
void initState() {
super.initState();
_c = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (req) {
if (req.url.startsWith(done)) { _finish(); return NavigationDecision.prevent; }
if (!req.url.startsWith('http')) { // wallet app deep link
launchUrl(Uri.parse(req.url), mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onUrlChange: (c) { if (c.url?.startsWith(done) ?? false) _finish(); },
))
..loadRequest(Uri.parse(widget.startUrl));
}
Future _finish() async {
if (_finished) return;
_finished = true;
final status = await confirmPayment(widget.attemptId); // §7
if (mounted) Navigator.of(context).pop(status);
}
@override
Widget build(BuildContext context) => PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) { if (!didPop) _finish(); },
child: Scaffold(appBar: AppBar(title: const Text('Payment')), body: WebViewWidget(controller: _c)),
);
}
```
`flutter_inappwebview` (6.x) works the same way via `shouldOverrideUrlLoading` if you need more control.
### Android (Kotlin, `WebView`)
[Section titled “Android (Kotlin, WebView)”](#android-kotlin-webview)
```kotlin
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
return when {
url.startsWith(DONE_URL) -> { finishPayment(); true }
!url.startsWith("http") -> { // wallet deep link
runCatching { startActivity(Intent(Intent.ACTION_VIEW, request.url)) }; true
}
else -> false
}
}
}
webView.loadUrl(startUrl)
```
### iOS (Swift, `WKWebView`)
[Section titled “iOS (Swift, WKWebView)”](#ios-swift-wkwebview)
```swift
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { return decisionHandler(.allow) }
if url.absoluteString.hasPrefix(doneURL) { finishPayment(); return decisionHandler(.cancel) }
if !["http", "https"].contains(url.scheme ?? "") { // wallet deep link
UIApplication.shared.open(url); return decisionHandler(.cancel)
}
decisionHandler(.allow)
}
```
## 5. System browser + deep link pattern
[Section titled “5. System browser + deep link pattern”](#5-system-browser--deep-link-pattern)
Use this for card/3DS flows or when a provider page misbehaves in a WebView.
**Expo / React Native**: `expo-web-browser` opens an auth session that closes automatically when the browser hits your redirect:
```ts
import * as WebBrowser from "expo-web-browser";
const result = await WebBrowser.openAuthSessionAsync(startUrl, "myapp://payments/done");
// result.type: "success" | "cancel" | "dismiss": in EVERY case, ask the server.
const status = await confirmPayment(attemptId);
```
Here the backend return handler finishes with a 302 to `myapp://payments/done?attempt=…` instead of the https done URL. Pass a `client=app` flag when creating the attempt so the server knows which finish URL to use.
**Flutter**: `url_launcher` with `LaunchMode.externalApplication` (or `inAppBrowserView` for Custom Tabs / SFSafariViewController), plus `app_links` (7.x) to receive `https://YOUR_DOMAIN/payments/done` as a universal/app link and route it to the result screen.
**Native**: Android Custom Tabs (`androidx.browser`) plus an intent filter with `android:autoVerify="true"` for app links. iOS `ASWebAuthenticationSession(url:callbackURLScheme:)`.
## 6. Native SDKs
[Section titled “6. Native SDKs”](#6-native-sdks)
* **Khalti**: Flutter `khalti_checkout_flutter`, Android `com.khalti:checkout-android`, iOS `KhaltiCheckout`. Full code in [`khalti.md`](/docs/providers/khalti/reference/) §8.
* **eSewa**: see [`esewa.md`](/docs/providers/esewa/reference/) (mobile SDK section).
In both cases the backend creates the payment first, and the SDK success callback triggers `POST /payments/:attemptId/verify`. The UI shows what the server returns.
## 7. After returning: confirming status in the app
[Section titled “7. After returning: confirming status in the app”](#7-after-returning-confirming-status-in-the-app)
```ts
async function confirmPayment(attemptId: string): Promise {
// The return handler usually verified already; /verify makes it deterministic.
let { status } = await api.post(`/payments/${attemptId}/verify`);
for (let i = 0, delay = 1000; status === "pending" && i < 6; i++, delay *= 2) {
await sleep(delay); // 1s, 2s, 4s ... ~1 min total
({ status } = await api.get(`/payments/${attemptId}`));
}
return status; // 'paid' -> success screen; 'pending' -> "We'll notify you"; else -> retry option
}
```
* On app start / resume, re-check any attempt the app left in `initiated`/`pending` (the OS may have killed the app mid-payment). The server’s reconciliation job settles it anyway; the app just needs to display it.
* Push a notification when reconciliation settles a pending payment.
* “Retry” creates a **new attempt** (new provider order id). Never resubmit the old one.
## 8. Gotchas
[Section titled “8. Gotchas”](#8-gotchas)
* **Wallet app switching from a WebView**: eSewa/Khalti pages may try to open `esewa://`, `khalti://` or `intent://` URLs. A WebView can’t load these, so hand them to the OS (shown above). On Android 11+, declare `` for those schemes/packages in `AndroidManifest.xml` or `canLaunch` returns false. On iOS, add the schemes to `LSApplicationQueriesSchemes`.
* **Android `intent://` URLs**: parse with `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`. If the app isn’t installed, open the URL in `browser_fallback_url`.
* **Form POST providers**: never build the form in the app (that requires the secret). Load your `/start` URL.
* **Cookies / third-party storage**: 3DS ACS pages sometimes need third-party cookies. On Android enable `CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)`. If problems persist, use the system-browser pattern.
* **Back button / close**: always confirm with the server when the user leaves the payment screen. “Closed” doesn’t mean “not paid”.
* **Don’t hardcode sandbox in release builds.** The backend decides the environment; the app only needs the Khalti public key for the SDK, injected per flavor.
* **Return URL must be reachable from the phone**: use your public HTTPS domain or a tunnel (ngrok / cloudflared) in development, never `localhost`.
# Testing and go-live
> Sandbox credentials for every provider, the bundled test suites, integration checks and a go-live checklist.
> Source: [`references/testing.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/testing.md). This is the same file the agent reads.
## 1. Sandbox credentials
[Section titled “1. Sandbox credentials”](#1-sandbox-credentials)
| Provider | Merchant credentials | Payer test login |
| ------------ | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| eSewa ePay | `EPAYTEST` / `8gBm/:&EnhH.1/q` | `9806800001`–`9806800005`, password `Nepal@123`, token/OTP `123456`, MPIN `1122` (app) |
| eSewa Intent | `INTENT` / access key `LB0REg8HUSw3MTYrI1s6JTE8Kyc6JyAqJiA3MQ==` | same; demo apps at gitlab.com/esewa-pub/esewa-intent-payment-app |
| Khalti | secret key from (signup OTP `987654`) | Khalti ID `9800000000`–`9800000005`, MPIN `1111`, OTP `987654` (wallet only; no e-banking or cards in sandbox) |
| Fonepay | dev sample `NBQM` / `a7e3512f5032480a83137793cb2021dc` (redirect only; txnVerification needs your own panel login) | scan with a Fonepay-enabled test bank app (from your bank) |
| connectIPS | request from connectIPS technical support (via your bank) | provided with the test account |
| CyberSource | Business Center test account (from your bank) | Visa `4111111111111111`, MC `5555555555554444`, any future expiry; 3DS cards in [`cybersource.md`](/docs/providers/cybersource/reference/) §6 |
| NPS | from NPS onboarding | provided by NPS |
| IME Pay | from IME / Khalti-by-IME merchant team | provided by IME |
The eSewa sandbox login has a reCAPTCHA, so completing a payment is manual (browser), but the form POST, status API and Intent book/status/cancel can be automated.
## 2. Running the bundled tests
[Section titled “2. Running the bundled tests”](#2-running-the-bundled-tests)
```bash
# TypeScript (Node >= 22.18 runs .ts directly)
cd scripts/ts && npm install && npm test # 49 offline tests: vectors, mocked HTTP, state machine
npm run typecheck # strict tsc
node test/sandbox.ts # live: eSewa (form, status, Intent) + Fonepay dev redirect
KHALTI_SECRET_KEY=... node test/sandbox.ts # + Khalti initiate/lookup
# Python (stdlib; connectIPS tests need `pip install cryptography`)
cd scripts/python && python -m unittest discover -s tests -v # 33 tests
# Java / Kotlin (JDK 17+; the Gradle wrapper downloads Gradle on first run)
cd scripts/java && ./gradlew test # offline tests
./gradlew sandbox # live eSewa + Fonepay checks
cd scripts/kotlin && ./gradlew test && ./gradlew sandbox
# PHP (8.1+; no local PHP? run each command in Docker:
# docker run --rm -v "$PWD":/app -w /app composer:2 )
cd scripts/php && composer install && vendor/bin/phpunit
php tests/sandbox.php # live eSewa + Fonepay checks
# Go (1.22+, stdlib only)
cd scripts/go && go test -race ./...
go run ./cmd/sandbox # live eSewa + Fonepay checks
# End-to-end demo (eSewa + Fonepay sandboxes, no setup)
cd scripts/ts && node examples/server.ts # http://localhost:3000
```
Every language suite runs the same signature vectors and mocked-provider cases as the TypeScript one, so a behaviour change has to land in all of them.
## 3. What to test in your integration
[Section titled “3. What to test in your integration”](#3-what-to-test-in-your-integration)
Automate these with mocked provider responses (see [`scripts/ts/test/adapters.test.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/test/adapters.test.ts) for the mocking pattern):
* [ ] **Signature vectors** for each provider you use (from the provider reference) pass in your language.
* [ ] Return URL with a **forged success payload** (valid shape, attacker-chosen status) does not fulfil: the status API decides.
* [ ] Return URL hit **before** the payer reaches the provider (`not_found`) leaves the attempt open, and a later real payment is still picked up.
* [ ] A provider **“paid” arriving after the attempt was closed** (`expired`/`failed`) is recovered and alerted, not dropped.
* [ ] A callback with its **signature removed** (Fonepay without `DV`, eSewa without `signature`) can’t force a failure or a success.
* [ ] A **“paid” with no amount** from the provider goes to manual review, not fulfilment.
* [ ] Paying an **already-paid order** again raises a duplicate-payment alert (refund it).
* [ ] **Amount mismatch** (provider says Rs 10 for a Rs 1,000 order) → alert, no fulfilment.
* [ ] **Callback for another attempt** (swapped `transaction_uuid` / `pidx` / `PRN`) is rejected.
* [ ] **Concurrent** return + webhook + reconciliation for the same attempt → exactly one fulfilment.
* [ ] Provider **timeout / 5xx** during verify → attempt stays open, user sees “confirming”, reconciliation settles it.
* [ ] **Refresh** of the return URL after success → no double fulfilment.
* [ ] Khalti lookup **HTTP 400 with `Expired`** maps to expired, not an exception.
* [ ] **Retry** after failure creates a **new** attempt id.
Manual sandbox runs per provider: one success, one cancel at the provider page, one abandoned session (close the tab, then confirm reconciliation expires it).
## 4. Local development (return URLs)
[Section titled “4. Local development (return URLs)”](#4-local-development-return-urls)
Providers redirect the payer’s browser, so `http://localhost` return URLs work for browser-only flows (eSewa, Khalti, Fonepay redirect). Server-to-server notifications (NPS notification URL, CyberSource Merchant POST, eSewa Intent `callback_url`) and phones on a different device need a public HTTPS URL:
```bash
cloudflared tunnel --url http://localhost:3000 # or: ngrok http 3000
PUBLIC_URL=https:// node examples/server.ts
```
connectIPS and CyberSource profiles have **registered** URLs. Register the tunnel URL in the UAT/test profile.
## 5. Go-live checklist per provider
[Section titled “5. Go-live checklist per provider”](#5-go-live-checklist-per-provider)
Common items: the architecture checklist ([`architecture.md`](/docs/guides/architecture/) §10), live keys in the secret store, `*_ENV=production`, one real low-value payment per provider verified end to end and then refunded, reconciliation job and alerts running.
| Provider | Extra go-live steps |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| eSewa | Live product code + secret key from eSewa merchant onboarding. Intent: production host + access key from eSewa (`ESEWA_INTENT_BASE_URL`). |
| Khalti | Complete KYC + contract, otherwise live payments are capped at **NPR 200**. Live keys from admin.khalti.com. |
| Fonepay | Live PID + secret + panel login via the acquiring bank. Confirm the live txnVerification host with Fonepay. |
| connectIPS | NCHL activation via your bank; live `.pfx`; **register live success/failure URLs** with NCHL. |
| CyberSource | Live SA profile keys (2-year expiry: put renewal in the calendar); Merchant POST URL configured; 3DS enabled; NPR enabled per card type. Agree an SA → Unified Checkout migration date with the bank. |
| NPS | Live API host and credentials from the onboarding PDF; Notification URL registered. |
| IME Pay | Server IP whitelisted for port 7979; confirm web checkout is still offered post-merger. |
# Web frameworks
> Wire the payment routes into Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor.
> Source: [`references/web.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/web.md). This is the same file the agent reads.
Every framework needs the same five routes (see [`mobile.md`](/docs/guides/mobile/) §3, which reuses them):
| Route | Purpose |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `POST /payments` `{orderId, provider}` | load the order, compute the amount **server-side**, insert the attempt, `adapter.initiate()`, persist `providerRef` |
| `GET /payments/:attemptId/start` | 302 to `url`, or `renderAutoSubmitForm()` for form providers |
| `GET+POST /payments/:provider/return[/:attemptId]` | `orderIdFromCallback()` → load attempt → `verify()` → conditional transition → redirect to the receipt page |
| `GET /payments/:attemptId` | status for polling (mobile, SPA) |
| provider notifications | NPS `GET /payments/nps/notify`, CyberSource Merchant POST URL, eSewa Intent `callback_url` |
The full reference implementation is [`scripts/ts/examples/server.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/examples/server.ts) (zero deps; run it with `node examples/server.ts`). The snippets below show how the same pieces map onto each framework. `verifyAndTransition` is the function from [`architecture.md`](/docs/guides/architecture/) §4.
**POST callbacks need CSRF exemptions**: CyberSource receipt + Merchant POST, IME Pay `RespUrl`, eSewa Intent callback. They’re cross-site POSTs without your CSRF token. Their integrity comes from signature verification and the server-side status check, never from the request itself.
## 1. Frontend (any framework)
[Section titled “1. Frontend (any framework)”](#1-frontend-any-framework)
```tsx
// The browser sends ONLY orderId + provider. No amounts, no signatures, no secrets.
async function pay(orderId: string, provider: "esewa" | "khalti" | "fonepay" | "connectips" | "nps" | "cybersource") {
const res = await fetch("/payments", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ orderId, provider }),
});
if (!res.ok) throw new Error("Could not start payment");
const { startUrl } = await res.json();
window.location.assign(startUrl); // server redirects or auto-submits the provider form
}
```
For the receipt page, poll `GET /payments/:attemptId` while the status is `pending`/`initiated` (1 s, 2 s, 4 s … about 1 min), then show “We’ll email you when it’s confirmed”.
## 2. Express
[Section titled “2. Express”](#2-express)
```ts
import express from "express";
import { createPayments, paymentsConfigFromEnv, renderAutoSubmitForm, newOrderId } from "./payments/index.ts";
const app = express();
const payments = createPayments(paymentsConfigFromEnv());
app.use(express.json());
app.use("/payments", express.urlencoded({ extended: false })); // CyberSource / IME POST callbacks
app.post("/payments", requireUser, async (req, res) => {
const order = await db.orders.findForUser(req.body.orderId, req.user.id);
const attemptId = newOrderId();
const input = { orderId: attemptId, amountPaisa: order.totalPaisa, description: order.title, returnUrl: returnUrlFor(req.body.provider, attemptId) };
const init = await payments.adapter(req.body.provider).initiate(input);
await db.attempts.insert({ id: attemptId, orderId: order.id, provider: req.body.provider, amountPaisa: order.totalPaisa,
providerRef: init.providerRef ?? null, initiate: init, status: "initiated" });
res.status(201).json({ attemptId, startUrl: `/payments/${attemptId}/start` });
});
app.get("/payments/:id/start", async (req, res) => {
const a = await db.attempts.get(req.params.id);
if (!a) return res.sendStatus(404);
a.initiate.kind === "redirect" ? res.redirect(a.initiate.url) : res.type("html").send(renderAutoSubmitForm(a.initiate));
});
// Exclude this route from csurf/lusca if you use them.
app.all("/payments/:provider/return{/:attemptId}", async (req, res) => { // Express 5 optional-param syntax; Express 4: "/:provider/return/:attemptId?"
const params = { ...req.query, ...(req.method === "POST" ? req.body : {}) } as Record;
const id = payments.adapter(req.params.provider).orderIdFromCallback(params) ?? req.params.attemptId;
const a = id && (await db.attempts.get(id));
if (!a || a.provider !== req.params.provider) return res.sendStatus(404);
await verifyAndTransition(a, params).catch((e) => log.warn({ e, attempt: a.id }, "verify failed; reconciliation will retry"));
res.redirect(303, `/orders/${a.orderId}?attempt=${a.id}`);
});
```
## 3. Hono (Cloudflare Workers / Bun / Node)
[Section titled “3. Hono (Cloudflare Workers / Bun / Node)”](#3-hono-cloudflare-workers--bun--node)
The adapters use only WebCrypto + `fetch`, so they run on Workers with no `nodejs_compat` flag.
```ts
import { Hono } from "hono";
import { createPayments, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
type Env = { Bindings: Record & { DB: D1Database } };
const app = new Hono();
app.get("/payments/:id/start", async (c) => {
const a = await getAttempt(c.env.DB, c.req.param("id"));
if (!a) return c.notFound();
return a.initiate.kind === "redirect" ? c.redirect(a.initiate.url) : c.html(renderAutoSubmitForm(a.initiate));
});
app.on(["GET", "POST"], "/payments/:provider/return/:attemptId?", async (c) => {
const payments = createPayments(paymentsConfigFromEnv(c.env)); // Workers: secrets come from c.env
const provider = c.req.param("provider");
const params: Record = { ...c.req.query() };
if (c.req.method === "POST") Object.assign(params, await c.req.parseBody());
const id = payments.adapter(provider).orderIdFromCallback(params) ?? c.req.param("attemptId");
const a = id ? await getAttempt(c.env.DB, id) : null;
if (!a || a.provider !== provider) return c.notFound();
await verifyAndTransition(c.env.DB, payments, a, params).catch((e) => console.error("verify", a.id, e));
return c.redirect(`/orders/${a.orderId}?attempt=${a.id}`, 303);
});
```
Store secrets with `wrangler secret put ESEWA_SECRET_KEY` etc. Never put them in `wrangler.jsonc` `vars`. Run reconciliation from a Cron Trigger (`scheduled()` handler).
## 4. Next.js App Router
[Section titled “4. Next.js App Router”](#4-nextjs-app-router)
app/payments/\[provider]/return/\[\[...attempt]]/route.ts
```ts
import { NextResponse, type NextRequest } from "next/server";
import { payments, verifyAndTransition, getAttempt } from "@/server/payments"; // server-only module
async function handle(req: NextRequest, { params }: { params: Promise<{ provider: string; attempt?: string[] }> }) {
const { provider, attempt } = await params; // params is a Promise (Next.js 15+)
const cb: Record = Object.fromEntries(req.nextUrl.searchParams);
if (req.method === "POST") for (const [k, v] of await req.formData()) cb[k] = String(v);
const id = payments.adapter(provider).orderIdFromCallback(cb) ?? attempt?.[0];
const a = id ? await getAttempt(id) : null;
if (!a || a.provider !== provider) return new NextResponse("Not found", { status: 404 });
await verifyAndTransition(a, cb).catch((e) => console.error("verify", a.id, e));
return NextResponse.redirect(new URL(`/orders/${a.orderId}?attempt=${a.id}`, req.url), 303);
}
export { handle as GET, handle as POST };
export const dynamic = "force-dynamic";
```
* Put adapters and secrets in a module that starts with `import "server-only"`. Never prefix payment secrets with `NEXT_PUBLIC_`.
* Server Actions can create attempts, but return `startUrl` and navigate with `window.location.assign`. A form-POST provider can’t be reached with `redirect()` from an action.
## 5. FastAPI
[Section titled “5. FastAPI”](#5-fastapi)
The Python adapters are synchronous (stdlib `urllib`), so run them in the threadpool from async routes.
```python
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse
from starlette.concurrency import run_in_threadpool
from nepal_payments import create_payments, config_from_env, render_auto_submit_form, new_order_id, FormResult
app = FastAPI()
payments = create_payments(config_from_env())
FINAL = {"paid", "failed", "canceled", "expired", "refunded", "partially_refunded"}
@app.get("/payments/{attempt_id}/start")
async def start(attempt_id: str):
a = await db.get_attempt(attempt_id) or _404()
init = a.initiate # stored RedirectResult / FormResult
return RedirectResponse(init.url, 302) if init.kind == "redirect" else HTMLResponse(render_auto_submit_form(init))
@app.api_route("/payments/{provider}/return", methods=["GET", "POST"])
@app.api_route("/payments/{provider}/return/{attempt_id}", methods=["GET", "POST"])
async def payment_return(provider: str, request: Request, attempt_id: str | None = None):
params = dict(request.query_params)
if request.method == "POST":
params.update({k: str(v) for k, v in (await request.form()).items()}) # needs python-multipart
adapter = payments[provider]
a = await db.get_attempt(adapter.order_id_from_callback(params) or attempt_id)
if not a or a.provider != provider:
raise HTTPException(404)
try:
result = await run_in_threadpool(adapter.verify, a.id, a.amount_paisa, a.provider_ref, params)
await db.transition(a, result) # decide_transition() + guarded UPDATE (architecture.md §4)
except Exception:
log.exception("verify failed; reconciliation will retry")
return RedirectResponse(f"/orders/{a.order_id}?attempt={a.id}", 303)
@app.get("/payments/nps/notify", response_class=PlainTextResponse)
async def nps_notify(MerchantTxnId: str):
a = await db.get_attempt(MerchantTxnId) or _404()
was_final = a.status in FINAL
result = await run_in_threadpool(payments["nps"].verify, a.id, a.amount_paisa)
a = await db.transition(a, result) # returns the stored attempt
if a.status not in FINAL:
return PlainTextResponse("pending", status_code=503) # not acknowledged: NPS retries
return payments["nps"].notification_response(already_processed=was_final)
```
## 6. Django
[Section titled “6. Django”](#6-django)
```python
from django.http import HttpResponse, HttpResponseNotFound, HttpResponseRedirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from nepal_payments import create_payments, config_from_env, render_auto_submit_form
payments = create_payments(config_from_env())
def start(request, attempt_id):
a = Attempt.objects.get(pk=attempt_id)
init = a.initiate_result()
return HttpResponseRedirect(init.url) if init.kind == "redirect" else HttpResponse(render_auto_submit_form(init))
@csrf_exempt # cross-site POSTs from CyberSource / IME Pay; integrity comes from verify()
@require_http_methods(["GET", "POST"])
def payment_return(request, provider, attempt_id=None):
params = {**request.GET.dict(), **request.POST.dict()}
adapter = payments[provider]
a = Attempt.objects.filter(pk=adapter.order_id_from_callback(params) or attempt_id, provider=provider).first()
if not a:
return HttpResponseNotFound()
try:
result = adapter.verify(a.pk, a.amount_paisa, a.provider_ref, params)
a.transition(result) # decide_transition() + guarded update; fulfil once
except Exception:
logger.exception("verify failed; reconciliation will retry")
return HttpResponseRedirect(f"/orders/{a.order_id}?attempt={a.pk}")
```
Conditional transition with the Django ORM: `Attempt.objects.filter(pk=a.pk, status__in=["initiated", "pending", "unknown"]).update(status=new_status, ...)` returns 1 for exactly one caller. Run reconciliation from Celery beat or a management command on cron.
## 7. Laravel (PHP adapter)
[Section titled “7. Laravel (PHP adapter)”](#7-laravel-php-adapter)
The PHP adapters ([`scripts/php/src/`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/php/src), namespace `NepalPayments`, PHP 8.1+, no Composer dependencies) are synchronous, which suits PHP-FPM. Copy `src/` to `app/Payments/NepalPayments/`, add `"NepalPayments\\": "app/Payments/NepalPayments/"` to `autoload.psr-4` in `composer.json`, and run `composer dump-autoload`.
```php
// config/payments.php: every variable from env.md, read through config() so `php artisan config:cache` keeps working
return ['env' => [
'ESEWA_ENV' => env('ESEWA_ENV'), 'ESEWA_PRODUCT_CODE' => env('ESEWA_PRODUCT_CODE'), 'ESEWA_SECRET_KEY' => env('ESEWA_SECRET_KEY'),
'KHALTI_ENV' => env('KHALTI_ENV'), 'KHALTI_SECRET_KEY' => env('KHALTI_SECRET_KEY'), 'KHALTI_WEBSITE_URL' => env('KHALTI_WEBSITE_URL'),
// ... the rest of env.md for the providers you enable
]];
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(\NepalPayments\Payments::class, fn () =>
\NepalPayments\Payments::create(\NepalPayments\Payments::configFromEnv(config('payments.env'))));
}
// routes/web.php
Route::get('/payments/{attempt}/start', [PaymentController::class, 'start']);
Route::match(['get', 'post'], '/payments/{provider}/return/{attemptId?}', [PaymentController::class, 'return']);
```
```php
use NepalPayments\{AttemptStatus, FormResult, Payments, RedirectResult, StateMachine, VerifyInput};
final class PaymentController
{
public function __construct(private Payments $payments) {}
public function start(PaymentAttempt $attempt)
{
$init = $attempt->initiateResult(); // RedirectResult | FormResult rebuilt from the stored JSON
return $init instanceof RedirectResult ? redirect()->away($init->url) : response($init->render());
}
public function return(Request $request, string $provider, ?string $attemptId = null)
{
$enabled = array_map(fn ($p) => $p->value, $this->payments->enabled());
abort_unless(in_array($provider, $enabled, true), 404);
$params = array_merge($request->query(), $request->post());
$id = $this->payments->adapter($provider)->orderIdFromCallback($params) ?? $attemptId;
$a = PaymentAttempt::where('provider', $provider)->find($id) ?? abort(404);
try {
$this->verifyAndTransition($a, $params);
} catch (\Throwable $e) {
report($e); // the attempt stays open; reconciliation retries
}
return redirect("/orders/{$a->order_id}?attempt={$a->id}", 303);
}
private function verifyAndTransition(PaymentAttempt $a, array $params): void
{
$result = $this->payments->adapter($a->provider)
->verify(new VerifyInput($a->id, $a->amount_paisa, $a->provider_ref, $params));
$prev = AttemptStatus::from($a->status);
$ageMs = (int) abs($a->created_at->diffInMilliseconds(now()));
$d = StateMachine::decideFor($prev, $result, $a->amount_paisa, $ageMs);
if ($d->next === null) {
return;
}
$won = PaymentAttempt::whereKey($a->id)->where('status', $prev->value)->update([ // 1 row for exactly one caller
'status' => $d->next->value,
'review_required' => $a->review_required || $d->reviewRequired,
'provider_txn_id' => $result->transactionId,
]);
if ($won === 1 && $d->next === AttemptStatus::Paid && !$d->reviewRequired) {
FulfilOrder::dispatch($a->order_id);
}
}
}
```
CSRF exemption for POST callbacks (Laravel 11+ `bootstrap/app.php`):
```php
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['payments/*/return', 'payments/*/return/*', 'payments/cybersource/notify']);
})
```
* The default transport is ext-curl. To route calls through Laravel’s `Http` client (for `Http::fake()` in feature tests), implement `NepalPayments\Http\HttpTransport` and pass it as the second argument to `Payments::create()`; the interface’s docblock has Laravel and Guzzle examples.
* connectIPS: `ConnectIpsConfig::fromPfx(...)` reads the creditor `.pfx` directly. Old RC2-encrypted files fail on OpenSSL 3 with a `pfx_legacy_encryption` error that includes the `openssl pkcs12 -legacy` conversion command.
* Run reconciliation from the scheduler (`Schedule::command('payments:reconcile')->everyFiveMinutes()->withoutOverlapping()`).
## 8. Spring Boot (Java)
[Section titled “8. Spring Boot (Java)”](#8-spring-boot-java)
The Java adapters ([`scripts/java/src/main/java/np/payments/`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/java/src/main/java/np/payments), JDK 17+, no dependencies) are blocking and thread-safe. On Java 21+, set `spring.threads.virtual.enabled=true` so provider calls don’t tie up platform threads.
```java
@Configuration
class PaymentsConfiguration {
@Bean
Payments payments() {
return Payments.create(Payments.configFromEnv()); // fails at startup on missing env or a bad connectIPS key
}
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.ignoringRequestMatchers( // cross-site POSTs; integrity comes from verify()
"/payments/*/return", "/payments/*/return/*", "/payments/cybersource/notify"));
// ... your existing authorizeHttpRequests(...) etc.
return http.build();
}
}
@Controller
class PaymentController {
// constructor-injected: Payments payments, AttemptRepository attempts, JdbcTemplate jdbc, Fulfilment fulfilment
@GetMapping("/payments/{id}/start")
ResponseEntity start(@PathVariable String id) {
Attempt a = attempts.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
InitiateResult init = a.initiateResult(); // rebuilt from the stored JSON
if (init instanceof InitiateResult.Redirect r) {
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(r.url())).build();
}
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(Html.renderAutoSubmitForm((InitiateResult.Form) init));
}
@RequestMapping(value = {"/payments/{provider}/return", "/payments/{provider}/return/{attemptId}"},
method = {RequestMethod.GET, RequestMethod.POST})
ResponseEntity paymentReturn(@PathVariable String provider, @PathVariable(required = false) String attemptId,
@RequestParam Map params) { // query string + form fields
if (payments.enabled().stream().noneMatch(p -> p.wire().equals(provider))) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
Attempt a = payments.adapter(provider).orderIdFromCallback(params)
.or(() -> Optional.ofNullable(attemptId))
.flatMap(attempts::findById)
.filter(x -> x.provider().equals(provider))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
try {
verifyAndTransition(a, params);
} catch (RuntimeException e) {
log.warn("verify failed for {}; reconciliation will retry", a.id(), e);
}
return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/orders/" + a.orderId() + "?attempt=" + a.id())).build();
}
void verifyAndTransition(Attempt a, Map params) {
VerifyResult r = payments.adapter(a.provider())
.verify(VerifyInput.of(a.id(), a.amountPaisa()).withProviderRef(a.providerRef()).withCallback(params));
AttemptStatus prev = AttemptStatus.fromWire(a.status());
long ageMs = Duration.between(a.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(prev, r, a.amountPaisa(), ageMs);
if (d.next() == null) return;
int won = jdbc.update( // 1 row for exactly one caller
"UPDATE payment_attempts SET status = ?, review_required = review_required OR ?, provider_txn_id = ? WHERE id = ? AND status = ?",
d.next().wire(), d.reviewRequired(), r.transactionId(), a.id(), prev.wire());
if (won == 1 && d.next() == AttemptStatus.PAID && !d.reviewRequired()) fulfilment.fulfil(a.orderId());
}
}
```
* connectIPS: `ConnectIps.Config.withPkcs12(...)` loads the creditor `.pfx` directly (no OpenSSL step).
* Run reconciliation with `@Scheduled(fixedDelay = 300_000)` plus a lock (ShedLock or a `SELECT ... FOR UPDATE SKIP LOCKED` batch) if several instances run.
* Quarkus/Micronaut: same calls; only the routing annotations differ.
## 9. Ktor (Kotlin)
[Section titled “9. Ktor (Kotlin)”](#9-ktor-kotlin)
The Kotlin adapters ([`scripts/kotlin/src/main/kotlin/np/payments/`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/kotlin/src/main/kotlin/np/payments)) expose `suspend` functions, so call them straight from route handlers.
```kotlin
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
val payments = Payments.create(paymentsConfigFromEnv()) // fails at boot on a bad key or missing env
fun Route.paymentRoutes(db: PaymentsDb) {
get("/payments/{id}/start") {
val a = db.attempt(call.parameters["id"]!!) ?: return@get call.respond(HttpStatusCode.NotFound)
when (val init = a.initiate) { // the stored InitiateResult
is InitiateResult.Redirect -> call.respondRedirect(init.url)
is InitiateResult.Form -> call.respondText(renderAutoSubmitForm(init), ContentType.Text.Html)
}
}
route("/payments/{provider}/return/{attemptId?}") {
handle { // GET and POST
val params = call.request.queryParameters.entries().associate { it.key to it.value.first() }.toMutableMap()
if (call.request.httpMethod == HttpMethod.Post) {
params += call.receiveParameters().entries().associate { it.key to it.value.first() }
}
val provider = call.parameters["provider"]!!
if (ProviderId.fromWire(provider) !in payments.enabled) return@handle call.respond(HttpStatusCode.NotFound)
val id = payments.adapter(provider).orderIdFromCallback(params) ?: call.parameters["attemptId"]
val a = id?.let { db.attempt(it) }
if (a == null || a.provider != provider) return@handle call.respond(HttpStatusCode.NotFound)
try {
verifyAndTransition(a, params)
} catch (e: Exception) {
log.warn("verify failed for ${a.id}; reconciliation will retry", e)
}
call.response.header(HttpHeaders.Location, "/orders/${a.orderId}?attempt=${a.id}")
call.respond(HttpStatusCode.SeeOther)
}
}
}
suspend fun verifyAndTransition(a: Attempt, params: Map?) {
val result = payments.adapter(a.provider).verify(VerifyInput(a.id, a.amountPaisa, a.providerRef, params))
val prev = AttemptStatus.fromWire(a.status)!!
val d = decideTransition(prev, result, a.amountPaisa, java.time.Duration.between(a.createdAt, Instant.now()).toKotlinDuration())
val next = d.next ?: return
// UPDATE payment_attempts SET status = :next, review_required = review_required OR :review, ...
// WHERE id = :id AND status = :prev -> 1 row for exactly one caller (architecture.md §4)
val won = db.transition(a.id, from = prev.wire, to = next.wire, reviewRequired = d.reviewRequired, result = result)
if (won && next == PaymentStatus.PAID && !d.reviewRequired) fulfil(a.orderId)
}
```
* Ktor’s CSRF plugin can be installed per route: install it on your session-authenticated routes, not around `/payments/{provider}/return`.
* NPS notifications: `payments.nps.notificationAck(wasFinal, currentStatus)` returns the body to send, or `null` while the status isn’t final (respond 503 so NPS retries).
* Spring Boot with Kotlin: use these adapters from `suspend` controller methods, with the Spring Security CSRF exemption from §8.
## 10. Other languages
[Section titled “10. Other languages”](#10-other-languages)
Go: use [`scripts/go/payments/`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/go/payments) (stdlib only). `payments.New(payments.ConfigFromEnv())`, then in a Go 1.22 `http.ServeMux` handler for `GET /payments/{provider}/return` and `POST /payments/{provider}/return`: flatten `r.URL.Query()` and `r.PostForm` into a `map[string]string`, call `p.Adapter(r.PathValue("provider"))`, `OrderIDFromCallback`, `Verify(r.Context(), …)`, then `payments.DecideTransition` and the guarded `UPDATE` exactly as in §8. The same applies to chi and Gin.
C#, Ruby, Rust, Dart (server): port from [`scripts/ts/src/.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/%3Cprovider%3E.ts). Each provider reference has signature snippets for PHP, Python, Go and Java, plus **test vectors**. Write the vector tests first and port until they pass, then run the sandbox checks in [`testing.md`](/docs/guides/testing/).
# Languages
> Runtime requirements, dependencies, test counts and test commands for the Nepal Payments adapters in TypeScript, Python, Java, Kotlin, PHP and Go.
All six adapters implement the same providers and the [same interface](/docs/api/). Every suite runs the providers’ signature test vectors and the same mocked-provider cases, 414 tests in total, and each language has a live eSewa and Fonepay sandbox check.
| Language | Runtime | Dependencies | Tests | Source |
| -------------- | --------------------------------------------- | ------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **TypeScript** | Node 22.18+, Bun, Deno, Workers, Next.js edge | Zero dependencies | 49 | [`scripts/ts/src/`](https://github.com/broisnees/nepal-payments-skill/tree/main/skills/nepal-payments/scripts/ts/src/) |
| **Python** | Python 3.10+ | Stdlib (cryptography for connectIPS) | 33 | [`scripts/python/nepal_payments/`](https://github.com/broisnees/nepal-payments-skill/tree/main/skills/nepal-payments/scripts/python/nepal_payments/) |
| **Java** | JDK 17+ (Spring Boot, Quarkus) | No dependencies | 111 | [`scripts/java/src/main/java/np/payments/`](https://github.com/broisnees/nepal-payments-skill/tree/main/skills/nepal-payments/scripts/java/src/main/java/np/payments/) |
| **Kotlin** | JDK 17+ (Ktor, Spring), suspend API | kotlin-stdlib only | 84 | [`scripts/kotlin/src/main/kotlin/np/payments/`](https://github.com/broisnees/nepal-payments-skill/tree/main/skills/nepal-payments/scripts/kotlin/src/main/kotlin/np/payments/) |
| **PHP** | PHP 8.1+ (Laravel, Symfony) | ext-curl and ext-openssl, no Composer deps | 69 | [`scripts/php/src/`](https://github.com/broisnees/nepal-payments-skill/tree/main/skills/nepal-payments/scripts/php/src/) |
| **Go** | Go 1.22+ | Stdlib only | 68 | [`scripts/go/payments/`](https://github.com/broisnees/nepal-payments-skill/tree/main/skills/nepal-payments/scripts/go/payments/) |
## Run the tests
[Section titled “Run the tests”](#run-the-tests)
From `skills/nepal-payments/scripts`:
```bash
(cd ts && npm ci && npm test && npm run typecheck)
(cd python && python -m unittest discover -s tests) # connectIPS tests need `pip install cryptography`
(cd java && ./gradlew test)
(cd kotlin && ./gradlew test)
(cd php && composer install && vendor/bin/phpunit)
(cd go && go test -race ./...)
```
No local PHP? Run each PHP command in Docker: `docker run --rm -v "$PWD":/app -w /app composer:2 `.
## Live sandbox checks
[Section titled “Live sandbox checks”](#live-sandbox-checks)
```bash
node test/sandbox.ts # TypeScript (add KHALTI_SECRET_KEY=... for Khalti)
./gradlew sandbox # Java, Kotlin
php tests/sandbox.php # PHP
go run ./cmd/sandbox # Go
```
TypeScript also has an end-to-end demo against the eSewa and Fonepay sandboxes: `cd ts && node examples/server.ts`, then open `http://localhost:3000`.
## Other languages
[Section titled “Other languages”](#other-languages)
For C#, Ruby, Rust or a Dart server, port from the TypeScript adapter of each provider. Write the provider’s test vectors as tests first (each provider page has them), port until they pass, then run the sandbox checks in [Testing and go-live](/docs/guides/testing/).
# Integrate connectIPS
> Integrate connectIPS direct bank debit step by step in TypeScript, Python, Java, Kotlin, PHP or Go: RSA TOKEN signing and validatetxn.
connectIPS (NCHL) debits the payer’s bank account directly. Your server signs a `TOKEN` with the RSA key from your creditor `.pfx`, posts a form to the connectIPS login page, and confirms with `validatetxn`.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
connectIPS direct debit flow
1. Your server signs the `TOKEN` (RSA-SHA256) and renders an auto-submitting form to the connectIPS login page.
2. The payer logs in to their bank and approves the debit.
3. connectIPS redirects to the success or failure URL registered with NCHL, adding `TXNID`.
4. Your server calls `validatetxn` with Basic auth (APPID and app password). That response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | --------------------------------------------------------------------------- |
| Required | `CONNECTIPS_MERCHANT_ID`, `CONNECTIPS_APP_ID`, `CONNECTIPS_PRIVATE_KEY_PEM` |
| Optional | `CONNECTIPS_ENV`, `CONNECTIPS_APP_NAME`, `CONNECTIPS_APP_PASSWORD` |
The full list for every provider is in [Environment variables](/docs/guides/env/).
### Convert the .pfx once
[Section titled “Convert the .pfx once”](#convert-the-pfx-once)
TypeScript and Go need the private key as PKCS#8 PEM. Add `-legacy` on OpenSSL 3 if the `.pfx` uses RC2 or 3DES.
```bash
openssl pkcs12 -in CREDITOR.pfx -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt -out creditor.key.pem
```
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
// No providerRef: validatetxn looks the payment up by TXNID + amount.
const init = await payments.get("connectips").initiate({
orderId: attempt.id, // TXNID: 1-20 chars [A-Za-z0-9-], unique per app
amountPaisa: attempt.amountPaisa, // TXNAMT is sent in paisa
description: "Order #42", // REMARKS and PARTICULARS unless you pass remarks / particulars
returnUrl: `${baseUrl}/payments/connectips/return/${attempt.id}`, // unused: NCHL holds the registered URLs
});
// init.kind === "form": serve renderAutoSubmitForm(init) as text/html from /payments/:id/start
```
* `CONNECTIPS_PRIVATE_KEY_PEM` must be PKCS#8 (`BEGIN PRIVATE KEY`). Convert the creditor .pfx once with `openssl pkcs12 ... | openssl pkcs8 -topk8 -nocrypt`.
* Success and failure URLs are registered with NCHL, so they can’t carry the attempt id; the handler reads `TXNID`.
* `getTxnDetail(orderId, amountPaisa)` adds the NCHL txnId, bank, charges and creditStatus.
* Python
```python
# No provider_ref: validatetxn looks the payment up by TXNID + amount.
init = payments["connectips"].initiate(
order_id=attempt.id, # TXNID: 1-20 chars [A-Za-z0-9-], unique per app
amount_paisa=attempt.amount_paisa, # TXNAMT is sent in paisa
description="Order #42", # REMARKS and PARTICULARS unless you pass remarks / particulars
return_url=f"{base_url}/payments/connectips/return/{attempt.id}", # unused: NCHL holds the registered URLs
)
# init.kind == "form": serve render_auto_submit_form(init) as text/html from /payments/{id}/start
```
* connectIPS signs with RSA, so it needs `pip install cryptography`. The PEM may be PKCS#1 or PKCS#8; `load_pfx_private_key_pem()` in `nepal_payments.core` extracts it from the creditor .pfx.
* Success and failure URLs are registered with NCHL, so they can’t carry the attempt id; the handler reads `TXNID`.
* `get_txn_detail(order_id, amount_paisa)` adds the NCHL txnId, bank, charges and creditStatus.
* Go
```go
res, err := p.ConnectIPS.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID, // TXNID: 1 to 20 chars [A-Za-z0-9-]
AmountPaisa: attempt.AmountPaisa,
Description: "Order #42", // REMARKS and PARTICULARS default to this
// unused: success/failure URLs are registered with NCHL and come back with ?TXNID=...
ReturnURL: baseURL + "/payments/connectips/return/" + attempt.ID,
ConnectIPS: &payments.ConnectIPSInitiateOptions{ReferenceID: attempt.ID}, // optional, <= 20 chars
})
if err != nil {
return err
}
// form result: serve payments.RenderAutoSubmitForm(res) as text/html (RSA-signed TOKEN)
```
* `CONNECTIPS_PRIVATE_KEY_PEM` is the creditor key as PEM; literal `\n` sequences in the env var are turned into newlines.
* Java
```java
// TXNID is the attempt id (max 20 chars; Payments.newOrderId() fits).
// connectIPS ignores returnUrl: success/failure URLs are registered with NCHL.
String returnUrl = baseUrl + "/payments/connectips/return/" + attempt.id();
InitiateResult init = payments.connectips().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl));
// connectIPS has no providerRef: TXNID is the attempt id.
InitiateResult.Form form = (InitiateResult.Form) init;
// Serve Html.renderAutoSubmitForm(form) as text/html; it POSTs the payer to the login page.
```
* Register the success and failure URLs with NCHL before testing; unregistered URLs show up as 403 after OTP.
* To load the creditor .pfx as issued, build the config with `ConnectIps.Config.withPkcs12(...)` instead of the PEM env var.
* `payments.connectips().getTxnDetail(id, amountPaisa)` returns the NCHL txnId, bank and charges.
* Kotlin
```kotlin
// TXNID is the attempt id (max 20 chars; newOrderId() fits).
// connectIPS ignores returnUrl: success/failure URLs are registered with NCHL.
val init = payments.connectips.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/connectips/return/${attempt.id}",
),
)
// connectIPS has no providerRef: TXNID is the attempt id.
// Respond with renderAutoSubmitForm(init as InitiateResult.Form) as text/html.
```
* Register the success and failure URLs with NCHL before testing; unregistered URLs show up as 403 after OTP.
* The PEM must be PKCS#8 (`-----BEGIN PRIVATE KEY-----`); `\n` escapes in the env var are accepted.
* `payments.connectips.getTxnDetail(id, amountPaisa)` returns the NCHL txnId, bank and charges.
* PHP
```php
$init = $payments->connectIps()->initiate(
new InitiateInput(
orderId: $attempt->id, // TXNID: 1 to 20 chars [A-Za-z0-9-]
amountPaisa: $attempt->amountPaisa,
description: 'Order #42', // REMARKS and PARTICULARS default to this
// unused: success/failure URLs are registered with NCHL and come back with ?TXNID=...
returnUrl: "{$baseUrl}/payments/connectips/return/{$attempt->id}",
),
referenceId: $attempt->id, // optional, <= 20 chars
);
// FormResult: echo $init->render(); auto-submits the RSA-signed form to the connectIPS login page
```
* `ConnectIpsConfig::fromPfx()` reads the creditor `.pfx` NCHL sent. Old RC2-encrypted files fail on OpenSSL 3 with `pfx_legacy_encryption`.
* `getTxnDetail($orderId, $amountPaisa)` returns the NCHL txnId, bank, charges and creditStatus.
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
A GET to the success or failure URL that NCHL registered for your app, with `TXNID`. connectIPS doesn’t take a return URL per request, so the `returnUrl` you pass is ignored.
* TypeScript
```ts
// connectIPS returns with ?TXNID=; load the attempt by it, then:
const result = await payments.get("connectips").verify({
orderId: attempt.id,
amountPaisa: attempt.amountPaisa, // signed into the validatetxn token
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
# connectIPS returns with ?TXNID=; load the attempt by it, then:
result = payments["connectips"].verify(
order_id=attempt.id,
amount_paisa=attempt.amount_paisa, # signed into the validatetxn token
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
// validatetxn with Basic auth: the order id and amount are all it needs
v, err := p.ConnectIPS.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// The redirect carries only ?TXNID=..., so validatetxn runs on stored values alone.
VerifyResult result = payments.connectips().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa()));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// The redirect carries only ?TXNID=..., so validatetxn runs on stored values alone.
val result = payments.connectips.verify(
VerifyInput(orderId = attempt.id, amountPaisa = attempt.amountPaisa),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// validatetxn with Basic auth; throws on network/5xx: leave the attempt open
$result = $payments->connectIps()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Request UAT credentials and a test `.pfx` from connectIPS technical support through your bank.
* The payer test login comes with the test account.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* `TXNID` is 1 to 20 characters of `[A-Za-z0-9-]`. `newOrderId()` fits.
* `TXNAMT` is in paisa, and `validatetxn` must get the same paisa amount.
* Commas and equals signs in remarks would corrupt the token string, so the adapter replaces them with spaces.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Get NCHL activation through your bank and a live `.pfx`.
* Register the live success and failure URLs with NCHL.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[connectIPS reference](/docs/providers/connectips/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# connectIPS reference
> Full connectIPS reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/connectips.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/connectips.md). This is the same file the agent reads.
Official docs: (heavy WAF; read slowly). Verified 2026-11 against the docs, three community SDKs, and live endpoint probes. Adapters: [`scripts/ts/src/connectips.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/connectips.ts), [`scripts/python/nepal_payments/connectips.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/connectips.py).
## 1. Environments and onboarding
[Section titled “1. Environments and onboarding”](#1-environments-and-onboarding)
| | UAT | Live |
| ------------------- | -------------------------------------------- | ------------------------------ |
| Base | `https://uat.connectips.com` | `https://login.connectips.com` |
| Gateway (form POST) | `/connectipswebgw/loginpage` | same |
| Validate | `/connectipswebws/api/creditor/validatetxn` | same |
| Detail | `/connectipswebws/api/creditor/gettxndetail` | same |
`https://connectips.com/...` returns 404 and `uat.connectips.com:7443` times out, yet both appear in community libraries. Use the hosts above.
**Onboarding**: the merchant enrols through its bank (KYC, creditor account), the bank lists it with NCHL, and NCHL emails:
* `MERCHANTID` (integer), `APPID` (e.g. `MER-550-APP-1`), `APPNAME`
* App **password** (Basic auth for the validation APIs)
* Creditor certificate **`.pfx`** + its password (`CREDITOR.pfx` in UAT)
One merchant can have several apps (one bank account each). Test accounts come from connectIPS technical support by email.
**Success/failure URLs are registered with NCHL** (localhost allowed in UAT); they aren’t sent per request. Unregistered URLs show up as `403 session expired` after OTP.
Env vars: `CONNECTIPS_ENV`, `CONNECTIPS_MERCHANT_ID`, `CONNECTIPS_APP_ID`, `CONNECTIPS_APP_NAME`, `CONNECTIPS_APP_PASSWORD`, `CONNECTIPS_PRIVATE_KEY_PEM` (PEM with `\n` escapes is fine).
## 2. Key preparation (.pfx)
[Section titled “2. Key preparation (.pfx)”](#2-key-preparation-pfx)
TypeScript (WebCrypto) needs a PKCS#8 PEM. Convert once and store the result in your secret manager:
```bash
openssl pkcs12 -in CREDITOR.pfx -nocerts -nodes -passin pass:"$PFX_PASSWORD" \
| openssl pkcs8 -topk8 -nocrypt -out creditor.key.pem
# OpenSSL 3+ with old RC2/3DES-encrypted .pfx files: add -legacy to the first command
```
Python can load the `.pfx` directly: `load_pfx_private_key_pem(pfx_bytes, password)` in `core.py` (needs `cryptography`).
Never commit the `.pfx`, its password or the PEM. Keep them on the server only.
## 3. Flow and form fields
[Section titled “3. Flow and form fields”](#3-flow-and-form-fields)
1. Server builds the fields and signs `TOKEN`. The browser **auto-POSTs** (form-urlencoded) to `loginpage`.
2. Payer logs into connectIPS, picks a bank account and enters the OTP.
3. connectIPS redirects to your registered success/failure URL with **only `?TXNID=`**.
4. Server calls **validatetxn** (optionally gettxndetail) and fulfils on `SUCCESS` + amount match.
| Field | Len | Notes |
| ------------- | ------ | ------------------------------------------------------------------------------------ |
| `MERCHANTID` | 20 | integer |
| `APPID` | 15 | |
| `APPNAME` | 30 | |
| `TXNID` | **20** | your attempt id, unique per app. Duplicate → 403 “multiple gateway payments at once” |
| `TXNDATE` | 10 | **`DD-MM-YYYY`** (Nepal date) |
| `TXNCRNCY` | 3 | `NPR` |
| `TXNAMT` | 20 | **paisa** (integer) |
| `REFERENCEID` | 20 | your reference |
| `REMARKS` | 50 | |
| `PARTICULARS` | 100 | |
| `TOKEN` | 512 | signature |
Over-long values return “invalid input”. The adapters clip them, and strip `,` and `=`, which would corrupt the token string.
## 4. TOKEN signature
[Section titled “4. TOKEN signature”](#4-token-signature)
Token string (no spaces; the literal `TOKEN=TOKEN` at the end):
```plaintext
MERCHANTID=550,APPID=MER-550-APP-1,APPNAME=Test,TXNID=txn123,TXNDATE=15-03-2022,TXNCRNCY=NPR,TXNAMT=500,REFERENCEID=REF-001,REMARKS=RMKS-001,PARTICULARS=PART-001,TOKEN=TOKEN
```
`TOKEN = base64( RSA-PKCS1v1.5-SHA256( creditor private key, token string ) )`. The docs describe it as “SHA-256 digest then sign”; that’s what SHA256withRSA does internally, so **don’t pre-hash**. (One docs template shows `", "` separators, but the worked example and every SDK use bare commas.)
```bash
# Shell check
printf '%s' "$TOKEN_STRING" | openssl dgst -sha256 -sign creditor.key.pem | base64
```
```php
openssl_sign($tokenString, $sig, $privateKey, OPENSSL_ALGO_SHA256); base64_encode($sig);
// $privateKey from openssl_pkcs12_read(file_get_contents('CREDITOR.pfx'), $certs, $pfxPassword) → $certs['pkey']
```
```java
Signature s = Signature.getInstance("SHA256withRSA"); s.initSign(privateKeyFromPkcs12); s.update(tokenString.getBytes(UTF_8));
Base64.getEncoder().encodeToString(s.sign());
```
```go
h := sha256.Sum256([]byte(tokenString))
sig, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h[:]) // key via software.sslmate.com/src/go-pkcs12
base64.StdEncoding.EncodeToString(sig)
```
## 5. Validation APIs
[Section titled “5. Validation APIs”](#5-validation-apis)
```plaintext
POST /connectipswebws/api/creditor/validatetxn
Authorization: Basic base64(APPID:appPassword)
Content-Type: application/json
{"merchantId":550,"appId":"MER-550-APP-1","referenceId":"txn123","txnAmt":500,"token":""}
```
* **`referenceId` is your `TXNID`**, not `REFERENCEID`.
* `token` signs `MERCHANTID=550,APPID=MER-550-APP-1,REFERENCEID=txn123,TXNAMT=500`.
* `txnAmt` in paisa must equal the original amount.
Response: `{merchantId, appId, referenceId, txnAmt:"500", token:null, status, statusDesc}`
| status / statusDesc | Normalized |
| --------------------------------------------- | ----------- |
| `SUCCESS` / TRANSACTION SUCCESSFUL | `paid` |
| `FAILED` / TRANSACTION UNSUCCESSFUL | `failed` |
| `ERROR` / TRANSACTION NOT FOUND | `not_found` |
| `ERROR` / TRANSACTION INCOMPLETE (no OTP yet) | `pending` |
**gettxndetail** (same request) adds `debitBankCode, txnId (NCHL), batchId, txnDate (epoch ms), txnCrncy, chargeAmt (paisa), chargeLiability (MN merchant / CG customer), refId, remarks, particulars, creditStatus`. Settlement OK when `creditStatus` ∈ {`000`, `999`, `DEFER`}. `txnAmt` is a string in validatetxn and a number (`1000.00`) in gettxndetail.
`401` means wrong APPID/password. There are no refunds and no webhooks in the gateway API: the redirect plus validatetxn (and your reconciliation job) are all you get.
## 6. Gotchas
[Section titled “6. Gotchas”](#6-gotchas)
* **TXNID ≤ 20 chars.** UUIDs don’t fit; use `newOrderId()`.
* **Amounts in paisa everywhere** (form and APIs).
* **Mobile WebViews must accept cookies.** connectIPS returns “session expired” after the captcha when cookies are blocked (Android: `CookieManager.setAcceptThirdPartyCookies(webView, true)`).
* **Register redirect URLs with NCHL** before testing. Otherwise you get 403 after OTP.
* Load the key once at startup, not per request. If a multi-entry `.pfx` exists, pick the private-key entry.
* NepalPay QR (NCHL) is a separate acquirer/PSP-level API (OAuth + RSA payload encryption + WebSocket) with credentials from NCHL. It is out of scope for typical merchants; use Fonepay QR or NPS instead.
# Integrate CyberSource
> Accept cards through Nepali banks with CyberSource Secure Acceptance, step by step in TypeScript, Python, Java, Kotlin, PHP or Go.
CyberSource Secure Acceptance takes cards through a Nepali acquiring bank. Your server signs every form field, the payer enters the card on CyberSource, and the signed response POST is the proof. There is no simple status API.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
CyberSource Secure Acceptance flow
1. Your server signs every field (HMAC-SHA256), including the `bill_to` address, and renders an auto-submitting form to `/pay`.
2. The payer enters the card and completes 3-D Secure.
3. CyberSource POSTs the signed result to your receipt page through the browser and to your Merchant POST URL server to server.
4. Your server checks the signature, the order id and the currency, then maps the decision. That response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | ---------------------------------------------------------------------------- |
| Required | `CYBERSOURCE_PROFILE_ID`, `CYBERSOURCE_ACCESS_KEY`, `CYBERSOURCE_SECRET_KEY` |
| Optional | `CYBERSOURCE_ENV`, `CYBERSOURCE_CURRENCY` |
The full list for every provider is in [Environment variables](/docs/guides/env/).
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
const init = await payments.get("cybersource").initiate({
orderId: attempt.id, // transaction_uuid and reference_number, 50 chars max
amountPaisa: attempt.amountPaisa,
description: "Order #42",
returnUrl: `${baseUrl}/payments/cybersource/return/${attempt.id}`, // sent only with useReturnUrlOverride
billTo: {
// required: collect it at checkout (state and postalCode only for US/CA)
forename: "Sita", surname: "Sharma", email: "sita@example.com",
addressLine1: "Durbar Marg", city: "Kathmandu", country: "NP",
},
});
// init.kind === "form": serve renderAutoSubmitForm(init) as text/html from /payments/:id/start
```
* The return is a cross-site form POST: parse urlencoded bodies on this route and exempt it from CSRF checks.
* The receipt URL comes from the Secure Acceptance profile; `returnUrl` is only sent when the config sets `useReturnUrlOverride: true`. Optional `merchantDefinedData` must not hold PII.
* Secure Acceptance has no status API, so reconciliation needs the REST Transaction Search (references/cybersource.md).
* Python
```python
init = payments["cybersource"].initiate(
order_id=attempt.id, # transaction_uuid and reference_number, 50 chars max
amount_paisa=attempt.amount_paisa,
description="Order #42",
return_url=f"{base_url}/payments/cybersource/return/{attempt.id}", # sent only with use_return_url_override
bill_to={
# required: collect it at checkout (state and postal_code only for US/CA)
"forename": "Sita", "surname": "Sharma", "email": "sita@example.com",
"address_line1": "Durbar Marg", "city": "Kathmandu", "country": "NP",
},
)
# init.kind == "form": serve render_auto_submit_form(init) as text/html from /payments/{id}/start
```
* The return is a cross-site form POST: read the form body on this route (FastAPI needs python-multipart) and exempt it from CSRF (`@csrf_exempt` in Django).
* The receipt URL comes from the Secure Acceptance profile; `return_url` is only sent when the adapter is built with `use_return_url_override=True`. Optional `merchant_defined_data` must not hold PII.
* Secure Acceptance has no status API, so reconciliation needs the REST Transaction Search (references/cybersource.md).
* Go
```go
res, err := p.CyberSource.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID, // transaction_uuid and reference_number, <= 50 chars
AmountPaisa: attempt.AmountPaisa,
Description: "Order #42",
ReturnURL: baseURL + "/payments/cybersource/return/" + attempt.ID,
CyberSource: &payments.CyberSourceInitiateOptions{
BillTo: payments.BillTo{ // required for sale/authorization
Forename: "Ram", Surname: "Shrestha", Email: "ram@example.com",
AddressLine1: "Durbar Marg", City: "Kathmandu", Country: "NP",
},
},
})
if err != nil {
return err
}
// form result: serve payments.RenderAutoSubmitForm(res) as text/html (POSTs to /pay)
```
* The receipt page comes from the Secure Acceptance profile. `ReturnURL` is sent only with `UseReturnURLOverride: true` on `CyberSourceConfig`, which `ConfigFromEnv` leaves off.
* The return is a POST: parse the form body into `params`. Without a callback (reconciliation) `Verify` fails; use REST Transaction Search.
* Optional `MerchantDefinedData` maps n to `merchant_defined_data` (no PII).
* Java
```java
// Bill-to details are required for sale and authorization.
CyberSource.BillTo billTo = CyberSource.BillTo.of(
"Ram", "Shrestha", "ram@example.com", "Thamel", "Kathmandu", "NP");
String returnUrl = baseUrl + "/payments/cybersource/return/" + attempt.id();
InitiateResult init = payments.cybersource().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl), billTo);
// CyberSource has no providerRef: transaction_uuid and reference_number are the attempt id.
InitiateResult.Form form = (InitiateResult.Form) init;
// Serve Html.renderAutoSubmitForm(form) as text/html; it POSTs the payer to /pay.
```
* The receipt page and Merchant POST URL are cross-site POSTs: exempt them from Spring Security CSRF.
* The receipt URL comes from the profile; `.withReturnUrlOverride(true)` on the config sends `returnUrl` per request instead.
* Without a callback, `verify` throws `verify_unavailable`; reconcile through REST Transaction Search.
* Kotlin
```kotlin
val init = payments.cybersource.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/cybersource/return/${attempt.id}",
),
// Bill-to details are required for sale and authorization.
CyberSourceInitiateExtras(
billTo = BillTo(
forename = "Ram", surname = "Shrestha", email = "ram@example.com",
addressLine1 = "Thamel", city = "Kathmandu", country = "NP",
),
),
)
// CyberSource has no providerRef: transaction_uuid and reference_number are the attempt id.
// Respond with renderAutoSubmitForm(init as InitiateResult.Form) as text/html.
```
* The receipt page and Merchant POST URL are cross-site POSTs: keep Ktor’s CSRF plugin off those routes.
* The receipt URL comes from the profile; `useReturnUrlOverride = true` on `CyberSourceConfig` sends `returnUrl` per request instead.
* Without a callback, `verify` throws `verify_unavailable`; reconcile through REST Transaction Search.
* PHP
```php
$init = $payments->cyberSource()->initiate(
new InitiateInput(
orderId: $attempt->id, // transaction_uuid and reference_number, <= 50 chars
amountPaisa: $attempt->amountPaisa,
description: 'Order #42',
returnUrl: "{$baseUrl}/payments/cybersource/return/{$attempt->id}",
),
billTo: new BillTo( // required for sale/authorization
forename: 'Ram', surname: 'Shrestha', email: 'ram@example.com',
addressLine1: 'Durbar Marg', city: 'Kathmandu', country: 'NP',
),
);
// FormResult: echo $init->render(); auto-submits the signed form to /pay
```
* The receipt page comes from the Secure Acceptance profile. `returnUrl` is sent only with `useReturnUrlOverride: true` on `CyberSourceConfig`, which `configFromEnv()` leaves off.
* The return is a POST: exempt it from CSRF. Without a callback (reconciliation) `verify()` throws `verify_unavailable`; use REST Transaction Search.
* Optional `merchantDefinedData:` maps n to `merchant_defined_data` (no PII).
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
A form POST to the receipt page and to the Merchant POST URL (Profile, Notifications). Both are cross-site POSTs: exempt them from CSRF middleware and rely on the signature. Reply to the Merchant POST within 10 seconds.
* TypeScript
```ts
const result = await payments.get("cybersource").verify({
orderId: attempt.id,
amountPaisa: attempt.amountPaisa,
callback: params, // the signed response POST is the only proof: no params, no verify
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
result = payments["cybersource"].verify(
order_id=attempt.id,
amount_paisa=attempt.amount_paisa,
callback=params, # the signed response POST is the only proof: no params, no verify
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
// params is the signed response POST: it IS the proof (no status API)
v, err := p.CyberSource.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
Callback: params,
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// Secure Acceptance has no status API: the signed POST body is the proof.
// verify() checks the signature, the reference number and the currency.
VerifyResult result = payments.cybersource().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa()).withCallback(params));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// Secure Acceptance has no status API: the signed POST body is the proof.
// verify() checks the signature, the reference number and the currency.
val result = payments.cybersource.verify(
VerifyInput(orderId = attempt.id, amountPaisa = attempt.amountPaisa, callback = params),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// $params is the signed response POST: it IS the proof (no status API)
// throws invalid_signature / callback_mismatch: answer 400 and leave the attempt open
$result = $payments->cyberSource()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
callback: $params,
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Get a Business Center test account and a Secure Acceptance profile from your bank.
* Test cards: Visa `4111111111111111`, Mastercard `5555555555554444`, any future expiry. 3-D Secure cards are in the reference.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* `billTo` is required for sale and authorization. Use country `NP`; state and postal code only apply to the US and Canada.
* Without a callback there is nothing to verify. For reconciliation use the REST Transaction Search through the official SDK.
* CyberSource is sunsetting Secure Acceptance in favour of Unified Checkout. Agree a migration date with your bank.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Use live profile keys. They expire after two years, so put the renewal in a calendar.
* Configure the Merchant POST URL, enable 3-D Secure and enable NPR for each card type.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[CyberSource reference](/docs/providers/cybersource/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# CyberSource reference
> Full CyberSource reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/cybersource.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/cybersource.md). This is the same file the agent reads.
Sources: developer.cybersource.com (Secure Acceptance guide, REST getting-started, Unified Checkout guide, 2026 release notes), KB KA-11531, official SDK sources and samples. Verified 2026-11. Adapters: [`scripts/ts/src/cybersource.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/cybersource.ts), [`scripts/python/nepal_payments/cybersource.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/cybersource.py) (Secure Acceptance).
## 1. Which integration (read first)
[Section titled “1. Which integration (read first)”](#1-which-integration-read-first)
* **Secure Acceptance (SA)** is what Nepali banks have historically issued (NIC Asia confirmed: profile ID + access key + secret key, NPR with optional USD). CyberSource is **sunsetting SA**: the migration target was **2026-09-30** and *“Secure Acceptance will no longer be supported or maintained after its applicable end-of-life date”*. Dates are communicated per merchant. It still works today. Use the adapter if your bank gave you SA credentials, and plan a migration.
* **New builds**: ask your bank for **Unified Checkout + REST** credentials and use the official `cybersource-rest-client` SDK with **JWT** auth (§5). REST **HTTP Signature** auth must move to JWT by **March 2027**.
* **Card acceptance without a direct CyberSource contract**: NPS ([`nps.md`](/docs/providers/nps/reference/)) routes to bank card checkouts (e.g. HBL Card Checkout).
* Other banks (Nabil, Global IME, Himalayan, SCB Nepal) may use different processors. HBL uses 2C2P PACO ([`others.md`](/docs/providers/others/)). Confirm with your bank which product you’re getting.
## 2. Secure Acceptance Hosted Checkout
[Section titled “2. Secure Acceptance Hosted Checkout”](#2-secure-acceptance-hosted-checkout)
| Purpose | Test | Production |
| ------------------- | -------------------------------------------------- | ---------------------------------------------- |
| Hosted pay page | `https://testsecureacceptance.cybersource.com/pay` | `https://secureacceptance.cybersource.com/pay` |
| Token create/update | `…/token/create`, `…/token/update` | same paths |
| Iframe | `…/embedded/pay` | same |
| Silent Order POST | `…/silent/pay` | same |
| Business Center | | |
**Credentials**: Business Center → Payment Configuration → Secure Acceptance Settings → profile → Security → Create Key (signature v1, **HMAC-SHA256**) → `access_key` (32 chars) + `secret_key`. `profile_id` is on the profile. Keys **expire after 2 years**; copy them immediately (the panel closes after 30 s). Test and live keys differ.
Env vars: `CYBERSOURCE_ENV`, `CYBERSOURCE_PROFILE_ID`, `CYBERSOURCE_ACCESS_KEY`, `CYBERSOURCE_SECRET_KEY`, `CYBERSOURCE_CURRENCY` (default `NPR`).
**Request** (browser auto-POSTs a form to `/pay`). Required signed fields: `access_key, profile_id, transaction_uuid, signed_field_names, unsigned_field_names, signed_date_time, locale, transaction_type, reference_number, amount, currency`.
| Field | Rule |
| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `transaction_type` | `sale` (auth + capture, typical) or `authorization` (capture later); `…,create_payment_token` variants for tokenization |
| `transaction_uuid` | unique per attempt, ≤ 50. Reuse within 15 min → reason 104 |
| `reference_number` | your order/attempt id, ≤ 50 |
| `amount` | `"1500.00"` |
| `currency` | `NPR` (enable per card type in the profile; availability depends on the acquirer) |
| `signed_date_time` | UTC `yyyy-MM-dd'T'HH:mm:ss'Z'`; server clock must be NTP-synced |
| `locale` | `en-us` |
| `bill_to_forename, bill_to_surname, bill_to_email, bill_to_address_line1, bill_to_address_city, bill_to_address_country` | required for sale/auth; country `NP`; state + postal code only for US/CA |
| `override_custom_receipt_page` / `override_custom_cancel_page` | optional per-request URLs (HTTPS, must be signed) |
| `merchant_defined_data1..100` | optional; no PII |
**Signature**: `base64(HMAC-SHA256(secret_key as raw UTF-8 bytes, "name=value,name=value,…"))` in `signed_field_names` order. `signed_field_names` lists itself and `unsigned_field_names` (sent empty). Don’t URL-encode values before signing.
Worked vector (secret `test_secret_key_do_not_use`):
```plaintext
access_key=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6,profile_id=PROFILE-0001,transaction_uuid=7f9c2b1e-5d1a-4c3b-9e8f-1a2b3c4d5e6f,signed_field_names=access_key,profile_id,transaction_uuid,signed_field_names,unsigned_field_names,signed_date_time,locale,transaction_type,reference_number,amount,currency,unsigned_field_names=,signed_date_time=2026-11-04T10:15:30Z,locale=en-us,transaction_type=sale,reference_number=ORDER-1001,amount=1500.00,currency=NPR
→ QRwjaW8ysHR/UGyQ0LqSIHiGTvnNebKSwwsFRmn4Weg=
```
```php
base64_encode(hash_hmac('sha256', $dataToSign, $secretKey, true)); // official PHP sample
```
## 3. Response verification + Merchant POST URL
[Section titled “3. Response verification + Merchant POST URL”](#3-response-verification--merchant-post-url)
SA POSTs the result (form-encoded) to the **customer receipt page** (through the browser) and to the **Merchant POST URL** (server-to-server; set under Profile → Notifications, HTTPS/TLS 1.2+). Configure both. The Merchant POST URL doesn’t depend on the customer’s browser, so it’s your most reliable signal.
Verification (`adapter.verify({ orderId, amountPaisa, callback: postBody })`):
1. Recompute the signature from the **response’s** `signed_field_names` with the same secret and compare in constant time. Mismatch → *“treat the POST as malicious and disregard it”*.
2. Use **only** fields listed in `signed_field_names` (the adapter drops the rest).
3. Check `req_reference_number` = your attempt id and `req_currency`, then compare `auth_amount` (or `req_amount`) with the stored amount.
4. Map `decision`: `ACCEPT` → `paid` (for `sale`; `authorization` → `pending` until captured), `REVIEW` → `pending` (manual review in Business Center), `DECLINE`/`ERROR` → `failed` (reasons 150–152 → `unknown`: system error, check before retrying), `CANCEL` → `canceled`.
Key response fields: `decision, reason_code, message, transaction_id (≤26), auth_code, auth_amount, auth_time, req_* (echo), invalid_fields, required_fields, payment_token, request_token, payer_authentication_*, signed_field_names, signed_date_time, signature`.
* The receipt route receives a cross-site POST: **exempt it from CSRF middleware**, but always verify the signature.
* Reply to the Merchant POST within 10 s (delays slow future notifications) and make it idempotent (the conditional update in [`architecture.md`](/docs/guides/architecture/) §4).
* SA has no simple status API. For reconciliation of attempts with no POST, use REST Transaction Search via the SDK (§5) or the Business Center.
## 4. Reason codes
[Section titled “4. Reason codes”](#4-reason-codes)
| Code | Meaning |
| --------------------------- | ----------------------------------------------------------------------------------------------------- |
| 100 | Success |
| 110 | Partial approval (`auth_amount` < requested; the amount guard catches it) |
| 101 / 102 | Missing / invalid fields (`required_fields` / `invalid_fields`) |
| 104 | Duplicate `transaction_uuid` within 15 min |
| 150 / 151 / 152 | System failure / timeouts: check status before retrying |
| 200 / 230 | Approved but failed AVS / CVN (REVIEW) |
| 202 / 203 / 204 / 205 | Expired card / general decline / insufficient funds / lost-stolen |
| 207 / 208 / 210 / 211 | Issuer unavailable / card not allowed for CNP / limit reached / invalid CVN |
| 231 / 232 / 233 / 236 / 240 | Invalid account / card type not accepted / processor decline / processor failure / card type mismatch |
| 475 / 476 / 478 | 3DS needed / 3DS failed / SCA required |
| 481 / 520 | Profile risk settings / Decision Manager |
3DS (Visa Secure, Mastercard Identity Check, UnionPay 2.0, etc.) must be enabled on the merchant ID by Customer Support, then ticked per card type in the profile. SA rejects cards without CVN or expiry, which affects some older UnionPay cards.
## 5. REST API + Unified Checkout (new builds)
[Section titled “5. REST API + Unified Checkout (new builds)”](#5-rest-api--unified-checkout-new-builds)
Hosts: test `https://apitest.cybersource.com`, live `https://api.cybersource.com`.
* Pay `POST /pts/v2/payments` (`processingInformation.capture: true` = sale) → 201 with `id`, `status` (`AUTHORIZED`, `PARTIAL_AUTHORIZED`, `AUTHORIZED_PENDING_REVIEW`, `PENDING_AUTHENTICATION`, `DECLINED`, `INVALID_REQUEST`…)
* Capture `POST /pts/v2/payments/{id}/captures`, refund `POST /pts/v2/payments/{id}/refunds`, details `GET /tss/v2/transactions/{id}`
**Use the official SDK for REST auth** rather than hand-rolling it: JWT claims carry an MLE requirement from March 2027, and the docs and SDK disagree on a claim name.
| Lang | Install | Latest (2026-11) |
| ------ | ---------------------------------------------- | ---------------- |
| Node | `npm i cybersource-rest-client` | 0.0.84 |
| Python | `pip install cybersource-rest-client-python` | 0.0.80 |
| PHP | `composer require cybersource/rest-client-php` | 0.0.77 |
| Java | `com.cybersource:cybersource-rest-client-java` | 0.0.95 |
| .NET | `CyberSource.Rest.Client` | 1.0.3 |
| Ruby | `cybersource_rest_client` | 0.0.88 |
```js
// Node: JWT with shared secret (same key id + secret as HTTP Signature)
const cybersource = require("cybersource-rest-client");
const config = {
authenticationType: "jwt",
jwtKeyType: "SHARED_SECRET",
runEnvironment: process.env.CYBERSOURCE_ENV === "production" ? "api.cybersource.com" : "apitest.cybersource.com",
merchantID: process.env.CYBERSOURCE_MERCHANT_ID,
merchantKeyId: process.env.CYBERSOURCE_KEY_ID,
merchantsecretKey: process.env.CYBERSOURCE_SHARED_SECRET,
};
const client = new cybersource.ApiClient();
const tss = new cybersource.TransactionDetailsApi(config, client);
tss.getTransaction(transactionId, (err, data) => { /* data.applicationInformation.status ... */ });
```
**Unified Checkout** (recommended SA replacement; hosted card and wallet UI, PCI SAQ A):
1. Server: `POST /uc/v1/sessions` with `targetOrigins: ["https://shop.example"]`, `country`, `locale`, `data.orderInformation.amountDetails { totalAmount, currency }` → capture-context JWT.
2. Client: load the script from the JWT’s `clientLibrary` + `clientLibraryIntegrity` (never hard-code them; they change), then:
```js
const client = await VAS.UnifiedCheckout(sessionJwt);
const checkout = await client.createCheckout();
const result = await checkout.mount("#payment-buttons"); // payment-result JWT (autoProcessing) or transient token
```
3. Server: with a transient token, `POST /pts/v2/payments` with `tokenInformation.transientTokenJwt`. Confirm with `GET /tss/v2/transactions/{id}` before fulfilling. Webhooks are JSON.
4. Mobile: KA-11531 says UC works in a **WebView**. Flex mobile SDKs (`flex-api-ios-sdk`, `com.cybersource:flex-api-android-client`) are stale (2022). Create capture contexts on your server only.
HTTP Signature (deprecated; supported until March 2027), for reference: headers `host, date (RFC1123), request-target, digest ("SHA-256=" + base64(sha256(body))), v-c-merchant-id`, signing string lines `name: value` joined by `\n`, signature `base64(HMAC-SHA256(base64-DECODED secret, string))`, header `signature: keyid="…", algorithm="HmacSHA256", headers="host date request-target digest v-c-merchant-id", signature="…"`.
## 6. Test cards (test environment; any future expiry and name)
[Section titled “6. Test cards (test environment; any future expiry and name)”](#6-test-cards-test-environment-any-future-expiry-and-name)
| Card | Number |
| ----------------------------------------------------- | --------------------------------------- |
| Visa | `4111111111111111` |
| Mastercard | `5555555555554444`, `2222420000001113` |
| Amex | `378282246310005` |
| JCB | `3566111111111113` |
| 3DS 2.x Visa frictionless success / challenge success | `4000000000002701` / `4000000000002503` |
| 3DS 2.x Mastercard challenge success | `5200000000002151` |
## 7. Gotchas
[Section titled “7. Gotchas”](#7-gotchas)
* **SA secret = raw string bytes; REST secret = base64-decoded.** Mixing them gives signature errors.
* **Sign on the server.** The form page must contain only the signed values, never the secret.
* **`signed_date_time` must be UTC with `Z`.** Clock drift → rejection.
* **Fresh `transaction_uuid` per attempt** (reason 104).
* **REST digest must hash the exact bytes sent.** Don’t re-serialize JSON after hashing.
* **Partial approvals (110)**: compare `auth_amount` with your amount; don’t fulfil short payments.
# Integrate eSewa
> Integrate eSewa ePay v2 step by step in TypeScript, Python, Java, Kotlin, PHP or Go: signed form, success callback and the status API.
eSewa ePay v2 is a signed form POST. eSewa sends the payer back to your success URL with a base64 `data` parameter, and the status API decides whether the payment went through. The mobile Intent flow is covered in the reference.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
eSewa ePay v2 payment flow
1. Your server signs `total_amount`, `transaction_uuid` and `product_code` with HMAC-SHA256 and renders an auto-submitting form.
2. The payer logs in and pays on eSewa.
3. eSewa redirects to `success_url?data=`, or to `failure_url` with no parameters.
4. Your server checks the signature on `data`, then calls the status API with the total you stored. That response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | -------------------------------------------------------------------------------------------- |
| Required | `ESEWA_PRODUCT_CODE`, `ESEWA_SECRET_KEY` |
| Optional | `ESEWA_ENV`, `ESEWA_INTENT_PRODUCT_CODE`, `ESEWA_INTENT_ACCESS_KEY`, `ESEWA_INTENT_BASE_URL` |
For the sandbox:
```bash
ESEWA_ENV=sandbox
ESEWA_PRODUCT_CODE=EPAYTEST
ESEWA_SECRET_KEY='8gBm/:&EnhH.1/q'
```
The full list for every provider is in [Environment variables](/docs/guides/env/).
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
// No providerRef: the status API looks the payment up by transaction_uuid + total.
const init = await payments.get("esewa").initiate({
orderId: attempt.id, // transaction_uuid: letters, digits and "-" only
amountPaisa: attempt.amountPaisa, // the TOTAL charged; verify() gets the same number
description: "Order #42",
returnUrl: `${baseUrl}/payments/esewa/return/${attempt.id}`, // no query string: eSewa appends ?data=
});
// init.kind === "form": serve renderAutoSubmitForm(init) as text/html from /payments/:id/start
```
* Optional `taxPaisa`, `serviceChargePaisa` and `deliveryChargePaisa` are a breakdown INCLUDED in `amountPaisa`, not added to it.
* Failure redirects carry no `data`, so the attempt id in the return path is how the handler finds the attempt.
* This is the ePay v2 web flow. The mobile Intent flow uses `intentBook()` and `intentVerify()`.
* Python
```python
# No provider_ref: the status API looks the payment up by transaction_uuid + total.
init = payments["esewa"].initiate(
order_id=attempt.id, # transaction_uuid: letters, digits and "-" only
amount_paisa=attempt.amount_paisa, # the TOTAL charged; verify() gets the same number
description="Order #42",
return_url=f"{base_url}/payments/esewa/return/{attempt.id}", # no query string: eSewa appends ?data=
)
# init.kind == "form": serve render_auto_submit_form(init) as text/html from /payments/{id}/start
```
* Optional `tax_paisa`, `service_charge_paisa` and `delivery_charge_paisa` are a breakdown INCLUDED in `amount_paisa`, not added to it.
* Failure redirects carry no `data`, so the attempt id in the return path is how the handler finds the attempt.
* This is the ePay v2 web flow. The mobile Intent flow uses `intent_book()` and `intent_verify()`.
* Go
```go
res, err := p.Esewa.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa, // the TOTAL charged
Description: "Order #42",
// no query string: eSewa appends ?data=..., so the attempt id goes in the path
ReturnURL: baseURL + "/payments/esewa/return/" + attempt.ID,
})
if err != nil {
return err
}
// form result: serve payments.RenderAutoSubmitForm(res) as text/html
```
* Tax, service and delivery charges go in `Esewa: &payments.EsewaInitiateOptions{TaxPaisa: ...}` and are included in `AmountPaisa`.
* eSewa answers NOT_FOUND for a wrong total, so the stored total doubles as the amount check.
* Java
```java
// eSewa appends ?data=... to the return URL, so the attempt id goes in the path.
// The same URL serves as failure_url, so failed payments can be matched too.
String returnUrl = baseUrl + "/payments/esewa/return/" + attempt.id();
InitiateResult init = payments.esewa().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl));
// eSewa has no providerRef: the form carries the signed fields.
InitiateResult.Form form = (InitiateResult.Form) init;
// Serve Html.renderAutoSubmitForm(form) as text/html; it POSTs the payer to eSewa.
```
* Tax, service and delivery charges go in `new Esewa.Extras(taxPaisa, servicePaisa, deliveryPaisa)` as the second `initiate` argument. They are included in `amountPaisa`, which stays the total.
* The status API answers NOT_FOUND for a wrong total, so always verify with the stored amount.
* Kotlin
```kotlin
// eSewa appends ?data=... to the return URL, so the attempt id goes in the path.
// The same URL serves as failure_url, so failed payments can be matched too.
val init = payments.esewa.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/esewa/return/${attempt.id}",
),
)
// eSewa has no providerRef: the form carries the signed fields.
// Respond with renderAutoSubmitForm(init as InitiateResult.Form) as text/html.
```
* Tax, service and delivery charges go in `EsewaInitiateExtras(taxPaisa, serviceChargePaisa, deliveryChargePaisa)` as the second `initiate` argument. They are included in `amountPaisa`, which stays the total.
* Adapter calls are `suspend` functions: call them from a Ktor handler or another coroutine.
* PHP
```php
$init = $payments->esewa()->initiate(
new InitiateInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa, // the TOTAL charged
description: 'Order #42',
// no query string: eSewa appends ?data=..., so the attempt id goes in the path
returnUrl: "{$baseUrl}/payments/esewa/return/{$attempt->id}",
),
);
// FormResult: echo $init->render(); auto-submits the signed form to eSewa
```
* Tax, service and delivery charges are optional `initiate()` args (`taxPaisa:`, `serviceChargePaisa:`, `deliveryChargePaisa:`), included in `amountPaisa`.
* eSewa answers NOT_FOUND for a wrong total, so the stored total doubles as the amount check.
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
Success adds a signed `data` parameter. Failure adds nothing, so keep the attempt id in the return URL path (`/payments/esewa/return/`) and keep the URL free of query strings, because eSewa appends `?data=`.
* TypeScript
```ts
const result = await payments.get("esewa").verify({
orderId: attempt.id,
amountPaisa: attempt.amountPaisa, // stored total; a wrong total comes back as not_found
callback: params, // ?data= on success: signature and transaction_uuid are checked, then the status API
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
result = payments["esewa"].verify(
order_id=attempt.id,
amount_paisa=attempt.amount_paisa, # stored total; a wrong total comes back as not_found
callback=params, # ?data= on success: signature and transaction_uuid are checked, then the status API
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
v, err := p.Esewa.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
Callback: params, // ?data=... on success; failure redirects carry nothing
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// On success params holds "data" (signed base64 JSON); failure redirects carry nothing.
// verify() checks that signature, then asks the status API with the stored total.
VerifyResult result = payments.esewa().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa()).withCallback(params));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// On success params holds "data" (signed base64 JSON); failure redirects carry nothing.
// verify() checks that signature, then asks the status API with the stored total.
val result = payments.esewa.verify(
VerifyInput(orderId = attempt.id, amountPaisa = attempt.amountPaisa, callback = params),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// throws on network/5xx/bad signature: leave the attempt open, reconciliation settles it
$result = $payments->esewa()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
callback: $params, // ?data=... on success; failure redirects carry nothing
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Merchant: product code `EPAYTEST`, secret key `8gBm/:&EnhH.1/q` (public, from eSewa’s docs).
* Payer: eSewa ID `9806800001` to `9806800005`, password `Nepal@123`, token `123456`.
* The sandbox login has a reCAPTCHA, so finish test payments in a browser.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* Format each amount once and use the same string in the form and the signature. `110` signed but `110.0` posted fails with ES104.
* The status API returns `NOT_FOUND` for a wrong total, so pass the total including tax and charges.
* Secret keys copied from config UIs can arrive HTML-escaped (`&`). The adapter unescapes them.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Get the live product code and secret key from eSewa merchant onboarding.
* For Intent, get the production host and access key from eSewa and set `ESEWA_INTENT_BASE_URL`.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[eSewa reference](/docs/providers/esewa/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# eSewa reference
> Full eSewa reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/esewa.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/esewa.md). This is the same file the agent reads.
Official docs: · Verified 2026-11 against the docs’ raw pages, the official WordPress plugin, and live sandbox calls. Adapters: [`scripts/ts/src/esewa.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/esewa.ts), [`scripts/python/nepal_payments/esewa.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/esewa.py).
## 1. Which flow
[Section titled “1. Which flow”](#1-which-flow)
| Need | Use |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Website, or mobile via WebView | **ePay v2**: signed form POST, then redirect back, then status API |
| Native mobile app with app-to-app switching to the eSewa app | **Intent**: the server books, the app opens the deeplink, then callback and status API. eSewa marks this “Recommended” and has deprecated its Android/iOS/Flutter SDKs in its favour. |
## 2. Environments and credentials
[Section titled “2. Environments and credentials”](#2-environments-and-credentials)
| | Sandbox | Production |
| ----------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| ePay form (POST, form-urlencoded) | `https://rc-epay.esewa.com.np/api/epay/main/v2/form` | `https://epay.esewa.com.np/api/epay/main/v2/form` |
| ePay status (GET) | `https://rc.esewa.com.np/api/epay/transaction/status/` | `https://esewa.com.np/api/epay/transaction/status/` (current docs; `https://epay.esewa.com.np/api/epay/transaction/status/` also answers) |
| Intent book / status / cancel (POST JSON) | `https://rc-checkout.esewa.com.np/api/client/intent/payment/{book,status,cancel}` | Issued by eSewa at go-live (set `intent.baseUrl`) |
| Merchant portal | | |
`uat.esewa.com.np`, used in most tutorials, **no longer resolves**. Use `rc.esewa.com.np` / `rc-epay.esewa.com.np`.
| Credential | Sandbox value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| ePay `product_code` | `EPAYTEST` |
| ePay secret key | `8gBm/:&EnhH.1/q` |
| Intent `product_code` | `INTENT` |
| Intent access key | `LB0REg8HUSw3MTYrI1s6JTE8Kyc6JyAqJiA3MQ==` (use as a **raw string**; base64-decoding it gives `IP-400 Invalid Signature`) |
| Test eSewa IDs | `9806800001` … `9806800005` (the docs also list `9711111111/2/3`) |
| Password / MPIN / OTP token | `Nepal@123` / `1122` (app only) / `123456` |
Production: the merchant gets their own product code and secret key (ePay) and an access key (Intent) from eSewa after onboarding. Env vars: `ESEWA_ENV`, `ESEWA_PRODUCT_CODE`, `ESEWA_SECRET_KEY`, and optionally `ESEWA_INTENT_PRODUCT_CODE`, `ESEWA_INTENT_ACCESS_KEY`, `ESEWA_INTENT_BASE_URL`.
The sandbox login page has a reCAPTCHA, so end-to-end payment tests are manual. The form POST and the status API can be tested automatically.
## 3. ePay v2
[Section titled “3. ePay v2”](#3-epay-v2)
### Flow
[Section titled “Flow”](#flow)
1. Server builds and signs the fields, then the browser **auto-submits** them as `application/x-www-form-urlencoded` POST to the form URL. A JSON body returns HTTP 409. eSewa responds `302 → /epay?bookingId=…`.
2. Payer logs in (ID + password + OTP) and confirms.
3. **Success** → `GET success_url?data=`. **Failure / cancel** → `failure_url` (its params are undocumented, so put your attempt id in the URL **path**: `/payments/esewa/return/`).
4. Server decodes `data`, verifies its signature, checks `transaction_uuid` + `product_code`, then calls the **status API** (authoritative).
### Request fields (all required; send `0` for unused charges)
[Section titled “Request fields (all required; send 0 for unused charges)”](#request-fields-all-required-send-0-for-unused-charges)
With the adapters, pass `amountPaisa` = the **total** you charge (store that number; verify with it). Optional `taxPaisa` / `serviceChargePaisa` / `deliveryChargePaisa` are a breakdown *included* in the total; the adapter derives eSewa’s `amount` = total − breakdown.
| Field | Notes |
| ---------------------------- | -------------------------------------------------------------------------------------------- |
| `amount` | Rupees, e.g. `100` or `100.50`. |
| `tax_amount` | `0` if none. Omitting it → `ES200 "tax_amount field is required."` |
| `product_service_charge` | `0` if none. |
| `product_delivery_charge` | `0` if none. |
| `total_amount` | Must equal amount + tax + service + delivery, else `ES704 Invalid total transaction amount.` |
| `transaction_uuid` | Unique per request; letters, digits and `-` only. Reuse → `Duplicate transaction UUID.` |
| `product_code` | `EPAYTEST` in sandbox. |
| `success_url`, `failure_url` | Your server routes. No query string (eSewa appends `?data=`). |
| `signed_field_names` | `total_amount,transaction_uuid,product_code` |
| `signature` | See §4. |
### Success payload (`data` decoded)
[Section titled “Success payload (data decoded)”](#success-payload-data-decoded)
```json
{"transaction_code":"0LD5CEH","status":"COMPLETE","total_amount":"1,000.0","transaction_uuid":"240613-134231",
"product_code":"NP-ES-ABHISHEK-EPAY",
"signed_field_names":"transaction_code,status,total_amount,transaction_uuid,product_code,signed_field_names",
"signature":"Mpwy0TFlHqpJjFUDGic+22mdoenITT+Ccz1LC61qMAc="}
```
`total_amount` may come back with a thousands separator. Sources disagree on whether the comma is part of the signed string, so the adapters try both. The status API is the final word either way.
## 4. Signature + test vectors
[Section titled “4. Signature + test vectors”](#4-signature--test-vectors)
`signature = base64( HMAC-SHA256( key = secret (UTF-8), message = "f1=v1,f2=v2,..." ) )`, with fields in `signed_field_names` order, no spaces, and standard base64 (not hex).
**The example signatures in eSewa’s docs are wrong** (they don’t reproduce with the documented key). Test against these instead (checked with Python, OpenSSL and the live sandbox):
| Key | Message | Signature |
| ------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------- |
| `8gBm/:&EnhH.1/q` | `total_amount=100,transaction_uuid=11-201-13,product_code=EPAYTEST` | `5DZywcrTKD0gia/rsSMcrRHmJl+4Tbol6S+lWgdJ94E=` |
| `8gBm/:&EnhH.1/q` | `total_amount=110,transaction_uuid=ab14a8f2b02c3,product_code=EPAYTEST` | `0Gh9pc1fiZDZH7OKOBdn42QMx8drH7tOeMPdRQhI83g=` |
| `LB0REg8HUSw3MTYrI1s6JTE8Kyc6JyAqJiA3MQ==` | `product_code=INTENT,amount=100,transaction_uuid=txn-20251220-001` | `oVRo2ZkjNpbt5vKs7RSNx1OCcewDaVssoguWsG31HMA=` |
Shell check: `printf '%s' 'total_amount=100,transaction_uuid=11-201-13,product_code=EPAYTEST' | openssl dgst -sha256 -hmac '8gBm/:&EnhH.1/q' -binary | base64`
The signature covers the **exact posted string**: posting `110.0` while signing `110` → `ES104 Invalid payload signature.` Format the amount once and reuse the string.
**Response signature:** rebuild the message from the decoded `data` using *its own* `signed_field_names` (which includes `signed_field_names=transaction_code,status,...` itself) and compare in constant time.
Other languages (same message, same key):
```php
base64_encode(hash_hmac('sha256', $message, $secret, true));
```
```python
base64.b64encode(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()).decode()
```
```go
m := hmac.New(sha256.New, []byte(secret)); m.Write([]byte(message)); base64.StdEncoding.EncodeToString(m.Sum(nil))
```
```java
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
Base64.getEncoder().encodeToString(mac.doFinal(message.getBytes(UTF_8)));
```
```dart
base64.encode(Hmac(sha256, utf8.encode(secret)).convert(utf8.encode(message)).bytes); // package:crypto
```
## 5. Status check API
[Section titled “5. Status check API”](#5-status-check-api)
`GET {status}?product_code=EPAYTEST&total_amount=100&transaction_uuid=…` →
```json
{"product_code":"EPAYTEST","transaction_uuid":"…","total_amount":100.0,"status":"COMPLETE","ref_id":"0001TS9"}
```
| status | Normalized | Meaning |
| --------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `COMPLETE` | `paid` | Paid. |
| `PENDING` | `pending` | Initiated, not completed (`ref_id` null). |
| `AMBIGUOUS` | `pending` | “Payment is at halt state”; keep re-checking. |
| `FULL_REFUND` / `PARTIAL_REFUND` | `refunded` / `partially_refunded` | |
| `NOT_FOUND` | `not_found` | Session expired, never paid, **or wrong `total_amount`**. |
| `CANCELED` | `canceled` | Cancelled or reversed by eSewa. |
| `{"code":0,"error_message":"Service is currently unavailable"}` | throw, retry later | The adapters read `error_message` or `errorMessage` and raise code `esewa_` (`esewa_0` here), or `http_` when the body has no code. |
* A wrong `total_amount` returns `NOT_FOUND` rather than an error, so pass the **total you charged** (amount + tax + charges). This doubles as the amount check. `100` and `100.0` both match.
* No refund API and no webhook in ePay v2. Refunds happen through eSewa and show up as statuses.
* The payment fails if not completed within 15 min of login. eSewa says to call status if you get no response within 5 min. The reconciliation job covers this.
## 6. Intent payment (mobile app-to-app)
[Section titled “6. Intent payment (mobile app-to-app)”](#6-intent-payment-mobile-app-to-app)
1. **Book** (server): `POST {intent}/book`
```json
{"product_code":"INTENT","amount":100,"transaction_uuid":"txn-20251220-001",
"signed_field_names":"product_code,amount,transaction_uuid","signature":"…",
"callback_url":"https://api.example.com/payments/esewa/intent-callback",
"redirect_url":"https://api.example.com/payments/done",
"properties":{"customer_id":"CUST12345","remarks":"Internet bill payment"}}
```
→ `{"code":"IP-201","data":{"booking_id":"VkQ=","deeplink":"https://rc-links.esewa.com.np/pay/VkQ=","correlation_id":"01KDJ…"},"message":"Success"}`. Errors look like `{"code":"IP-400","error_message":"Invalid Signature"}` in the docs; the live sandbox has also sent `{"code":0,"errorMessage":"Service is currently unavailable"}`, so the adapters read both keys. `properties` keys must be snake_case and are shown to the user. Decimal amounts (e.g. `100.5`, signed as `amount=100.5`) were accepted in sandbox.
2. **App** opens `deeplink` (a smart link: it opens the eSewa app if installed, else a web fallback). Persist `booking_id|correlation_id` as the attempt’s `provider_ref`.
3. eSewa **POSTs `callback_url`** (JSON: `product_code, amount, reference_code, correlation_id, status, signature, signed_field_names`, signed with the access key) and sends the user to `redirect_url`. Pass the **raw request body** to `intentCallbackValid` (Python `intent_callback_valid`): JSON parsing turns `100.0` into `100`, and I haven’t yet captured a real callback to confirm which form eSewa signs. With the raw body the adapters accept either `amount=100.0` (as sent) or `amount=100` (JavaScript `String(v)`); a parsed object can only be checked in the `String(v)` form. The callback is a hint either way: call Intent status before fulfilling.
4. **Status** (server, authoritative): `POST {intent}/status` with `{booking_id, product_code, correlation_id, signed_field_names:"booking_id,product_code,correlation_id", signature}` → `data.status` ∈ `BOOKED` (pending) · `SUCCESS` (paid) · `PENDING` · `FAILED` · `CANCELED` · `REVERTED` (refunded). `data.transaction_id` is **your** uuid (the adapter checks it). `data.reference_code` is eSewa’s reference. The response has no amount, because the amount was signed at booking. Live sandbox returns code `IP-200` (docs say `IP-201`) and reports a user-cancelled booking as `FAILED`.
5. **Cancel** an abandoned booking: `POST {intent}/cancel` with `{booking_id, product_code, signed_field_names:"booking_id,product_code", signature}` → `IP-210`. If it was already processed → `IP-410`.
App-side detection (optional, the smart link already falls back):
* Android 11+: ``
* iOS: `LSApplicationQueriesSchemes` contains `esewa`, then `canOpenURL("esewa://")`. The bundle id is `com.f1soft.fonebank.eSewa`.
Demo apps:
### Flutter (Intent)
[Section titled “Flutter (Intent)”](#flutter-intent)
```dart
final r = await api.post('/payments', {'orderId': orderId, 'provider': 'esewa', 'flow': 'intent'});
await launchUrl(Uri.parse(r['deeplink']), mode: LaunchMode.externalApplication);
// When the app resumes (WidgetsBindingObserver.didChangeAppLifecycleState == resumed)
// or the redirect_url app link arrives, ask the server:
final status = await confirmPayment(r['attemptId']); // see mobile.md §7
```
React Native: `Linking.openURL(deeplink)`, then on `AppState` → `active`, call your verify endpoint. Native: `Intent(ACTION_VIEW, Uri.parse(deeplink))` / `UIApplication.shared.open(url)`.
## 7. Deprecated: v1 API and legacy mobile SDKs
[Section titled “7. Deprecated: v1 API and legacy mobile SDKs”](#7-deprecated-v1-api-and-legacy-mobile-sdks)
Migrate any code that has:
* **v1 ePay**: `/epay/main` with `tAmt, amt, txAmt, psc, pdc, scd, pid, su, fu`; success redirect `?oid&amt&refId`; verification `/epay/transrec` returning XML `Success`; status `api/epay/txn_status/v2`. Production v1 returns “Service unavailable.”
* **Legacy SDKs**: Android `eSewaSdk.aar` (`ESewaConfiguration`, `ESewaPayment`, `ESewaPaymentActivity`), iOS `EsewaSDK` xcframework / pod `EsewaSDK` 0.0.3, Flutter `esewa_flutter_sdk` (Drive zip; the pub.dev package of that name is unofficial and discontinued), and verification via `/mobile/transaction?txnRefId=` with `merchantId`/`merchantSecret` headers. The docs now show “migrate to the Intent Payment flow” on these pages.
* Community packages (`esewa_flutter`, `esewa_wallet`, `react-native-esewa`, `rn-esewa`) are unofficial or stale. Prefer Intent or ePay v2 in a WebView.
## 8. Gotchas
[Section titled “8. Gotchas”](#8-gotchas)
* **Sign on the server.** Many tutorials (including ones that compute `createSignature` in a React component) ship the secret key to the browser. Anyone could then sign arbitrary payloads.
* **Format amounts once** (`paisaToRupees`) and reuse the same string in the field and the signature.
* **Don’t fulfil from `data` alone.** Verify signature + uuid + product code, then the status API with your stored total.
* **`+` becomes a space** if `data` isn’t URL-encoded and your framework decodes the query string. The adapters restore it before base64-decoding.
* **HTML-escaped secrets**: config UIs and templating can turn `&` in `8gBm/:&EnhH.1/q` into `&`. The adapter un-escapes it.
* **No query strings in `success_url`/`failure_url`.** Use path segments for your attempt id.
* **`uat.esewa.com.np` is dead.** Sandbox is `rc.esewa.com.np` (status) and `rc-epay.esewa.com.np` (form).
* **Max 3 `properties[...]`** optional fields on ePay.
# Integrate Fonepay
> Integrate Fonepay web payments step by step in TypeScript, Python, Java, Kotlin, PHP or Go: DV signing, callback check and txnVerification.
Fonepay web payments are a signed redirect. Every request and response carries a `DV` (HMAC-SHA512) and the verification API confirms the result. Dynamic QR for point of sale is covered in the reference.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
Fonepay web payment flow
1. Your server builds the request parameters and their `DV`, then redirects the browser to Fonepay.
2. The payer scans or pays from their bank app.
3. Fonepay redirects to your return URL with `PRN`, `PID`, `PS`, `RC`, `UID`, `BC`, `INI`, `P_AMT`, `R_AMT` and `DV`.
4. Your server checks the response `DV`, then calls txnVerification (or the legacy verification). That response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | ----------------------------------------------------- |
| Required | `FONEPAY_MERCHANT_CODE`, `FONEPAY_SECRET_KEY` |
| Optional | `FONEPAY_ENV`, `FONEPAY_USERNAME`, `FONEPAY_PASSWORD` |
For the sandbox:
```bash
FONEPAY_ENV=sandbox
FONEPAY_MERCHANT_CODE=NBQM
FONEPAY_SECRET_KEY=a7e3512f5032480a83137793cb2021dc
```
The full list for every provider is in [Environment variables](/docs/guides/env/).
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
// No providerRef: verification looks the payment up by PRN + amount.
const init = await payments.get("fonepay").initiate({
orderId: attempt.id, // PRN: 3-25 chars
amountPaisa: attempt.amountPaisa,
description: "Order #42", // R1, up to 160 chars
returnUrl: `${baseUrl}/payments/fonepay/return/${attempt.id}`, // RU: 150 chars max, no query string
});
// init.kind === "redirect": 302 the browser to init.url
```
* Set `FONEPAY_USERNAME` and `FONEPAY_PASSWORD`: `verify()` then uses txnVerification, which also works without a callback (reconciliation). Without them it needs the DV-signed UID and BC from the redirect.
* Optional `remarks2` fills R2 (50 chars, defaults to “N/A”).
* Python
```python
# No provider_ref: verification looks the payment up by PRN + amount.
init = payments["fonepay"].initiate(
order_id=attempt.id, # PRN: 3-25 chars
amount_paisa=attempt.amount_paisa,
description="Order #42", # R1, up to 160 chars
return_url=f"{base_url}/payments/fonepay/return/{attempt.id}", # RU: 150 chars max, no query string
)
# init.kind == "redirect": 302 the browser to init.url
```
* Set `FONEPAY_USERNAME` and `FONEPAY_PASSWORD`: `verify()` then uses txnVerification, which also works without a callback (reconciliation). Without them it needs the DV-signed UID and BC from the redirect.
* Optional `remarks2` fills R2 (50 chars, defaults to “N/A”).
* Go
```go
res, err := p.Fonepay.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID, // PRN, 3 to 25 chars
AmountPaisa: attempt.AmountPaisa,
Description: "Order #42", // R1
// RU: at most 150 chars, no query string
ReturnURL: baseURL + "/payments/fonepay/return/" + attempt.ID,
Fonepay: &payments.FonepayInitiateOptions{Remarks2: "web"}, // R2, optional (default "N/A")
})
if err != nil {
return err
}
// redirect: http.Redirect(w, r, res.URL, http.StatusSeeOther)
```
* Set `FONEPAY_USERNAME` and `FONEPAY_PASSWORD` to verify through txnVerification, which also works without a callback (reconciliation). Without them `Verify` needs the DV-signed UID and BC from the redirect.
* Dynamic QR: `p.Fonepay.QRCreate`, then `p.Fonepay.QRStatus(ctx, orderID, &amountPaisa)` before fulfilling.
* Java
```java
// RU must stay under 150 chars with no query string; the attempt id rides in the path.
String returnUrl = baseUrl + "/payments/fonepay/return/" + attempt.id();
InitiateResult init = payments.fonepay().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl));
// Fonepay has no providerRef: PRN is the attempt id.
InitiateResult.Redirect redirect = (InitiateResult.Redirect) init;
// Send the payer to redirect.url() with a 302.
```
* Set `FONEPAY_USERNAME` and `FONEPAY_PASSWORD`: verify then uses txnVerification, which also works without a callback for reconciliation. Without them it falls back to the legacy check, which needs UID and BC from a signed redirect.
* The second `initiate` argument sets R2 (max 50 chars, default “N/A”).
* Kotlin
```kotlin
// RU must stay under 150 chars with no query string; the attempt id rides in the path.
val init = payments.fonepay.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/fonepay/return/${attempt.id}",
),
)
// Fonepay has no providerRef: PRN is the attempt id.
// Redirect the payer to (init as InitiateResult.Redirect).url.
```
* Set `FONEPAY_USERNAME` and `FONEPAY_PASSWORD`: verify then uses txnVerification, which also works without a callback for reconciliation. Without them it falls back to the legacy check, which needs UID and BC from a signed redirect.
* `FonepayInitiateExtras(remarks2 = ...)` sets R2 (max 50 chars, default “N/A”).
* PHP
```php
$init = $payments->fonepay()->initiate(
new InitiateInput(
orderId: $attempt->id, // PRN, 3 to 25 chars
amountPaisa: $attempt->amountPaisa,
description: 'Order #42', // R1
// RU: at most 150 chars, no query string
returnUrl: "{$baseUrl}/payments/fonepay/return/{$attempt->id}",
),
remarks2: 'web', // R2, optional (default "N/A")
);
// RedirectResult: header("Location: {$init->url}", true, 303);
```
* Set `FONEPAY_USERNAME` and `FONEPAY_PASSWORD` to verify through txnVerification, which also works without a callback (reconciliation). Without them `verify()` needs the DV-signed UID and BC from the redirect.
* Dynamic QR: `qrCreate()`, then `qrStatus($orderId, $amountPaisa)` before fulfilling.
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
A GET carrying `PRN` (your attempt id) and the result fields, signed by `DV`. A missing or wrong `DV` is rejected, so nobody can force a status with `?PRN=&PS=false`.
* TypeScript
```ts
const result = await payments.get("fonepay").verify({
orderId: attempt.id,
amountPaisa: attempt.amountPaisa,
callback: params, // PRN, PS, RC, UID, BC, ... DV: trusted only when the DV checks out
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
result = payments["fonepay"].verify(
order_id=attempt.id,
amount_paisa=attempt.amount_paisa,
callback=params, # PRN, PS, RC, UID, BC, ... DV: trusted only when the DV checks out
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
v, err := p.Fonepay.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
Callback: params, // PRN, PS, RC, UID, BC, ..., DV: checked before anything is trusted
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// params carry PRN, PS, UID, BC and the DV signature; a missing or bad DV is rejected.
VerifyResult result = payments.fonepay().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa()).withCallback(params));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// params carry PRN, PS, UID, BC and the DV signature; a missing or bad DV is rejected.
val result = payments.fonepay.verify(
VerifyInput(orderId = attempt.id, amountPaisa = attempt.amountPaisa, callback = params),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// throws on network/5xx or a missing/invalid DV: leave the attempt open
$result = $payments->fonepay()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
callback: $params, // PRN, PS, RC, UID, BC, ..., DV: checked before anything is trusted
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Merchant: the public dev sample `NBQM` / `a7e3512f5032480a83137793cb2021dc` covers the redirect only.
* txnVerification and Dynamic QR need your own merchant-panel username and password.
* Payer: scan with a Fonepay-enabled test bank app from your bank.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* `PRN` must be 3 to 25 characters and unique. A reused one fails with `Duplicate PRN` (HTTP 409).
* Fonepay only accepts dates as `MM/DD/YYYY` in Asia/Kathmandu. The adapter formats them.
* The dev merchant-API hosts are not in public DNS. Fonepay whitelists or issues them during onboarding.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Get the live PID, secret key and panel login through your acquiring bank.
* Confirm the live txnVerification host with Fonepay.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[Fonepay reference](/docs/providers/fonepay/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# Fonepay reference
> Full Fonepay reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/fonepay.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/fonepay.md). This is the same file the agent reads.
Sources: official “Technical Specification Document (Fonepay Web Integration) v2.0” (Sept 2020), the Dynamic QR spec v1.1 (2024, community transcription matched by two independent codebases), official Java samples, and live dev-gateway tests (2026-11). Adapters: [`scripts/ts/src/fonepay.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/fonepay.ts), [`scripts/python/nepal_payments/fonepay.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/fonepay.py).
## 1. Environments and credentials
[Section titled “1. Environments and credentials”](#1-environments-and-credentials)
| Purpose | Dev | Live |
| ------------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Web payment redirect | `https://dev-clientapi.fonepay.com/api/merchantRequest` | `https://clientapi.fonepay.com/api/merchantRequest` |
| txnVerification (POST) | `https://dev-merchantapi.fonepay.com/api/merchant/merchantDetailsForThirdParty/txnVerification`\* | `https://merchantapi.fonepay.com/api/merchant/merchantDetailsForThirdParty/txnVerification` |
| Legacy verification (GET, XML) | `https://dev-clientapi.fonepay.com/api/merchantRequest/verificationMerchant` | `https://clientapi.fonepay.com/api/merchantRequest/verificationMerchant` |
| Dynamic QR | `https://dev-merchantapi.fonepay.com/api/merchant/merchantDetailsForThirdParty/…`\* | `https://merchantapi.fonepay.com/api/merchant/merchantDetailsForThirdParty/…` |
| WebSocket | `wss://dev-ws.fonepay.com/convergent-webSocket-web/merchantEndPoint`\* | `wss://ws.fonepay.com/convergent-webSocket-web/merchantEndPoint` |
| Merchant panel | | |
\* Dev merchant-API hosts are not in public DNS; Fonepay issues or whitelists them during onboarding. Override them with the adapter’s `txnVerificationUrl` / `qrBaseUrl` options.
**Credentials** (from your acquiring bank / Fonepay onboarding):
* `PID`: merchant code, 3–20 chars.
* **Secret key**: the HMAC key, shown on the merchant profile page. Test and live keys differ.
* **Merchant-panel username + password**: needed for txnVerification (recommended) and Dynamic QR.
Public dev sample from the official docs: `PID=NBQM`, secret `a7e3512f5032480a83137793cb2021dc`. The dev gateway accepted it in 2026-11 tests, so you can test redirects without onboarding.
Env vars: `FONEPAY_ENV`, `FONEPAY_MERCHANT_CODE`, `FONEPAY_SECRET_KEY`, `FONEPAY_USERNAME`, `FONEPAY_PASSWORD`.
## 2. Web payment flow
[Section titled “2. Web payment flow”](#2-web-payment-flow)
1. Server builds params + DV, then redirects the browser (GET, URL-encoded query) to `merchantRequest`.
2. Fonepay answers `303` to its payment page. The payer pays with a bank app or scans the QR.
3. Fonepay redirects to `RU` with `PRN, PID, PS, RC, UID, BC, INI, P_AMT, R_AMT, DV`.
4. Server verifies the response DV, then calls **txnVerification** (or the legacy verification).
| Param | Rule |
| ----- | -------------------------------------------------------------------- |
| `PID` | merchant code |
| `MD` | `P` |
| `PRN` | your attempt id, **3–25 chars**, unique (`Duplicate PRN` → HTTP 409) |
| `AMT` | **rupees** (`10`, `10.50`) |
| `CRN` | `NPR` |
| `DT` | `MM/DD/YYYY` in **Asia/Kathmandu** |
| `R1` | description, ≤ 160 chars |
| `R2` | remarks, ≤ 50 chars, `N/A` if none |
| `RU` | return URL, ≤ 150 chars |
| `DV` | HMAC (below) |
**Callback params are trusted only with a valid DV.** A missing DV counts as invalid: otherwise anyone could send `?PRN=&PS=false` and force a failure. The adapters throw `invalid_signature` (strict mode) and never derive a status from unsigned flags.
Return params: `PS` = `true`/`false`. `RC` = `successful` / `failed` / `cancel`. `UID` = Fonepay trace id (store it). `BC` = paying bank SWIFT or `N/A`. `INI` = initiator. `P_AMT` = amount paid (can differ due to charges or discounts). `R_AMT` = **amount requested by the merchant** (not a refund amount, despite some community docs).
## 3. Signatures (DV) + test vectors
[Section titled “3. Signatures (DV) + test vectors”](#3-signatures-dv--test-vectors)
`HMAC-SHA512(key = secret UTF-8, message = values joined by ",")` → **hex**. The official Java sample emits uppercase and PHP `hash_hmac` emits lowercase. The dev gateway accepted both in live tests, so compare case-insensitively.
| Use | Field order |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Request DV | `PID,MD,PRN,AMT,CRN,DT,R1,R2,RU` (raw values, **not** URL-encoded) |
| Response DV | `PRN,PID,PS,RC,UID,BC,INI,P_AMT,R_AMT` (raw strings as received, e.g. `20.0`) |
| txnVerification `auth` header | `username,password,POST,application/json,/merchant/merchantDetailsForThirdParty/txnVerification,` |
| QR create `dataValidation` | `amount,prn,merchantCode,remarks1,remarks2` (lowercase hex in samples) |
| QR status `dataValidation` | `prn,merchantCode` |
| Legacy verification DV | `PID,AMT,PRN,BID,UID` |
Verified vectors (secret `a7e3512f5032480a83137793cb2021dc`):
* Response DV of `d1580724437729,NBQM,true,successful,36463,NICENPKA,9841845631,20.0,30` → `667860224021DF1891F7DE873A37B1DEDA720CCDC43F63BC88F86ED20F579E0DE66526D37C71B1D14A8D466E4B740D17D4FF274CD2819FD6ED2AA3D9A89D7C52`
* `auth` for `gnw71090@zwoho.com,Fonepay@12345,POST,application/json,/merchant/merchantDetailsForThirdParty/txnVerification,{"prn": "d1549791827186","merchantCode": "NBQM","amount": "30"}` → `0d3ebe65246182596de0d417e8e39d37ebe2f5da028c52ae0a33a05062a3bded8a737c3ff82b6f1a6d9442d11cfcb0432d86998af81f481948c0af7b346f4fa2`
* QR `14,5d76d323-d1f6,NBQM,test1,test2` → `5ae718032328ae5615fa4694dfbf3ecd13432bd49031e7285a3f8bc122ecbb8e833e83c7a11f895d30f3e8fd1906317aece113675166ae0d804ecf32a8bbdbaf`
The spec’s **request** DV example (`099d85…`) doesn’t reproduce with any field variant. Use the live gateway instead: a bad DV returns `409 {"message":"Data Validation Failed"}`, and a good one returns `303` to the payment page.
```php
strtoupper(hash_hmac('sha512', implode(',', [$pid,'P',$prn,$amt,'NPR',$dt,$r1,$r2,$ru]), $secret));
```
```python
hmac.new(secret.encode(), ",".join(values).encode(), hashlib.sha512).hexdigest().upper()
```
```go
m := hmac.New(sha512.New, []byte(secret)); m.Write([]byte(strings.Join(values, ","))); strings.ToUpper(hex.EncodeToString(m.Sum(nil)))
```
## 4. Verification
[Section titled “4. Verification”](#4-verification)
### txnVerification (recommended; works without a callback, so reconciliation can use it)
[Section titled “txnVerification (recommended; works without a callback, so reconciliation can use it)”](#txnverification-recommended-works-without-a-callback-so-reconciliation-can-use-it)
```plaintext
POST …/txnVerification
Authorization: Basic base64(username:password)
auth:
Content-Type: application/json
{"prn": "d1549791827186","merchantCode": "NBQM","amount": "30"}
```
Response: `{fonepayTraceId, merchantCode, paymentStatus, prn, requestedAmount, totalTransactionAmount, transactionDate, initiator}`. `paymentStatus` is `success` → paid, `pending` → pending, `failed` → failed. Errors: `401 {"errorCode":401,"message":"cannot access this resource"}`, or a validation array.
**The `auth` HMAC covers the exact body bytes.** Build the JSON string once and send that same string. Don’t let your HTTP client re-serialize it (Python’s `json.dumps` adds spaces after commas, a known community bug). The adapters reproduce the spec’s format (`": "` after keys, no space after commas) and match the spec vector byte for byte.
### Legacy GET verification (2019, still answers)
[Section titled “Legacy GET verification (2019, still answers)”](#legacy-get-verification-2019-still-answers)
`GET …/verificationMerchant?PRN&PID&BID(=BC)&AMT&UID&DV` returns XML. Success is `successful` and `true`. A non-200 or unparseable reply throws (the attempt stays open); it never reads as “not paid”. It needs `UID` and `BC` from the redirect, so it can’t reconcile abandoned sessions. The adapters use it only when no username/password is configured.
## 5. Dynamic QR + WebSocket
[Section titled “5. Dynamic QR + WebSocket”](#5-dynamic-qr--websocket)
For POS, kiosks, desktop checkouts and “scan to pay” screens. Needs username/password.
1. `qrCreate({orderId, amountPaisa, remarks1, remarks2})` → POST `thirdPartyDynamicQrDownload` with `{amount, remarks1, remarks2, prn, merchantCode, dataValidation, username, password}` → `{qrMessage, status:"CREATED", statusCode:201, thirdpartyQrWebSocketUrl}`. Render `qrMessage` (an EMV/NepalQR string) with any QR library. Errors return `406 "Data Validation Failed"`.
2. Connect to `thirdpartyQrWebSocketUrl`. Messages carry `transactionStatus` as a JSON **string**: first `{"QRVerified":true}` (scanned), then `{"paymentSuccess":true, traceId, amount}`. The URL has been seen with a `localhost` host; the adapter rewrites it to `ws.fonepay.com`.
3. **Always** confirm with `qrStatus(orderId)` → POST `thirdPartyDynamicQrGetStatus` (`paymentStatus`: `success` / `failed` / `pending`) before fulfilling. Never trust the socket alone.
```ts
const { qrMessage, webSocketUrl } = await fp.qrCreate({ orderId, amountPaisa, remarks1: "Table 4" });
const ws = new WebSocket(webSocketUrl!);
ws.onmessage = async (ev) => {
const msg = JSON.parse(ev.data);
const st = typeof msg.transactionStatus === "string" ? JSON.parse(msg.transactionStatus) : msg.transactionStatus;
if (st?.paymentSuccess) {
const v = await fp.qrStatus(orderId); // authoritative
if (v.status === "paid") await markPaid(orderId); // same conditional update as architecture.md §4
ws.close();
}
};
// Also poll qrStatus every ~5 s as a fallback in case the socket drops.
```
Amount units: the official examples use rupees (`"14"`). One community SDK sends paisa; the adapters follow the official examples.
## 6. Gotchas
[Section titled “6. Gotchas”](#6-gotchas)
* **AMT in DV must be byte-identical to AMT sent.** `10` ≠ `10.00` ≠ `10.0`. The adapters format once with `paisaToRupees`.
* **`DT` uses Nepal time.** Near midnight UTC, Nepal is already on the next day. `fonepayDate()` handles that.
* **PRN length**: max 25 on the web flow (UUIDs don’t fit; use `newOrderId()`), max 50 on txnVerification.
* **Response DV uses raw received strings.** Don’t parse or reformat `P_AMT` before hashing.
* **Never compute DV on a client.** Some unofficial Flutter packages do; that ships your secret.
* **Hosts**: `dev.fonepay.com/api/merchantRequest` and `fonepay.com/api/merchantRequest` (seen in some starters) return 404. Use the `clientapi` hosts above.
* **Refunds**: no public refund API. Use the merchant panel or your bank. `thirdPartyPostTaxRefund` is a tax refund, not a payment refund.
* “Checkout by Fonepay” (launched July 2026) and a newer “third-party v2” API (RSA-signed, Intent QR) exist without public specs. Ask Fonepay if you’re starting fresh.
# Integrate IME Pay
> Integrate IME Pay wallet checkout step by step in TypeScript, Python, Java, Kotlin, PHP or Go: GetToken, checkout redirect and Recheck.
IME Pay merged with Khalti in July 2025, so new builds should use Khalti. For existing IME Pay merchants: get a token, redirect to checkout, and confirm with `Recheck`.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
IME Pay checkout flow
1. Your server calls `GetToken` and stores the `TokenId`.
2. The payer is redirected to IME Pay web checkout and pays.
3. IME POSTs `ResponseCode`, `RefId`, `TransactionId`, `TranAmount` and `Msisdn` to your response URL.
4. Your server calls `Recheck` with the stored token. `ResponseCode` 0 means paid; that response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | ------------------------------------------------ |
| Required | `IMEPAY_MERCHANT_CODE`, `IMEPAY_USERNAME` |
| Optional | `IMEPAY_ENV`, `IMEPAY_PASSWORD`, `IMEPAY_MODULE` |
The full list for every provider is in [Environment variables](/docs/guides/env/).
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
const init = await payments.get("imepay").initiate({
orderId: attempt.id, // RefId
amountPaisa: attempt.amountPaisa,
description: "Order #42",
returnUrl: `${baseUrl}/payments/imepay/return/${attempt.id}`, // RespUrl (and CancelUrl unless failureUrl is set)
});
// TokenId: Recheck needs it
if (init.providerRef) await db.saveProviderRef(attempt.id, init.providerRef);
// init.kind === "redirect": 302 the browser to init.url
```
* IME Pay POSTs the result to RespUrl / CancelUrl: parse urlencoded bodies and exempt the route from CSRF checks.
* Recheck maps every non-zero ResponseCode to pending, never failed; reconciliation escalates at its cutoff.
* The API runs on port 7979; outbound traffic to it must be allowed.
* Python
```python
init = payments["imepay"].initiate(
order_id=attempt.id, # RefId
amount_paisa=attempt.amount_paisa,
description="Order #42",
return_url=f"{base_url}/payments/imepay/return/{attempt.id}", # RespUrl (and CancelUrl unless failure_url is set)
)
# TokenId: Recheck needs it
db.save_provider_ref(attempt.id, init.provider_ref)
# init.kind == "redirect": 302 the browser to init.url
```
* IME Pay POSTs the result to RespUrl / CancelUrl: read the form body and exempt the route from CSRF checks.
* Recheck maps every non-zero ResponseCode to pending, never failed; reconciliation escalates at its cutoff.
* The API runs on port 7979; outbound traffic to it must be allowed.
* Go
```go
res, err := p.IMEPay.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID, // RefId
AmountPaisa: attempt.AmountPaisa,
Description: "Order #42",
ReturnURL: baseURL + "/payments/imepay/return/" + attempt.ID, // RespUrl and CancelUrl
})
if err != nil {
return err
}
// TokenId: Recheck needs it
if err := db.SaveProviderRef(ctx, attempt.ID, res.ProviderRef); err != nil {
return err
}
// redirect: http.Redirect(w, r, res.URL, http.StatusSeeOther)
```
* IME Pay merged into Khalti in July 2025; prefer Khalti for new builds.
* IME POSTs to the return URL, and outbound port 7979 must be reachable.
* Non-zero Recheck codes map to pending, never failed; reconciliation escalates them at its cutoff.
* Java
```java
String returnUrl = baseUrl + "/payments/imepay/return/" + attempt.id();
InitiateResult init = payments.imepay().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl));
// providerRef is the IME Pay TokenId; Recheck needs it.
db.saveProviderRef(attempt.id(), init.providerRef());
InitiateResult.Redirect redirect = (InitiateResult.Redirect) init;
// Send the payer to redirect.url() with a 302.
```
* The return is a cross-site POST: exempt the route from CSRF.
* Outbound port 7979 must be reachable from the server.
* IME Pay merged into Khalti in July 2025; use this for existing IME Pay merchant accounts.
* Kotlin
```kotlin
val init = payments.imepay.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/imepay/return/${attempt.id}",
),
)
// providerRef is the IME Pay TokenId; Recheck needs it.
init.providerRef?.let { db.saveProviderRef(attempt.id, it) }
// Redirect the payer to (init as InitiateResult.Redirect).url.
```
* The return is a cross-site POST: keep Ktor’s CSRF plugin off that route.
* Outbound port 7979 must be reachable from the server.
* IME Pay merged into Khalti in July 2025; use this for existing IME Pay merchant accounts.
* PHP
```php
$init = $payments->imePay()->initiate(
new InitiateInput(
orderId: $attempt->id, // RefId
amountPaisa: $attempt->amountPaisa,
description: 'Order #42',
returnUrl: "{$baseUrl}/payments/imepay/return/{$attempt->id}", // RespUrl and CancelUrl
),
);
// TokenId: Recheck needs it
$db->saveProviderRef($attempt->id, $init->providerRef);
// RedirectResult: header("Location: {$init->url}", true, 303);
```
* IME Pay merged into Khalti in July 2025; prefer Khalti for new builds.
* IME POSTs to the return URL (exempt it from CSRF), and outbound port 7979 must be reachable.
* Non-zero Recheck codes map to pending, never failed; reconciliation escalates them at its cutoff.
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
A form POST with `RefId` (your attempt id). IME Pay callbacks are unsigned, so only `Recheck` decides.
* TypeScript
```ts
const result = await payments.get("imepay").verify({
orderId: attempt.id,
amountPaisa: attempt.amountPaisa,
providerRef: attempt.providerRef, // the stored TokenId; the POSTed callback is unsigned
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
result = payments["imepay"].verify(
order_id=attempt.id,
amount_paisa=attempt.amount_paisa,
provider_ref=attempt.provider_ref, # the stored TokenId; the POSTed callback is unsigned
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
// callbacks are unsigned: Recheck with the stored TokenId is the only proof
v, err := p.IMEPay.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
ProviderRef: attempt.ProviderRef,
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// IME Pay POSTs an UNSIGNED result, so Recheck with the stored TokenId is the only proof.
VerifyResult result = payments.imepay().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa()).withProviderRef(attempt.providerRef()));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// IME Pay POSTs an UNSIGNED result, so Recheck with the stored TokenId is the only proof.
val result = payments.imepay.verify(
VerifyInput(orderId = attempt.id, amountPaisa = attempt.amountPaisa, providerRef = attempt.providerRef),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// callbacks are unsigned: Recheck with the stored TokenId is the only proof
$result = $payments->imePay()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
providerRef: $attempt->providerRef,
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Credentials come from the IME or Khalti-by-IME merchant team.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* Outbound port 7979 must be open, and IME may need to whitelist your server IP.
* Non-zero codes are documented inconsistently, so the adapter treats them as pending and lets reconciliation expire the attempt.
* Don’t copy the WooCommerce plugin’s `validate_ipn()`: it fetches Recheck and then ignores the result.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Get your server IP whitelisted for port 7979.
* Confirm with the merchant team that web checkout is still offered after the merger.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[IME Pay reference](/docs/providers/imepay/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# IME Pay reference
> Full IME Pay reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/imepay.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/imepay.md). This is the same file the agent reads.
**Status**: IME Pay merged with Khalti in July 2025 (“Khalti by IME”); its dev docs now live at `github.com/khalti/imepay-dev-docs`. For new builds use **Khalti** (it reaches the former IME Pay user base). Use this for existing IME Pay merchant accounts, after confirming with the Khalti-by-IME merchant team that web checkout is still open to you.
Source: IME Pay’s **official** WooCommerce and WHMCS plugins (no public web-API spec exists). Items from community code only are marked UNVERIFIED. Adapters: [`scripts/ts/src/imepay.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/imepay.ts), [`scripts/python/nepal_payments/imepay.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/imepay.py).
## Environments and credentials
[Section titled “Environments and credentials”](#environments-and-credentials)
| | Base |
| ---- | ------------------------------------ |
| Test | `https://stg.imepay.com.np:7979` |
| Live | `https://payment.imepay.com.np:7979` |
Port **7979** must be reachable outbound from your server; IME support may need to whitelist your server IP. Credentials: `MerchantCode`, API username, API password, `Module` name. Env vars: `IMEPAY_ENV`, `IMEPAY_MERCHANT_CODE`, `IMEPAY_USERNAME`, `IMEPAY_PASSWORD`, `IMEPAY_MODULE`.
Headers on API calls: `Authorization: Basic base64(user:pass)`, `Module: base64(ModuleName)`, `Content-Type: application/json`. IME Pay uses **no HMAC** anywhere, and its **callbacks are unsigned**.
## Flow
[Section titled “Flow”](#flow)
1. `POST /api/Web/GetToken` `{"MerchantCode","Amount":"100.00","RefId"}` → `{TokenId, ResponseCode}` (0 = OK). Persist `TokenId` as `provider_ref`.
2. Redirect to `GET /WebCheckout/Checkout?data=base64(TokenId|MerchantCode|RefId|TranAmount|Method|RespUrl|CancelUrl)` with `Method=POST` (WooCommerce plugin). The WHMCS plugin instead form-POSTs the fields `TokenId, MerchantCode, RefId, TranAmount, RespUrl, CancelUrl` to `/WebCheckout/Checkout`.
3. IME POSTs form fields `ResponseCode, RefId, TransactionId, TranAmount, Msisdn` to `RespUrl` (or `CancelUrl`). `TokenId` is not posted; the official plugin carries it in its own RespUrl query string, which works because query strings are allowed here.
4. `POST /api/Web/Recheck` `{MerchantCode, RefId, TokenId}` → `ResponseCode` **0 = paid**.
Codes other than 0 are inconsistently documented (community code reads 1 as “failed” in one library and “pending” in another, 3 as “cancelled”). The adapters therefore treat non-zero as `pending` and let the reconciliation job expire the attempt, so a payment that later succeeds is never marked failed. `POST /api/Web/Confirm` `{MerchantCode, RefId, TokenId, TransactionId, Msisdn}` appears only in community code (UNVERIFIED).
**Don’t copy the official WooCommerce plugin’s `validate_ipn()`**: it fetches Recheck and then ignores the result. Always recheck and compare `RefId` and amount yourself.
## Mobile SDKs
[Section titled “Mobile SDKs”](#mobile-sdks)
* Android `com.swifttechnology.imepaysdk:payment-service:4.0.1`, iOS `github.com/imepay/IMEPay`, Flutter `imepay_merchant_sdk`. **Security caveat:** the SDK takes the API user/password inside the app, and the merchant must host “Recording” and “Delivery” APIs specified by IME. Prefer the web checkout in a WebView ([`mobile.md`](/docs/guides/mobile/) §4) so credentials stay on your server.
# Integrate Khalti
> Integrate Khalti KPG-2 web checkout step by step in TypeScript, Python, Java, Kotlin, PHP or Go: initiate, return URL and lookup by pidx.
Khalti KPG-2 returns a payment URL and a `pidx`. Redirect the payer there, store the `pidx`, and confirm with the lookup API when the payer comes back.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
Khalti KPG-2 payment flow
1. Your server calls initiate with the amount in integer paisa and gets `payment_url` plus `pidx`.
2. Store `pidx` on the attempt, then redirect the payer to `payment_url`.
3. Khalti redirects to your `return_url` with `pidx`, `status`, `purchase_order_id` and more.
4. Your server looks the payment up by the `pidx` you stored. That response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | ----------------------------------------- |
| Required | `KHALTI_SECRET_KEY`, `KHALTI_WEBSITE_URL` |
| Optional | `KHALTI_ENV`, `KHALTI_PUBLIC_KEY` |
For the sandbox:
```bash
KHALTI_ENV=sandbox
KHALTI_SECRET_KEY=... # from test-admin.khalti.com
KHALTI_WEBSITE_URL=https://shop.example.com
```
The full list for every provider is in [Environment variables](/docs/guides/env/).
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
const init = await payments.get("khalti").initiate({
orderId: attempt.id, // purchase_order_id
amountPaisa: attempt.amountPaisa, // minimum 1000 (Rs 10)
description: "Order #42", // purchase_order_name
returnUrl: `${baseUrl}/payments/khalti/return/${attempt.id}`,
});
// pidx: lookup needs it and Khalti has no lookup by order id
if (init.providerRef) await db.saveProviderRef(attempt.id, init.providerRef);
// init.kind === "redirect": 302 the browser to init.url (the link expires at init.expiresAt)
```
* Optional extras: `customer` ({ name, email, phone }), `amountBreakdown` (must sum to `amountPaisa`), `productDetails`, and `merchantExtras` with `merchant_`-prefixed keys.
* Expired and canceled payments come back from lookup as HTTP 400 with a normal body; `verify()` maps them instead of throwing.
* Python
```python
init = payments["khalti"].initiate(
order_id=attempt.id, # purchase_order_id
amount_paisa=attempt.amount_paisa, # minimum 1000 (Rs 10)
description="Order #42", # purchase_order_name
return_url=f"{base_url}/payments/khalti/return/{attempt.id}",
)
# pidx: lookup needs it and Khalti has no lookup by order id
db.save_provider_ref(attempt.id, init.provider_ref)
# init.kind == "redirect": 302 the browser to init.url (the link expires at init.expires_at)
```
* Optional extras: `customer` ({“name”, “email”, “phone”}), `amount_breakdown` (must sum to `amount_paisa`), `product_details`, and `merchant_extras` with `merchant_`-prefixed keys.
* Expired and canceled payments come back from lookup as HTTP 400 with a normal body; `verify()` maps them instead of raising.
* Go
```go
res, err := p.Khalti.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa, // minimum 1000 (Rs 10)
Description: "Order #42",
ReturnURL: baseURL + "/payments/khalti/return/" + attempt.ID,
})
if err != nil {
return err
}
// pidx: Verify needs it and Khalti has no lookup by order id
if err := db.SaveProviderRef(ctx, attempt.ID, res.ProviderRef); err != nil {
return err
}
// redirect: http.Redirect(w, r, res.URL, http.StatusSeeOther)
```
* Optional `Khalti: &payments.KhaltiInitiateOptions{...}`: `AmountBreakdown` (must sum to the amount), `ProductDetails`, `MerchantExtras` (keys start with `merchant_`).
* Set `Customer: &payments.Customer{Name: ..., Email: ..., Phone: ...}` on `InitiateInput` to prefill `customer_info`.
* Java
```java
String returnUrl = baseUrl + "/payments/khalti/return/" + attempt.id();
InitiateResult init = payments.khalti().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl));
// providerRef is the pidx. Lookup needs it and Khalti has no lookup by order id.
db.saveProviderRef(attempt.id(), init.providerRef());
InitiateResult.Redirect redirect = (InitiateResult.Redirect) init;
// Send the payer to redirect.url() with a 302.
```
* Optional payer details: `.withCustomer(new Customer(name, email, phone))` on the `InitiateInput`.
* `new Khalti.Extras(amountBreakdown, productDetails, merchantExtras)` as the second `initiate` argument; the breakdown must sum to `amountPaisa` and merchant keys must start with `merchant_`.
* Kotlin
```kotlin
val init = payments.khalti.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/khalti/return/${attempt.id}",
),
)
// providerRef is the pidx. Lookup needs it and Khalti has no lookup by order id.
init.providerRef?.let { db.saveProviderRef(attempt.id, it) }
// Redirect the payer to (init as InitiateResult.Redirect).url.
```
* Optional payer details: `customer = Customer(name, email, phone)` on the `InitiateInput`.
* `KhaltiInitiateExtras(amountBreakdown, productDetails, merchantExtras)` as the second `initiate` argument; the breakdown must sum to `amountPaisa` and merchant keys must start with `merchant_`.
* PHP
```php
$init = $payments->khalti()->initiate(
new InitiateInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa, // minimum 1000 (Rs 10)
description: 'Order #42',
returnUrl: "{$baseUrl}/payments/khalti/return/{$attempt->id}",
),
);
// pidx: verify() needs it and Khalti has no lookup by order id
$db->saveProviderRef($attempt->id, $init->providerRef);
// RedirectResult: header("Location: {$init->url}", true, 303);
```
* Optional `initiate()` args: `amountBreakdown:` (must sum to the amount), `productDetails:`, and `merchantExtras:` (keys start with `merchant_`).
* Pass `customer: new Customer($name, $email, $phone)` to `InitiateInput` to prefill `customer_info`.
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
A GET with `pidx`, `status`, `transaction_id`, `amount` and `purchase_order_id`. The adapter refuses a callback whose `pidx` differs from the one you stored, which blocks replayed callbacks.
* TypeScript
```ts
const result = await payments.get("khalti").verify({
orderId: attempt.id,
amountPaisa: attempt.amountPaisa,
providerRef: attempt.providerRef, // the pidx saved at initiate, never the one in params
callback: params, // a params.pidx that differs from the stored one is rejected
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
result = payments["khalti"].verify(
order_id=attempt.id,
amount_paisa=attempt.amount_paisa,
provider_ref=attempt.provider_ref, # the pidx saved at initiate, never the one in params
callback=params, # a params["pidx"] that differs from the stored one is rejected
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
v, err := p.Khalti.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
ProviderRef: attempt.ProviderRef, // the stored pidx, never the one in params
Callback: params, // a different callback pidx is rejected
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// Lookup uses the pidx from MY database; a different pidx in params is rejected.
VerifyResult result = payments.khalti().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa())
.withProviderRef(attempt.providerRef())
.withCallback(params));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// Lookup uses the pidx from MY database; a different pidx in params is rejected.
val result = payments.khalti.verify(
VerifyInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
providerRef = attempt.providerRef,
callback = params,
),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// throws on network/5xx: leave the attempt open, reconciliation settles it
$result = $payments->khalti()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
providerRef: $attempt->providerRef, // the stored pidx, never the one in $params
callback: $params, // a different callback pidx throws pidx_mismatch
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Merchant: sign up at test-admin.khalti.com (signup OTP `987654`) and copy the secret key.
* Payer: Khalti ID `9800000000` to `9800000005`, MPIN `1111`, OTP `987654`.
* The sandbox has the wallet only, no e-banking or cards.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* The amount is integer paisa with a minimum of `1000` (Rs 10).
* Payment links expire (60 minutes in production), after which lookup reports the attempt as expired.
* An expired payment comes back as HTTP 400 with a normal body. The adapter maps it instead of throwing.
* `KHALTI_PUBLIC_KEY` is only for mobile SDK builds. The server never needs it.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Complete KYC and the contract, otherwise live payments are capped at NPR 200.
* Use live keys from admin.khalti.com and set `KHALTI_ENV=production`.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[Khalti reference](/docs/providers/khalti/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# Khalti reference
> Full Khalti reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/khalti.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/khalti.md). This is the same file the agent reads.
Official docs: · Verified 2026-11 against the docs, SDK sources and live sandbox calls. Adapters: [`scripts/ts/src/khalti.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/khalti.ts), [`scripts/python/nepal_payments/khalti.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/khalti.py).
## 1. Environments and credentials
[Section titled “1. Environments and credentials”](#1-environments-and-credentials)
| | Sandbox | Production |
| ------------------ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| API base | `https://dev.khalti.com/api/v2/` | `https://khalti.com/api/v2/` |
| Initiate | `POST /epayment/initiate/` | same |
| Lookup | `POST /epayment/lookup/` | same |
| Refund | `POST https://dev.khalti.com/api/merchant-transaction/{transaction_id}/refund/` (not under `/v2/`) | `https://khalti.com/api/merchant-transaction/{transaction_id}/refund/` |
| Payment page | `https://test-pay.khalti.com/?pidx=…` | `https://pay.khalti.com/?pidx=…` |
| Merchant dashboard | (login OTP `987654`) | |
`a.khalti.com` (used in pre-2025 tutorials) still answers but the docs moved to `dev.khalti.com`. Use `dev.khalti.com`.
* **Server** uses the *live secret key* from the dashboard. Sandbox keys are also labelled “live”; `test_…` keys are from the dead v1 API.
* **Mobile SDKs** use the *live public key* (only for opening checkout; verification still happens on your server).
* Header: `Authorization: Key `. `Bearer` or a bare key → `401 "Authentication credentials were not provided."`
* Env vars: `KHALTI_SECRET_KEY`, `KHALTI_ENV=sandbox|production`, `KHALTI_PUBLIC_KEY` (mobile only).
**Sandbox test wallets:** Khalti ID `9800000000` … `9800000005`, MPIN `1111`, OTP `987654`. E-banking and cards are not available in sandbox.
## 2. Flow
[Section titled “2. Flow”](#2-flow)
1. Server creates the attempt with a unique `purchase_order_id` and calls **initiate** (amount in **paisa**). Response has `pidx` and `payment_url`. **Store `pidx` on the attempt immediately**: lookup only accepts `pidx`, there is no lookup by order id.
2. Web: redirect to `payment_url`. Mobile: pass `pidx` + `payment_url` + public key to the SDK, or open `payment_url` in a WebView.
3. Khalti redirects the payer to `return_url` (GET) with query params.
4. Server calls **lookup** with the **stored** `pidx`. Fulfil only when `status == "Completed"` **and** `total_amount` equals your stored amount.
## 3. Initiate
[Section titled “3. Initiate”](#3-initiate)
`POST {base}/epayment/initiate/` with JSON:
| Field | Req | Notes |
| --------------------- | --- | ------------------------------------------------------------------------------------ |
| `return_url` | ✓ | Must accept GET. |
| `website_url` | ✓ | Your site URL. |
| `amount` | ✓ | **Integer paisa.** Minimum `1000` (Rs 10). Send a number, not a string. |
| `purchase_order_id` | ✓ | Your unique attempt id. |
| `purchase_order_name` | ✓ | Product / order name. |
| `customer_info` | – | `{ "name", "email", "phone" }` |
| `amount_breakdown` | – | `[{ "label", "amount" }]`, must sum to `amount` (else “Amount Breakdown mismatch.”). |
| `product_details` | – | `[{ "identity", "name", "total_price", "quantity", "unit_price" }]` |
| `merchant_*` | – | Any key prefixed `merchant_` is echoed back in the success payload. |
Response `200`:
```json
{ "pidx": "bZQLD9wRVWo4CdESSfuSsB", "payment_url": "https://test-pay.khalti.com/?pidx=bZQLD9wRVWo4CdESSfuSsB",
"expires_at": "2023-05-25T16:26:16.471649+05:45", "expires_in": 1800 }
```
Links expire (60 min by default in production; the sandbox sample shows 1800 s).
## 4. Return URL callback
[Section titled “4. Return URL callback”](#4-return-url-callback)
GET `return_url?pidx=…&status=…&transaction_id=…&tidx=…&amount=…&total_amount=…&mobile=…&purchase_order_id=…&purchase_order_name=…`
* `status`: `Completed` | `Pending` | `User canceled` (URL-encoded space).
* `tidx` = `transaction_id`; `total_amount` = `amount` (paisa); `mobile` is masked (`98XXXXX904`).
* On cancel, `transaction_id`, `tidx`, `mobile` are empty.
* Any `merchant_*` extras come back too.
**These params are informational.** Load the attempt by `purchase_order_id`, then call lookup with the `pidx` from *your* database (reject if the callback’s `pidx` differs).
## 5. Lookup (verification)
[Section titled “5. Lookup (verification)”](#5-lookup-verification)
`POST {base}/epayment/lookup/` body `{ "pidx": "…" }` →
```json
{ "pidx": "…", "total_amount": 1000, "status": "Completed", "transaction_id": "GFq9PFS7b2iYvL8Lir9oXe", "fee": 0, "refunded": false }
```
| `status` | HTTP | Normalized | Action |
| ------------------------------ | ------- | -------------------- | ------------------------------------------------------------------ |
| `Completed` | 200 | `paid` | Fulfil (after amount check). |
| `Pending` | 200 | `pending` | Don’t fulfil; re-check later; contact Khalti if stuck. |
| `Initiated` | 200 | `pending` | Payer hasn’t paid yet. |
| `Refunded` | 200 | `refunded` | Don’t fulfil. |
| `Partially Refunded` | 200 | `partially_refunded` | Compare case-insensitively (docs also write “Partially refunded”). |
| `Expired` | **400** | `expired` | Failed; new attempt needed. |
| `User canceled` | **400** | `canceled` | Failed; new attempt needed. |
| body `{"detail":"Not found."}` | 404 | `not_found` | Wrong pidx or wrong environment/key. |
Expired and canceled come back as **HTTP 400 with a normal body**, so parse the body before treating non-2xx as an error. The adapters do this.
## 6. Refund
[Section titled “6. Refund”](#6-refund)
`POST https://{dev.}khalti.com/api/merchant-transaction/{transaction_id}/refund/` with `Authorization: Key …`. `transaction_id` comes from lookup.
| Case | Body |
| --------------- | -------------------------------------- |
| Wallet, full | `{}` |
| Wallet, partial | `{ "amount": }` |
| Bank, full | `{ "mobile": "98XXXXXXXX" }` |
| Bank, partial | `{ "mobile": "…", "amount": }` |
Success: `{ "detail": "Transaction refund successful." }`. The docs say partial `amount` is paisa but their example shows `50.00`, so test a partial refund in sandbox before relying on it. Refunds can also be done from the dashboard. Khalti has **no webhooks** and no API to cancel a `pidx`.
## 7. Errors
[Section titled “7. Errors”](#7-errors)
| Case | Shape |
| ---------------------- | --------------------------------------------------------------------------------------------------------------- |
| Validation | `{ "amount": ["Amount should be greater than Rs. 10, that is 1000 paisa."], "error_key": "validation_error" }` |
| Wrong key | `401 { "detail": "Invalid token.", "status_code": 401 }` |
| Missing / wrong prefix | `401 { "detail": "Authentication credentials were not provided.", "status_code": 401 }` |
| Unknown pidx | `{ "detail": "Not found.", "error_key": "validation_error" }` |
| Pre-KYC merchant | `"Amount must be less than 200."`: new merchants are capped at NPR 200/txn until KYC and the contract are done. |
| Sandbox fee | `"Fee not found."`: use an amount between Rs 10 and Rs 200 in sandbox. |
## 8. Mobile SDKs
[Section titled “8. Mobile SDKs”](#8-mobile-sdks)
Every SDK flow is: **your backend initiates** (secret key) → the app receives `pidx` + `payment_url` → the SDK opens checkout → on `onPaymentResult` the app **calls your backend**, which runs lookup and returns the real status. The SDKs’ own status check uses the public key and is only for UX.
### Flutter: `khalti_checkout_flutter` (Android + iOS only)
[Section titled “Flutter: khalti_checkout_flutter (Android + iOS only)”](#flutter-khalti_checkout_flutter-android--ios-only)
```yaml
dependencies:
khalti_checkout_flutter: ^1.0.1 # Dart ^3.4, Flutter >= 3.22
```
```dart
import 'package:khalti_checkout_flutter/khalti_checkout_flutter.dart';
Future payWithKhalti(BuildContext context, String orderId) async {
// 1. Backend initiates and returns {pidx, payment_url}
final init = await api.post('/payments', {'orderId': orderId, 'provider': 'khalti'});
final khalti = await Khalti.init(
enableDebugging: kDebugMode,
payConfig: KhaltiPayConfig(
publicKey: const String.fromEnvironment('KHALTI_PUBLIC_KEY'),
pidx: init['providerRef'],
paymentUrl: init['url'],
environment: kReleaseMode ? Environment.prod : Environment.test,
),
onPaymentResult: (paymentResult, khalti) async {
khalti.close(context);
// 2. Server-side lookup decides; never fulfil from paymentResult alone.
final verified = await api.post('/payments/$orderId/verify', {});
showResult(verified['status']); // 'paid' | 'pending' | ...
},
onMessage: (khalti, {description, statusCode, event, needsPaymentConfirmation}) async {
if (needsPaymentConfirmation == true) {
await api.post('/payments/$orderId/verify', {}); // network blip mid-payment: ask server
}
khalti.close(context);
},
onReturn: () {}, // return_url loaded
);
khalti.open(context);
}
```
`KhaltiEvent`: `kpgDisposed, returnUrlLoadFailure, networkFailure, paymentLookupfailure, unknown`. `2.0.0-beta.1` (Oct 2026, prerelease) is breaking: `pidx` is removed from the config (read from `paymentUrl`), `open()` returns a Future and tries the Khalti app first, there are new events `cancelled`, `paymentExpired`, `kpgLoadFailure`, and Android minSdk is 24. Pin `^1.0.1` until 2.0 is stable.
### Android: `com.khalti:checkout-android`
[Section titled “Android: com.khalti:checkout-android”](#android-comkhalticheckout-android)
```groovy
implementation "com.khalti:checkout-android:0.08.00"
// android { compileOptions { sourceCompatibility JavaVersion.VERSION_1_8; targetCompatibility JavaVersion.VERSION_1_8 } }
```
```kotlin
val config = KhaltiPayConfig(
publicKey = BuildConfig.KHALTI_PUBLIC_KEY,
pidx = init.providerRef,
paymentUrl = init.url, // required in 0.08.00 (docs samples omit it and won't compile)
environment = if (BuildConfig.DEBUG) Environment.TEST else Environment.PROD, // enum is TEST/PROD
)
val khalti = Khalti.init(
this, config,
onPaymentResult = { result, k -> k.close(); viewModel.verifyOnServer(orderId) },
onMessage = { payload, k ->
if (payload.needsPaymentConfirmation) viewModel.verifyOnServer(orderId)
k.close()
},
onReturn = { _ -> },
)
khalti.open()
```
### iOS: `KhaltiCheckout` (iOS 12+)
[Section titled “iOS: KhaltiCheckout (iOS 12+)”](#ios-khalticheckout-ios-12)
SPM (preferred, latest 1.1.1): `.package(url: "https://github.com/khalti/checkout-sdk-ios.git", from: "1.1.1")`, product `KhaltiCheckout`. CocoaPods `pod 'KhaltiCheckout'` only has 1.0.0.
```swift
import KhaltiCheckout
let config = KhaltiPayConfig(publicKey: khaltiPublicKey, pIdx: initResp.providerRef, // note capital I
paymentUrl: initResp.url, environment: isDebug ? .TEST : .PROD)
let khalti = Khalti(config: config,
onPaymentResult: { result, khalti in khalti?.close(); verifyOnServer(orderId) },
onMessage: { payload, khalti in
if payload.needsPaymentConfirmation == true { verifyOnServer(orderId) }
khalti?.close()
},
onReturn: { _ in })
khalti.open(viewController: self)
```
### React Native / Expo / Ionic / Capacitor
[Section titled “React Native / Expo / Ionic / Capacitor”](#react-native--expo--ionic--capacitor)
No official SDK (the `khalti-sdk-react-native` repo is empty; third-party npm packages are unofficial). Use the generic pattern in [`mobile.md`](/docs/guides/mobile/): backend initiates → open `payment_url` in `react-native-webview` or an in-app browser → intercept navigation to your `return_url` → call your backend’s verify endpoint.
### Web
[Section titled “Web”](#web)
No KPG-2 JS SDK exists or is needed: initiate on the server, then `window.location = payment_url` (or a server 302). Khalti sends no CORS headers, so calling its API from the browser fails anyway.
## 9. Deprecated APIs (don’t follow old tutorials)
[Section titled “9. Deprecated APIs (don’t follow old tutorials)”](#9-deprecated-apis-dont-follow-old-tutorials)
All of these are dead or archived. If you see them in existing code, migrate to KPG-2:
* Web widget `khalti-checkout-web` (npm) with `new KhaltiCheckout({ publicKey, productIdentity, eventHandler })`, followed by `POST /api/v2/payment/verify/ { token, amount }`
* Wallet API `/api/v2/payment/initiate/` and `/confirm/`, `/api/v2/payment/status/`
* SDKs `com.khalti:khalti-android`, `pod 'Khalti'`, pub `khalti` / `khalti_flutter` (KPG-v1, archived “outdated and no longer supported”)
* Sandbox host `a.khalti.com` (works, but superseded by `dev.khalti.com`)
## 10. Gotchas
[Section titled “10. Gotchas”](#10-gotchas)
* **Trailing slash is mandatory.** `/epayment/initiate` → 301 → most HTTP clients re-send as GET without the body → confusing validation errors.
* **Amount is integer paisa.** Rs 100 = `10000`.
* **Lookup returns 400 for Expired / User canceled.** Parse the body.
* **Persist `pidx` at initiate.** There is no lookup by `purchase_order_id`. Verify with the stored pidx, never the callback’s.
* **Server only.** No CORS headers; the secret key must not ship in apps.
* **NPR 200 cap before KYC.** Production initiates above Rs 200 fail until Khalti finishes merchant KYC.
* **Sandbox may stick at `Pending`.** Khalti’s maintainers advise generating a new `pidx`.
* **`return_url` must match** between initiate and SDK usage.
* No idempotency key exists, so don’t retry `initiate` blindly on timeout. Create a new attempt instead.
# Integrate NPS OnePG
> Integrate NPS OnePG step by step in TypeScript, Python, Java, Kotlin, PHP or Go to reach about 29 banks, wallets and cards in one API.
NPS OnePG (NPX) reaches about 29 banks, wallets and card checkouts through one integration. Get a `ProcessId`, post a multipart form to the gateway, and confirm with `CheckTransactionStatus`.
## How the payment flows
[Section titled “How the payment flows”](#how-the-payment-flows)
NPS OnePG payment flow
1. Your server calls `GetProcessId` (HMAC-SHA512, Basic auth) and stores the `ProcessId`.
2. The browser auto-posts a multipart form to the NPS gateway, and the payer picks a bank or wallet.
3. NPS calls your Notification URL server to server, and the browser returns to your `ResponseUrl`, both with `MerchantTxnId`.
4. Your server calls `CheckTransactionStatus`. That response decides.
## 1. Set the environment variables
[Section titled “1. Set the environment variables”](#1-set-the-environment-variables)
| | Variables |
| -------- | ---------------------------------------------------------------------- |
| Required | `NPS_MERCHANT_ID`, `NPS_SECRET_KEY` |
| Optional | `NPS_ENV`, `NPS_MERCHANT_NAME`, `NPS_API_USERNAME`, `NPS_API_PASSWORD` |
The full list for every provider is in [Environment variables](/docs/guides/env/).
## 2. Create the payments client
[Section titled “2. Create the payments client”](#2-create-the-payments-client)
Build it once at startup from the environment. Every provider whose required variables are set is enabled.
* TypeScript
```ts
import { createPayments, decideTransition, paymentsConfigFromEnv, renderAutoSubmitForm } from "./payments/index.ts";
// Enables every provider whose env vars are set (references/env.md). Build it once per process.
export const payments = createPayments(paymentsConfigFromEnv());
// Workers / edge: pass the env bindings instead of process.env.
// const payments = createPayments(paymentsConfigFromEnv(env));
```
* Python
```python
import time
from nepal_payments import config_from_env, create_payments, decide_transition, render_auto_submit_form
# Enables every provider whose env vars are set (references/env.md). Build it once per process.
payments = create_payments(config_from_env())
# The adapters are synchronous (stdlib urllib). From async views (FastAPI, Starlette),
# call them through starlette.concurrency.run_in_threadpool.
```
* Go
```go
import (
"log"
"time"
"github.com/broisnees/nepal-payments-skill/skills/nepal-payments/scripts/go/payments"
)
// Standard library only, Go 1.22+. Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md);
// a provider is enabled when its required vars are set, otherwise its field on p is nil.
p, err := payments.New(payments.ConfigFromEnv())
if err != nil {
log.Fatal(err)
}
// Every call takes a context first; in a handler use ctx := r.Context().
```
* Java
```java
import np.payments.*;
import java.time.Duration;
import java.time.Instant;
// Build once at startup (a Spring @Bean or a static field). Providers whose required
// env vars are missing stay disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here.
Payments payments = Payments.create(Payments.configFromEnv());
```
* Kotlin
```kotlin
import np.payments.*
import java.time.Instant
import kotlin.time.toKotlinDuration
// Build once at startup. Providers whose required env vars are missing stay
// disabled; a bad CONNECTIPS_PRIVATE_KEY_PEM fails here, at boot.
val payments = Payments.create(paymentsConfigFromEnv())
```
* PHP
```php
use NepalPayments\{AttemptStatus, InitiateInput, Payments, StateMachine, VerifyInput};
use NepalPayments\CyberSource\BillTo;
// Autoload scripts/php/src as the NepalPayments\ namespace (PSR-4). PHP 8.1+, no Composer dependencies.
// Reads ESEWA_*, KHALTI_*, FONEPAY_*, ... (env.md). A provider is enabled when its required vars are set.
$payments = Payments::create(Payments::configFromEnv());
```
## 3. Start a payment
[Section titled “3. Start a payment”](#3-start-a-payment)
Create a payment attempt in your database first (its id is the order id the provider sees), then:
* TypeScript
```ts
const init = await payments.get("nps").initiate({
orderId: attempt.id, // MerchantTxnId
amountPaisa: attempt.amountPaisa,
description: "Order #42", // TransactionRemarks, 100 chars max
returnUrl: `${baseUrl}/payments/nps/return/${attempt.id}`, // ResponseUrl
});
// ProcessId from GetProcessId
if (init.providerRef) await db.saveProviderRef(attempt.id, init.providerRef);
// init.kind === "form" (multipart): serve renderAutoSubmitForm(init) from /payments/:id/start
```
* Optional `instrumentCode` (from `getInstruments()`) skips the NPS bank picker.
* NPS also GETs your Notification URL (set at onboarding). Run the same verify there and answer with `notificationResponse(alreadyProcessed)`.
* Python
```python
init = payments["nps"].initiate(
order_id=attempt.id, # MerchantTxnId
amount_paisa=attempt.amount_paisa,
description="Order #42", # TransactionRemarks, 100 chars max
return_url=f"{base_url}/payments/nps/return/{attempt.id}", # ResponseUrl
)
# ProcessId from GetProcessId
db.save_provider_ref(attempt.id, init.provider_ref)
# init.kind == "form" (multipart): serve render_auto_submit_form(init) from /payments/{id}/start
```
* Optional `instrument_code` (from `get_instruments()`) skips the NPS bank picker.
* NPS also GETs your Notification URL (set at onboarding). Run the same verify there and answer with `notification_response(already_processed)`.
* Go
```go
res, err := p.NPS.Initiate(ctx, payments.InitiateInput{
OrderID: attempt.ID, // MerchantTxnId
AmountPaisa: attempt.AmountPaisa,
Description: "Order #42", // TransactionRemarks
ReturnURL: baseURL + "/payments/nps/return/" + attempt.ID, // ResponseUrl
})
if err != nil {
return err
}
// ProcessId from GetProcessId
if err := db.SaveProviderRef(ctx, attempt.ID, res.ProviderRef); err != nil {
return err
}
// form result: serve payments.RenderAutoSubmitForm(res) as text/html (multipart POST)
```
* The Notification URL is set with NPS at onboarding. Run the same verify there and reply with `p.NPS.NotificationResponse(alreadyProcessed)` (plain text `received` / `already received`).
* `NPS: &payments.NPSInitiateOptions{InstrumentCode: ...}` skips the NPS bank/wallet picker.
* Java
```java
String returnUrl = baseUrl + "/payments/nps/return/" + attempt.id();
InitiateResult init = payments.nps().initiate(
InitiateInput.of(attempt.id(), attempt.amountPaisa(), "Order #42", returnUrl));
// providerRef is the NPS ProcessId.
db.saveProviderRef(attempt.id(), init.providerRef());
InitiateResult.Form form = (InitiateResult.Form) init;
// Serve Html.renderAutoSubmitForm(form) as text/html; it sets enctype="multipart/form-data".
```
* Pass an InstrumentCode as the second `initiate` argument to skip the NPS bank picker; `payments.nps().getInstruments()` lists them.
* The Notification URL is set with NPS at onboarding. Answer it with `payments.nps().notificationResponse(alreadyProcessed)` only after a final status; while pending, return a non-2xx so NPS retries.
* Kotlin
```kotlin
val init = payments.nps.initiate(
InitiateInput(
orderId = attempt.id,
amountPaisa = attempt.amountPaisa,
description = "Order #42",
returnUrl = "$baseUrl/payments/nps/return/${attempt.id}",
),
)
// providerRef is the NPS ProcessId.
init.providerRef?.let { db.saveProviderRef(attempt.id, it) }
// Respond with renderAutoSubmitForm(init as InitiateResult.Form) as text/html;
// it sets enctype="multipart/form-data".
```
* `NpsInitiateExtras(instrumentCode = ...)` skips the NPS bank picker; `payments.nps.getInstruments()` lists the codes.
* Notification URL handler: `payments.nps.notificationAck(wasFinal, currentStatus)` returns the body to send, or `null` while the status isn’t final (respond 503 so NPS retries).
* PHP
```php
$init = $payments->nps()->initiate(
new InitiateInput(
orderId: $attempt->id, // MerchantTxnId
amountPaisa: $attempt->amountPaisa,
description: 'Order #42', // TransactionRemarks
returnUrl: "{$baseUrl}/payments/nps/return/{$attempt->id}", // ResponseUrl
),
instrumentCode: null, // optional: skips the NPS bank/wallet picker
);
// ProcessId from GetProcessId
$db->saveProviderRef($attempt->id, $init->providerRef);
// FormResult: echo $init->render(); multipart auto-POST to /Payment/Index
```
* The Notification URL is set with NPS at onboarding. Run the same verify there and reply with `notificationResponse($alreadyProcessed)` (plain text `received` / `already received`).
* `getInstruments()` lists banks and wallets for your own picker; pass the chosen code as `instrumentCode:`.
## 4. Verify on return
[Section titled “4. Verify on return”](#4-verify-on-return)
The Notification URL (registered at onboarding) and the `ResponseUrl` both carry `MerchantTxnId` and `GatewayTxnId`, unsigned. From the Notification URL, reply `received` once you’ve recorded a final status, `already received` for repeats, and a non-2xx while the payment is still pending.
* TypeScript
```ts
const result = await payments.get("nps").verify({
orderId: attempt.id, // CheckTransactionStatus looks up MerchantTxnId
amountPaisa: attempt.amountPaisa,
});
const d = decideTransition(attempt.status, result, attempt.amountPaisa, Date.now() - attempt.createdAt.getTime());
// Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
// d.reviewRequired or d.latePaid: alert a human.
if (d.next === "paid") {
// fulfil the order here, only when that UPDATE changed a row
}
```
* Python
```python
result = payments["nps"].verify(
order_id=attempt.id, # CheckTransactionStatus looks up MerchantTxnId
amount_paisa=attempt.amount_paisa,
)
d = decide_transition(attempt.status, result.status, result.amount_paisa, attempt.amount_paisa,
time.time() - attempt.created_at.timestamp())
# Write d.next with UPDATE ... WHERE id = attempt.id AND status = attempt.status.
# d.review_required or d.late_paid: alert a human.
if d.next == "paid":
pass # fulfil the order here, only when that UPDATE changed a row
```
* Go
```go
// CheckTransactionStatus by MerchantTxnId: the order id and amount are all it needs
v, err := p.NPS.Verify(ctx, payments.VerifyInput{
OrderID: attempt.ID,
AmountPaisa: attempt.AmountPaisa,
})
if err != nil {
return err // network, 5xx or bad signature: leave the attempt open, reconciliation settles it
}
d := payments.DecideTransition(attempt.Status, v, attempt.AmountPaisa,
time.Since(attempt.CreatedAt), payments.DefaultSessionWindow)
if d.Next != nil {
// UPDATE payment_attempts SET status = *d.Next WHERE id = attempt.ID AND status = attempt.Status
if *d.Next == payments.StatusPaid {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
* Java
```java
// CheckTransactionStatus looks the payment up by MerchantTxnId (the attempt id).
VerifyResult result = payments.nps().verify(
VerifyInput.of(attempt.id(), attempt.amountPaisa()));
long ageMs = Duration.between(attempt.createdAt(), Instant.now()).toMillis();
TransitionDecision d = StateMachine.decideTransition(
AttemptStatus.fromWire(attempt.status()), result, attempt.amountPaisa(), ageMs);
if (d.reviewRequired() || d.latePaid()) {
// alert a human with d.reason()
}
if (d.next() != null) {
// UPDATE payment_attempts SET status = d.next().wire(), provider_txn_id = result.transactionId()
// WHERE id = attempt.id() AND status = attempt.status()
if (d.next() == AttemptStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* Kotlin
```kotlin
// CheckTransactionStatus looks the payment up by MerchantTxnId (the attempt id).
val result = payments.nps.verify(
VerifyInput(orderId = attempt.id, amountPaisa = attempt.amountPaisa),
)
val age = java.time.Duration.between(attempt.createdAt, Instant.now()).toKotlinDuration()
val d = decideTransition(AttemptStatus.fromWire(attempt.status)!!, result, attempt.amountPaisa, age)
if (d.reviewRequired || d.latePaid) {
// alert a human with d.reason
}
d.next?.let { next ->
// UPDATE payment_attempts SET status = next.wire, provider_txn_id = result.transactionId
// WHERE id = attempt.id AND status = attempt.status
if (next == PaymentStatus.PAID) {
// fulfil the order here, only if that UPDATE changed a row
}
}
```
* PHP
```php
// CheckTransactionStatus by MerchantTxnId; throws on network/5xx: leave the attempt open
$result = $payments->nps()->verify(new VerifyInput(
orderId: $attempt->id,
amountPaisa: $attempt->amountPaisa,
));
$ageMs = (time() - $attempt->createdAt->getTimestamp()) * 1000;
$d = StateMachine::decideFor(AttemptStatus::from($attempt->status), $result, $attempt->amountPaisa, $ageMs);
if ($d->next !== null) {
// UPDATE payment_attempts SET status = $d->next->value WHERE id = ? AND status = $attempt->status
if ($d->next === AttemptStatus::Paid) {
// fulfil the order here, only if that UPDATE touched one row
}
}
```
Fulfil only on paid
Apply `next` with a conditional `UPDATE ... WHERE status = prev`, and fulfil only when it is `paid`. The same handler serves retries, duplicate callbacks and the reconciliation job. See [Production architecture](/docs/guides/architecture/).
## 5. Test in the sandbox
[Section titled “5. Test in the sandbox”](#5-test-in-the-sandbox)
* Sandbox credentials and test logins come from NPS onboarding.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* `Amount`, `MerchantTxnId` and `ProcessId` in the form must match the `GetProcessId` call exactly.
* The gateway requires `multipart/form-data`. The adapter’s form result sets `enctype`.
* Pass `instrumentCode` to skip the NPS picker when you show your own bank list.
## Go-live checklist
[Section titled “Go-live checklist”](#go-live-checklist)
* Use the live API host and credentials from the onboarding PDF.
* Register the Notification URL with NPS.
* Run one real low-value payment end to end, then refund it.
* Keep the reconciliation job and its alerts running.
## Next
[Section titled “Next”](#next)
[NPS OnePG reference](/docs/providers/nps/reference/)Every field, status code and test vector, as the agent reads it.
[Wire your framework](/docs/guides/web/)Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes.
# NPS OnePG reference
> Full NPS OnePG reference: endpoints, fields, statuses, signature test vectors and gotchas, as the coding agent reads it.
> Source: [`references/nps.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/nps.md). This is the same file the agent reads.
One integration routes to \~29 banks’ mobile banking, e-banking, card checkouts (including HBL Card Checkout) and wallets (Khalti, MORU, MyPay, Hamro Pay). It’s the cheapest way to cover “all banks” plus cards without per-bank contracts.
Source: “DEVELOPER GUIDELINES – API DOCUMENT – GATEWAY 2025” (given to merchants at onboarding; not public), cross-checked against \~10 community implementations and live host probes (2026-11). NPS has rebranded to “NPX” (nepalpayment.com). Adapters: [`scripts/ts/src/nps.ts`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/ts/src/nps.ts), [`scripts/python/nepal_payments/nps.py`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/scripts/python/nepal_payments/nps.py).
## Environments and credentials
[Section titled “Environments and credentials”](#environments-and-credentials)
| | API base | Gateway (browser POST) |
| ------- | ------------------------------------- | ------------------------------------------------------- |
| Sandbox | `https://apisandbox.nepalpayment.com` | `https://gatewaysandbox.nepalpayment.com/Payment/Index` |
| Live | `https://apigateway.nepalpayment.com` | `https://gateway.nepalpayment.com/Payment/Index` |
Some repos use `api.nepalpayment.com` for live; it does not resolve. `apigateway.nepalpayment.com` answers (401 unauthenticated, same as sandbox). Confirm live URLs in your onboarding PDF.
Credentials: `MerchantId`, `MerchantName`, API username, API password, `SecretKey`. Env vars: `NPS_ENV`, `NPS_MERCHANT_ID`, `NPS_MERCHANT_NAME`, `NPS_API_USERNAME`, `NPS_API_PASSWORD`, `NPS_SECRET_KEY`.
## Auth + signature
[Section titled “Auth + signature”](#auth--signature)
* Every API call: `Authorization: Basic base64(apiUsername:apiPassword)`, `Content-Type: application/json`.
* `Signature` = **HMAC-SHA512**(SecretKey, the **values** of all payload keys **sorted alphabetically** (excluding `Signature`) **concatenated with no separator**), as **lowercase hex**.
* `{"MerchantId":"9","MerchantName":"TestMerchant"}` → sign `"9TestMerchant"`
* GetProcessId keys sort as `Amount, MerchantId, MerchantName, MerchantTxnId` → `"100" + "9" + "TestMerchant" + "T1"`
* The browser redirect needs no auth and no signature.
```php
ksort($payload); $sig = hash_hmac('sha512', implode('', array_values($payload)), $secretKey);
```
```python
hmac.new(secret.encode(), "".join(str(p[k]) for k in sorted(p)).encode(), hashlib.sha512).hexdigest()
```
Every response has the shape `{"code":"0"|"1","message":"…","errors":[{"error_code","error_message"}],"data":…}`.
## Flow
[Section titled “Flow”](#flow)
1. *(optional)* `POST /GetPaymentInstrumentDetails` `{MerchantId, MerchantName, Signature}` → `[{InstitutionName, InstrumentName, InstrumentCode, BankType, LogoUrl}]`. `BankType` is `MBanking`, `EBanking`, `CheckoutGateway` (wallets) or `checkoutcard`. Use it to render your own bank/wallet picker.
2. *(optional)* `POST /GetServiceCharge` `{Amount, InstrumentCode, MerchantId, MerchantName}` → `{Amount, CommissionType, ChargeValue, TotalChargeAmount}`.
3. `POST /GetProcessId` `{MerchantId, MerchantName, Amount, MerchantTxnId, Signature}` → `data.ProcessId`. A reused `MerchantTxnId` → code 1 “Duplicate Record”. **Persist ProcessId** as the attempt’s `provider_ref`.
4. Browser auto-POSTs **multipart/form-data** to the gateway with `MerchantId, MerchantName, Amount, MerchantTxnId, ProcessId`, plus optional `TransactionRemarks`, `InstrumentCode` (skips NPS’s picker) and `ResponseUrl`. `Amount`, `MerchantTxnId` and `ProcessId` must match step 3 exactly. `renderAutoSubmitForm()` honours `enctype`.
5. **Notification URL** (configured with NPS at onboarding): NPS calls `GET {url}?MerchantTxnId=…&GatewayTxnId=…`. It is **unsigned** and unauthenticated. Call CheckTransactionStatus, run the guarded update, and reply in **plain text** `received` (you just recorded a final status) or `already received` (it was already final). **While the payment is still pending, don’t acknowledge**: return a non-2xx so NPS keeps notifying (reconciliation covers it too). `adapter.notificationResponse(wasAlreadyFinal)` returns the right string.
6. **ResponseUrl**: the browser lands here with the same two params. Verify and show the result; it is not proof.
```ts
// Notification URL handler (Hono)
const FINAL = ["paid", "failed", "canceled", "expired", "refunded", "partially_refunded"];
app.get("/payments/nps/notify", async (c) => {
const attempt = await getAttempt(c.req.query("MerchantTxnId") ?? "");
if (!attempt) return c.text("unknown", 404);
const wasFinal = FINAL.includes(attempt.status);
const after = await verifyAndTransition(attempt); // architecture.md §4; returns the stored attempt
if (!FINAL.includes(after.status)) return c.text("pending", 503); // not acknowledged: NPS retries
return c.text(payments.get("nps").notificationResponse(wasFinal));
});
```
## Status check
[Section titled “Status check”](#status-check)
`POST /CheckTransactionStatus` `{MerchantId, MerchantName, MerchantTxnId, Signature}` → `data` with `GatewayReferenceNo, Amount, ServiceCharge, TransactionRemarks, ProcessId, TransactionDate ("yyyy-MM-dd HH:mm:ss"), MerchantTxnId, CbsMessage, Status, Institution, Instrument, PaymentCurrency, ExchangeRate`.
| `data.Status` | Normalized |
| --------------------------------------------------------- | ----------- |
| `Success` | `paid` |
| `Fail` | `failed` |
| `Pending` | `pending` |
| code 1 + error_code `3` “Transaction not initialized yet” | `not_found` |
A failed payment still returns `code:"0"`, so always read `data.Status`. `Amount` excludes `ServiceCharge`; compare it with your stored amount.
## Gotchas
[Section titled “Gotchas”](#gotchas)
* Amounts are **rupee strings**. Sign exactly what you send: `"100"` ≠ `"100.00"`. The adapters use one formatted string for both GetProcessId and the form.
* The gateway form must be **multipart/form-data**.
* Make the notification handler idempotent and fast. It’s unauthenticated, so never trust its params: always call CheckTransactionStatus.
* No official SDK. Community references: `PrabeshThapa/laravel-gateway` (`NepalPaymentDriver.php`), `Devloops-Software-Pvt-Ltd/drf-npx-package` (Django).
# Other gateways
> Notes for HBL (2C2P PACO), Prabhu Pay, Moru, CellPay and Namaste Pay, and how to build them on the same adapter interface.
> Source: [`references/others.md`](https://github.com/broisnees/nepal-payments-skill/blob/main/skills/nepal-payments/references/others.md). This is the same file the agent reads.
These have no adapter in `scripts/`. HBL needs bank-issued RSA keys to test, and the wallets have little or no public documentation. Each section gives what is verifiable and how to build it on the same `PaymentAdapter` interface (`initiate` / `verify` / `orderIdFromCallback`), keeping every rule in [`architecture.md`](/docs/guides/architecture/).
Shortcut: **NPS ([`nps.md`](/docs/providers/nps/reference/)) already routes to HBL Card Checkout, Khalti, MORU, MyPay, Hamro Pay and \~29 banks.** Only integrate these directly if you need your own acquiring contract.
## Himalayan Bank (HBL): 2C2P PACO, JOSE
[Section titled “Himalayan Bank (HBL): 2C2P PACO, JOSE”](#himalayan-bank-hbl-2c2p-paco-jose)
HBL’s card gateway runs on **2C2P PACO**. Docs: devzone.2c2p.com (`/docs/integration-1`, `/reference/payviapaymentpage`, `/docs/payment-status`). HBL supplies credentials plus 2C2P sample code (C#, PHP, Java). One npm package claims HBL uses Mastercard MPGS instead (UNVERIFIED, conflicts with every other source), so **confirm the product with HBL**.
| | Base |
| ---------- | --------------------------------- |
| UAT | `https://core.demo-paco.2c2p.com` |
| Production | `https://core.paco.2c2p.com` |
Endpoints:
* Payment: `api/1.0/Payment/prePaymentUi` (HBL samples) or `api/2.0/Payment/prePaymentUi` (current 2C2P docs); `api/2.0/Payment/nonUi`
* Inquiry: `GET api/2.0/Inquiry/transactionStatus?orderNo=…`, `api/2.0/Inquiry/transactionList`
* Void / settlement / refund: `api/2.0/void`, `PUT api/2.0/settlement`, `api/2.0/Refund/refund`
**Keys**: `officeId` (merchant id) + API key from HBL; PACO `kid` (UAT `7664a2ed0dee4879bdfca0e8ce1ac313`, PRD `19f84b5655f04e25a99b09f1ee2fac78`); PACO’s encryption and signing public keys (from HBL/2C2P); and **two RSA-4096 key pairs you generate** (signing + encryption). Give both public keys to HBL.
**Request** = JWS (PS256, your signing key) wrapped in JWE (RSA-OAEP + A128CBC-HS256, PACO encryption key, `kid` set). Body is the compact JWE, sent with `Content-Type: application/jose; charset=utf-8`, `Accept: application/jose`, and the API key header **as in HBL’s sample** (`CompanyApiKey`; 2C2P docs variously say `apiKey` / `token`).
```ts
// npm i jose (panva/jose: supports PS256, RSA-OAEP, A128CBC-HS256)
import { SignJWT, CompactEncrypt, compactDecrypt, jwtVerify, importPKCS8, importSPKI } from "jose";
async function pacoRequest(path: string, body: unknown) {
const signKey = await importPKCS8(process.env.HBL_MERCHANT_SIGNING_PRIVATE_KEY!, "PS256");
const pacoEnc = await importSPKI(process.env.HBL_PACO_ENCRYPTION_PUBLIC_KEY!, "RSA-OAEP");
const jws = await new SignJWT({ request: body, CompanyApiKey: process.env.HBL_API_KEY })
.setProtectedHeader({ alg: "PS256", typ: "JWT" })
.setIssuer(process.env.HBL_API_KEY!).setAudience("PacoAudience")
.setIssuedAt().setNotBefore("0s").setExpirationTime("5m")
.sign(signKey);
const jwe = await new CompactEncrypt(new TextEncoder().encode(jws))
.setProtectedHeader({ alg: "RSA-OAEP", enc: "A128CBC-HS256", kid: process.env.HBL_PACO_KID!, typ: "JWT" })
.encrypt(pacoEnc);
const res = await fetch(`${process.env.HBL_BASE_URL}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/jose; charset=utf-8", Accept: "application/jose", CompanyApiKey: process.env.HBL_API_KEY! },
body: jwe, signal: AbortSignal.timeout(20_000),
});
// Response: decrypt with YOUR encryption private key, verify with PACO's signing public key
const encKey = await importPKCS8(process.env.HBL_MERCHANT_ENCRYPTION_PRIVATE_KEY!, "RSA-OAEP");
const pacoSign = await importSPKI(process.env.HBL_PACO_SIGNING_PUBLIC_KEY!, "PS256");
const { plaintext } = await compactDecrypt(await res.text(), encKey);
const { payload } = await jwtVerify(new TextDecoder().decode(plaintext), pacoSign, { issuer: "PacoIssuer", audience: process.env.HBL_API_KEY });
return payload as any;
}
```
**prePaymentUi body**: `apiRequest{requestMessageID (GUID), requestDateTime (UTC ISO with ms + Z), language:"en-US"}`, `officeId`, `orderNo` (≤40), `productDescription` (≤50), `paymentType:"CC"`, `paymentCategory:"ECOM"`, `request3dsFlag:"Y"`, `transactionAmount{amountText:"000000100000" (minor units, 12-digit zero-padded), currencyCode:"NPR", decimalPlaces:2, amount:1000.00}`, `notificationURLs{confirmationURL, failedURL, cancellationURL, backendURL}`. Success: `apiResponse.responseCode == "PC-B050000"` (errors can come with HTTP 200). Redirect to `response.Data.paymentPage.paymentPageURL`.
**Status**: `paymentStatusInfo.paymentStatus`: **`A` (approved, step PA) or `S` (settled, step ST) = paid**. `I`/`P`/`PCPS` = pending, `F` rejected, `E` expired, `C` cancelled, `V` voided, `R`/`RI` refund. **Webhook**: PACO POSTs a JOSE-encrypted result to `backendURL`; decrypt and verify it like a response. The browser redirect URLs are not proof.
Gotchas: an empty `kid` is rejected; the samples default to THB, so set `NPR`; use `request3dsFlag:"Y"` in production. Community packages: Packagist `bickyraj/hbl`, `thebikramlama/hbl`; WordPress `hbl-payment-for-woocommerce`.
## Prabhu Pay (UNVERIFIED: community plugin only)
[Section titled “Prabhu Pay (UNVERIFIED: community plugin only)”](#prabhu-pay-unverified-community-plugin-only)
No public docs. Get them from the Prabhu Pay merchant team. A community WordPress plugin (`wc-prabhu-pay`, author not affiliated) uses:
* Initiate: `POST https://stagesys.prabhupay.com/api/EPayment/Initiate` (live `sys.prabhupay.com`) with `{totalAmount, merchantId, invoiceNo, returnUrl, remarks, password, productDetails:[{productName, quantity, rate, total}]}` → `data.redirectionUrl`
* Return: `?status&message&transactionId&invoiceNo`
* Verify: `POST /api/EPayment/CheckStatus` with `{merchantId, invoiceNo, transactionId, password}`; `status == "00"` = success
## Moru (official docs)
[Section titled “Moru (official docs)”](#moru-official-docs)
Docs:
* **Web**: load `https://web.payment-gateway.moru.com.np/sdk/build/moru-checkout.js` or npm `@moruwallet/moru-web-sdk`, then `new MoruCheckout({ access_key, transaction_id, additional_fields, callback_handler: { onSuccess, onError, onClose } }).open({ amount })`. `onSuccess` gives `{amount, moru_txn_identifier, status:"success"}` and is **not proof**.
* **Verify (mandatory, server)**: `POST https://test.moru-gateway.pnpl.com.np/gateway/v1/check-payment` (live `https://api.payment-gateway.moru.com.np/gateway/v1/check-payment`) with `{secret_key, txn_identifier}` → `data{amount, complete, state:"Completed", moru_txn_identifier, external_txn_identifier}`. Paid = `state == "Completed"` and amount matches.
* Flutter: `moru_payment_gateway`. Amounts appear to be rupees.
## CellPay (mobile SDK only)
[Section titled “CellPay (mobile SDK only)”](#cellpay-mobile-sdk-only)
Android `github.com/cellcom-nepal/cellpay-sdk`, Flutter `cellpay_sdk`. Config: `Config(publicKey, merchantId, invoiceNumber, description, amount:Long, extras, listener)`. Success keys: `cellpay_ref_id, merchant_invoice_number, cellpay_transaction_status, merchant_amount`. There is **no public server-side verification API**; ask CellPay for one before going live, because an SDK callback alone is not proof.
## MoCo / Namaste Pay
[Section titled “MoCo / Namaste Pay”](#moco--namaste-pay)
Nothing public (no docs, SDKs or packages). Specs come from Namaste Pay’s business team.
# Use it with your agent
> Example prompts and the six-step workflow the Nepal Payments skill gives your coding agent, from picking providers to reconciliation and tests.
Talk to your agent normally. The skill loads when you mention a provider, NPR or Nepali payments, or when you’re debugging a Nepali payment signature, callback or status check. You don’t need to say “skill”.
## Prompts that work well
[Section titled “Prompts that work well”](#prompts-that-work-well)
* "Add eSewa and Khalti payments to my Hono API on Cloudflare Workers with D1."
* "Integrate Fonepay dynamic QR for our restaurant POS."
* "Our eSewa callback says ES104 invalid signature. Fix it."
* "Add Khalti checkout to the Flutter app; the backend is FastAPI."
* "Set up connectIPS with our .pfx and a reconciliation job."
Name the providers and your stack when you know them. If you don’t name a provider, the skill recommends eSewa and Khalti first, then NPS or Fonepay to cover banks, connectIPS for direct bank debit and CyberSource only for card acquiring through a bank.
## What the agent does
[Section titled “What the agent does”](#what-the-agent-does)
The skill gives the agent this workflow:
1. **Pick providers** from the provider table, or recommend a set.
2. **Detect the stack** from `package.json`, `pyproject.toml`, `composer.json`, `pubspec.yaml`, `go.mod` and similar, and find where server routes and database models live.
3. **Install the adapters** by copying the folder for your language and keeping only the providers you need. See [Use the adapters directly](/docs/adapters/).
4. **Add persistence and routes**: a `payment_attempts` table and the return handler from [Production architecture](/docs/guides/architecture/), wired into your framework with [Web frameworks](/docs/guides/web/) or [Mobile apps](/docs/guides/mobile/).
5. **Add reconciliation**, so customers who pay and close the tab still get their order.
6. **Test** with the bundled suites, then the integration checks in [Testing and go-live](/docs/guides/testing/).
Keep secrets out of the chat
Give the agent credentials through environment variables or your secret manager, not by pasting them into the prompt. Sandbox values are public and listed in the testing guide.
## Debugging with the skill
[Section titled “Debugging with the skill”](#debugging-with-the-skill)
The provider references list the failure modes I hit while building and testing the adapters, such as eSewa’s ES104 invalid signature, Fonepay’s date format and Khalti’s integer-paisa amounts. Paste the error and the agent checks it against the matching reference.