---
title: "Service clients"
space: "BWH Payments (Next)"
url: "https://docs.bwh.tech/bwh-payments-next/build/service-clients"
updated: "2026-10-11"
---

Every gateway in BWH Payments talks to its provider through a **service client**. This page explains what goes in a client, what stays in the controller, and why.

<picture>
  <source srcset="/files/bwhp-service-clients-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/files/bwhp-service-clients-light.svg" alt="The settings controller calls a client. On a site the live client calls the gateway. In tests the stub answers from memory.">
</picture>

## The layout

Each service has a folder with three files:

```text
bwh_payments/services/razorpay/
├── base.py   # the calls, and models of Razorpay's JSON
├── live.py   # HTTP calls, logging, error reading
└── stub.py   # in-memory Razorpay for tests
```

The gateway's settings controller picks one:

```python
def get_client(self) -> BaseRazorpayClient:
	return (
		StubRazorpayClient()
		if frappe.in_test
		else LiveRazorpayClient(self.key_id, self.get_password("key_secret"))
	)
```

There is no setting or flag for this. A real site always gets the live client.

## What goes where


| Lives in | Owns | Examples |
| --- | --- | --- |
| Controller | Every decision about money and state | Minor-unit conversion, return URLs, status map, capture and refund rules, webhook signature checks |
| `base.py` | The shape of the conversation | One method per provider call. Models with only the fields the controller reads. |
| `live.py` | Transport | The HTTP or SDK call, `create_request_log` with the endpoint and ids, turning a refusal into `frappe.ValidationError` |
| `stub.py` | A believable fake provider | State in `ClassVar`s, controls such as `pay()`, recordings such as `created_links` |


The rule of thumb: **if it can be wrong in a way that costs money, it goes in the controller.** The live client isn't tested, so it must have nothing to get wrong except the call itself.

### Why signature checks stay in the controller

Webhook verification is local cryptography. It doesn't call the provider, so it doesn't belong to the client. More importantly, tests replace the client with a stub. If the check lived in the client, a test, or a bug, could swap in something that accepts a forged event. In the controller, the real check runs in every test.

## Models

Models in `base.py` extend `bwh_payments.services.GatewayModel`:

```python
class RazorpayPaymentLink(GatewayModel):
	id: str
	status: str | None = None
	short_url: str | None = None
	# Razorpay sends null rather than [] for a link nobody has paid against.
	payments: list[RazorpayPayment] | None = None
```

- Unknown fields are ignored, so a provider adding fields never breaks parsing.
- A bare number is read into a `str` field. Some providers send ids as numbers.
- Keep only fields the controller reads, and make a field optional wherever the provider sends `null`.

For an SDK that returns objects rather than dicts, parse with `model_validate(obj, from_attributes=True)`, as the Stripe client does.

## Stubs

A stub implements the same base class as the live client, in memory:


| Part         | Purpose                                                          | Razorpay example                                                                       |
| ------------ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| State        | What the provider knows                                          | `links: ClassVar[dict[str, RazorpayPaymentLink]]`                                      |
| `reset()`    | Clears state. Called in every test's `setUp`.                    | `StubRazorpayClient.reset()`                                                           |
| Controls     | Move the provider to a state, as a shopper or the provider would | `pay(link_id)`, `add_payment(link_id, "authorized")`, `set_status(link_id, "expired")` |
| Recordings   | What the controller sent, in the provider's own shape            | `created_links[-1]["amount"]`                                                          |
| Test helpers | Build and sign webhook bodies                                    | `sign_payload(...)`, `build_payment_link_event(...)`                                   |


A stub follows the provider's real state machine for the paths you support, including refusals. It raises for an unknown id the same way the live client does.

## The four built-in clients


| Service  | Transport                                     | Stub controls                                                    |
| -------- | --------------------------------------------- | ---------------------------------------------------------------- |
| Stripe   | Official SDK, one `StripeClient` per instance | `pay`                                                            |
| Razorpay | Payment Links API over HTTP                   | `add_link`, `add_payment`, `pay`, `set_status`                   |
| Telr     | `order.json` and `remote.xml` over HTTP       | `add_order`, `pay`, `set_status`                                 |
| Tabby    | Checkout and Payments API over HTTP           | `add_payment`, `authorise`, `set_status`, `reject_next_checkout` |


The Stripe, Razorpay and Telr stubs also have `next_refund_status`. Set it to test a refused refund.

## Next steps

- [Build a payment gateway](/bwh-payments-next/build/build-a-payment-gateway): write a controller and client from scratch
- [Test with stubs](/bwh-payments-next/build/test-with-stubs): what to cover, and how