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
POSTonly, 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:
- Load the secret, and throw if it's missing. A missing secret must never mean "skip the check".
- Read the signature header by its title-case name, for example
X-Acme-Signature, notx-acme-signature. - Compute the signature over
payloadexactly as received. Don't parse and re-serialise it first: the bytes change and the signature never matches. - Compare in constant time with
hmac.compare_digest, after checking the header is ASCII. A plain==leaks timing, andcompare_digestraisesTypeErroron non-ASCII text. - 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_idis the id yourcreate_sessionreturned.statusis a BWH Payments status:Paid,Not Paid,CancelledorExpired. ReturningPendingchanges 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_idis 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_webhookshould only verify and translate. Your app'son_updatehook runs inside the same request, so enqueue order creation and emails withfrappe.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: the full step-by-step guide
- Gateway reference:
handle_webhookparameters and return values