How to Integrate Paystack Webhooks Into a Django App

Getting a Paystack payment link working takes about twenty minutes. Getting your backend to reliably respond to what happens after a customer pays — that takes a proper architecture. Webhooks are where most payment integrations either harden into something production-ready or quietly rot into a source of midnight incidents.

This guide skips the basics and goes straight to what your Django app actually needs: verified webhook ingestion, idempotency guards, and failure retry logic.


Why Webhooks, Not Just the Payment Redirect

When a user completes payment, Paystack redirects them to your callback_url. It is tempting to use that redirect to confirm payment. Do not.

Redirects can fail. Users close browser tabs. Networks drop. The redirect is for UX — showing the customer a success screen. The source of truth for payment confirmation must be the webhook Paystack sends server-to-server to your webhook_url. This is an HTTP POST Paystack fires independently of what the user's browser does.


Step 1: Register Your Webhook URL

In your Paystack dashboard under Settings → API Keys & Webhooks, set your webhook URL to something like:

https://yourdomain.com/payments/webhook/

Make sure the endpoint is publicly accessible and does not sit behind authentication middleware. Paystack's servers need to reach it without a session cookie.

In urls.py:

from django.urls import path
from . import views

urlpatterns = [
    path("payments/webhook/", views.paystack_webhook, name="paystack-webhook"),
]

Exempt this view from Django's CSRF protection — Paystack is not a browser client and will not send a CSRF token. Use the @csrf_exempt decorator.


Step 2: Verify the Webhook Signature — Always

Every webhook Paystack sends includes an x-paystack-signature header. This is an HMAC-SHA512 hash of the raw request body, signed with your secret key. Verifying it is non-negotiable. Without it, any actor who discovers your webhook URL can send forged events.

import hashlib
import hmac
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import json

@csrf_exempt
@require_POST
def paystack_webhook(request):
    paystack_secret = settings.PAYSTACK_SECRET_KEY.encode("utf-8")
    signature = request.headers.get("x-paystack-signature", "")

    computed = hmac.new(
        paystack_secret,
        msg=request.body,
        digestmod=hashlib.sha512
    ).hexdigest()

    if not hmac.compare_digest(computed, signature):
        return HttpResponse(status=400)

    payload = json.loads(request.body)
    handle_event(payload)

    return HttpResponse(status=200)

A few things to note here:

  • Use request.body, not request.POST — the raw bytes must be hashed exactly as received.
  • Use hmac.compare_digest instead of == to prevent timing attacks.
  • Return 200 quickly. Paystack expects a fast acknowledgement. Do the heavy lifting asynchronously.

Step 3: Build an Idempotency Guard

Paystack can send the same webhook event more than once — network hiccups, timeouts on your end, or Paystack's own retry policy can all cause duplicates. If your handler credits a wallet or provisions a subscription, processing the same event twice is a real problem.

The fix is an idempotency guard backed by your database. Store processed event IDs and check before acting.

from .models import ProcessedWebhookEvent

def handle_event(payload):
    event_id = payload.get("id")  # Paystack includes a unique event ID
    event_type = payload.get("event")

    if not event_id:
        return

    # Guard: skip if already processed
    if ProcessedWebhookEvent.objects.filter(event_id=event_id).exists():
        return

    # Mark as processed atomically before acting
    ProcessedWebhookEvent.objects.create(event_id=event_id, event_type=event_type)

    if event_type == "charge.success":
        handle_charge_success(payload.get("data", {}))
    elif event_type == "transfer.success":
        handle_transfer_success(payload.get("data", {}))
    # Add more event types as needed

Your ProcessedWebhookEvent model needs just two fields: event_id (unique, indexed) and event_type. Add created_at for auditability.

For high-concurrency systems, wrap the exists() check and create() call in a select_for_update() block or use get_or_create() with a unique constraint to prevent race conditions between simultaneous deliveries of the same event.


Step 4: Process Asynchronously With Celery

Your webhook view should return 200 in milliseconds. Any database writes, email sends, or downstream API calls that happen inline risk a timeout — and Paystack will retry, creating more duplicates for your idempotency guard to catch.

Dispatch the payload to a Celery task immediately after acknowledgement:

from .tasks import process_paystack_event

@csrf_exempt
@require_POST
def paystack_webhook(request):
    # ... signature verification ...

    payload = json.loads(request.body)
    process_paystack_event.delay(payload)

    return HttpResponse(status=200)

In your Celery task, run the full handle_event logic. If the task itself fails (database down, external API error), Celery's built-in retry mechanism handles it — giving you a clean separation between acknowledging the event and acting on it.


Step 5: Handle Failures Gracefully

Production systems fail. Here is how to make your webhook pipeline resilient:

  • Log every raw payload. Before any processing, persist the raw JSON to a WebhookLog table. If a bug corrupts processing, you can replay events from the log.
  • Alert on repeated failures. If a Celery task hits its max retries, send a Slack or email alert so your team can intervene manually.
  • Expose an admin replay action. Add a Django admin action on WebhookLog that re-queues the payload through process_paystack_event.delay(). This saves hours during incident recovery.
  • Watch for charge.failed and transfer.failed. These are as important as the success events. Unhandled failures mean users stuck in limbo with no feedback.

Supported Event Types Worth Implementing

EventWhat it means
charge.successCustomer payment confirmed
charge.failedPayment attempt failed
transfer.successPayout to recipient completed
transfer.failedPayout failed — investigate
subscription.createRecurring subscription activated
subscription.disableSubscription cancelled or lapsed
invoice.payment_failedRecurring charge failed

Start with charge.success and charge.failed. Add the rest as your product grows.


Why This Matters for Your Project

A payment integration that only handles the happy path is a liability. Whether you are building a SaaS platform, a marketplace, or a fintech product, the reliability of your revenue recognition layer directly affects your business. Getting webhook verification, idempotency, and async processing right from day one means fewer incidents, fewer duplicate transactions, and a system that holds up when Paystack retries at 3 a.m. That is the difference between software that works in a demo and software that works in production.