After paying, or giving up, the shopper is sent back to your site. BWH Payments doesn't include these pages. You build them in your own app, then enter their URLs in each gateway's settings.
You need two pages:
| 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.
# 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:
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);
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: how a payment moves from Pending to Paid
- Webhooks and security: the other way a payment settles