Mark orders paid from the Paywell webhook
Python · Django · intermediate · modification
Moves payment confirmation from the post-checkout redirect to Paywell's webhook, so a customer who closes the tab after paying still gets their order. The view parses the `Paywell-Signature` header strictly (anything malformed is a 400) and enforces Paywell's 5-minute timestamp window, so old deliveries are turned away. The body's shape and types are checked before use, the amount and currency must match the order, and the status change is a conditional `update()`, so Paywell's retries, parallel ones included, change nothing after the first. CSRF is exempted for this view only, since Paywell cannot send our token. Tested with Paywell's CLI forwarding test events to staging: orders flip to paid, a repeated delivery changes nothing.
Django 5.1 shop with the default `startproject` middleware (CSRF included). `Order` (existing, `shop/models.py`) has `reference` (unique string, e.g. `ORD-1001`), `total_minor` (positive integer, minor units), `currency` (ISO code, e.g. `EUR`), `status` (`pending` / `paid` / `cancelled`, a `TextChoices` named `Order.Status`) and `paid_at` (nullable); an order's total and currency never change after checkout, and a customer sees their order's reference and total on the confirmation page and in the confirmation email. `paid` releases the order to the warehouse, which ships it: the warehouse picks up orders by querying `status='paid'`; nothing hooks `Order.save()` or its signals. `settings.PAYWELL_WEBHOOK_SECRET` (an ASCII string) is set in every environment; Paywell's dashboard shows the same secret. Paywell's documented event body is `{"id": "evt_...", "type": "payment.succeeded", "data": {"order_reference": "ORD-1001", "amount": 4999, "currency": "EUR"}}`, where `amount` is an integer in minor units and `currency` is the uppercase ISO 4217 code, exactly as `Order.currency` stores it; Paywell bodies are small JSON objects of this shape, and `t` in the header never has leading zeros. The endpoint is reachable from the public internet; Paywell does not publish a fixed list of sender IPs.
Requirements
- Add `POST /webhooks/paywell/`. Paywell calls it after a card payment; on a `payment.succeeded` event, move the matching `Order` (by `data.order_reference`) from `pending` to `paid` and set `paid_at`. It replaces the redirect-based confirmation, whose paid-marking is removed in the follow-up PR; from then on this endpoint is the only thing that marks an order paid.
- Only act on requests that really come from Paywell. Every request carries a `Paywell-Signature` header of the form `t=<unix seconds>,v1=<64 lowercase hex>`, where `v1` is the HMAC-SHA256, keyed with `settings.PAYWELL_WEBHOOK_SECRET`, of the bytes `<t>.` followed by the raw request body. Compare it in constant time. Reject with 400, and change nothing, when the header is missing or malformed, `t` is more than 5 minutes away from our clock in either direction, or `v1` does not match.
- A verified event gets 400 unless its body is a JSON object with a string `type` and a `data` object holding a string `order_reference`, an integer `amount` and a string `currency` (other fields, `id` included, are not needed and are ignored). A verified event of another type, for an unknown reference, or whose `data.amount`/`data.currency` differ from the order's `total_minor`/`currency` gets 200 and changes nothing (the last two are logged as warnings for support).
- Paywell retries a delivery until it gets a 2xx, so the same event can arrive more than once, possibly concurrently: only a `pending` order may become `paid`, and repeats must be harmless. Do not log the raw request body or the signature.
Files touched
- config/urls.py
- shop/webhooks.py
--- config/urls.py
from django.urls import include, path
+from shop.webhooks import paywell_webhook
+
urlpatterns = [
path("admin/", admin.site.urls),
+ path("webhooks/paywell/", paywell_webhook, name="paywell-webhook"),
path("", include("shop.urls")),
]
--- shop/webhooks.py
+import json
+import logging
+import re
+import time
+
+from django.http import HttpResponse, HttpResponseBadRequest
+from django.utils import timezone
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
+
+from .models import Order
+
+log = logging.getLogger(__name__)
+
+TOLERANCE_SECONDS = 300
+_HEADER_RE = re.compile(r"t=([1-9][0-9]{0,10}),v1=([0-9a-f]{64})")
+
+
+def parse_signature_header(header):