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

Source: https://nepal-payments-skill.pages.dev/docs/providers/esewa/

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

<Diagram html={"<figure class=\"np-diagram\" data-kind=\"sequence\"><div class=\"np-diagram-scroll\"><svg viewBox=\"0 0 673 564\" width=\"673\" height=\"564\" role=\"img\" aria-labelledby=\"d-esewa-t d-esewa-d\" style=\"--d-min:600px\"><title id=\"d-esewa-t\">eSewa ePay v2 payment flow</title><desc id=\"d-esewa-d\">1. Payer's browser to Your backend: Pay with eSewa 2. Your backend: insert attempt, sign fields (HMAC-SHA256) 3. Your backend to Payer's browser: auto-submitting form 4. Payer's browser to Provider: POST the signed form 5. Provider: payer logs in and pays 6. Provider to Payer's browser: redirect success_url?data=… 7. Payer's browser to Your backend: return URL with data 8. Your backend: check the data signature 9. Your backend to Provider: status API with the stored total 10. Provider to Your backend: COMPLETE + ref_id 11. Your backend: decideTransition(), fulfil once 12. Your backend to Payer's browser: receipt</desc><defs><marker id=\"d-esewa-a\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"9\" markerHeight=\"9\" markerUnits=\"userSpaceOnUse\" orient=\"auto-start-reverse\"><path class=\"d-head\" d=\"M0 0 10 5 0 10z\"/></marker><marker id=\"d-esewa-r\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"9\" markerHeight=\"9\" markerUnits=\"userSpaceOnUse\" orient=\"auto-start-reverse\"><path class=\"d-head-reply\" d=\"M0 0 10 5 0 10z\"/></marker></defs><line class=\"d-life\" x1=\"83.25\" y1=\"48\" x2=\"83.25\" y2=\"556\"/><rect class=\"d-actor\" x=\"16\" y=\"10\" width=\"134.5\" height=\"38\" rx=\"8\"/><text class=\"d-actor-t\" x=\"83.25\" y=\"34\" text-anchor=\"middle\">Payer's browser</text><line class=\"d-life\" x1=\"288.79999999999995\" y1=\"48\" x2=\"288.79999999999995\" y2=\"556\"/><rect class=\"d-actor\" x=\"226.79999999999995\" y=\"10\" width=\"124\" height=\"38\" rx=\"8\"/><text class=\"d-actor-t\" x=\"288.79999999999995\" y=\"34\" text-anchor=\"middle\">Your backend</text><line class=\"d-life\" x1=\"564\" y1=\"48\" x2=\"564\" y2=\"556\"/><rect class=\"d-actor\" x=\"502\" y=\"10\" width=\"124\" height=\"38\" rx=\"8\"/><text class=\"d-actor-t\" x=\"564\" y=\"34\" text-anchor=\"middle\">Provider</text><line class=\"d-msg\" x1=\"85.25\" y1=\"92\" x2=\"285.79999999999995\" y2=\"92\" marker-end=\"url(#d-esewa-a)\"/><text class=\"d-label\" x=\"186.02499999999998\" y=\"84\" text-anchor=\"middle\">Pay with eSewa</text><text class=\"d-num\" x=\"93.25\" y=\"84\">1</text><rect class=\"d-note\" x=\"129.24999999999997\" y=\"110\" width=\"319.09999999999997\" height=\"28\" rx=\"6\"/><text class=\"d-note-t\" x=\"288.79999999999995\" y=\"128.5\" text-anchor=\"middle\">insert attempt, sign fields (HMAC-SHA256)</text><line class=\"d-reply\" x1=\"286.79999999999995\" y1=\"172\" x2=\"86.25\" y2=\"172\" marker-end=\"url(#d-esewa-r)\"/><text class=\"d-label\" x=\"186.02499999999998\" y=\"164\" text-anchor=\"middle\">auto-submitting form</text><text class=\"d-num\" x=\"93.25\" y=\"164\">3</text><line class=\"d-msg\" x1=\"85.25\" y1=\"212\" x2=\"561\" y2=\"212\" marker-end=\"url(#d-esewa-a)\"/><text class=\"d-label\" x=\"323.625\" y=\"204\" text-anchor=\"middle\">POST the signed form</text><text class=\"d-num\" x=\"93.25\" y=\"204\">4</text><rect class=\"d-note\" x=\"471.9\" y=\"230\" width=\"184.2\" height=\"28\" rx=\"6\"/><text class=\"d-note-t\" x=\"564\" y=\"248.5\" text-anchor=\"middle\">payer logs in and pays</text><line class=\"d-reply\" x1=\"562\" y1=\"292\" x2=\"86.25\" y2=\"292\" marker-end=\"url(#d-esewa-r)\"/><text class=\"d-label\" x=\"323.625\" y=\"284\" text-anchor=\"middle\">redirect <tspan class=\"d-code\">success_url?data=…</tspan></text><text class=\"d-num\" x=\"93.25\" y=\"284\">6</text><line class=\"d-msg\" x1=\"85.25\" y1=\"332\" x2=\"285.79999999999995\" y2=\"332\" marker-end=\"url(#d-esewa-a)\"/><text class=\"d-label\" x=\"186.02499999999998\" y=\"324\" text-anchor=\"middle\">return URL with <tspan class=\"d-code\">data</tspan></text><text class=\"d-num\" x=\"93.25\" y=\"324\">7</text><rect class=\"d-note\" x=\"187.99999999999994\" y=\"350\" width=\"201.6\" height=\"28\" rx=\"6\"/><text class=\"d-note-t\" x=\"288.79999999999995\" y=\"368.5\" text-anchor=\"middle\">check the <tspan class=\"d-code\">data</tspan> signature</text><line class=\"d-msg\" x1=\"290.79999999999995\" y1=\"412\" x2=\"561\" y2=\"412\" marker-end=\"url(#d-esewa-a)\"/><text class=\"d-label\" x=\"426.4\" y=\"404\" text-anchor=\"middle\">status API with the stored total</text><text class=\"d-num\" x=\"298.79999999999995\" y=\"404\">9</text><line class=\"d-reply\" x1=\"562\" y1=\"452\" x2=\"291.79999999999995\" y2=\"452\" marker-end=\"url(#d-esewa-r)\"/><text class=\"d-label\" x=\"426.4\" y=\"444\" text-anchor=\"middle\"><tspan class=\"d-code\">COMPLETE</tspan> + <tspan class=\"d-code\">ref_id</tspan></text><text class=\"d-num\" x=\"298.79999999999995\" y=\"444\">10</text><rect class=\"d-note\" x=\"157.54999999999995\" y=\"470\" width=\"262.5\" height=\"28\" rx=\"6\"/><text class=\"d-note-t\" x=\"288.79999999999995\" y=\"488.5\" text-anchor=\"middle\"><tspan class=\"d-code\">decideTransition()</tspan>, fulfil once</text><line class=\"d-reply\" x1=\"286.79999999999995\" y1=\"532\" x2=\"86.25\" y2=\"532\" marker-end=\"url(#d-esewa-r)\"/><text class=\"d-label\" x=\"186.02499999999998\" y=\"524\" text-anchor=\"middle\">receipt</text><text class=\"d-num\" x=\"93.25\" y=\"524\">12</text></svg></div><figcaption>eSewa ePay v2 payment flow</figcaption></figure>"} />

<Steps>

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=<base64 JSON>`, 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.

</Steps>

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

Build it once at startup from the environment. Every provider whose required variables are set is enabled.

<Tabs syncKey="lang">
<TabItem label="TypeScript">

```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));
```

</TabItem>
<TabItem label="Python">

```python

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

</TabItem>
<TabItem label="Go">

```go
	"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().
```

</TabItem>
<TabItem label="Java">

```java

// 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());
```

</TabItem>
<TabItem label="Kotlin">

```kotlin

// 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())
```

</TabItem>
<TabItem label="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());
```

</TabItem>
</Tabs>

## 3. Start a payment

Create a payment attempt in your database first (its id is the order id the provider sees), then:

<Tabs syncKey="lang">
<TabItem label="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()`.

</TabItem>
<TabItem label="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()`.

</TabItem>
<TabItem label="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.

</TabItem>
<TabItem label="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.

</TabItem>
<TabItem label="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.

</TabItem>
<TabItem label="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.

</TabItem>
</Tabs>

## 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/<attemptId>`) and keep the URL free of query strings, because eSewa appends `?data=`.

<Tabs syncKey="lang">
<TabItem label="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
}
```

</TabItem>
<TabItem label="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
```

</TabItem>
<TabItem label="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
	}
}
```

</TabItem>
<TabItem label="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
    }
}
```

</TabItem>
<TabItem label="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
    }
}
```

</TabItem>
<TabItem label="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
    }
}
```

</TabItem>
</Tabs>

<Aside type="caution" title="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/).
</Aside>

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

- 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 (`&amp;`). The adapter unescapes them.

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

<CardGrid>
  <LinkCard title="eSewa reference" description="Every field, status code and test vector, as the agent reads it." href="/docs/providers/esewa/reference/" />
  <LinkCard title="Wire your framework" description="Express, Hono, Next.js, FastAPI, Django, Laravel, Spring Boot and Ktor routes." href="/docs/guides/web/" />
</CardGrid>
