DEV Community

Cover image for How to Build a Payment Gateway with Django and PayPal: A Step-by-Step Guide
kihuni
kihuni

Posted on

How to Build a Payment Gateway with Django and PayPal: A Step-by-Step Guide

Hey there, today we’re diving into an exciting project: building a Payment Gateway API using Django and PayPal! A Payment Gateway API is a RESTful service that lets businesses accept online payments securely, and we’ll create one you can deploy and use. By the end, you’ll have a working system (like Payment-gateway) to handle transactions with ease. Let’s get started!

Why Build a Payment Gateway?

A payment gateway is like a digital cashier for online stores, securely processing payments. Integrating PayPal with Django gives you a reliable, user-friendly solution without starting from scratch. This project uses minimal data (name, email, amount), skips authentication for simplicity, and includes automated tests.

What You’ll Need

Before we jump in, ensure you have:

  • Python 3.12: Check with python --version.
  • Django 5.0.6 and Django REST Framework 3.15.1: For the API backbone.
  • A PayPal Developer Account: To get API credentials (see below).
  • PostgreSQL 16 or above: For database management.
  • Git: For version control.
  • Basic knowledge of Python, Django, and REST APIs.

How to Get PayPal Credentials

To integrate PayPal, you need a Client ID and Secret.

Here’s how to get them:

Sign Up or Log In:

Paypal

Create an App:

  • Click Log in to Dashboard > My Apps & Credentials.
  • Under REST API apps, click Create App

dashboard

  • Give it a name (e.g., DjangoPaymentGateway) and select your sandbox account.

Get Credentials:

  • After creating the app, you’ll see a Client ID and a Secret under the app details.

Copy these values, they’ll go into your .env file later.

Sandbox Mode:

  • For testing, we will use the sandbox environment. Switch to live mode in production after testing.

Save Securely:

  • Store them in a safe place (not hardcoded in your code!).

Step-by-Step Guide

Step 1. Project Setup

Start by setting up a new Django project.

  • Create a folder and cd into it
mkdir payment_gateway
cd payment_gateway
Enter fullscreen mode Exit fullscreen mode
  • Create a virtual environment and activate it
python -m venv venv
source venv/bin/activate  # Linux/macOS
venv\Scripts\activate     # Windows
Enter fullscreen mode Exit fullscreen mode
  • Install dependencies:
pip install django==5.0.6 djangorestframework==3.15.1 paypalrestsdk==1.13.1 psycopg2-binary==2.9.9 gunicorn==22.0.0 whitenoise==6.7.0 python-dotenv==1.0.1

Enter fullscreen mode Exit fullscreen mode
  • Create a django project and an app
django-admin startproject payment_gateway .
python manage.py startapp payments
Enter fullscreen mode Exit fullscreen mode

Configure Settings

Configure the Django project’s environment, database, middleware, PostgreSQL, and PayPal

from pathlib import Path
import os
from dotenv import load_dotenv
import paypalrestsdk


load_dotenv()


# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
#DEBUG = True # development
DEBUG = False

ALLOWED_HOSTS = [
    'payment-gateway-api-2c52.onrender.com',
    'localhost',  # For local testing
    '127.0.0.1',  # For local testing
]

PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '')
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '')
PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox')


paypalrestsdk.configure({
  "mode": PAYPAL_MODE,
  "client_id": PAYPAL_CLIENT_ID,
  "client_secret": PAYPAL_CLIENT_SECRET
})

# Application definition

INSTALLED_APPS = [

  "payments",
  "rest_framework",

]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ]
}

MIDDLEWARE = [

    'whitenoise.middleware.WhiteNoiseMiddleware',

]

ROOT_URLCONF = 'payment_gateway.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'payment_gateway.wsgi.application'


# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases

# DATABASES = {
#     'default': {
#         'ENGINE': 'django.db.backends.sqlite3',
#         'NAME': BASE_DIR / 'db.sqlite3',
#     }
# }

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME', 'payment_gateway'),
        'USER': os.getenv('DB_USER', 'payment_gateway'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST', 'localhost'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/

STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
Enter fullscreen mode Exit fullscreen mode

Create a .env file:

DB_NAME=payment_gateway
DB_USER=payment_gateway
DB_PASSWORD=payment_gateway
DB_HOST=localhost
DB_PORT=5432
SECRET_KEY=your_secret_key
PAYPAL_CLIENT_ID=your_paypal_client_id
PAYPAL_CLIENT_SECRET=your_paypal_client_secret
PAYPAL_MODE=sandbox
DEBUG=True

Enter fullscreen mode Exit fullscreen mode

Set Up PostgreSQL

Install PostgreSQL and create a Database:

bash

sudo apt update && sudo apt install postgresql postgresql-contrib  # Ubuntu
psql -U postgres
Enter fullscreen mode Exit fullscreen mode

sql

CREATE DATABASE mydb;
CREATE USER myuser WITH PASSWORD 'mypassword';
GRANT ALL PRIVILEGES ON DATABASE mydb TO myuser;

# exit SQL shell
\q

Enter fullscreen mode Exit fullscreen mode

Run migrations:

python manage.py makemigrations
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Step 2. Define the Data Model

In payments/models.py, create a Payment model to store transaction details:

from django.db import models

class Payment(models.Model):
    PAYMENT_STATUS = [
        ('created', 'Created'),
        ('approved', 'Approved'),
        ('failed', 'Failed'),
        ('completed', 'Completed'),
    ]

    customer_name = models.CharField(max_length=100)
    customer_email = models.EmailField()
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    paypal_payment_id = models.CharField(max_length=100, unique=True)
    status = models.CharField(max_length=20, choices=PAYMENT_STATUS, default='completed')
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.customer_name} - {self.amount}"
Enter fullscreen mode Exit fullscreen mode

This tracks payment details and statuses.

Step 3. Build the API

Let’s create the endpoints to initiate, execute, cancel, and check payments.

Serializers
In payments/serializers.py, define a serializer:

payments/serializers.py

from rest_framework import serializers
from .models import Payment

class PaymentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Payment
        fields = '__all__'
        read_only_fields = ['status', 'paypal_payment_id', 'created_at']

Enter fullscreen mode Exit fullscreen mode

Views
In payments/views.py, implement the API logic:

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from .models import Payment
from .serializers import PaymentSerializer
import paypalrestsdk
import logging

logger = logging.getLogger(__name__)

class PaymentCreateView(APIView):
    def post(self, request):
        try:
            serializer = PaymentSerializer(data=request.data)
            if not serializer.is_valid():
                return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

            data = serializer.validated_data

            payment = paypalrestsdk.Payment({
                "intent": "sale",
                "payer": {"payment_method": "paypal"},
                "redirect_urls": {
                    "return_url": "https://payment-gateway-api-2c52.onrender.com/api/v1/payment/execute",
                    "cancel_url": "https://payment-gateway-api-2c52.onrender.com/api/v1/payment/cancel"
                },
                "transactions": [{
                    "item_list": {
                        "items": [{
                            "name": "Business Payment",
                            "sku": "001",
                            "price": str(data['amount']),
                            "currency": "USD",
                            "quantity": 1
                        }]
                    },
                    "amount": {
                        "total": str(data['amount']),
                        "currency": "USD"
                    },
                    "description": f"Payment by {data['customer_name']}"
                }]
            })

            if payment.create():
                new_payment = Payment.objects.create(
                    customer_name=data['customer_name'],
                    customer_email=data['customer_email'],
                    amount=data['amount'],
                    paypal_payment_id=payment.id,
                    status='created'
                )
                return Response({
                    "status": "success",
                    "payment_id": new_payment.id,
                    "paypal_payment_id": payment.id,
                    "approval_url": next(link.href for link in payment.links if link.rel == "approval_url")
                }, status=status.HTTP_201_CREATED)

            logger.error(f"PayPal payment creation failed: {payment.error}")
            return Response({"status": "error", "message": str(payment.error)}, status=status.HTTP_400_BAD_REQUEST)
        except Exception as e:
            logger.error(f"Error in PaymentCreateView: {str(e)}", exc_info=True)
            return Response({
                "status": "error",
                "message": f"An unexpected error occurred: {str(e)}"
            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

class PaymentStatusView(APIView):
    def get(self, request, pk):
        try:
            payment = Payment.objects.get(id=pk)
            serializer = PaymentSerializer(payment)
            return Response({
                "payment": serializer.data,
                "status": "success",
                "message": "Payment details retrieved successfully."
            }, status=status.HTTP_200_OK)
        except Payment.DoesNotExist:
            return Response({
                "status": "error",
                "message": "Payment not found."
            }, status=status.HTTP_404_NOT_FOUND)
        except Exception as e:
            logger.error(f"Error in PaymentStatusView for pk={pk}: {str(e)}", exc_info=True)
            return Response({
                "status": "error",
                "message": "An unexpected error occurred."
            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

class PaymentExecuteView(APIView):
    def get(self, request):
        try:
            payment_id = request.GET.get('paymentId')
            payer_id = request.GET.get('PayerID')
            if not payment_id or not payer_id:
                return Response({
                    "status": "error",
                    "message": "Missing paymentId or PayerID. Please ensure the payment is approved via PayPal."
                }, status=status.HTTP_400_BAD_REQUEST)

            payment = paypalrestsdk.Payment.find(payment_id)
            if payment.execute({"payer_id": payer_id}):
                db_payment = Payment.objects.get(paypal_payment_id=payment_id)
                db_payment.status = 'completed'
                db_payment.save()
                return Response({
                    "status": "success",
                    "message": "Payment executed successfully",
                    "payment_id": db_payment.id
                }, status=status.HTTP_200_OK)
            else:
                error = payment.error
                logger.error(f"Payment execution failed: {error}")
                if error.get('name') == 'PAYMENT_NOT_APPROVED_FOR_EXECUTION':
                    return Response({
                        "status": "error",
                        "message": "Payment not approved by payer. Please complete approval on PayPal."
                    }, status=status.HTTP_400_BAD_REQUEST)
                return Response({
                    "status": "error",
                    "message": str(error)
                }, status=status.HTTP_400_BAD_REQUEST)
        except paypalrestsdk.ResourceNotFound:
            return Response({
                "status": "error",
                "message": "Payment not found"
            }, status=status.HTTP_404_NOT_FOUND)
        except Payment.DoesNotExist:
            return Response({
                "status": "error",
                "message": "Payment not found"
            }, status=status.HTTP_404_NOT_FOUND)
        except Exception as e:
            logger.error(f"Error in PaymentExecuteView: {str(e)}", exc_info=True)
            return Response({
                "status": "error",
                "message": f"An unexpected error occurred: {str(e)}"
            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

class PaymentCancelView(APIView):
    def get(self, request):
        try:
            payment_id = request.GET.get('paymentId')
            if not payment_id:
                return Response({
                    "status": "error",
                    "message": "Missing paymentId"
                }, status=status.HTTP_400_BAD_REQUEST)

            db_payment = Payment.objects.get(paypal_payment_id=payment_id)
            db_payment.status = 'cancelled'
            db_payment.save()
            return Response({
                "status": "success",
                "message": "Payment cancelled successfully",
                "payment_id": db_payment.id
            }, status=status.HTTP_200_OK)
        except Payment.DoesNotExist:
            return Response({
                "status": "error",
                "message": "Payment not found"
            }, status=status.HTTP_404_NOT_FOUND)
        except Exception as e:
            logger.error(f"Error in PaymentCancelView: {str(e)}", exc_info=True)
            return Response({
                "status": "error",
                "message": f"An unexpected error occurred: {str(e)}"
            }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

Enter fullscreen mode Exit fullscreen mode

URLs
In payments/urls.py:

payments/urls.py

from django.urls import path
from .views import PaymentCreateView, PaymentStatusView, PaymentExecuteView, PaymentCancelView

urlpatterns = [
    path('payments/', PaymentCreateView.as_view(), name='initiate-payment'),
    path('payments/<int:pk>/', PaymentStatusView.as_view(), name='payment-status'),
    path('payment/execute/', PaymentExecuteView.as_view(), name='payment-execute'),
    path('payment/cancel/', PaymentCancelView.as_view(), name='payment-cancel'),
]
Enter fullscreen mode Exit fullscreen mode

In payment_gateway/urls.py:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('payments.urls')),
]
Enter fullscreen mode Exit fullscreen mode

flowchart

Step 4. Test Your API

Create tests in payments/tests.py to ensure everything works:

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from unittest.mock import patch, MagicMock
import paypalrestsdk

class PaymentTests(APITestCase):
    def setUp(self):
        # Mock PayPal payment
        self.mock_payment = MagicMock()
        self.mock_payment.id = "PAY-123456"
        self.mock_payment.create = lambda: True
        # Mock links as a list of MagicMock objects with rel and href attributes
        link = MagicMock()
        link.rel = "approval_url"
        link.href = "https://paypal.com/approve"
        self.mock_payment.links = [link]
        self.mock_payment.execute = lambda data: True

    @patch('paypalrestsdk.Payment')
    def test_get_payment_status_success(self, MockPayment):
        MockPayment.return_value = self.mock_payment
        create_url = reverse('initiate-payment')
        data = {
            "customer_name": "John Smith",
            "customer_email": "john@example.com",
            "amount": 30.00
        }
        create_response = self.client.post(create_url, data, format='json')
        payment_id = create_response.data.get("payment_id")
        self.assertEqual(create_response.status_code, status.HTTP_201_CREATED)

        status_url = reverse('payment-status', args=[payment_id])
        response = self.client.get(status_url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["payment"]["customer_name"], "John Smith")

    def test_create_payment_missing_fields(self):
        url = reverse('initiate-payment')
        data = {
            "customer_email": "incomplete@example.com"
        }
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    def test_create_payment_invalid_amount(self):
        url = reverse('initiate-payment')
        data = {
            "customer_name": "Test User",
            "customer_email": "test@example.com",
            "amount": "invalid"
        }
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    @patch('paypalrestsdk.Payment')
    def test_payment_execute_success(self, MockPayment):
        MockPayment.return_value = self.mock_payment
        MockPayment.find.return_value = self.mock_payment
        create_url = reverse('initiate-payment')
        data = {
            "customer_name": "John Smith",
            "customer_email": "john@example.com",
            "amount": 30.00
        }
        create_response = self.client.post(create_url, data, format='json')
        payment_id = create_response.data.get("payment_id")
        self.assertEqual(create_response.status_code, status.HTTP_201_CREATED)

        execute_url = reverse('payment-execute') + f'?paymentId=PAY-123456&PayerID=123'
        response = self.client.get(execute_url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["status"], "success")

    @patch('paypalrestsdk.Payment')
    def test_payment_execute_not_found(self, MockPayment):
        # Mock ResourceNotFound with a mock response object
        mock_response = MagicMock()
        mock_response.status_code = 404
        MockPayment.find.side_effect = paypalrestsdk.ResourceNotFound(response=mock_response)
        execute_url = reverse('payment-execute') + f'?paymentId=INVALID&PayerID=123'
        response = self.client.get(execute_url)
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(response.data["status"], "error")

    @patch('paypalrestsdk.Payment')
    def test_payment_cancel_success(self, MockPayment):
        MockPayment.return_value = self.mock_payment
        create_url = reverse('initiate-payment')
        data = {
            "customer_name": "John Smith",
            "customer_email": "john@example.com",
            "amount": 30.00
        }
        create_response = self.client.post(create_url, data, format='json')
        payment_id = create_response.data.get("payment_id")
        self.assertEqual(create_response.status_code, status.HTTP_201_CREATED)

        cancel_url = reverse('payment-cancel') + f'?paymentId=PAY-123456'
        response = self.client.get(cancel_url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["status"], "success")

    def test_payment_cancel_not_found(self):
        cancel_url = reverse('payment-cancel') + f'?paymentId=INVALID'
        response = self.client.get(cancel_url)
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(response.data["status"], "error") 
    @patch('paypalrestsdk.Payment')
    def test_payment_execute_missing_payer_id(self, MockPayment):
        mock_payment = MagicMock()
        mock_payment.id = "PAY-123456"
        MockPayment.find.return_value = mock_payment
        execute_url = reverse('payment-execute') + '?paymentId=PAY-123456'
        response = self.client.get(execute_url)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertEqual(response.data["message"], "Missing paymentId or PayerID. Please ensure the payment is approved via PayPal.")


Enter fullscreen mode Exit fullscreen mode

Run tests:

python manage.py test
Enter fullscreen mode Exit fullscreen mode

testing api

Step 5. Deploy with CI/CD

Set up GitHub Actions in .github/workflows/django.yml:

name: Django CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:17.2
        env:
          POSTGRES_DB: ${{ secrets. DB_NAME}}
          POSTGRES_USER: ${{ secrets.DB_USER }}
          POSTGRES_PASSWORD:  ${{ secrets.DB_PASSWORD }}
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      SECRET_KEY: ${{ secrets.SECRET_KEY }}
      PAYPAL_CLIENT_ID: ${{ secrets.PAYPAL_CLIENT_ID }}
      PAYPAL_CLIENT_SECRET: ${{ secrets.PAYPAL_CLIENT_SECRET }}
      DB_NAME: ${{ secrets. DB_NAME}}
      DB_USER: ${{ secrets.DB_USER }}
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      DB_HOST: ${{ secrets.DB_HOST }}
      DB_PORT: 5432

    steps:
    - uses: actions/checkout@v2

    - name: Set up Python '3.10'
      uses: actions/setup-python@v2
      with:
        python-version: '3.10'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt

    - name: Wait for PostgreSQL
      run: |
        until pg_isready -h localhost -p 5432; do sleep 1; done

    - name: Run migrations
      run: |
        python manage.py migrate

    - name: Collect static files
      run: |
        python manage.py collectstatic --noinput

    - name: Run tests
      run: |
        python manage.py test

    - name: Deploy to Render
      if: github.ref == 'refs/heads/main' && success()
      run: |
        curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK }}
Enter fullscreen mode Exit fullscreen mode

Deploy to Render:

  • Push to GitHub.
  • Connect to Render, set environment variables (matching .env), and use:
  • Build Command: pip install -r requirements.txt && python manage.py migrate
  • Start Command: gunicorn payment_gateway.wsgi:application

6. Common Pitfalls & Fixes

  • PayPal Error (PAYMENT_NOT_APPROVED_FOR_EXECUTION): I hit this when testing manually without approving the payment. Ensure the user follows the approval_URL and includes the Payer_ID.
  • Test Failures: Fixed by mocking PayPal responses correctly. Check tests.py for details.
  • Database Issues: Use PostgreSQL to avoid local vs. production mismatches.

7. Test It Out!

Try it live:

{
  "customer_name": "John Doe",
  "customer_email": "john@example.com",
  "amount": 50.00
}

Enter fullscreen mode Exit fullscreen mode

Follow the approval_url, approve or cancel, and check the status with:

Image description

https://payment-gateway-api-2c52.onrender.com/api/v1/payments/1
Enter fullscreen mode Exit fullscreen mode

Image description

Conclusion

You’ve now built a Payment Gateway API with Django and PayPal!. Experiment by adding a frontend or more features,
like refunds. Happy coding!

GitHub repo:
Payment-Gateway
Questions? Drop a comment below!

Top comments (1)

Collapse
 
richard150916260714872 profile image
Richard

Hey friend! mint your exclusive about $15 in DuckyBSC airdrop tokens while it lasts! — Earn free crypto! Only available to connected crypto wallets. 👉 duckybsc.xyz