text
Mobile App (WebView / WebBrowser) ↓ Khalti Hosted Payment Page ↓ GET /v1/store/payments/khalti ← Redirect with Query Params ↓ Your Backend Processing:
- Parse query parameters
- Find payment by
pidx - Call Khalti LOOKUP API
- Apply atomic database transaction
- Redirect user to app deep link
Purpose: The URL where Khalti redirects users after payment completion.
-
HTTP Method: Must support
GETrequests -
Accessibility: Must be reachable by Khalti servers
-
Control: Must be backend-controlled endpoint
-
Function: Payment callback entry point for your system
json
{ "return_url": "https://api.yourdomain.com/v1/store/payments/khalti" }
text
User completes payment on Khalti ↓ Khalti redirects browser to your return_url ↓ GET /v1/store/payments/khalti?pidx=...&status=Completed&transaction_id=... ↓ Your backend processes and finalizes the order
-
Parse query parameters (
pidx,status,transaction_id, etc.) -
Find payment record using the
pidx -
Call Khalti LOOKUP API to verify payment authenticity
-
Apply atomic database transaction to update order status
-
Redirect user to appropriate app deep link or success page
Purpose: Your official website URL for merchant identification and compliance.
-
NOT a callback URL
-
NOT used for redirects
-
NOT required to handle payment requests
-
Should point to your public-facing website
json
{ "website_url": "https://yourdomain.com" }
-
Merchant information display during payment
-
Risk & fraud assessment (verifying legitimate businesses)
-
Compliance & trust (regulatory requirements)
-
UI context within Khalti app/web interfaces
| Parameter | Purpose | Required | Example | Criticality |
|---|---|---|---|---|
return_url |
Payment callback endpoint | ✅ Yes | https://api.domain.com/payments/khalti |
BUSINESS-CRITICAL |
website_url |
Merchant identification | ✅ Yes | https://domain.com |
Informational |
-
Use HTTPS endpoint
-
Implement proper error handling
-
Add idempotency checks for duplicate callbacks
-
Log all incoming requests for debugging
-
Keep processing time minimal (< 5 seconds)
-
Use your primary domain (not subdomains)
-
Ensure the website is publicly accessible
-
Keep it updated if your domain changes
-
Use a professional, legitimate business website
-
❌ DON'T use the same URL for both parameters
-
❌ DON'T use frontend/app URLs for
return_url -
❌ DON'T use API endpoints for
website_url -
✅ DO test both URLs in sandbox environment
-
✅ DO monitor
return_urlendpoints for failures
-
Validate all incoming parameters from Khalti
-
Always verify payments using Khalti LOOKUP API before updating database
-
Implement CSRF protection if applicable
-
Use HTTPS for all endpoints
-
Monitor for unusual payment patterns
Below is a clear internal guide (like a mini-spec) for how your checkout + payment system works end-to-end: order creation, cart locking, gateway initiation, redirects, verification, idempotency, and app UX. You can drop this into your repo as docs/payments.md.
- Goals
This system is designed to ensure:
-
No "paid but order not updated" bugs
-
No "cart converted without payment" bugs
-
Idempotency (safe with retries, duplicate redirects, user refresh, etc.)
-
Gateway payloads are NOT trusted (redirect params can be tampered)
-
Source of truth is always the gateway verification API
-
Mobile app gets a clean result via deep links:
khel://checkout/<state>?...
- Data Model & Key Fields
Each payment attempt is a row:
-
id(internal ID) -
order_id(FK) -
provider(khalti,esewa, ...) -
provider_ref(gateway reference)-
Khalti:
pidx -
eSewa:
transaction_uuid
-
-
amount_cents -
currencydefaultNPR -
statusenum:pending | paid | failed | refunded... -
gateway_responseJSONB: raw gateway payload we want to keep -
Unique:
(provider, provider_ref)to avoid duplicates when ref exists
Stores events:
-
payment_id -
log_type:redirect | webhook | response | error -
payload: JSONB
This is important for diagnosing real-world issues.
- Checkout Flow Overview
User clicks "Checkout".
Server does:
-
Validate cart
- items available, totals correct, etc.
-
Create Order (if not exists) and link to cart
-
ordersrow created withstatus = awaiting_payment(or similar) -
orders.payment_status = pending -
record totals, shipping info, etc.
-
-
Lock cart for checkout
-
cart becomes
checkout_pending -
cart is locked to this order:
checkout_order_id = order.id
-
prevents:
-
double checkouts
-
multiple orders from same cart
-
cart edits while payment is happening
-
-
Result: at this point the system has a stable "checkout snapshot".
- Creating a Payment Attempt
When user selects provider and presses "Pay":
Server does:
-
Create
paymentsrow-
provider = 'khalti'orprovider = 'esewa' -
status = pending -
amount_cents,currency -
returns
payment.id
-
-
Call provider adapter:
InitiatePayment(...)-
Khalti → returns
payment_url+pidx -
eSewa → returns
form_url+ form fields includingtransaction_uuid
-
-
Save
provider_refimmediately (best-effort)-
SetProviderRef(payment.id, ref, rawResponse) -
For Khalti:
ref = pidx -
For eSewa:
ref = transaction_uuid
-
Why save it now?
- So return handlers can locate the payment from the redirect using
provider_ref
- Return to mobile:
-
either a URL to open
-
or form fields (for eSewa)
-
- Gateway Redirect/Return (Browser → API → App)
Both Khalti and eSewa are redirect based: after payment they send user back to your backend (Return URL). That endpoint is hit by the user's browser/webview, not a true webhook.
GET /v1/store/payments/khalti?pidx=...&status=...&...
GET /v1/store/payments/esewa/return?result=success|failure&data=<base64_json>
These endpoints do the same high-level steps:
-
Parse required ref from query:
-
Khalti:
pidx -
eSewa: decode base64 JSON and read
transaction_uuid
-
-
Lookup internal payment:
Payments.GetByProviderRef(provider, ref)
-
Log redirect payload:
PayLogs.InsertPaymentLog(payment.id, "redirect", query/payload)
-
Verify with gateway API
-
Do not trust redirect status alone.
-
Khalti:
lookupAPI withpidx -
eSewa:
statusAPI with-
product_code -
total_amount -
transaction_uuid
-
-
-
Apply state transition atomically in DB (transaction)
-
Redirect back to app using
redirectPaymentResult()-
deep link:
khel://checkout/<state>?... -
fallback to web if app not installed
-
- Verification Is the Source of Truth
Redirect payload can be:
-
tampered (user can change query params)
-
incomplete
-
wrong in edge cases (pending/ambiguous)
Therefore, we always call:
-
Khalti lookup API
-
eSewa transaction status API
Your adapters return:
-
Success(true only when actually paid) -
Terminal-
false when gateway says "still pending"
-
true when gateway says "failed/canceled/expired/not_found/refund"
-
-
Stateraw gateway state string -
ProviderRef(pidx/transaction_uuid)
-
Success only if
Completed -
Pending:
Pending,Initiated -
Terminal fail:
Expired,User canceled,Refunded,Partially refunded
-
Success only if
COMPLETE -
Pending:
PENDING,AMBIGUOUS -
Terminal fail:
NOT_FOUND,CANCELED,FULL_REFUND,PARTIAL_REFUND
- Atomic DB Transitions (Most Important Part)
Inside a single DB transaction:
-
Re-read payment row (idempotency guard)
- if already
paid, stop
- if already
-
Payments.MarkPaid(paymentID)-
sets
payments.status = paid -
updates
orders.payment_status = paid -
updates
orders.status = processing -
sets
orders.paid_at = now()
-
-
Convert cart (strict)
-
Carts.ConvertCheckoutCart(orderID) -
should only convert if:
-
cart
status = checkout_pending -
checkout_order_id = orderID
-
-
Outcome: paid order + converted cart, always consistent.
We do nothing:
-
keep payment
pending -
keep cart locked
Mobile should show Pending and keep polling.
This avoids failing a payment that may complete a moment later.
Inside DB transaction:
-
Re-check payment (idempotency)
-
payments.status = failed -
Optionally update order status:
payment_failed -
Carts.UnlockCheckoutCart(orderID)- user can retry checkout or choose another payment
Outcome: user is not stuck.
- Deep Link Response to App
Instead of returning JSON errors to a browser, we send an HTML page that:
-
tries to open
khel://checkout/<state>?query -
after ~1.2s redirects to web fallback if app not installed
-
shows a button "Open in app"
Deep link format:
khel://checkout/<state>?order_id=...&payment_id=...&provider=...&ref=...&state=...&reason=...
<state>is one of:-
success -
pending -
failed
-
Query keys:
-
order_id(helpful for UI and fetching order) -
payment_id(needed for verify polling) -
provider(khalti/esewa) -
ref(pidx or transaction_uuid) -
state(gateway state string) -
reason(internal debug reason)
- Mobile App Behavior
-
Clears local cart
-
invalidates
storeCartquery -
shows confirmation + "Continue shopping"
-
optional "View Order"
-
shows error
-
shows metadata (order_id/payment_id/provider/state/reason)
-
"Try again" → back to checkout
-
reads deep link params:
payment_id,provider,ref -
calls
POST /store/payments/verify- body:
{ payment_id, method: provider, data: { pidx/transaction_uuid } }
- body:
-
if
success→ go success -
if
terminal→ go failed -
else keep polling
Note: pending is normal in real payments---avoid failing too quickly.
- The Verify Endpoint (POST /verify)
This endpoint exists for the app to confirm status again (and for retries).
Flow:
-
Validate payload: payment_id + method
-
Load payment row
-
if already paid → return success idempotent
-
log inbound data
-
verify with gateway (network call outside transaction)
-
apply atomic DB transitions:
-
paid → MarkPaid + ConvertCart
-
pending → no-op
-
terminal fail → failed + UnlockCart
-
Return:
{ "success": true|false, "terminal": true|false, "state": "Completed|Pending|COMPLETE|PENDING|..." }
- Why Some Errors Redirect Instead of JSON Errors
Return handlers are hit by a browser/webview, not your API client.
So returning 400 JSON is not helpful; the user sees a blank page.
Rule:
- If the request comes from redirect/return endpoints:
-
prefer redirect back to app (failed/pending) with a
reason -
keep JSON errors mainly for internal API calls (POST /verify)
-
In your latest code, you switched to redirecting for missing params --- that's the right UX.
- Idempotency & Race Conditions
Handled at 2 levels:
-
Early check
- if payment already paid, return early
-
Inside transaction
-
re-read payment status before writing
-
prevents double MarkPaid due to:
-
duplicate redirects
-
user refresh
-
repeated callback hits
-
multiple app polls
-
-
Also, (provider, provider_ref) uniqueness prevents duplicate provider refs.
- Operational Notes / Best Practices
-
Keep gateway response in
gateway_response(payments table) for visibility -
Keep all inbound redirect payloads in
payment_logs -
Treat network verify errors as pending, not failed (better UX)
-
Always do verification network call outside DB tx
-
Always do DB transitions inside tx
- Quick "Flow Diagram"
-
Mobile: checkout → server creates order + locks cart
-
Mobile: choose provider → server creates payment(pending)
-
Server: calls gateway initiate → saves provider_ref
-
User pays on gateway → gateway redirects browser to your return endpoint
-
Return handler:
-
get provider_ref
-
find payment
-
verify via gateway API
-
tx: mark paid + convert cart OR fail + unlock cart OR keep pending
-
deep link back to app
-
-
Mobile:
-
success → clear cart
-
pending → poll /verify
-
failed → retry
-
- Gateway return URLs
✅ Must be GET endpoints
✅ Must be reachable from public internet
✅ Must match what you configured in Khalti/eSewa dashboard
Khalti
-
return_urlmust be your GET endpoint:
/v1/store/payments/khalti -
Expect query params like
pidx,status,transaction_id, etc. -
You still must call
lookup APIafter redirect.
eSewa
-
success_url&failure_urlmust point to your endpoint:
/v1/store/payments/esewa/return -
eSewa sends base64
data, don't trust it alone. -
Always call status-check API after redirect.
✅ Your current approach is correct: redirect → verify → DB tx → deep link.
- Provider reference (provider_ref) mapping
This is critical because your return handlers depend on it.
✅ Save provider_ref as soon as initiate returns:
-
Khalti:
provider_ref = pidx -
eSewa:
provider_ref = transaction_uuid
If you don't save provider_ref, you'll get:
- return handler receives ref but can't match payment → "payment not found"
✅ You already do this:
switch method { case "khalti": transaction_uuid/pidx ... case "esewa": ... }
- Never trust redirect payload as success
Redirect params can be:
-
tampered
-
missing
-
"pending" even if user paid
-
"success" even if later reversed
✅ Always verify using gateway API:
-
Khalti
lookup -
eSewa status-check
✅ In UX:
- if verify fails due to network → show pending not failed
- Do network calls outside DB transaction
✅ Correct:
-
verify with gateway outside TX
-
then TX only for state transitions
Otherwise you risk:
-
long DB locks
-
deadlocks
-
slowdowns under load
- Atomic transitions (must be in ONE TX)
When payment is actually paid, inside one transaction:
-
payments.status = paid -
orders.payment_status = paid -
orders.status = processing -
orders.paid_at = now() -
cart converted(only if locked to this order)
If these happen in separate transactions you can get:
-
paid but cart not converted
-
cart converted but payment not marked paid
✅ Your MarkPaid + ConvertCheckoutCart approach is correct.
- Idempotency (duplicate redirects / polling)
You MUST assume:
-
user refreshes return URL
-
browser hits return twice
-
app polls /verify multiple times
-
gateway retries
✅ Guardrails:
-
return handler: if already paid, just redirect success
-
inside TX: re-check status before updating
- Pending states are normal
Do NOT mark failed just because it's not immediately "Complete".
✅ eSewa:
PENDING,AMBIGUOUS=> keep pending and keep cart locked
✅ Khalti:
Pending,Initiated=> keep pending
Terminal failure only for:
- canceled / expired / not_found / refund states
- Return endpoints should redirect, not JSON error
Return handlers are opened in browser/SFSafariViewController.
If you respond with JSON 400/500, user will see:
-
blank page
-
confusing error
✅ Best practice:
-
Always
redirectPaymentResult(...)for return endpoints -
Reserve JSON error responses for internal API endpoints like POST
/verify
Query payments by:
-
id -
OR
(provider, provider_ref)
Example:
-
Khalti pidx:
provider='khalti' AND provider_ref='<pidx>' -
eSewa uuid:
provider='esewa' AND provider_ref='<transaction_uuid>'
Check:
-
status: pending/paid/failed
-
gateway_response contains initiate data
Look at payment_logs for that payment_id:
-
redirectlog: did return handler receive query/payload? -
errorlog: did verification fail? why? -
any
webhooklogs (if you log them)
This tells you if the problem is:
-
missing provider_ref saved earlier
-
return handler not being hit
-
verify call failing
-
DB tx failing
Call your verify endpoint:
POST /v1/store/payments/verify
Payload:
- Khalti:
{ "payment_id": 123, "method": "khalti", "data": { "pidx": "..." } }
- eSewa:
{ "payment_id": 123, "method": "esewa", "data": { "transaction_uuid": "...", "product_code":"...", "total_amount":"..." } }
If verify succeeds but DB didn't update:
-
problem in transaction logic
-
cart convert/unlock logic
-
MarkPaid update queries
Confirm cart is:
-
checkout_pendingwhen payment pending -
convertedafter paid -
unlocked after terminal failure
If cart stays locked forever:
-
verify never reached terminal state
-
OR you're not unlocking on terminal fail
-
OR status-check API is failing and you're not rechecking later
FRONTEND_URL=https://web.gocloudnepal.com
Used for fallback when deep link fails.
APP_SCHEME=khel
Deep link is:khel://checkout/<state>?...
-
KHALTI_SECRET_KEY=... -
KHALTI_IS_PROD=false|true
KHALTI_RETURN_URL=https://api.gocloudnepal.com/v1/store/payments/khalti
Must be GET supported.
KHALTI_WEBSITE_URL=https://gocloudnepal.com
This is your public site, used by Khalti to validate merchant context.
-
ESEWA_MERCHANT_CODE=... -
ESEWA_SECRET_KEY=... -
ESEWA_IS_PROD=false|true
-
ESEWA_SUCCESS_URL=https://api.gocloudnepal.com/v1/store/payments/esewa/return -
ESEWA_FAILURE_URL=https://api.gocloudnepal.com/v1/store/payments/esewa/return
Below is the "full story" in the exact order things happen.
- User builds cart (client + server consistency)
-
You have a local cart store (
useCartStore) for UI speed -
You also maintain a server cart (
storeCartquery) -
At checkout time, server becomes the source of truth for totals and stock
- User clicks "Place order" (Checkout request)
-
Validate user + cart
-
Validate shipping info exists (address, city, phone)
-
Create an Order
-
order totals, shipping data stored
-
status likely
awaiting_payment
-
-
Lock cart:
-
cart becomes
checkout_pending -
cart points to
checkout_order_id = order.id
-
-
Create Payment row:
-
payment.status = pending
-
payment.provider = chosen method
-
Return to client:
-
order_id,payment_id -
if COD: done
-
if online: payment_url + payment_data
- COD flow (simple)
If payment method is cash_on_delivery:
-
clear cart
-
order becomes "processing"
-
redirect to
/checkout/success?order_id=...
No gateway involved.
- Online flow (Khalti/eSewa)
Client navigates to payment UI (WebView or expo-web-browser).
-
Server initiate returns
payment_urlandpidx -
Save provider_ref =
pidx -
Client opens
payment_url
After payment:
-
Khalti redirects to your
KHALTI_RETURN_URLwith query params -
Your
khaltiReturnHandler:-
reads
pidx -
finds payment by provider_ref
-
calls Khalti lookup API
-
TX:
-
paid → MarkPaid + ConvertCart
-
pending → no-op
-
terminal fail → fail + unlock cart
-
-
deep link back to app
-
-
Server initiate returns
PaymentURL+ form fields includingtransaction_uuid -
Save provider_ref =
transaction_uuid -
Client opens form via POST
After payment:
-
eSewa redirects to your return url with
database64 -
esewaReturnHandler:-
decode base64
-
optional signature check (integrity)
-
find payment by provider_ref=transaction_uuid
-
call eSewa status-check API
-
TX:
-
COMPLETE → MarkPaid + ConvertCart
-
PENDING/AMBIGUOUS → keep pending
-
terminal fail → fail + unlock cart
-
-
deep link back to app
-
- Mobile app receives deep link → routes to screens
Deep link format:
khel://checkout/<state>?order_id=...&payment_id=...&provider=...&ref=...&state=...&reason=...
Your screens should:
-
success: show confirmation, clear local cart, optionally fetch order
-
failed: show reason + "Try again"
-
pending: poll
/verifyusingpayment_id + provider + ref
- Pending screen (poll /verify)
Your polling logic is good in concept.
But now, since your return handlers already verify and transition,
pending is mainly needed when:
-
gateway returns pending/ambiguous
-
your server verification temporarily fails
-
user closed browser too fast
-
method:
provider -
data should include:
-
Khalti:
{ pidx: ref } -
eSewa: at minimum
{ transaction_uuid: ref }- (your server can fetch total_amount/product_code from DB if you store them)
-
For eSewa verify, you currently require:
-
product_code
-
total_amount
-
transaction_uuid
If the app is polling and only has transaction_uuid, you can make your server smarter:
✅ Store product_code and total_amount in gateway_response during initiate, so verify can load them from DB by payment_id.
That way the client only sends:
- payment_id + transaction_uuid
and your server fills the rest.
This makes your polling much more reliable.
Payment flow: paymentProvider(esewa, khalti):
- Opens in system browser
- Provider redirect to backend happens in browser
- backend redirects after verification to khel://checkout/status
- if app is not open fallback to website
Khalti:
- return_url → backend handler Backend:
- logs redirect
- calls lookup API
- applies atomic DB transaction
- redirects to deep link
- App opens automatically
note for self
- Use provider_ref for the stable identifier:
- Khalti => pidx
- eSewa => transaction_uuid
- Use gateway_response JSONB to store everything else (init response, lookup raw, etc)
/store/payments/esewa/start?payment_id=xxx (handler to auto-post HTML)
- this is the best way to use expo-web-browser because system browser can't do a raw post body easily like webview hack can but html can auto-submit.
What it does
- validate payment_id
- loads payment
- calls initiatePayment() again (fresh uuid each attempt)
- overrides success and failure url to include payment_id
- save provider_ref = transaction_uuid
- gateway_response = form fields
- returns an html page that auto-submits to esewa form url
payment callback handler
- redirectToAppReturn is correct and matches your app redirect target:
- khel://payments/return?...
- eSewa start: re-initiate with a fresh transaction_uuid ✅
- eSewa return: decode base64 → verify signature → call status-check API (source of truth) ✅
- Khalti return: lookup by pidx → call lookup API → apply DB tx atomically ✅
Quick mental model
Order table constraint, only these states are allowed:
✅ active + checkout_order_id NULL ✅ checkout_pending + checkout_order_id SET ✅ checkout_pending + checkout_order_id NULL (not typical but allowed) ✅ converted + checkout_order_id NULL ✅ abandoned + checkout_order_id NULL
❌ converted + checkout_order_id SET (your current convert does this) ❌ active + checkout_order_id SET (if you set order id before status)
- Internally (DB & repository): use int64 IDs
- Externally (API): use encoded string IDs
Flow:
- Encode bookingID before sending response to client
- Client uses encoded bookingID in API requests
- Decode bookingID in handler before calling repository
Rule:
Encode → only in responses
Decode → only in handlers
Repository → always uses int64 IDs