Webhooks Guide

Understanding PayFast Webhooks

PayFast uses webhooks (also called ITN - Instant Transaction Notifications) to notify your application about payment events in real-time. This guide covers everything you need to know about implementing and handling webhooks effectively.

What are Webhooks?

Webhooks are HTTP callbacks that PayFast sends to your server when a payment event occurs. These events include:

  • Payment completion

  • Payment failure

  • Payment cancellation

  • Subscription updates

  • Refund notifications

Why Webhooks are Important

Webhooks are critical for your payment system because:

  1. Real-time Updates: Get instant notification when payments complete

  2. Reliability: Even if users close their browser, you still receive updates

  3. Automation: Automatically grant access, send emails, update inventory

  4. Reconciliation: Keep your database synchronized with PayFast

  5. Security: Server-side validation ensures payment authenticity

How Webhooks Work

The webhook flow follows these steps:

1. User completes payment on PayFast
2. PayFast sends POST request to your webhook URL
3. Your server validates the request
4. Your server updates payment status
5. Your server responds with HTTP 200
6. PayFast marks notification as delivered

Important: You must respond with HTTP 200 within 10 seconds, or PayFast will retry.

Setting Up Webhooks

Step 1: Configure Webhook URL

Your webhook URL must be:

  • Publicly accessible (not localhost)

  • HTTPS (required for production)

  • POST-enabled (GET requests will fail)

  • Not behind authentication (PayFast can’t log in)

Example webhook URL:

https://yourdomain.com/payfast/notify/

For Local Development:

Use ngrok to expose your local server:

# Install ngrok
brew install ngrok  # macOS
# or download from https://ngrok.com

# Start ngrok
ngrok http 8000

# Use the generated URL
# Example: https://abc123.ngrok.io/payfast/notify/

Step 2: Add URL to Django

The webhook URL is automatically configured when you include payfast URLs:

# urls.py
from django.urls import path, include

urlpatterns = [
    path('payfast/', include('payfast.urls')),
]

This creates the endpoint: /payfast/notify/

Step 3: Configure in PayFast Form

Include the webhook URL when generating payment forms:

from django.urls import reverse

notify_url = request.build_absolute_uri(
    reverse('payfast:notify')
)

form = PayFastPaymentForm(initial={
    'amount': 99.99,
    'item_name': 'Premium Plan',
    'm_payment_id': payment_id,
    'email_address': user.email,
    'notify_url': notify_url,  # Webhook URL
})

Webhook Security

dj-payfast implements multiple security layers to protect your webhooks.

1. IP Address Validation

Webhooks only come from PayFast servers:

Valid PayFast IPs:

  • www.payfast.co.za

  • sandbox.payfast.co.za

  • w1w.payfast.co.za

  • w2w.payfast.co.za

The library validates these automatically:

from payfast.utils import validate_ip

ip_address = get_client_ip(request)
if not validate_ip(ip_address):
    return HttpResponseForbidden('Invalid IP')

2. Signature Verification

Every webhook includes a cryptographic signature:

from payfast.utils import verify_signature

post_data = request.POST.dict()

if not verify_signature(post_data, PAYFAST_PASSPHRASE):
    return HttpResponseBadRequest('Invalid signature')

How Signatures Work:

  1. PayFast generates MD5 hash of payment data + passphrase

  2. Signature is included in webhook POST data

  3. Your server recalculates the signature

  4. If signatures match, request is authentic

3. Server-Side Validation

The final security layer validates with PayFast servers:

import requests
from payfast.conf import PAYFAST_VALIDATE_URL

response = requests.post(
    PAYFAST_VALIDATE_URL,
    data=post_data,
    timeout=10
)

if response.text != 'VALID':
    return HttpResponseBadRequest('Validation failed')

Webhook Data Structure

PayFast sends the following data in webhook requests:

Standard Fields

{
    # Payment Identifiers
    'm_payment_id': 'your-unique-id',       # Your payment ID
    'pf_payment_id': '1234567',             # PayFast's payment ID

    # Payment Status
    'payment_status': 'COMPLETE',            # COMPLETE, FAILED, PENDING, CANCELLED

    # Amount Details
    'amount_gross': '100.00',                # Gross amount
    'amount_fee': '5.75',                    # PayFast fee
    'amount_net': '94.25',                   # Net amount (what you receive)

    # Item Information
    'item_name': 'Premium Plan',
    'item_description': '1 month subscription',

    # Customer Details
    'name_first': 'John',
    'name_last': 'Doe',
    'email_address': 'john@example.com',

    # Security
    'signature': 'abc123def456...',          # MD5 signature

    # Custom Fields (if provided)
    'custom_str1': 'order_123',
    'custom_int1': '5',
}

Payment Status Values

PayFast sends one of these statuses:

Status

Description

COMPLETE

Payment successfully completed

FAILED

Payment failed (insufficient funds, etc.)

PENDING

Payment initiated but not yet complete

CANCELLED

User cancelled the payment

Handling Webhooks

Built-in Webhook Handler

dj-payfast includes a complete webhook handler:

# payfast/views.py
from payfast.views import PayFastNotifyView

# Already included in payfast.urls
# Available at: /payfast/notify/

The built-in handler automatically:

  1. ✅ Validates IP address

  2. ✅ Verifies signature

  3. ✅ Validates with PayFast servers

  4. ✅ Updates payment record

  5. ✅ Logs notification

  6. ✅ Returns appropriate response

Custom Webhook Processing

To add custom logic, subclass the view:

# myapp/views.py
from payfast.views import PayFastNotifyView
from django.core.mail import send_mail

class CustomPayFastNotifyView(PayFastNotifyView):

    def post(self, request, *args, **kwargs):
        # Call parent processing first
        response = super().post(request, *args, **kwargs)

        # Only run custom logic if validation passed
        if response.status_code == 200:
            post_data = request.POST.dict()
            payment_status = post_data.get('payment_status')
            m_payment_id = post_data.get('m_payment_id')

            # Your custom logic
            if payment_status == 'COMPLETE':
                self.handle_successful_payment(m_payment_id)
            elif payment_status == 'FAILED':
                self.handle_failed_payment(m_payment_id)

        return response

    def handle_successful_payment(self, payment_id):
        """Process successful payment"""
        payment = PayFastPayment.objects.get(m_payment_id=payment_id)

        # Grant access
        if payment.user:
            self.activate_subscription(payment.user)

        # Send email
        send_mail(
            subject='Payment Confirmed!',
            message=f'Your payment of R{payment.amount} was successful.',
            from_email='noreply@example.com',
            recipient_list=[payment.email_address],
        )

        # Update inventory
        self.update_stock(payment)

    def handle_failed_payment(self, payment_id):
        """Handle failed payment"""
        payment = PayFastPayment.objects.get(m_payment_id=payment_id)

        # Send notification
        send_mail(
            subject='Payment Failed',
            message='Your payment was unsuccessful. Please try again.',
            from_email='noreply@example.com',
            recipient_list=[payment.email_address],
        )

Register your custom view:

# myapp/urls.py
from django.urls import path
from .views import CustomPayFastNotifyView

urlpatterns = [
    path('payfast/notify/', CustomPayFastNotifyView.as_view()),
]

Using Django Signals

Recommended approach for handling payment events:

# myapp/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from payfast.models import PayFastPayment
from django.core.mail import send_mail

@receiver(post_save, sender=PayFastPayment)
def handle_payment_update(sender, instance, created, **kwargs):
    """Handle payment status changes"""

    # Only process completed payments
    if instance.status == 'complete' and instance.payment_status == 'COMPLETE':
        handle_successful_payment(instance)

def handle_successful_payment(payment):
    """Process completed payment"""

    # 1. Grant access to user
    if payment.user:
        payment.user.profile.is_premium = True
        payment.user.profile.save()

    # 2. Send confirmation email
    send_mail(
        subject='Payment Confirmed',
        message=f'''
            Thank you for your payment of R{payment.amount}.
            Transaction ID: {payment.m_payment_id}

            Your premium access has been activated!
        ''',
        from_email='noreply@example.com',
        recipient_list=[payment.email_address],
    )

    # 3. Process custom fields
    if payment.custom_str1:
        process_order(payment.custom_str1)

    # 4. Log the event
    logger.info(f'Payment completed: {payment.m_payment_id}')

Register signals in apps.py:

# myapp/apps.py
from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = 'myapp'

    def ready(self):
        import myapp.signals  # Register signals

Webhook Debugging

Logging Webhooks

All webhook attempts are logged in PayFastNotification model:

from payfast.models import PayFastNotification

# Get all notifications
notifications = PayFastNotification.objects.all()

# Get failed notifications
failed = PayFastNotification.objects.filter(is_valid=False)

# Check errors
for notification in failed:
    print(f"Error: {notification.validation_errors}")
    print(f"Data: {notification.raw_data}")
    print(f"IP: {notification.ip_address}")

View in Django Admin

Navigate to: /admin/payfast/payfastnotification/

You’ll see:

  • All webhook attempts

  • Validation status

  • Error messages

  • Raw POST data

  • IP addresses

  • Timestamps

Testing Webhooks Locally

Method 1: ngrok (Recommended)

# Start Django
python manage.py runserver

# In another terminal, start ngrok
ngrok http 8000

# Use the ngrok URL in your payment form
notify_url = 'https://abc123.ngrok.io/payfast/notify/'

Method 2: Manual Testing

Send a test POST request:

import requests

test_data = {
    'm_payment_id': 'test-123',
    'pf_payment_id': '1234567',
    'payment_status': 'COMPLETE',
    'amount_gross': '100.00',
    'amount_fee': '5.75',
    'amount_net': '94.25',
    'item_name': 'Test Item',
    'email_address': 'test@example.com',
}

response = requests.post(
    'http://localhost:8000/payfast/notify/',
    data=test_data
)

print(response.status_code)
print(response.text)

Common Webhook Issues

Issue 1: Webhook Not Receiving Notifications

Symptoms: Payments complete on PayFast but webhook never fires.

Causes & Solutions:

  1. URL Not Accessible

    # Test if your webhook is accessible
    curl -X POST https://yourdomain.com/payfast/notify/
    
    # Should return: "Method Not Allowed" (405) - This is correct!
    # Means the endpoint exists and can receive POST requests
    
  2. Using localhost

    PayFast can’t reach http://localhost:8000

    Solution: Use ngrok for local development

  3. Firewall Blocking

    Check server firewall allows incoming connections

    Solution: Whitelist PayFast IP addresses

  4. Wrong URL in Form

    # ❌ WRONG - Relative URL
    notify_url = '/payfast/notify/'
    
    # ✅ CORRECT - Absolute URL
    notify_url = request.build_absolute_uri(
        reverse('payfast:notify')
    )
    

Issue 2: Signature Verification Failing

Symptoms: Webhook returns “Invalid signature”

Causes & Solutions:

  1. Passphrase Mismatch

    # settings.py
    PAYFAST_PASSPHRASE = 'YourExactPassphrase'  # Must match PayFast dashboard
    
  2. Test vs Production Passphrase

    # Use different passphrases for test and production
    if PAYFAST_TEST_MODE:
        PAYFAST_PASSPHRASE = 'SandboxPassphrase'
    else:
        PAYFAST_PASSPHRASE = 'ProductionPassphrase'
    
  3. Debug Signature

    from payfast.utils import generate_signature
    
    data = request.POST.dict()
    calculated = generate_signature(data, PAYFAST_PASSPHRASE)
    received = data.get('signature')
    
    print(f"Calculated: {calculated}")
    print(f"Received: {received}")
    print(f"Match: {calculated == received}")
    

Issue 3: Payment Status Not Updating

Symptoms: Webhook fires but payment stays “pending”

Solution: Check webhook logs for errors

from payfast.models import PayFastNotification

# Get recent failed notifications
failed = PayFastNotification.objects.filter(
    is_valid=False,
    created_at__gte=timezone.now() - timedelta(hours=1)
)

for notification in failed:
    print(notification.validation_errors)

Issue 4: Duplicate Webhooks

Symptoms: Same webhook received multiple times

Causes:

  • PayFast retries if you don’t respond with HTTP 200

  • Network issues

  • Slow processing

Solution: Implement idempotency

from django.db import transaction

@transaction.atomic
def process_webhook(post_data):
    m_payment_id = post_data.get('m_payment_id')

    # Get payment with lock to prevent race conditions
    payment = PayFastPayment.objects.select_for_update().get(
        m_payment_id=m_payment_id
    )

    # Only process if still pending
    if payment.status == 'pending':
        payment.status = 'complete'
        payment.save()

        # Process additional logic
        grant_access(payment)

    # Always return success if payment exists
    return True

Webhook Best Practices

1. Respond Quickly

PayFast expects response within 10 seconds:

def post(self, request):
    # ✅ GOOD - Quick processing
    payment_id = request.POST.get('m_payment_id')

    # Update database (fast)
    payment = PayFastPayment.objects.get(m_payment_id=payment_id)
    payment.mark_complete()

    # Queue slow tasks (email, API calls) for later
    send_confirmation_email.delay(payment.id)

    return HttpResponse('OK', status=200)

2. Use Background Tasks

For slow operations, use Celery:

# tasks.py
from celery import shared_task

@shared_task
def send_confirmation_email(payment_id):
    payment = PayFastPayment.objects.get(id=payment_id)
    # Send email (slow)
    send_mail(...)

@shared_task
def update_external_system(payment_id):
    # Call external API (slow)
    requests.post(...)

3. Implement Idempotency

Handle duplicate webhooks gracefully:

def process_payment(payment_id):
    payment = PayFastPayment.objects.get(m_payment_id=payment_id)

    # Check if already processed
    if payment.status == 'complete':
        logger.info(f"Payment {payment_id} already processed")
        return True

    # Process payment
    payment.mark_complete()
    grant_access(payment.user)

    return True

4. Log Everything

import logging

logger = logging.getLogger('payfast.webhooks')

def handle_webhook(request):
    logger.info(f"Webhook received from {request.META.get('REMOTE_ADDR')}")

    try:
        # Process webhook
        logger.info("Webhook processed successfully")
    except Exception as e:
        logger.error(f"Webhook processing failed: {e}")
        raise

5. Monitor Webhook Health

from payfast.models import PayFastNotification
from django.utils import timezone
from datetime import timedelta

def check_webhook_health():
    """Check for webhook issues in last hour"""

    one_hour_ago = timezone.now() - timedelta(hours=1)

    total = PayFastNotification.objects.filter(
        created_at__gte=one_hour_ago
    ).count()

    failed = PayFastNotification.objects.filter(
        created_at__gte=one_hour_ago,
        is_valid=False
    ).count()

    success_rate = ((total - failed) / total * 100) if total > 0 else 100

    if success_rate < 90:
        send_alert(f"Webhook success rate: {success_rate}%")

Production Checklist

Before going live:

☐ Webhook URL is HTTPS
☐ Webhook URL is publicly accessible
☐ Passphrase is configured correctly
☐ Signature verification is enabled
☐ IP validation is enabled
☐ Server-side validation is enabled
☐ Webhook logging is configured
☐ Error monitoring is set up
☐ Background task processing is configured
☐ Idempotency is implemented
☐ Tested with real PayFast account
☐ Monitoring alerts are configured

Next Steps