BWH Payments (Next)

BWH Payments (Next)

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

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

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:

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:

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

Last updated 17 hours ago
Was this helpful?
Thanks!