---
title: "Gateway contract"
space: "BWH Payments (Next)"
url: "https://docs.bwh.tech/bwh-payments-next/reference/gateway-contract"
updated: "2026-10-12"
---

Every gateway is a Single DocType whose controller extends both `Document` and `PaymentGatewayBase`:

```python
from frappe.model.document import Document

from bwh_payments.base_class import CheckoutSession, PaymentGatewayBase, RefundResult, WebhookEvent


class AcmePayGatewaySettings(Document, PaymentGatewayBase): ...
```

Methods return pydantic models from `bwh_payments.base_class`. BWH Payments reads every result with `<Model>.model_validate`, so:

- a gateway that still returns a plain `dict` with the same keys keeps working
- a result missing a required field fails at once, with a clear error, instead of saving a broken request

<picture>
  <source srcset="/files/bwhp-gateway-contract-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/files/bwhp-gateway-contract-light.svg" alt="BWH Payments calls five methods on a gateway, and each returns a model.">
</picture>

| Method | Required | Called when |
|---|---|---|
| `create_session` | ✅ | A Gateway Payment Request is created |
| `get_payment_status` | ✅ | An app checks a payment without waiting for a webhook |
| `refund_payment` | ✅ | A refund is issued |
| `handle_webhook` | ✅ | The gateway posts to the webhook endpoint |
| `cancel_session` | | Your app calls `release_if_unpaid()` on an open checkout |
| `get_transaction_ref` | | A payment becomes Paid |
| `get_refund_status` | | `sync_refunds` checks a Pending refund |
| `supported_currencies` | | An app lists gateways for a currency, or checks one |
| `get_gateway_name` | | Never by BWH Payments. It's for your own use. |

The optional methods have defaults, so a gateway that does not define them keeps working.

## Rules for every method

- **Money is in major units.** `49.99` means $49.99. Convert with the currency helpers below, and use the same conversion for charges and refunds.
- **Statuses are BWH Payments statuses**, already mapped from the gateway's own wording: `Pending`, `Paid`, `Not Paid`, `Cancelled`, `Expired`.
- **To report failure, throw.** Use `frappe.throw` with a message a person can act on.

---

## create_session

```python
def create_session(
	self,
	amount: float,
	currency: str,
	reference: str | None = None,
	customer: dict | None = None,
	return_url: str | None = None,
) -> CheckoutSession
```

Opens a hosted checkout. Runs when a Gateway Payment Request is saved without a gateway session, which normally means once, on insert.

**Parameters**

| Name | Type | Description |
|---|---|---|
| `amount` | `float` | Amount to charge, in major units |
| `currency` | `str` | ISO 4217 code, for example `USD` or `KWD` |
| `reference` | `str` | The Gateway Payment Request name. Send it as the gateway's merchant reference. |
| `customer` | `dict` | The shopper. See Customer below. Any field may be empty. |
| `return_url` | `str` | Passed only when the app set `redirect_to`. Send the shopper here after a payment, a failure or a cancel, instead of your settings URLs. Do not add parameters to it. |

**Returns** a `CheckoutSession`:

| Field | Required | Description |
|---|---|---|
| `session_id` | ✅ | The gateway's id for this checkout, stored as the request's `order_ref`. **It must be the id the gateway's webhooks and status endpoint use.** |
| `redirect_url` | ✅ | The hosted checkout URL the shopper is sent to |
| `success_url` | | Where the shopper returns after paying |
| `cancel_url` | | Where the shopper returns after cancelling |
| `failure_url` | | Where the shopper returns after a failed payment |

**Throw when** the currency isn't supported, a required setting is missing, or the gateway refuses to open a checkout. The request isn't saved, and your message is raised to the app that created it.

:::tip
If the gateway can't send its own id back to your success URL, add `reference_id=<reference>` to the return URLs, so your confirmation page can find the request.
:::

---

## get_payment_status

```python
def get_payment_status(self, session_id: str) -> str
```

Reads the current status **from the gateway's API**. It is called:

- when the shopper returns to the site
- by your own scheduled job, if you run one to settle Pending payments no one confirmed
- by `release_if_unpaid()`, before an open checkout is cancelled

For gateways with no trustworthy webhook, this is the only source of truth.

**Parameters**

| Name | Type | Description |
|---|---|---|
| `session_id` | `str` | The `session_id` your `create_session` returned |

**Returns** one of `Pending`, `Paid`, `Not Paid`, `Cancelled`, `Expired`.

- Map anything you don't recognise to `Pending`.
- Return `Paid` only when the money has actually been captured.
- Only a request that is still `Pending` changes. Once it's Paid, Cancelled, Not Paid or Expired, a later answer is ignored.

---

## refund_payment

```python
def refund_payment(self, session_id: str, amount: float, currency: str | None = None) -> RefundResult
```

Refunds part or all of a paid checkout. Before your method runs, BWH Payments has already:

- checked the payment is refundable
- checked `amount` isn't more than what's left
- skipped a refund Payment Entry (or an amendment of one) that was already refunded

**Parameters**

| Name | Type | Description |
|---|---|---|
| `session_id` | `str` | The checkout to refund |
| `amount` | `float` | Amount to refund, in major units |
| `currency` | `str` | The payment's currency |

**Returns** a `RefundResult`. Every field is optional: it's read after the money has moved, so a thin answer from the gateway must not fail the refund.

| Field | Description |
|---|---|
| `refund_id` | The gateway's refund id. Every partial refund's id is kept for reconciliation. |
| `status` | The gateway's own refund status. `succeeded`, `processed` or no status mean done. `pending` or anything unknown means Pending. `failed`, `canceled` or `cancelled` mean Failed. |
| `amount` | Informational: the amount refunded, in major units. The refunded total is increased by the amount requested, not this value. |

**Throw when** the gateway refuses the refund. The Gateway Refund becomes Failed with your message, and the payment's refund total does not change.

---

## handle_webhook

```python
def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None
```

Verifies a delivery to the webhook endpoint and says what it means.

**Parameters**

| Name | Type | Description |
|---|---|---|
| `payload` | `bytes` | The raw request body, exactly as received. Verify signatures against this. |
| `headers` | `dict` | Request headers, with **title-case** names such as `X-Acme-Signature` |

**Returns**

| Return | Meaning | HTTP response |
|---|---|---|
| *(throws)* | Verification failed | 400 |
| `None` | Genuine, but not something to act on | 200 |
| `WebhookEvent(session_id, status, event_id)` | Apply `status` to the checkout with this `session_id` | 200 |
| `WebhookEvent(refund_id, status, refund_amount, transaction_ref, event_id)` | Apply `status` to the refund with this `refund_id`, or record it against the payment whose `transaction_ref` matches | 200 |
| Anything that isn't a valid `WebhookEvent` | A verified delivery you couldn't turn into an event | 400, so the gateway retries |

`event_id` must stay the same when the gateway retries a delivery, and differ between events. A repeated `event_id` is ignored.

See [Webhooks and security](/bwh-payments-next/build/webhooks-and-security).

---

## cancel_session

```python
def cancel_session(self, session_id: str) -> bool
```

*Optional. The default returns `False`.*

Cancels an open checkout. Called by `release_if_unpaid()`. Return `True` only when the gateway confirms the checkout can no longer be paid. On any gateway error, return `False` instead of throwing.

If this returns `False`, the payment stays Pending until the gateway settles it, and `release_if_unpaid()` returns `False`.

---

## get_transaction_ref

```python
def get_transaction_ref(self, session_id: str) -> str | None
```

*Optional. The default returns `None`.*

Returns the gateway's id for the settled payment, for example Stripe's `payment_intent` or Razorpay's `pay_…` id. BWH Payments stores it as the request's `gateway_transaction_ref` when the payment becomes Paid. A refund webhook for a refund raised on the gateway's dashboard finds the payment through it. If this method throws, the error is logged and the payment still becomes Paid.

---

## get_refund_status

```python
def get_refund_status(self, session_id: str, refund_id: str) -> RefundResult | None
```

*Optional. The default returns `None`, which means the gateway cannot report a refund.*

Reads a refund's current status from the gateway, for `sync_refunds`. Return a `RefundResult` with the gateway's refund status.

---

## supported_currencies

```python
def supported_currencies(self) -> tuple[str, ...] | None
```

*Optional. The default returns `None`, which means any currency the site has.*

Returns the ISO codes the gateway can charge in. `get_gateway_options` and `validate_currency` use it.

You can also set the class attributes `logo` and `logo_dark` to site paths of your gateway's logo, for example `/assets/your_app/images/acme-pay.svg`. `get_gateway_options` returns them.

---

## get_gateway_name

```python
def get_gateway_name(self) -> str
```

*Optional. The default is the class name.* Returns a human-readable name, for example `"Acme Pay"`. BWH Payments doesn't call it. It's there for your own code.

---

## Models

```python
class CheckoutSession(BaseModel):
	session_id: str
	redirect_url: str
	success_url: str | None = None
	cancel_url: str | None = None
	failure_url: str | None = None


class RefundResult(BaseModel):
	refund_id: str | None = None
	status: str | None = None
	amount: float | None = None  # major units


class WebhookEvent(BaseModel):
	session_id: str | None = None  # a payment event
	status: str  # a Gateway Payment Request status, or the gateway's refund status
	event_id: str | None = None
	refund_id: str | None = None  # a refund event
	refund_amount: float | None = None  # major units
	transaction_ref: str | None = None  # the refunded payment's id

	# A WebhookEvent needs session_id or refund_id.
```

---

## Customer

The `customer` argument to `create_session`:

```python
{
	"ref": "CUST-0001",  # linked customer id, or ""
	"email": "shopper@example.com",  # or ""
	"phone": "+15550100",  # or ""
	"name": {"forenames": "Ada", "surname": "Lovelace"},
	"address": {"line1": "1 Main St", "city": "Dubai", "country": "United Arab Emirates"},  # or None
}
```

Guest checkouts often leave fields empty. Drop the empty ones before you send the dict to the gateway.

## Currency helpers

```python
from bwh_payments.currency import (
	from_minor_units,
	get_minor_unit_exponent,
	to_minor_units,
	validate_transaction_currency,
)
```

| Helper | Example | Use it to |
|---|---|---|
| `to_minor_units(amount, currency)` | `(49.99, "USD")` → `4999`, `(12.345, "KWD")` → `12345` | Send amounts to gateways that bill in cents |
| `from_minor_units(amount, currency)` | `(4999, "USD")` → `49.99` | Read the gateway's echoed amounts |
| `get_minor_unit_exponent(currency)` | `"KWD"` → `3`, `"JPY"` → `0`, `"USD"` → `2` | Format major-unit strings, e.g. `f"{amount:.{exponent}f}"` |
| `validate_transaction_currency(currency)` | Throws for an unknown code | Check the currency exists on the site before calling out |

Exponents follow ISO 4217. They aren't read from the site's Currency settings.

:::caution[Three-decimal currencies]
Frappe rounds currency to the site's number format, usually 2 decimals. For 3-decimal currencies, turn on **System Settings → Use Number Format From Currency**, and leave **Currency Precision** blank or set it to 3 or more. Otherwise a Gateway Payment Request for 12.345 KWD is refused rather than charged a rounded amount.
:::

## URL helper

```python
from bwh_payments.bwh_payments.utils import get_localised_url
```

`get_localised_url(url)` swaps a leading language segment, such as `/en/`, for the shopper's current language, so they return to the page in their own language. Use it on every return URL you send the gateway.
