---
title: "Accept a payment"
space: "BWH Payments (Next)"
url: "https://docs.bwh.tech/bwh-payments-next/get-started/accept-a-payment"
updated: "2026-10-12"
---

This page shows how your app takes a payment: create the request, send the shopper to the gateway, and react when the money arrives.

## Requirements

- BWH Payments installed on your site
- A gateway set up and enabled, for example [Stripe](/bwh-payments-next/gateways/stripe)
- A document the payment is for, such as an order

## 1. Create the payment

Call `create_payment` from your server code:

```python
from bwh_payments.api import create_payment


def start_checkout(order):
	payment_request = create_payment(
		ref_doctype=order.doctype,
		ref_docname=order.name,
		amount=order.grand_total,  # major units: 49.99, not 4999
		currency=order.currency,
		gateway="Stripe",  # the Payment Gateway Profile name
		customer_email=order.email,
		customer_forenames=order.first_name,
		customer_surname=order.last_name,
		redirect_to=f"/orders/{order.name}",  # optional: where the shopper lands afterwards
	)
	return payment_request.order_url
```

`create_payment` inserts a **Gateway Payment Request**. While it saves, BWH Payments opens a checkout session at the gateway and stores:

| Field | Holds |
|---|---|
| `order_ref` | The gateway's session id |
| `order_url` | The hosted checkout URL. Send the shopper here. |
| `status` | `Pending` |

If the gateway refuses to open a checkout, `create_payment` raises and nothing is saved.

### Extra fields

Apart from the five arguments, `create_payment` accepts only `company`, `redirect_to` and fields that start with `customer_`. The customer fields are `customer_email`, `customer_phone`, `customer_forenames`, `customer_surname`, `customer_ref` (a Customer) and `customer_address` (an Address). Gateways use them to fill in the checkout form, and leave out any that are empty.

:::danger[It skips permission checks]
`create_payment` inserts with `ignore_permissions=True`, so a website checkout works for guests. Check that the shopper may buy what they're paying for before you call it. Never call it from a whitelisted method with arguments taken straight from the request.

That's also why it refuses other fields. A caller that forwards user input can't set `status`, `order_ref` or the refund totals.
:::

### Choose a gateway

List the gateways a shopper can pick from, with their logos:

```python
from bwh_payments.api import get_gateway_options, validate_currency

get_gateway_options("SAR")  # only the gateways that can charge SAR
validate_currency("Tabby", order.currency)  # throws before any checkout is opened
```

`get_payment_gateways()` returns only the names. Both read a list that is cached in Redis and cleared when a profile changes.

## 2. Send the shopper to the gateway

Redirect the browser to `order_url`. The shopper pays on the gateway's page, then comes back:

- With `redirect_to`, BWH Payments confirms the payment with the gateway, then redirects to your path.
- Without it, the gateway sends the shopper to the **Success URL** or **Failure URL** in its settings.

[Return pages](/bwh-payments-next/get-started/return-pages) covers both.

## 3. React when it's paid

When a request becomes **Paid**, BWH Payments calls `on_payment_authorized` on the document you passed as `ref_doctype` and `ref_docname`:

```python
class SalesOrder(Document):
	def on_payment_authorized(self, status: str):
		# status is "Completed"
		frappe.enqueue(
			"your_app.orders.fulfil", order=self.name, enqueue_after_commit=True
		)
```

- It's called once per payment, whether a webhook, a status check or a scheduled sync settled it.
- It's the same convention as the `frappe/payments` app, so a document written for that app works unchanged.
- If your document has no such method, nothing happens.

:::caution
`on_payment_authorized` runs inside the transaction that settled the payment: a webhook job, the shopper's return or a status check. Keep it fast and enqueue heavy work. If it throws, the status change is rolled back. A webhook is then marked **Failed** in its Integration Request, and a System Manager can run it again with `retry_webhook` after the fix. See [Webhooks and security](/bwh-payments-next/build/webhooks-and-security#when-your-document-fails).
:::

### Or use a hook

To react to every status, not only Paid, add a `doc_events` hook instead:

```python
# hooks.py
doc_events = {
	"Gateway Payment Request": {
		"on_update": "your_app.payments.on_payment_request_update",
	}
}
```

```python
def on_payment_request_update(doc, method=None):
	if doc.has_value_changed("status") and doc.status == "Not Paid":
		frappe.enqueue(release_stock, order=doc.ref_docname, enqueue_after_commit=True)
```

Check `has_value_changed`. The hook runs on every save, not only when the status changes.

## 4. Settle payments nobody confirmed

Some shoppers close the tab after paying. A webhook usually settles those payments, but [Telr](/bwh-payments-next/gateways/telr) has no trusted webhook and [Tabby](/bwh-payments-next/gateways/tabby) captures only on a status check. Run a scheduled job that calls `sync_status()` on requests that are still Pending:

```python
def sync_pending_payments():
	for name in frappe.get_all(
		"Gateway Payment Request",
		filters={"status": "Pending", "creation": ("<", frappe.utils.add_to_date(None, minutes=-15))},
		pluck="name",
	):
		try:
			frappe.get_doc("Gateway Payment Request", name).sync_status()
			frappe.db.commit()
		except Exception:
			frappe.db.rollback()
			frappe.log_error(title=f"Payment sync failed: {name}")
```

## Refund a payment

```python
from bwh_payments.api import refund

refund(payment_request.name)  # the rest of the payment
gateway_refund = refund(payment_request.name, amount=10, reference=my_refund.name)  # part of it
```

Each refund is a **Gateway Refund** with the status `Pending`, `Processed` or `Failed`. A refund that the gateway accepts but has not finished stays `Pending` until a refund webhook or `sync_refunds` settles it. Only a `Processed` refund changes the request to **Partially Refunded** or **Refunded**.

BWH Payments refuses a refund larger than what is left, and a `Pending` refund counts against what is left. To follow a refund, add `on_refund_update` to your document:

```python
class SalesOrder(Document):
	def on_refund_update(self, refund):
		# refund.reference is the id you passed, or empty for a refund raised on the gateway's dashboard
		if refund.status == "Processed":
			mark_refunded(self, refund.amount)
```

## Next steps

- [Return pages](/bwh-payments-next/get-started/return-pages): confirm the payment when the shopper comes back
- [Public API](/bwh-payments-next/reference/public-api): every function in `bwh_payments.api`
