Shop It Docs
Developer ResourcesPayment

Browser Return Flow

Unified browser payment flow for subscriptions, orders, and future gateways, including the shared payment result page contract and frontend implementation guide.

Browser Return Flow

Audience: Frontend developers, backend developers, QA Scope: Shared payment return flow, /payments/result contract, subscription and order scenarios, gateway-agnostic behavior


Why This Exists

The browser should follow one payment return path regardless of:

  • the feature being paid for: subscription, order, content, donation
  • the gateway used: eSewa today, connectIPS / Fonepay / Khalti later
  • the gateway callback format: POST form fields, GET query params, encoded payloads

The frontend should not verify payments directly. The backend owns verification and then redirects the browser to a single payment result page.


Final Shape

Frontend feature page
  -> Backend initiate API
  -> Gateway checkout page
  -> Backend callback/redirect endpoint
  -> Shared payment result page
  -> Frontend next destination

This means the payment result page is a single reusable screen for all payment scopes and all gateways.


End-to-End Sequence


Backend Responsibilities

1. Initiation

During payment initiation the backend:

  1. creates a pending payment record
  2. stores the final frontend destination in payment.metadata.redirectUrl
  3. generates backend-owned callback URLs:
    • /api/payments/redirect/:paymentId/success
    • /api/payments/redirect/:paymentId/failure
  4. passes those callback URLs to the gateway instead of sending the gateway directly back to the frontend

This logic lives in:

2. Redirect Verification

When the gateway returns, the backend:

  1. loads the payment record using paymentId
  2. parses the gateway return payload
  3. verifies the payment
  4. marks the payment as completed or failed
  5. emits domain events
  6. redirects to the single payment result page

For eSewa, the backend also decodes the base64 data payload and verifies the signature plus the status API response.


Frontend Responsibilities

The frontend should do only two payment-specific things:

  1. initiate payment and submit the gateway form/redirect
  2. implement the shared /payments/result page when a frontend is available

It should not call a verify endpoint after the user returns from the gateway.


Shared Result Page Contract

The backend redirects the browser to a single result page:

${PAYMENT_RESULT_PAGE_URL}

If PAYMENT_RESULT_PAGE_URL is configured, the browser is redirected there.

If PAYMENT_RESULT_PAGE_URL is not configured, the backend uses its temporary fallback page:

${API_PUBLIC_BASE_URL}/api/payments/result

This backend fallback page is intended for local development and backend-only environments where no frontend result screen exists yet.

Query Parameters Sent To The Result Page

ParamMeaningExample
payment_statusFinal verified statuscompleted
payment_idInternal payment row ID57
reference_typeWhat the payment was forsubscription
reference_idID of the paid entity1a2b3c...
nextFinal frontend destination after showing result UIhttps://app.example.com/subscriptions/1a2b3c
subscription_idPresent when reference_type=subscription1a2b3c...
order_idPresent when reference_type=order128

Result Page Behavior

The result page should:

  1. read the query params
  2. show a gateway-agnostic success/failure UI
  3. decide how long to remain visible
  4. redirect to next
import { useEffect, useMemo } from "react";
import { useSearchParams, useRouter } from "next/navigation";

export default function PaymentResultPage() {
  const params = useSearchParams();
  const router = useRouter();

  const status = params.get("payment_status");
  const next = params.get("next");
  const referenceType = params.get("reference_type");
  const referenceId = params.get("reference_id");

  const isSuccess = status === "completed";

  const title = useMemo(() => {
    if (isSuccess) return "Payment successful";
    return "Payment failed";
  }, [isSuccess]);

  const description = useMemo(() => {
    if (referenceType === "subscription" && isSuccess) {
      return "Your subscription has been activated.";
    }
    if (referenceType === "order" && isSuccess) {
      return "Your order payment has been confirmed.";
    }
    if (referenceType === "subscription") {
      return "We could not complete your subscription payment.";
    }
    if (referenceType === "order") {
      return "We could not complete your order payment.";
    }
    return isSuccess
      ? "Your payment has been confirmed."
      : "Your payment could not be completed.";
  }, [isSuccess, referenceType]);

  useEffect(() => {
    if (!next) return;
    const timeout = window.setTimeout(() => {
      router.replace(next);
    }, isSuccess ? 1800 : 2500);
    return () => window.clearTimeout(timeout);
  }, [router, next, isSuccess]);

  return (
    <main>
      <h1>{title}</h1>
      <p>{description}</p>
      <p>Reference: {referenceId}</p>
      {next ? <p>Redirecting...</p> : null}
    </main>
  );
}

Temporary Backend Fallback Result Page

When no frontend result page exists, the API serves a temporary HTML result page at:

/api/payments/result

That page:

  1. shows success or failure
  2. displays the payment and reference IDs
  3. shows the next URL
  4. automatically redirects the browser to next after a short delay

This lets the entire payment flow remain usable before the frontend implements /payments/result.


Subscription Scenario

Sequence

What The Frontend Sends

The subscription purchase request should use returnUrl as the final destination after payment flow completes, for example:

https://app.example.com/subscriptions/3e8ce1d8-...

The frontend is not telling eSewa where to return. It is telling the backend where the user should finally land after backend verification finishes.

What The User Sees

  1. Subscription purchase screen
  2. eSewa checkout screen
  3. shared payment result page
  4. subscription detail page

Order Scenario

Sequence

For orders, returnUrl should usually be:

https://app.example.com/orders/:orderId

The shared result page can then show a neutral message like:

  • "Payment successful. Redirecting to your order..."
  • "Payment failed. Redirecting back to your order..."

eSewa-Specific Notes In This Unified Model

eSewa currently returns:

  • success: encoded data payload
  • failure: browser redirect to failure URL

In the new backend-owned flow:

  1. eSewa returns to the API, not the frontend
  2. the API decodes data
  3. the API verifies signature and status
  4. the API redirects to the shared result page

So even though eSewa has a special encoded success response, the frontend still sees the same generic result-page contract as every other gateway.


Future Gateway Compatibility

This flow is designed to survive gateway differences.

eSewa

  • initiation: form_post
  • return shape: encoded data
  • verification: signature + status API

connectIPS

  • initiation: likely form_post
  • return shape: gateway-specific form/query fields
  • verification: gateway-specific callback validation

Fonepay

  • initiation: redirect
  • return shape: query parameters or callback fields
  • verification: gateway-specific checksum / status check

Khalti

  • initiation: likely sdk
  • return shape: token / pidx / lookup response
  • verification: backend lookup API

As long as each gateway implementation can:

  1. initiate payment
  2. verify payment
  3. optionally parse its redirect payload

the browser flow stays the same.


QA Checklist

  • Payment initiation returns paymentId, redirectUrl, and gatewayPayload
  • Gateway success returns to /api/payments/redirect/:paymentId/success
  • Gateway failure returns to /api/payments/redirect/:paymentId/failure
  • Backend verifies before redirecting to the shared result page
  • Frontend /payments/result works for both subscription and order when implemented
  • Backend fallback /api/payments/result works when no frontend result page exists
  • next is preserved correctly
  • Refreshing the callback URL is idempotent
  • Refreshing the result page does not re-trigger verification

Current Backend Status

The subscription browser flow now uses backend-owned verification and no longer needs a public verify-payment endpoint. The shared result-page redirect is implemented in the API. If the frontend does not yet provide /payments/result, the backend fallback page at /api/payments/result handles the return flow temporarily.