Unified Python SDK for Pakistani payment gateways: JazzCash, EasyPaisa, Safepay, NayaPay. One API, signature-verified callbacks, FastAPI & Django plugins.
Project description
pay-pak
pay-pak is a unified Python SDK for accepting payments through Pakistani payment gateways: JazzCash, EasyPaisa, Safepay, NayaPay (plus PayFast and Raast scaffolds).
One API for every gateway. Every driver verifies callback signatures with constant-time comparison and never fakes flows it does not support.
Python port of the Laravel package pak-pay/pak-pay.
pip install pay-pak
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest
paypak = PayPak()
request = PaymentRequest(amount=150_00, order_id="ORDER-1001")
paypak.gateway("jazzcash").checkout(request) # hosted redirect
paypak.gateway("jazzcash").charge(request) # direct wallet
paypak.gateway("jazzcash").verify_callback(inbound) # signed callback
Table of contents
- What is pay-pak?
- How it works
- Requirements
- Installation
- Quick start
- Configuration
- Usage
- Framework integration
- API reference
- Gateway capability matrix
- Payment states
- Security model
- Troubleshooting
- Testing
- Publishing to PyPI
- License
What is pay-pak?
If you run an online shop in Pakistan, customers pay with JazzCash, EasyPaisa, Safepay, NayaPay, and others. Each gateway has different APIs, signing rules, and callback formats.
pay-pak is a single Python library that handles all of that for you:
- Customer clicks Pay on your site.
- Your app calls
checkout()with amount and order id. - Customer is sent to the gateway hosted page.
- Gateway returns a signed callback to your app.
- You call
verify_callback()— pay-pak checks the signature and tells you paid or not paid.
You write a few lines; pay-pak handles signing, security, and gateway-specific details.
Status: JazzCash, EasyPaisa, Safepay, NayaPay are fully implemented. PayFast and Raast are scaffolds (they raise
UnsupportedFlowExceptionuntil implemented).
How it works
Your app → PayPak.gateway("jazzcash") → GatewayDriver → Payment gateway
Gateway → signed callback/webhook → verify_callback() → PaymentResult
Hosted checkout flow:
checkout()builds a signed payload and stores it behind a one-time token.- Your app redirects the customer to
/{prefix}/redirect/{token}. - The redirect handler renders an auto-submitting HTML form POST to the gateway.
- Customer pays on the gateway page.
- Gateway POSTs/redirects back to your
return_url. verify_callback()verifiespp_SecureHash(or gateway equivalent) before trusting any field.
Requirements
- Python 3.10+
- Core dependencies (installed automatically):
httpx,pydantic,pydantic-settings,pycryptodome
Installation
Core package
pip install pay-pak
Optional extras
pip install "pay-pak[fastapi]" # FastAPI redirect router + helpers
pip install "pay-pak[django]" # Django redirect view + helpers
pip install "pay-pak[redis]" # Redis cache for multi-worker deployments
pip install "pay-pak[dev]" # pytest, ruff, mypy (contributors)
From source (development)
git clone https://github.com/your-org/pay-pak.git
cd pay-pak
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
Quick start
1. Set environment variables
Create a .env file (or export variables):
PAKPAY_MODE=sandbox
PAKPAY_GATEWAY=jazzcash
JAZZCASH_MERCHANT_ID=your_merchant_id
JAZZCASH_PASSWORD=your_password
JAZZCASH_INTEGRITY_SALT=your_salt
JAZZCASH_RETURN_URL=https://your-app.com/payments/jazzcash/return
2. Start a payment
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest
paypak = PayPak()
request = PaymentRequest(
amount=150_00, # PKR 150.00 — always integer paisa
order_id="ORDER-1001",
description="Order #1001",
)
checkout = paypak.gateway("jazzcash").checkout(request)
# checkout.redirect_url → e.g. /pakpay/redirect/{uuid}
# checkout.token → one-time token for the redirect handler
Redirect the customer with HTTP 302 to checkout.redirect_url.
3. Verify the callback
from pay_pak.dto import InboundRequest
from pay_pak.exceptions import PakPayException
# Build InboundRequest from your web framework (see Framework integration)
result = paypak.gateway("jazzcash").verify_callback(inbound)
if result.is_paid():
# Fulfill order — store result.transaction_id for refunds/status
print(f"Paid! txn={result.transaction_id}")
else:
print(f"Not paid: {result.message}")
Standalone demo (no web server)
python examples/demo.py
Signs a JazzCash payload, verifies a genuine callback, and rejects a tampered one.
Configuration
Global settings
| Variable | Default | Description |
|---|---|---|
PAKPAY_MODE |
sandbox |
sandbox or production — flips all gateway endpoints |
PAKPAY_GATEWAY |
jazzcash |
Default gateway when calling paypak.checkout() without gateway() |
PAKPAY_ROUTE_PREFIX |
pakpay |
URL prefix for hosted redirect (/{prefix}/redirect/{token}) |
PAKPAY_REDIRECT_BASE_URL |
"" |
Optional absolute base URL prepended to redirect paths |
Programmatic configuration
from pay_pak import PayPak
from pay_pak.config import PayPakSettings, default_gateways_config
settings = PayPakSettings(
mode="sandbox",
default_gateway="jazzcash",
route_prefix="pakpay",
redirect_base_url="https://shop.example.com",
)
# Override gateway config dict (e.g. in tests)
gateways = default_gateways_config()
gateways["jazzcash"]["merchant_id"] = "MC1234"
paypak = PayPak(settings=settings, gateways=gateways)
JazzCash
Credentials from your JazzCash merchant portal (Integration → API keys).
| Variable | Required | Default | Description |
|---|---|---|---|
JAZZCASH_MERCHANT_ID |
✅ | — | Merchant ID (pp_MerchantID) |
JAZZCASH_PASSWORD |
✅ | — | Merchant password (pp_Password) |
JAZZCASH_INTEGRITY_SALT |
✅ | — | HMAC-SHA256 signing salt |
JAZZCASH_RETURN_URL |
✅ | — | Callback URL where JazzCash posts the result |
JAZZCASH_CURRENCY |
❌ | PKR |
Transaction currency |
JAZZCASH_LANGUAGE |
❌ | EN |
Hosted form language |
JAZZCASH_HOSTED_SEND_PASSWORD |
❌ | true |
Set false to omit password from browser HTML form |
JAZZCASH_MERCHANT_ID=
JAZZCASH_PASSWORD=
JAZZCASH_INTEGRITY_SALT=
JAZZCASH_RETURN_URL=https://your-app.com/payments/jazzcash/return
EasyPaisa
| Variable | Required | Description |
|---|---|---|
EASYPAISA_STORE_ID |
✅ | Store ID from onboarding pack |
EASYPAISA_HASH_KEY |
✅ | 16-byte AES key for merchantHashedReq |
EASYPAISA_ACCOUNT_NUM |
✅ | Merchant account number |
EASYPAISA_RETURN_URL |
✅ | Signed return URL |
Safepay
| Variable | Required | Description |
|---|---|---|
SAFEPAY_CLIENT_KEY |
✅ | Public key (safe for browser / Smart Button) |
SAFEPAY_SECRET_KEY |
✅ | Secret API key (server-side only) |
SAFEPAY_WEBHOOK_SECRET |
✅ | Webhook signing secret |
SAFEPAY_RETURN_URL |
✅ | Success return URL |
SAFEPAY_CANCEL_URL |
❌ | Cancel return URL |
SAFEPAY_CURRENCY |
❌ | Default PKR |
NayaPay
| Variable | Required | Description |
|---|---|---|
NAYAPAY_MERCHANT_ID |
✅ | Merchant ID |
NAYAPAY_API_KEY |
✅ | API key (Bearer token for status API) |
NAYAPAY_HASH_KEY |
✅ | HMAC signing key |
NAYAPAY_RETURN_URL |
✅ | Signed return URL |
NAYAPAY_CURRENCY |
❌ | Default PKR |
PayFast / Raast (scaffold only)
Env keys PAYFAST_* and RAAST_* are reserved. Drivers resolve but every flow raises UnsupportedFlowException.
Usage
Golden rule: amounts are always integers in paisa.
150_00= PKR 150.00. Never use floats.
Hosted checkout (redirect)
checkout = paypak.gateway("jazzcash").checkout(request)
# RedirectResponse: checkout.redirect_url, checkout.token
Works for JazzCash, EasyPaisa, NayaPay (token + auto-submit form). Safepay returns a direct URL to hosted checkout (no token).
Direct wallet charge (JazzCash)
from pay_pak.dto import Customer
request = PaymentRequest(
amount=150_00,
order_id="ORDER-1002",
customer=Customer(mobile="03019680302", cnic_last6="123456"),
)
result = paypak.gateway("jazzcash").charge(request)
EasyPaisa mobile account (OTP)
request = PaymentRequest(
amount=150_00,
order_id="ORDER-1003",
customer=Customer(mobile="03019680302", email="ali@shop.test"),
)
result = paypak.gateway("easypaisa").charge(request)
Safepay Smart Button (frontend)
data = paypak.gateway("safepay").smart_button_data(request)
# data: environment, clientKey, tracker, orderId, amount, currency
Pass data to the Safepay JavaScript Smart Payment Button on your frontend.
Safepay webhook
inbound = InboundRequest(
method="POST",
query={},
form={},
body=raw_body_bytes,
headers={"x-sfpy-signature": signature_header},
)
result = paypak.gateway("safepay").verify_callback(inbound)
Status inquiry
status = paypak.gateway("jazzcash").status("T20260101120000ABCDEF")
if status.is_paid():
...
Refund
refund = paypak.gateway("jazzcash").refund("T20260101120000ABCDEF", 50_00)
if refund.success:
print(refund.refund_id)
Switch gateways
paypak.gateway("jazzcash").checkout(request)
paypak.gateway("easypaisa").checkout(request)
paypak.gateway("safepay").checkout(request)
paypak.gateway("nayapay").checkout(request)
Framework integration
Flask
from flask import Flask, redirect, request
from pay_pak import PayPak
from pay_pak.dto import InboundRequest, PaymentRequest
from pay_pak.redirect import consume_redirect_payload, render_auto_submit_form
app = Flask(__name__)
paypak = PayPak()
@app.get("/pay")
def pay():
req = PaymentRequest(amount=150_00, order_id="ORDER-1")
checkout = paypak.gateway("jazzcash").checkout(req)
return redirect(checkout.redirect_url)
@app.get("/pakpay/redirect/<token>")
def pakpay_redirect(token: str):
result = consume_redirect_payload(paypak.cache, token)
if result is None:
return "Redirect expired. Please restart checkout.", 410
action, fields, _ = result
return render_auto_submit_form(action, fields)
@app.route("/payments/jazzcash/return", methods=["GET", "POST"])
def jazzcash_return():
inbound = InboundRequest(
method=request.method,
query={k: str(v) for k, v in request.args.items()},
form={k: str(v) for k, v in request.form.items()},
body=request.get_data(),
headers={k.lower(): v for k, v in request.headers.items()},
)
result = paypak.gateway("jazzcash").verify_callback(inbound)
return "Thank you!" if result.is_paid() else "Payment failed"
FastAPI
pip install "pay-pak[fastapi]"
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest
from pay_pak_fastapi import create_pakpay_router, to_inbound_request_async
app = FastAPI()
paypak = PayPak()
app.include_router(create_pakpay_router(prefix="pakpay", cache=paypak.cache))
@app.get("/pay")
def pay():
req = PaymentRequest(amount=150_00, order_id="ORDER-1")
checkout = paypak.gateway("jazzcash").checkout(req)
return RedirectResponse(checkout.redirect_url, status_code=302)
@app.api_route("/payments/jazzcash/return", methods=["GET", "POST"])
async def jazzcash_return(request: Request):
inbound = await to_inbound_request_async(request)
result = paypak.gateway("jazzcash").verify_callback(inbound)
return {"paid": result.is_paid(), "txn": result.transaction_id}
Django
pip install "pay-pak[django]"
# urls.py
from django.urls import path
from pay_pak_django import redirect_view, set_cache
from pay_pak import PayPak
paypak = PayPak()
set_cache(paypak.cache)
urlpatterns = [
path("pakpay/redirect/<uuid:token>/", redirect_view, name="paypak_redirect"),
]
# views.py
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest
from pay_pak_django import to_inbound_request
paypak = PayPak()
def start_payment(request):
req = PaymentRequest(amount=150_00, order_id="ORDER-1")
checkout = paypak.gateway("jazzcash").checkout(req)
from django.shortcuts import redirect
return redirect(checkout.redirect_url)
@csrf_exempt
def jazzcash_return(request):
result = paypak.gateway("jazzcash").verify_callback(to_inbound_request(request))
return JsonResponse({"paid": result.is_paid()})
Redis cache (production, multi-worker)
pip install "pay-pak[redis]"
from pay_pak.cache import RedisCache
from pay_pak import PayPak
paypak = PayPak(cache=RedisCache("redis://localhost:6379/0"))
Redirect tokens and Safepay passport tokens are shared across workers.
API reference
PayPak (alias for PayPakManager)
| Method | Description |
|---|---|
gateway(name=None) |
Resolve a gateway driver (jazzcash, easypaisa, safepay, nayapay, payfast, raast) |
checkout(request) |
Proxy to default gateway checkout() |
charge(request) |
Proxy to default gateway charge() |
verify_callback(inbound) |
Proxy to default gateway verify_callback() |
refund(txn_id, amount) |
Proxy to default gateway refund() |
status(txn_id) |
Proxy to default gateway status() |
GatewayDriver methods
| Method | Returns | Description |
|---|---|---|
checkout(request) |
CheckoutResponse |
Start hosted payment |
charge(request) |
PaymentResult |
Direct wallet / OTP charge |
verify_callback(inbound) |
PaymentResult |
Verify signed callback/webhook |
refund(txn_id, amount) |
RefundResult |
Refund a transaction |
status(txn_id) |
PaymentStatus |
Query transaction status |
PaymentRequest
| Field | Type | Required | Description |
|---|---|---|---|
amount |
int |
✅ | Amount in paisa (> 0) |
order_id |
str |
✅ | Your unique order reference |
currency |
str |
❌ | Default PKR |
description |
str | None |
❌ | Shown on hosted page |
customer |
Customer | None |
❌ | Required for wallet charge() |
return_url |
str | None |
❌ | Override gateway return URL |
meta |
dict |
❌ | Arbitrary metadata |
request.amount_as_decimal() # "150.00"
PaymentRequest.from_dict({"amount": 15000, "order_id": "ORDER-1"})
Customer
| Field | Type | Description |
|---|---|---|
name |
str | None |
Full name |
email |
str | None |
Email address |
mobile |
str | None |
MSISDN — required for wallet charge |
cnic_last6 |
str | None |
Last 6 CNIC digits (JazzCash pp_CNIC) |
PaymentResult / PaymentStatus
| Field | Type | Description |
|---|---|---|
success |
bool |
Whether operation succeeded |
state |
str |
Normalised PaymentState value |
transaction_id |
str | None |
Gateway transaction id |
order_id |
str | None |
Your order id |
amount |
int | None |
Amount in paisa |
gateway_code |
str | None |
Raw gateway response code |
message |
str | None |
Human-readable message |
raw |
dict |
Full gateway payload for auditing |
result.is_paid() # success and state == "paid"
CheckoutResponse
| Field | Type | Description |
|---|---|---|
redirect_url |
str |
URL to redirect the customer |
token |
str | None |
One-time redirect token (null for Safepay direct URL) |
InboundRequest
Framework-agnostic HTTP request for verify_callback():
| Field | Type | Description |
|---|---|---|
method |
str |
HTTP method |
query |
dict[str, str] |
Query parameters |
form |
dict[str, str] |
Form fields |
body |
bytes |
Raw body (webhooks) |
headers |
dict[str, str] |
Lowercase header names |
Exceptions
| Exception | When raised |
|---|---|
PakPayException |
Base class — catch for any pay-pak error |
GatewayException |
Misconfiguration or gateway error response |
SignatureVerificationException |
Invalid, missing, or replayed signature |
UnsupportedFlowException |
Flow not supported by gateway (never faked) |
Gateway capability matrix
| Gateway | checkout | charge | refund | status | verify |
|---|---|---|---|---|---|
jazzcash |
✅ | ✅ Mobile Wallet | ✅ | ✅ | ✅ HMAC pp_SecureHash |
easypaisa |
✅ | ✅ Mobile Account | ❌ | ✅ | ✅ HMAC return signature |
safepay |
✅ | ❌ PCI | ✅ | ✅ | ✅ X-SFPY-Signature webhook |
nayapay |
✅ | ❌ PCI | ❌ | ✅ | ✅ HMAC signature |
payfast |
🚧 scaffold | 🚧 | 🚧 | 🚧 | 🚧 |
raast |
🚧 scaffold | 🚧 | 🚧 | 🚧 | 🚧 |
Payment states
All gateways normalise to one vocabulary (pay_pak.enums.PaymentState):
| State | Value | Meaning |
|---|---|---|
PENDING |
pending |
Created, no money moved |
PROCESSING |
processing |
Redirected / OTP issued |
PAID |
paid |
Funds captured |
FAILED |
failed |
Declined or abandoned |
CANCELLED |
cancelled |
Cancelled before completion |
REFUNDED |
refunded |
Funds returned |
UNKNOWN |
unknown |
Unmapped gateway state |
from pay_pak.enums.payment_state import PaymentState
if result.state == PaymentState.PAID:
...
PaymentState.is_successful(result.state) # True only for PAID
PaymentState.is_final(result.state) # PAID, FAILED, CANCELLED, REFUNDED
Security model
- Signature first: every callback is verified with
hmac.compare_digestbefore any field is trusted. - One-time redirect tokens: 10-minute TTL; consumed on first use (replay → HTTP 410).
- Safepay replay protection: optional
X-SFPY-Timestampheader; rejects payloads older than 300 seconds. - No PCI card data: Safepay and NayaPay
charge()intentionally raiseUnsupportedFlowException. - No fake flows: unsupported operations raise
UnsupportedFlowException, never return fake success. - Secrets in env only: never hardcode merchant passwords or salts in source code.
- JazzCash password in browser:
JAZZCASH_HOSTED_SEND_PASSWORD=falsekeepspp_Passwordout of the hosted HTML form when your integration allows it.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
Missing required configuration [merchant_id] |
Env var not set | Add credentials to .env |
Signature verification failed |
Wrong salt/secret or tampered payload | Check INTEGRITY_SALT / HASH_KEY; never trust payload before verify |
Gateway [easypaisa] does not support the [refund] flow |
Unsupported flow | Use a gateway that supports refund, or handle manually |
Cache backend is required for checkout |
No cache passed | Use PayPak(cache=MemoryCache()) or Redis |
Redirect expired (410) |
Token reused or expired | Customer must restart checkout |
ValueError: amount must be positive |
Amount ≤ 0 or float used | Use int paisa: 150_00 not 150.0 |
Testing
pip install -e ".[dev]"
pytest -v
55 tests cover signature vectors (matching PHP test suite), driver flows, redirect replay, and framework plugins.
Publishing to PyPI
See docs/PUBLISHING.md for step-by-step instructions.
Quick summary:
pip install build twine
python -m build
twine upload dist/*
Author
MK Developer — devtesting123mk@gmail.com
License
MIT — Copyright (c) 2026 MK Developer. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pay_pak-1.0.0.tar.gz.
File metadata
- Download URL: pay_pak-1.0.0.tar.gz
- Upload date:
- Size: 31.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8858707efd31da9fc3c6d7beb607173150da92eb7ac63822ed30ce2adcf56d9d
|
|
| MD5 |
27b0d9f0f36da31f6b58bbe4dc447dc0
|
|
| BLAKE2b-256 |
2030de103e70b0e2bf50bc7cacaec3e2b69c8182c43edc565e7ed0509c799c8b
|
File details
Details for the file pay_pak-1.0.0-py3-none-any.whl.
File metadata
- Download URL: pay_pak-1.0.0-py3-none-any.whl
- Upload date:
- Size: 30.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
16d295f93ee7b048b11bc9e5b1ff9ee5b3f78e8c8296c369020f32922495950b
|
|
| MD5 |
dead5563f4049e647631618c122cf138
|
|
| BLAKE2b-256 |
dd0b1c9a3d4def3df9fdf1d093c8fb908f3ed035c4bf476cbd9d78caddbb7af9
|