Skip to main content

Unified Python SDK for Pakistani payment gateways: JazzCash, EasyPaisa, Safepay, NayaPay. One API, signature-verified callbacks, FastAPI & Django plugins.

Project description

pay-pak

Python License Tests

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

  1. What is pay-pak?
  2. How it works
  3. Requirements
  4. Installation
  5. Quick start
  6. Configuration
  7. Usage
  8. Framework integration
  9. API reference
  10. Gateway capability matrix
  11. Payment states
  12. Security model
  13. Troubleshooting
  14. Testing
  15. Publishing to PyPI
  16. 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:

  1. Customer clicks Pay on your site.
  2. Your app calls checkout() with amount and order id.
  3. Customer is sent to the gateway hosted page.
  4. Gateway returns a signed callback to your app.
  5. 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 UnsupportedFlowException until implemented).


How it works

Your app  →  PayPak.gateway("jazzcash")  →  GatewayDriver  →  Payment gateway
Gateway   →  signed callback/webhook     →  verify_callback()  →  PaymentResult

Hosted checkout flow:

  1. checkout() builds a signed payload and stores it behind a one-time token.
  2. Your app redirects the customer to /{prefix}/redirect/{token}.
  3. The redirect handler renders an auto-submitting HTML form POST to the gateway.
  4. Customer pays on the gateway page.
  5. Gateway POSTs/redirects back to your return_url.
  6. verify_callback() verifies pp_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_digest before any field is trusted.
  • One-time redirect tokens: 10-minute TTL; consumed on first use (replay → HTTP 410).
  • Safepay replay protection: optional X-SFPY-Timestamp header; rejects payloads older than 300 seconds.
  • No PCI card data: Safepay and NayaPay charge() intentionally raise UnsupportedFlowException.
  • 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=false keeps pp_Password out 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 Developerdevtesting123mk@gmail.com


License

MIT — Copyright (c) 2026 MK Developer. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pay_pak-1.0.0.tar.gz (31.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pay_pak-1.0.0-py3-none-any.whl (30.7 kB view details)

Uploaded Python 3

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

Hashes for pay_pak-1.0.0.tar.gz
Algorithm Hash digest
SHA256 8858707efd31da9fc3c6d7beb607173150da92eb7ac63822ed30ce2adcf56d9d
MD5 27b0d9f0f36da31f6b58bbe4dc447dc0
BLAKE2b-256 2030de103e70b0e2bf50bc7cacaec3e2b69c8182c43edc565e7ed0509c799c8b

See more details on using hashes here.

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

Hashes for pay_pak-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 16d295f93ee7b048b11bc9e5b1ff9ee5b3f78e8c8296c369020f32922495950b
MD5 dead5563f4049e647631618c122cf138
BLAKE2b-256 dd0b1c9a3d4def3df9fdf1d093c8fb908f3ed035c4bf476cbd9d78caddbb7af9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page