Reusable Django payment infrastructure with idempotency and webhook support
Project description
payment_infra
Reusable Django payment infrastructure for production payment orchestration with provider abstractions, idempotency, secure webhooks, subscriptions, and virtual/dedicated accounts.
Features
- Provider abstraction (
paystack,stripe,monnify) through shared interfaces. - Idempotent payment initialization that binds keys to amount, currency, email, callback URL, plan, and principal.
- Provider-neutral status/result objects for payments, subscriptions, and virtual accounts.
- Secure webhook processing: raw-body HMAC validation, event normalization, duplicate-event protection, payload redaction, and amount/currency checks before payment state changes.
- Subscription support: create, fetch/verify, cancel, status mapping, webhook normalization.
- Virtual account support: create, fetch, deactivate where supported, payment event normalization, reconciliation references.
- Optional Django persistence/admin models for transactions, webhook events, subscriptions, virtual accounts, and virtual account transactions.
Architecture discovered / package layout
payment_infra/
├── api/ # DRF serializers, views, and URL routes
├── application/
│ ├── interfaces/ # Provider/repository contracts
│ ├── services/ # PaymentService, WebhookService, SubscriptionService, VirtualAccountService
│ └── webhooks/ # Provider event mappers
├── domain/entities/ # Dataclasses, provider-neutral result/status types, Django models
├── infrastructure/
│ ├── idempotency/ # Durable/in-memory idempotency + Redis/local lock fallback
│ ├── providers/ # Paystack/Stripe/Monnify adapters
│ └── repositories/ # Django data access adapters
└── migrations/ # Django migrations for optional persistence
The public payment API remains centered on get_payment_service().process_payment(...) and verify_payment(...). New capabilities follow the same pattern via get_subscription_service(...) and get_virtual_account_service(...) while Paystack-specific HTTP details remain inside the Paystack adapter.
Installation
pip install payment_infra
For local development:
git clone https://github.com/0FFSIDE1/payment_infra.git
cd payment_infra
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
Django setup
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"rest_framework",
"payment_infra",
]
from django.urls import include, path
urlpatterns = [
path("payments/", include("payment_infra.api.urls")),
]
Run migrations:
python manage.py migrate
Required configuration
Set DJANGO_PAYMENTS_PROVIDER to one of paystack, stripe, or monnify.
Paystack
PAYSTACK_SECRET_KEYrequired for provider calls and webhook HMAC validation.PAYSTACK_PUBLIC_KEYfor frontend checkout use.PAYSTACK_BASE_URLoptional; defaults tohttps://api.paystack.coand must remain HTTPS and on the allowed-host list.PAYSTACK_CALLBACK_URLfor redirects.
Stripe
STRIPE_SECRET_KEYSTRIPE_PUBLIC_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_BASE_URLoptional; defaults tohttps://api.stripe.com/v1.
Monnify
MONNIFY_API_KEYMONNIFY_SECRET_KEYMONNIFY_CONTRACT_CODEMONNIFY_PUBLIC_KEYoptionalMONNIFY_BASE_URLoptional
Common hardening settings
| Setting | Default | Purpose |
|---|---|---|
DJANGO_PAYMENTS_PROVIDER |
paystack |
Active provider used by get_payment_service() and the API when a provider-specific webhook URL is not used. |
PAYMENT_REQUIRE_AUTHENTICATION |
False |
When True, charge/verify/customer API views require an authenticated Django user. Webhooks always allow provider calls and verify signatures instead. |
PAYMENT_REQUIRE_HTTPS_CALLBACK_URL |
True |
Requires user-supplied callback URLs to use HTTPS. Set to False only for local testing to allow http://localhost, http://127.0.0.1, or http://[::1] callbacks. |
PAYMENT_ALLOWED_CALLBACK_HOSTS |
Hosts inferred from configured provider callback URLs | Optional allow-list for user-supplied callback hosts. Local loopback HTTP callbacks bypass this only when PAYMENT_REQUIRE_HTTPS_CALLBACK_URL=False. |
PAYMENT_ALLOWED_PROVIDER_HOSTS |
Provider defaults | Restricts custom provider API base URLs to approved hosts. |
PAYMENT_ALLOWED_CURRENCIES |
NGN, USD, GHS, ZAR |
Allowed API currencies. |
PAYMENT_MIN_AMOUNT |
1.00 |
Minimum amount accepted by the API serializer. |
REDIS_URL |
unset/application-specific | Lock backend for durable idempotency in multi-worker deployments. |
Local callback URLs for development
Production callback URLs should use HTTPS. For local browser checkout tests, disable the HTTPS callback requirement and point your provider callback URL at localhost:
PAYMENT_REQUIRE_HTTPS_CALLBACK_URL = False
PAYSTACK_CALLBACK_URL = "http://localhost:8000/paystack/callback/"
# Optional when you also want to accept browser-supplied non-local callback hosts.
PAYMENT_ALLOWED_CALLBACK_HOSTS = ["localhost", "127.0.0.1"]
With that setting disabled, http://localhost, http://127.0.0.1, and http://[::1] callback URLs are accepted for testing. Private LAN/link-local/reserved hosts such as http://192.168.1.10/... remain blocked, and the default True value rejects non-HTTPS callback URLs.
HTTP API flows
Mount payment_infra.api.urls wherever you want the API to live. If mounted at /payments/, the available routes are:
| Method | Route | Flow | Notes |
|---|---|---|---|
POST |
/payments/charge/ |
Initialize a payment | Validates amount/currency/callback URL, creates or reuses an idempotent local transaction, then calls the active provider. |
GET |
/payments/verify/<reference>/ |
Verify a payment | Calls the provider verification endpoint and only marks success after amount/currency match the local transaction. |
POST |
/payments/webhooks/ |
Active-provider webhook | Uses DJANGO_PAYMENTS_PROVIDER to select the provider. |
POST |
/payments/webhooks/<provider_name>/ |
Provider-specific webhook | provider_name can be paystack, stripe, or monnify. |
POST |
/payments/customers/ |
Create a provider customer | Supports idempotent customer creation where an idempotency_key is supplied. |
GET |
/payments/customers/<identifier>/ |
Fetch a provider customer | Persists the normalized customer record locally. |
POST |
/payments/customers/<customer_code>/verify/ |
Start/submit customer verification | For providers that support customer identification/KYC verification. |
Initialize a payment
from decimal import Decimal
from payment_infra.infrastructure.providers.registry import get_payment_service
service = get_payment_service("paystack")
result = service.process_payment(
email="customer@example.com",
amount=Decimal("1000.00"),
currency="NGN",
idempotency_key="checkout-ord-1001-v1",
principal="user-42",
metadata={"callback_url": "https://example.com/payments/callback"},
)
HTTP endpoint when mounted at /payments/:
curl -X POST https://merchant.example.com/payments/charge/ \
-H 'Content-Type: application/json' \
-d '{
"email": "customer@example.com",
"amount": "1000.00",
"currency": "NGN",
"idempotency_key": "checkout-ord-1001-v1",
"callback_url": "https://example.com/payments/callback"
}'
Verify a payment
verification = service.verify_payment("checkout-ord-1001-v1", principal="user-42")
HTTP endpoint when mounted at /payments/:
curl https://merchant.example.com/payments/verify/checkout-ord-1001-v1/
Verification checks provider amount/currency against the local transaction before success is accepted.
Webhook verification and processing
Use raw request bytes exactly as received from the provider. Do not JSON-decode and re-encode before signature verification.
from payment_infra.application.services.webhook_service import WebhookService
from payment_infra.infrastructure.providers.registry import get_mapper, get_provider
provider = get_provider("paystack")
mapper = get_mapper("paystack")
service = WebhookService(provider, mapper)
result = service.handle(raw_body=request.body, signature=request.headers["x-paystack-signature"])
Security behavior:
- HMAC signatures are validated with constant-time comparison.
- Invalid signatures and malformed JSON are logged with SHA-256 body hashes/previews, not secrets.
- Duplicate events are ignored idempotently via provider event IDs.
- Payment success webhooks must match the local reference, amount, and currency.
- Payload metadata is treated as untrusted. For high-value fulfillment, verify directly with the provider before crediting value.
Idempotency guidance
- Generate one idempotency key per logical checkout/subscription/virtual-account creation request.
- Reusing a key with different amount, currency, plan, callback URL, customer, or principal raises a conflict.
- Store order IDs in your application and use stable, non-guessable idempotency keys such as
checkout-<uuid>-v1. - Do not expose provider secret keys, authorization codes, or full raw webhook payloads in logs.
Customer flow
Create a customer when you want a stable provider customer code for subscriptions, dedicated accounts, or later checkout metadata:
from payment_infra.infrastructure.providers.registry import get_customer_service
customers = get_customer_service("paystack")
created = customers.create_customer(
email="customer@example.com",
first_name="Ada",
last_name="Lovelace",
phone="+2348000000000",
idempotency_key="customer-user-42-v1",
metadata={"source": "signup"},
)
fetched = customers.fetch_customer(created["customer_code"])
verified = customers.verify_customer(
created["customer_code"],
verification_type="bank_account",
value="0000000000",
country="NG",
)
HTTP examples when mounted at /payments/:
curl -X POST https://merchant.example.com/payments/customers/ \
-H 'Content-Type: application/json' \
-d '{"email":"customer@example.com","first_name":"Ada","idempotency_key":"customer-user-42-v1"}'
curl https://merchant.example.com/payments/customers/CUS_xxxxx/
curl -X POST https://merchant.example.com/payments/customers/CUS_xxxxx/verify/ \
-H 'Content-Type: application/json' \
-d '{"verification_type":"bank_account","value":"0000000000","country":"NG"}'
Customer statuses normalize to active, inactive, pending, verified, unverified, or unknown, and successful operations upsert the optional local Customer model.
Subscriptions
from payment_infra.infrastructure.providers.registry import get_subscription_service
subscriptions = get_subscription_service("paystack")
created = subscriptions.create_subscription(
customer="CUS_xxxxx", # provider customer code or supported customer identifier
plan_code="PLN_basic_monthly",
authorization="AUTH_xxxxx", # provider authorization where required
idempotency_key="sub-user-42-basic-v1",
)
fetched = subscriptions.fetch_subscription(created["subscription_code"])
cancelled = subscriptions.cancel_subscription(created["subscription_code"], email_token=created.get("email_token"))
Provider statuses are normalized to: active, pending, cancelled, disabled, expired, non_renewing, or unknown. Subscription create requests are idempotent when an idempotency key is supplied, and normalized subscription records are persisted where the optional Django models are installed.
Virtual / dedicated accounts
from payment_infra.infrastructure.providers.registry import get_virtual_account_service
virtual_accounts = get_virtual_account_service("paystack")
account = virtual_accounts.create_virtual_account(
customer={"email": "customer@example.com", "customer_code": "CUS_xxxxx"},
account_reference="va-user-42-ngn",
preferred_bank="wema-bank",
idempotency_key="va-user-42-ngn-v1",
)
same_account = virtual_accounts.fetch_virtual_account("va-user-42-ngn")
Virtual-account payment webhooks normalize to virtual_account.payment and are recorded by provider reference for reconciliation. Process fulfillment only after reconciling the reference, expected account, amount, and provider verification result. Virtual account creation is idempotent when an idempotency key is supplied.
Provider-specific notes
- Paystack: supports payments, raw-body webhook signature validation with
PAYSTACK_SECRET_KEY, customers, subscriptions, and dedicated/virtual accounts where enabled on your Paystack account. Checkout initialization receivescallback_url, optionalplan_code, and sanitized metadata. - Stripe: supports payment initialization/verification through the provider abstraction and Stripe webhook signatures with
STRIPE_WEBHOOK_SECRET. Pass a callback URL as the Stripe return URL where applicable. - Monnify: supports payment initialization/verification, Monnify webhook signature validation, and virtual-account style flows through the normalized service contracts where supported by the adapter. Configure
MONNIFY_CONTRACT_CODEfor Monnify payment initialization.
Data and idempotency model
The package ships optional Django models and migrations for:
Payment: local transaction state, amount/currency, idempotency key, callback URL, and provider verification fields.PaymentWebhookLog: duplicate protection, signature validity, normalized event details, and sanitized payload diagnostics.IdempotencyKey: durable idempotency records that bind a key to a request hash and principal.Customer: normalized customer profile and verification status.Subscription: normalized subscription status and provider identifiers.VirtualAccountandVirtualAccountTransaction: dedicated account details and reconciled incoming transfers.
Use stable, non-guessable idempotency keys for every create-style operation. Reusing a key with a different request body or principal raises a conflict instead of silently creating a second provider resource.
Production security checklist
- Use HTTPS for callback and webhook URLs.
- Keep provider secret keys in a secret manager or environment variables.
- Restrict callback hosts and provider base URLs.
- Verify raw webhook signatures before parsing.
- Treat webhook payload amounts/statuses as untrusted until matched against local records and/or provider verification.
- Use durable idempotency storage and a Redis lock in multi-worker deployments.
- Make provider references and idempotency keys non-guessable.
- Redact emails, authorization codes, and raw provider internals from logs/admin views.
- Monitor duplicate/invalid webhook logs for replay or forgery attempts.
Testing
Run non-integration tests without real provider calls:
pytest -m "not integration"
Run all tests, including opt-in integration tests, only when explicitly configured:
RUN_PAYMENT_INTEGRATION=1 pytest
Backwards compatibility / migration notes
- Existing
PaymentService.process_payment(...),verify_payment(...),get_payment_service(...), and webhook URL behavior are preserved. initialize_payment(...)is an alias forprocess_payment(...).- Existing provider adapters continue to implement
charge,verify, andverify_signature; optional subscription and virtual-account methods are additive. - New migration
0005adds callback/provider verification fields, webhook event IDs, and optional subscription/virtual-account persistence models. - Webhook duplicates now return duplicate status internally without reapplying side effects.
Changelog-style summary
- Added secure exception hierarchy and provider-neutral result/status types.
- Added Paystack subscription and dedicated account operations.
- Added subscription and virtual account services.
- Hardened payment metadata, amount/currency validation, provider error wrapping, and verification mismatch detection.
- Hardened webhook signature, parsing, redaction, idempotency, subscription normalization, and virtual-account payment handling.
- Added Django models/admin/migration for subscriptions and virtual accounts.
- Added comprehensive unit tests with mocked providers only.
License
MIT — 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 payment_infra-1.0.7.tar.gz.
File metadata
- Download URL: payment_infra-1.0.7.tar.gz
- Upload date:
- Size: 40.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9bbc2b5cef0a3b4209c7f5156e64366b0bafb6b4ce636bc14a68b9ae2126c61f
|
|
| MD5 |
7f895cc1cb20f8d2bf3d67f98d010dd4
|
|
| BLAKE2b-256 |
d4eaf6417ba80ae9cc65abfc77c8bf9e0337eaafadd99cd6d728462e3a064e9b
|
Provenance
The following attestation bundles were made for payment_infra-1.0.7.tar.gz:
Publisher:
cd.yml on 0FFSIDE1/payment_infra
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
payment_infra-1.0.7.tar.gz -
Subject digest:
9bbc2b5cef0a3b4209c7f5156e64366b0bafb6b4ce636bc14a68b9ae2126c61f - Sigstore transparency entry: 1573354404
- Sigstore integration time:
-
Permalink:
0FFSIDE1/payment_infra@ef7c7e3aebe031d4a584561b46833235d9cf1407 -
Branch / Tag:
refs/tags/v1.0.7 - Owner: https://github.com/0FFSIDE1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@ef7c7e3aebe031d4a584561b46833235d9cf1407 -
Trigger Event:
push
-
Statement type:
File details
Details for the file payment_infra-1.0.7-py3-none-any.whl.
File metadata
- Download URL: payment_infra-1.0.7-py3-none-any.whl
- Upload date:
- Size: 50.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3800215b0ac3ba3b9ac51b5bb1bf3c72610bb5267f37eb2319d2130085315f60
|
|
| MD5 |
9d78c5ac07a40c71962dbbae303789bb
|
|
| BLAKE2b-256 |
bbcc69cb380407e08a47dd604bd8c0a907721730cd0b29c060c0b6304ad6f20a
|
Provenance
The following attestation bundles were made for payment_infra-1.0.7-py3-none-any.whl:
Publisher:
cd.yml on 0FFSIDE1/payment_infra
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
payment_infra-1.0.7-py3-none-any.whl -
Subject digest:
3800215b0ac3ba3b9ac51b5bb1bf3c72610bb5267f37eb2319d2130085315f60 - Sigstore transparency entry: 1573354414
- Sigstore integration time:
-
Permalink:
0FFSIDE1/payment_infra@ef7c7e3aebe031d4a584561b46833235d9cf1407 -
Branch / Tag:
refs/tags/v1.0.7 - Owner: https://github.com/0FFSIDE1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@ef7c7e3aebe031d4a584561b46833235d9cf1407 -
Trigger Event:
push
-
Statement type: