Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in Django

To verify a Paystack payment in Django, use the requests library to GET https://api.paystack.co/transaction/verify/:reference with your secret key. Check that data.data.status is "success", the amount matches your Order model, and the currency is correct. Use update() with a filter on paid=False for idempotent fulfillment.

Basic Verification Function

# payments/utils.py
import requests
from django.conf import settings

def verify_paystack_transaction(reference):
    """Call Paystack verify endpoint and return the result."""
    headers = {
        'Authorization': 'Bearer ' + settings.PAYSTACK_SECRET_KEY,
    }

    response = requests.get(
        'https://api.paystack.co/transaction/verify/' + reference,
        headers=headers,
    )

    return response.json()

This function returns the raw Paystack response. Your views and webhook handlers will use it and apply their own business logic on top.

Full Verification with Amount Check

# payments/utils.py (continued)
from django.utils import timezone
from .models import Order

def verify_and_fulfill(reference):
    """Verify payment and fulfill the order idempotently."""
    # 1. Look up the order
    try:
        order = Order.objects.get(reference=reference)
    except Order.DoesNotExist:
        return {'error': 'Order not found'}

    if order.paid:
        return {'success': True, 'already_fulfilled': True}

    # 2. Verify with Paystack
    result = verify_paystack_transaction(reference)

    if not result.get('status'):
        return {'error': 'Verification request failed'}

    data = result['data']

    if data['status'] != 'success':
        return {'error': 'Payment not successful', 'paystack_status': data['status']}

    # 3. Check amount
    if data['amount'] != order.amount:
        return {'error': 'Amount mismatch: expected ' + str(order.amount) + ' got ' + str(data['amount'])}

    # 4. Check currency
    if data['currency'] != order.currency:
        return {'error': 'Currency mismatch'}

    # 5. Idempotent fulfillment using update() with filter
    updated = Order.objects.filter(
        reference=reference,
        paid=False,
    ).update(paid=True, paid_at=timezone.now())

    if updated == 0:
        return {'success': True, 'already_fulfilled': True}

    # 6. Deliver value
    # send_confirmation_email(order.email, order)

    return {'success': True, 'already_fulfilled': False}

The Order.objects.filter(reference=reference, paid=False).update() pattern is atomic in Django. If two requests arrive simultaneously (webhook and callback), only one of them will update the row. The other gets updated == 0 and skips fulfillment.

Using Verification in the Callback View

# payments/views.py
from django.shortcuts import render
from .utils import verify_and_fulfill

def payment_callback(request):
    reference = request.GET.get('reference') or request.GET.get('trxref')

    if not reference:
        return render(request, 'payments/failed.html', {'error': 'Missing reference'})

    result = verify_and_fulfill(reference)

    if result.get('success'):
        return render(request, 'payments/success.html', {'reference': reference})

    return render(request, 'payments/failed.html', {'error': result.get('error')})

The same verify_and_fulfill function should be called from your webhook handler. This ensures consistent verification logic regardless of which notification path fires first.

Retry Logic for Network Failures

# payments/utils.py
import time

def verify_with_retry(reference, max_retries=3):
    """Verify with exponential backoff on failure."""
    for attempt in range(max_retries):
        try:
            result = verify_paystack_transaction(reference)
            if result.get('status') is not None:
                return result
        except requests.exceptions.RequestException as e:
            print('Verify attempt ' + str(attempt + 1) + ' failed: ' + str(e))

        if attempt < max_retries - 1:
            time.sleep(2 ** attempt)  # 1s, 2s, 4s

    return None

If all retries fail, log the reference for manual investigation. The webhook will independently deliver the event, so the order can still be fulfilled through that path.

Common Verification Mistakes

  • Trusting form data for the amount. Always compare the Paystack amount against your Order model, not against what the user submitted in the form.
  • Not URL-encoding the reference. If your reference contains special characters, the verify URL will break. Use urllib.parse.quote() or let requests handle it.
  • Mixing test and live keys. Initializing with sk_test_ but verifying with sk_live_ returns "Transaction not found."
  • Forgetting to check currency. An attacker could pay in a different currency. Always verify the currency matches.
  • Using get() instead of filter().update(). Using order.save() after modifying paid=True is not atomic. Another request could save between your get and save.

Ship Payments Faster

Key Takeaways

  • Verification is a single GET request to the Paystack API using the requests library.
  • Always check three things: transaction status equals "success", amount matches your Order model, and currency is correct.
  • Use Django ORM update() with a filter for atomic, idempotent fulfillment that prevents double-granting.
  • Create a reusable verify_and_fulfill function that both your callback view and webhook handler call.
  • If the verify request fails due to network issues, retry. The transaction state on Paystack does not change.
  • Never trust the redirect URL parameters as proof of payment. Always verify server-side.

Frequently Asked Questions

Can I verify a transaction multiple times?
Yes. The Paystack verify endpoint is idempotent. It returns the same result for the same reference every time.
Should I use select_for_update for concurrency safety?
filter().update() is sufficient for most cases because it is a single atomic SQL statement. Use select_for_update() if your fulfillment logic involves multiple queries that must be consistent.
How long does Paystack keep transaction data?
Paystack stores transaction data indefinitely. You can verify transactions months or years after they were created.
What if the customer pays but my callback view crashes?
This is why webhooks matter. Even if your callback view fails, the webhook will independently deliver the charge.success event. Your webhook handler calls the same verify_and_fulfill function and fulfills the order.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon