---
title: "Webhooks and security"
space: "BWH Payments"
url: "https://docs.bwh.tech/bwh-payments/build/webhooks-and-security"
updated: "2026-09-21"
---

Gateways use webhooks to tell your site that a payment 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 applies the result. 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 an empty dict
        E-->>G: 200 (ignored)
    else you return session_id, status, event_id
        E->>P: find the checkout by session_id
        alt no checkout has that session_id
            E-->>G: 200 (logged as Failed)
        else checkout belongs to another gateway
            E-->>G: 400
        else found
            P->>P: apply status once, skip repeated event_id
            E-->>G: 200
        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 |
| `{}` | Genuine, but an event you don't handle | 200 |
| `{"session_id": ..., "status": ..., "event_id": ...}` | The payment changed | 200 |

- `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.

:::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 |

## 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 `{}` 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`. 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 |
| "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. Your app's `on_update` hook runs inside the same request, so enqueue order creation and emails with `frappe.enqueue(..., enqueue_after_commit=True)`. If the hook throws, the gateway gets a 500 and the status change is rolled back.
- **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/build/build-a-payment-gateway): the full step-by-step guide
- [Gateway reference](/bwh-payments/build/gateway-reference): `handle_webhook` parameters and return values
