Taking recurring payments in a Django app means thinking in webhooks: the payment provider calls your server when something happens, not the other way round. This note uses Paystack as the example provider; the same design fits Stripe and others. Related: 2. Web Application Security, Celery and Message Brokers.
The model
sequenceDiagram
participant C as Customer
participant A as Django app
participant P as Payment provider
C->>A: Start subscription
A->>P: Initialise transaction
P-->>C: Hosted payment page
C->>P: Pays
P->>A: Webhook: charge.success
A->>A: Verify signature, record, activate access
Note over P,A: Later billing cycles
P->>A: Webhook: invoice.payment_failed
A->>A: Mark past due, notify customer
P->>A: Webhook: subscription.disable
A->>A: End access
Libraries from other ecosystems often hide the flow; building it directly teaches the real lifecycle. Never trust the browser redirect to say a payment succeeded; trust a verified webhook (or a server-side verify call).
Events to handle (Paystack names)
| Event | Meaning | Typical action |
|---|---|---|
charge.success |
A payment went through | Record payment, activate or extend the subscription |
subscription.create |
A subscription started | Store the provider's subscription code |
invoice.create / invoice.update |
An upcoming or updated invoice | Optional: show the next billing date |
invoice.payment_failed |
A renewal charge failed | Mark past due, start a retry / grace flow, email the customer |
subscription.expiring_cards |
Cards expire soon | Proactive email asking the customer to update the card before billing |
subscription.disable |
Subscription ended or cancelled | Remove access (at period end) |
Soft failures (insufficient funds, retryable) are different from hard cancellations; model both so users are not locked out by a single declined card.
Subscription state machine
stateDiagram-v2
[*] --> Pending
Pending --> Active: charge.success
Active --> PastDue: invoice.payment_failed
PastDue --> Active: charge.success
PastDue --> Cancelled: retries exhausted or subscription.disable
Active --> Cancelled: subscription.disable
Cancelled --> [*]
Keep transitions in one place and reject invalid ones (see Django Data Modelling Lessons). Gate content on the subscription status, not on "they paid once".
A Safe Webhook Endpoint
Rules:
1. Verify the signature over the raw request body. Paystack sends x-paystack-signature, an HMAC SHA-512 of the body using your secret key
2. Compare with a constant-time function
3. Make handlers idempotent: providers retry and can send duplicates
4. Respond 200 quickly; do the real work in a background task
5. Do not expose the URL as a normal page; exempt it from CSRF (it is authenticated by the signature instead)
import hashlib, hmac, json
from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def paystack_webhook(request):
signature = request.headers.get("x-paystack-signature", "")
expected = hmac.new(
settings.PAYSTACK_SECRET_KEY.encode(),
request.body, # raw bytes, not re-serialised JSON
hashlib.sha512,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return HttpResponseForbidden()
event = json.loads(request.body)
process_paystack_event.delay(event) # Celery task; return fast
return HttpResponse(status=200)
Idempotency
@shared_task
def process_paystack_event(event):
reference = event["data"].get("reference") or event["data"].get("id")
obj, created = WebhookEvent.objects.get_or_create(
provider="paystack", event_type=event["event"], reference=str(reference),
defaults={"payload": event},
)
if not created:
return # already processed
handlers[event["event"]](event["data"])
- Store every event (payload, received time, processed flag) for audit and replay
- A unique constraint on (provider, type, reference) enforces idempotency
- Handle out-of-order delivery: look at the current state before changing it
- For
charge.success, optionally verify the transaction with the provider's API and check amount and currency match what you expected
Cards, Expiry and Dunning
- Send an email before the card expires (
subscription.expiring_cards) - On failure: grace period, retry schedule, reminder emails, then cancel (dunning)
- Show clear billing status and a self-service update card link
- Keep payment data with the provider; never store card numbers (PCI scope)
Testing
- Use the provider's test keys and a tunnel (for example
ngrokorcloudflared) to receive webhooks locally - Replay stored payloads in unit tests; test duplicate, out-of-order and bad-signature cases
- Keep keys in environment variables or a secret store (5. Secure Coding and Supply Chain)
Checklist
- [ ] Signature verified on the raw body with constant-time comparison
- [ ] Endpoint returns 200 fast; work done in Celery
- [ ] Events stored; handlers idempotent
- [ ] Access tied to subscription state, with grace for soft failures
- [ ] Amount and currency verified
- [ ] Secrets outside git; HTTPS only