BWH Payments

BWH Payments

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Webhooks and security

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

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

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.

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:

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

Last updated 3 weeks ago
Was this helpful?
Thanks!