---
title: "Return pages"
space: "BWH Payments (Next)"
url: "https://docs.bwh.tech/bwh-payments-next/get-started/return-pages"
updated: "2026-10-12"
---

After paying, or giving up, the shopper is sent back to your site. BWH Payments does not include these pages. You build them in your own app.

There are two ways to send the shopper back:

| Way | Set | BWH Payments confirms the payment |
|---|---|---|
| **Per payment** | `redirect_to` on `create_payment` | Yes, before the shopper reaches your page |
| **Per gateway** | The return URLs in each gateway's settings | No. Your page must confirm it. |

## Per payment: redirect_to

Pass a path on your site:

```python
create_payment(order.doctype, order.name, order.grand_total, order.currency, "Stripe", redirect_to=f"/orders/{order.name}")
```

Every gateway then returns the shopper, after a payment, a failure or a cancel, to this endpoint:

```
/api/method/bwh_payments.bwh_payments.checkout.return_from_gateway?request=<Gateway Payment Request name>
```

The endpoint asks the gateway for the status, saves it, and redirects to `redirect_to`. If the payment is now Paid, your document's `on_payment_authorized` has already run. Your page reads the status of your own document.

- The settings' return URLs are not used for this payment.
- The endpoint accepts guests, and is rate limited to 60 requests per minute per IP address.
- If the gateway check fails, the shopper still goes to your page. The webhook settles the payment later.

Your page still shows a Pending state, because some payments settle after the shopper comes back. See [If the status is still Pending](#if-the-status-is-still-pending).

## Per gateway: settings URLs

You enter the URLs of two pages in each gateway's settings:

| Page | Enter it as | It should |
|---|---|---|
| **Confirmation** | Success URL (Telr: Authorised URL) | Confirm the payment with the gateway, then show *Paid*, or *We're confirming your payment* while it's still Pending |
| **Payment failed** | Failure URL, Cancelled URL (Telr: Declined URL, Cancelled URL) | Say the payment didn't go through, and offer a retry or another payment method |

Use full `https://` URLs, for example `https://shop.example.com/en/payment/confirmation`.

## What the gateway adds to the URL

Each gateway sends the shopper back with a query parameter that identifies the payment:

| Gateway | Parameter | Holds |
|---|---|---|
| Stripe | `session_id` | The gateway session id, which is the request's `order_ref` |
| Razorpay | `reference_id` | The Gateway Payment Request's `name` |
| Telr | `reference_id` | The Gateway Payment Request's `name` |
| Tabby | `reference_id` | The Gateway Payment Request's `name` |

Razorpay and Tabby add their own parameters as well, which you can ignore. Your page should accept either value, and look the request up by `order_ref` first, then by `name`.

## Confirm the payment

The redirect proves nothing: anyone can open your success URL. Ask the gateway instead, with `sync_status()`, from a **POST** request.

```python
# your_app/api.py
import frappe
from frappe import _


@frappe.whitelist(allow_guest=True, methods=["POST"])
def confirm_payment(reference: str) -> dict:
	name = frappe.db.get_value(
		"Gateway Payment Request", {"order_ref": reference}, "name"
	) or frappe.db.exists("Gateway Payment Request", reference)
	if not name:
		frappe.throw(_("Payment not found"), frappe.DoesNotExistError)

	payment_request = frappe.get_doc("Gateway Payment Request", name)
	validate_shopper_owns(payment_request)  # your check: is ref_docname theirs?

	if payment_request.status == "Pending":
		payment_request.sync_status()

	return {"status": payment_request.status}
```

Call it when the page loads:

```js
const params = new URLSearchParams(window.location.search);
const reference = params.get('session_id') || params.get('reference_id');

const response = await frappe.call({
	method: 'your_app.api.confirm_payment',
	args: { reference },
	type: 'POST',
});
showPaymentStatus(response.message.status);
```

:::danger[Don't sync inside get_context]
Frappe only saves database changes on POST, PUT and DELETE requests. If your page's `get_context` calls `sync_status()` during a normal page load (a GET), the shopper sees *Paid*, but the new status is thrown away when the request ends. Confirm from a POST, as shown above.
:::

:::caution[Check who's asking]
`confirm_payment` accepts guests, so anyone can call it with any reference. Before you show anything, check that the payment belongs to the current shopper, for example that `ref_docname` is their order. `sync_status()` only brings the status up to date from the gateway, but order details are private.
:::

## If the status is still Pending

The shopper can arrive before the gateway has settled the payment. Show a "confirming your payment" state, and call `confirm_payment` again after a few seconds. The webhook usually settles it shortly afterwards.

Some shoppers never come back to your site, for example when they close the tab after paying. Run a scheduled job that calls `sync_status()` on requests that are still Pending, so those payments settle too. Telr has no trustworthy webhook, and Tabby captures payments only on a status check, so both depend on this.

## Language

For Razorpay, Telr and Tabby, a language segment at the start of your URL's path is switched to the shopper's current language. `https://shop.example.com/en/payment/confirmation` becomes `/ar/payment/confirmation` for an Arabic session. Stripe uses your URLs exactly as entered.

## Next steps

- [Overview](/bwh-payments-next/get-started/overview): how a payment moves from Pending to Paid
- [Webhooks and security](/bwh-payments-next/build/webhooks-and-security): the other way a payment settles
