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:
- Go to the PayPal Developer Portal and log in with your PayPal account.
Create an App:
- Click Log in to Dashboard > My Apps & Credentials.
- Under REST API apps, click Create App
- 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
- Create a virtual environment and activate it
python -m venv venv
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
- 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
- Create a django project and an app
django-admin startproject payment_gateway .
python manage.py startapp payments
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'
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
Set Up PostgreSQL
Install PostgreSQL and create a Database:
bash
sudo apt update && sudo apt install postgresql postgresql-contrib # Ubuntu
psql -U postgres
sql
CREATE DATABASE mydb;
CREATE USER myuser WITH PASSWORD 'mypassword';
GRANT ALL PRIVILEGES ON DATABASE mydb TO myuser;
# exit SQL shell
\q
Run migrations:
python manage.py makemigrations
python manage.py migrate
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}"
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']
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)
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'),
]
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')),
]
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.")
Run tests:
python manage.py test
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 }}
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 thePayer_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
}
Follow the approval_url, approve or cancel, and check the status with:
https://payment-gateway-api-2c52.onrender.com/api/v1/payments/1
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)
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