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
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 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:
- 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 |
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_idis the id yourcreate_sessionreturned.statusis a BWH Payments status:Paid,Not Paid,CancelledorExpired. ReturningPendingchanges nothing.- For a refund event,
statusis the gateway's refund status, for examplesucceeded,processedorfailed. BWH Payments maps it toPending,ProcessedorFailed. refund_amountis in major units.transaction_refis 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_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 |
When your document fails
The job that applies an event runs your document's on_payment_authorized or on_refund_update. If that throws:
- The job rolls the change back, so the payment stays as it was.
- The error goes to the Error Log, and the event's Integration Request becomes Failed.
- 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_webhookshould 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: the full step-by-step guide
- Gateway reference:
handle_webhookparameters and return values