---
title: "Webhooks and security"
space: "BWH Payments (Next)"
url: "https://docs.bwh.tech/bwh-payments-next/build/webhooks-and-security"
updated: "2026-10-12"
---

Gateways use webhooks to tell your site that a payment or a refund has settled, even if the shopper closed the tab. BWH Payments has **one webhook endpoint** for every gateway. It finds your gateway, hands the delivery to your `handle_webhook`, and queues the result. A background job applies it. Your job is to verify the delivery and translate it.

## Your webhook URL

```
https://your.site/api/v2/method/bwh_payments.bwh_payments.webhook.handle?gateway=<Payment Gateway Profile name>
```

- Use the profile's exact name, URL-encoded: `?gateway=Acme%20Pay`.
- The endpoint accepts `POST` only, and is rate limited to 120 requests per minute per IP address.
- A disabled profile rejects every delivery.

Register this URL in the gateway's dashboard, and subscribe only to the events you handle.

## What happens to a delivery

```mermaid
sequenceDiagram
    participant G as Gateway
    participant E as Webhook endpoint
    participant H as Your handle_webhook
    participant P as Gateway Payment Request

    G->>E: POST ?gateway=Acme Pay
    E->>H: handle_webhook(raw body, headers)
    alt you throw
        E-->>G: 400
    else you return None
        E-->>G: 200 (ignored)
    else you return something that isn't a valid WebhookEvent
        E-->>G: 400 (logged in Error Log)
    else you return a WebhookEvent
        E->>P: find the checkout by session_id, or the refund by refund_id
        alt nothing matches
            E-->>G: 200 (logged as Failed)
        else checkout belongs to another gateway
            E-->>G: 400
        else event_id already logged
            E-->>G: 200 (not queued again)
        else found
            E->>E: log the event as Queued, then queue a job
            E-->>G: 200
            E->>P: the job applies the status once
        end
    end
```



## Verify every delivery

The webhook URL is public, so anyone can post to it. Verify the delivery before you read it:

1. **Load the secret, and throw if it's missing.** A missing secret must never mean "skip the check".
2. **Read the signature header by its title-case name**, for example `X-Acme-Signature`, not `x-acme-signature`.
3. **Compute the signature over** `payload` **exactly as received.** Don't parse and re-serialise it first: the bytes change and the signature never matches.
4. **Compare in constant time** with `hmac.compare_digest`, after checking the header is ASCII. A plain `==` leaks timing, and `compare_digest` raises `TypeError` on non-ASCII text.
5. **Only then parse the body.**

```python
secret = self.get_password("webhook_secret", raise_exception=False)
if not secret:
	frappe.throw(_("Set the webhook secret in Acme Pay Gateway Settings"))

signature = headers.get("X-Acme-Signature") or ""
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
if not signature.isascii() or not hmac.compare_digest(expected, signature):
	frappe.throw(_("Acme Pay webhook signature does not match"))

event = frappe.parse_json(payload.decode())
```

If the gateway ships an official verification helper, as Stripe does with `stripe.Webhook.construct_event`, use it instead. Most helpers also reject old timestamps, which blocks replayed signatures.

:::note
Every verification failure gets the same response: `400 Webhook could not be verified`. That includes an unknown gateway, a disabled profile and a bad signature, so callers can't use the endpoint to discover which gateways a site has set up. Signature failures are recorded in the site's **Error Log**. Unknown or disabled gateways aren't logged.
:::

## What to return


| Return                                                   | When                                                                     | Response                    |
| -------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------- |
| *(throw)*                                                | The delivery isn't genuine, or you can't read an event you should act on | 400, so the gateway retries |
| `None`                                                   | Genuine, but an event you don't handle                                   | 200                         |
| `WebhookEvent(session_id=..., status=..., event_id=...)` | The payment changed                                                      | 200                         |
| `WebhookEvent(refund_id=..., status=..., refund_amount=..., transaction_ref=..., event_id=...)` | A refund changed | 200 |


A result that isn't `None` and isn't a valid `WebhookEvent`, for example a dict without `session_id`, is treated like a throw: 400, and an Error Log entry. Earlier versions answered 200 and only logged it.

- `session_id` is the id your `create_session` returned.
- `status` is a BWH Payments status: `Paid`, `Not Paid`, `Cancelled` or `Expired`. Returning `Pending` changes nothing.
- For a refund event, `status` is the gateway's refund status, for example `succeeded`, `processed` or `failed`. BWH Payments maps it to `Pending`, `Processed` or `Failed`.
- `refund_amount` is in major units. `transaction_ref` is the id of the refunded payment. Together they record a refund raised on the gateway's dashboard, which BWH Payments has no record of.

:::caution
Throwing on an event you *should* handle is intentional. If a "paid" event arrives that you can't read, a 200 would leave the order unpaid with no retry. A 400 makes the gateway send it again, and the failure lands in the Error Log.
:::

## Replays and duplicates

Gateways retry, and a webhook can arrive at the same moment the shopper returns to your site. BWH Payments applies a status once:

- **Only a Pending payment changes.** A second "paid" delivery, or a status check after the webhook, finds it already Paid and does nothing.
- **A repeated** `event_id` **is skipped**, and still answered with 200 so the gateway stops retrying.

Choose an `event_id` that stays the same across retries of one event:


| Gateway sends           | Use                                                            |
| ----------------------- | -------------------------------------------------------------- |
| An event id in the body | That id                                                        |
| An event id in a header | That header, and throw if it's missing                         |
| No event id             | A hash of the payment id, its status and its last-updated time |


## When your document fails

The job that applies an event runs your document's `on_payment_authorized` or `on_refund_update`. If that throws:

1. The job rolls the change back, so the payment stays as it was.
2. The error goes to the **Error Log**, and the event's Integration Request becomes **Failed**.
3. The gateway already got its 200, so it does not send the event again.

After you fix the cause, run the event again as a System Manager, for example from `bench console`:

```python
from bwh_payments.bwh_payments.webhook import retry_webhook

retry_webhook("<Integration Request name>")
```

`retry_webhook` is also a whitelisted POST method, so a System Manager can call it over the API.

A deadlock or a lock timeout is retried by the job runner up to five times. The job raises `RetryBackgroundJobError` for these, because Frappe does not retry its wrapped `QueryDeadlockError` and `QueryTimeoutError` on its own.

## Gateways that don't sign webhooks

Some gateways don't sign their webhooks at all. Never apply an unverified status. Pick one of these:


| Approach                           | How                                                                                                                                                           | Used by |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| **Ignore the webhook**             | Return `None` every time, and rely on `get_payment_status`                                                                                                    | Telr    |
| **Shared token and IP allow-list** | You choose a token, register it with the gateway, and compare it with `hmac.compare_digest`. Also check the sender's IP against the gateway's published list. | Tabby   |


A shared token proves who sent the delivery, not what it says. Treat it like a password.

## Test a webhook locally

Build a signed delivery and post it to your site:

```python
import hashlib, hmac, json

payload = json.dumps(
	{"id": "evt_1", "type": "checkout.succeeded", "data": {"id": "chk_123"}}
).encode()
signature = hmac.new(b"test_secret", payload, hashlib.sha256).hexdigest()
print(signature)
```

```bash
curl -X POST "http://your.site:8000/api/v2/method/bwh_payments.bwh_payments.webhook.handle?gateway=Acme%20Pay" \
  -H "Content-Type: application/json" \
  -H "X-Acme-Signature: <signature>" \
  --data-raw '{"id": "evt_1", "type": "checkout.succeeded", "data": {"id": "chk_123"}}'
```

The body you post must be **byte-for-byte** the one you signed. `chk_123` must be the `order_ref` of a Pending Gateway Payment Request on that site.

## Debug a delivery

Every delivery for an enabled gateway is logged as an **Integration Request** with the service `<gateway> Webhook`. It holds the parsed event only: ids, status and amounts. Verification failures also go to the **Error Log** as `<gateway> webhook verification failed`.


| Symptom                                                              | Likely cause                                                                   | Fix                                                                                    |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| Every delivery gets 400, and the Error Log has "verification failed" | Wrong or empty webhook secret, or the header name doesn't match                | Paste the secret from the gateway's dashboard. Read the header by its title-case name. |
| Signatures never match, but the secret is right                      | You signed a re-serialised body, or a proxy changes it                         | Sign the raw `payload` bytes. Check your proxy doesn't rewrite bodies.                 |
| 400 with no Error Log entry                                          | `?gateway=` is missing or wrong, or the profile is disabled                    | Match the profile name exactly, and URL-encode spaces                                  |
| 200, but the Integration Request says Failed                         | No payment has that `session_id`                                               | Return the same id from the webhook that `create_session` returned                     |
| 200, but the status doesn't change                                   | The payment wasn't Pending, the `event_id` repeated, or you returned `Pending` | Check the payment's status and your status mapping                                     |
| The Integration Request stays Queued                                 | No background worker is running on the `short` queue                           | Start the workers, then run `retry_webhook` on the request                              |
| The Integration Request says Failed, with an Error Log entry         | Your document's `on_payment_authorized` or `on_refund_update` threw            | Fix it, then run `retry_webhook`                                                       |
| "webhook gateway mismatch" in the Error Log                          | The URL names a different gateway from the one that took the payment           | Give each gateway its own webhook URL                                                  |


## Best practices

- **Keep the webhook fast.** `handle_webhook` should only verify and translate. The status is applied in a background job, so a slow or broken document never makes the gateway wait or retry.
- **Don't log the payload.** It can hold card and personal data. Log ids only.
- **Don't trust the redirect.** A shopper landing on your success URL proves nothing. Call `sync_status()` to ask the gateway.
- **Rotate secrets on both sides together.** Update the gateway's dashboard and your settings at the same time, or deliveries fail in between.

## Next steps

- [Build a payment gateway](/bwh-payments-next/build/build-a-payment-gateway): the full step-by-step guide
- [Gateway reference](/bwh-payments-next/reference/gateway-contract): `handle_webhook` parameters and return values