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

Files touched

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

Review this PR

Python practice