Production-grade Python server-side SDK for the SabPaisa PG 2.0 payment gateway.
Project description
SabPaisa Python SDK
Server-side Python SDK for the SabPaisa Payment Gateway — hosted checkout (PG v2), native UPI S2S (PG v3), transaction enquiry, refunds, and webhook signature verification.
- Package:
sabpaisa-pythonon PyPI - Version: 1.0.0
- Requires: Python ≥ 3.11
- License: MIT
About this SDK
Accept payments via SabPaisa from any Python backend across every supported method (cards, UPI, net banking, wallets). The SDK signs and ships every request, parses every response into pydantic models, and gives you typed exceptions for every documented gateway error code — so the only code you write is your own business logic.
Advantages
- Complete API coverage. Hosted checkout (PG v2) plus native UPI S2S (PG v3), status enquiry, refunds (create / get / list with auto-pagination), and webhook signature verification — one SDK, one auth model.
- Fully typed. pydantic-modelled requests and responses; PEP 561
py.typedships in the wheel so consumers get full IDE / mypy support. - Typed errors for every gateway code.
InvalidSignatureError,S2sNotEnabledError,PaymentFailedError,DuplicateTransactionError, the refund family, transientRateLimit/Server— each a distinct catchable subclass ofSabPaisaError, carryingcode,http_status,trace_id, response body. - Wire-format correctness baked in. S2S
timestampis sent as a JSON string,webhook_urlis included, the checksum base string is exact — none of which you have to remember. - Built on
httpx. Connection pooling, thread-safe, configurable timeouts. - Safe by default. TLS verification on; retries with exponential backoff on idempotent verbs only (POSTs never auto-retried — no double charges); secret held as
pydantic.SecretStrto stay out of frame dumps. - Framework-agnostic. Works with Django, Flask, FastAPI, or standalone scripts.
When to use this SDK
- You're building a server-side integration in Python (Django, Flask, FastAPI, cron/batch jobs) and want a typed, framework-agnostic SDK.
- You want one library for both hosted checkout and native UPI S2S, with an explicit, catchable fallback path between them.
- You want to reconcile payments from background jobs —
payments.status()plusrefunds.list_iter()cover that cleanly.
If your stack is PHP, use sabpaisa/sabpaisa-php. For OpenCart, use the ready-built plugins. For mobile apps, use the Android or React Native SDK.
Installation
pip install sabpaisa-python
Type hints ship via py.typed. No native dependencies.
Step-by-step integration process
A complete SabPaisa integration in Python is seven steps. Every later section expands one of these.
- Install —
pip install sabpaisa-python(see Installation). - Configure credentials — API key, secret key, merchant ID, optional webhook secret (Merchant credential configuration).
- Build the request — populate the payload from your order (Payment request payload).
- Sign it — HMAC-SHA256 over the canonical base string (Checksum generation; the SDK does this for you).
- Send it —
POST /api/v2/paymentsfor hosted,POST /api/v2/payments/s2sfor native UPI (API endpoints; Checkout redirection flow). - Handle the return — verify state server-side via the enquiry endpoint when the customer comes back (Callback handling; Response handling).
- Receive webhooks — accept and verify SabPaisa's server-pushed final status (Webhook handling).
Steps 1–6 are the minimum viable integration; step 7 is strongly recommended for production and practically required for UPI (async payment).
Merchant credential configuration
SabPaisa onboarding gives you three credentials, plus an optional fourth:
| Field | Format | Used as |
|---|---|---|
api_key |
sp_… |
X-Api-Key HTTP header |
secret_key |
sec_… |
HMAC-SHA256 signing key (server-side only) |
merchant_id |
short code (e.g. BRIG1) |
X-Merchant-Id header + request body field |
webhook_secret (optional) |
whsec_… |
HMAC key for verifying SabPaisa's webhook signatures |
Critical gotcha. SabPaisa hands out two HMAC-shaped keys:
secret_key(sec_…) and a legacyhmac_api_key(64-char hex). The SDK signs withsecret_key. Usinghmac_api_keyproducesInvalidSignatureError.
Explicit construction:
from sabpaisa_sdk import SabPaisaClient
client = SabPaisaClient(
api_key="sp_…",
secret_key="sec_…",
merchant_id="YOUR_MERCHANT_CODE",
environment="staging", # or "production"
)
From environment variables (recommended for production):
# .env (NEVER commit)
SABPAISA_API_KEY=sp_...
SABPAISA_SECRET_KEY=sec_...
SABPAISA_MERCHANT_ID=YOUR_MERCHANT_CODE
SABPAISA_ENV=staging
# optional:
SABPAISA_BASE_URL=
SABPAISA_TIMEOUT=30
SABPAISA_MAX_RETRIES=3
SABPAISA_PROXY=
client = SabPaisaClient.from_env()
The secret is held as a pydantic.SecretStr — the raw value never appears in frame dumps (Sentry / debuggers / rich tracebacks).
Payment request payload
What you supply per payment session. Field names match the wire format.
| Field | Type | Required | Notes |
|---|---|---|---|
merchant_txn_id |
str ≤ 100 | yes | unique per attempt; alphanumeric + _ / - |
amount |
int | yes | paise — 50000 = ₹500.00. Range 100–100 000 000 |
currency |
enum / str | yes | Currency.INR (default) |
customer_name |
str 2–100 | yes | |
customer_email |
str (valid email, ≤ 255) | yes | |
customer_phone |
str ≤ 20 | yes | 10-digit Indian mobile |
return_url |
str (HTTPS) | yes (hosted) | where SabPaisa redirects the customer |
mode |
UpiPaymentMode |
yes (S2S) | UpiPaymentMode.QR or UpiPaymentMode.INTENT |
webhook_url |
str | recommended | where SabPaisa POSTs the final status |
customer_id |
str | optional | your own customer reference |
description |
str ≤ 500 | optional | shown on the checkout page |
metadata |
dict | optional | free-form JSON object, echoed back on enquiry/webhook |
language |
str ≤ 10 | optional | checkout-page language (e.g. "en") |
udf |
dict | optional | echoed back in the webhook (udf1–udf20) |
billing_address |
BillingAddress / dict |
optional | see structured objects below |
shipping_address |
ShippingAddress / dict |
optional | see structured objects below |
shipping_same_as_billing |
bool | optional | top-level flag (not nested in the address) |
line_items |
list[LineItem] / dict |
optional | up to 100 items |
order_summary |
OrderSummary / dict |
optional | subtotal, fees, discounts, total |
timestamp |
int seconds (hosted) / str (S2S, wire) | yes | the SDK fills automatically |
checksum |
hex str, 64 chars | yes | the SDK fills automatically |
The structured optional objects accept either a typed model or a plain dict (camelCase wire keys). Full field list:
billing_address/shipping_address—name,line1,line2,landmark,city,state,postalCode,country,phone- ⚠️ When an address is sent,
countrymust be a 2-letter ISO code (e.g.IN) andstatea valid state code (e.g.KA) — the gateway rejects full names likeIndia/KarnatakawithVALIDATION_ERROR.
- ⚠️ When an address is sent,
line_items[]—name,description,sku,category,hsnCode,quantity,unitPrice,discount,taxPercent,tax,imageUrl,productUrl( required)order_summary—subtotal,shippingAmount,shippingMethod,discountAmount,discountCode,taxAmount,convenienceFee,totalAmount(* required)
Hosted-checkout body on the wire:
{
"merchantId": "BRIG1",
"merchantTxnId": "ORDER-1001",
"amount": 50000,
"currency": "INR",
"customerName": "Shubham Saurav",
"customerEmail": "shubham@example.com",
"customerPhone": "9876543210",
"returnUrl": "https://shop.example.com/payments/return",
"timestamp": 1779272712,
"checksum": "55b2485dcd54f9f988f88c3dfc7010f5194d0d89ee45845836ef86ff893f88d4"
}
S2S UPI body — timestamp is a JSON string, webhookUrl is included:
{
"merchantId": "BRIG1",
"merchantTxnId": "ORDER-1001",
"amount": 50000,
"currency": "INR",
"customerName": "Shubham Saurav",
"customerEmail": "shubham@example.com",
"customerPhone": "9876543210",
"paymentMode": "UPI_QR",
"webhookUrl": "https://shop.example.com/sabpaisa/webhook",
"timestamp": "1779272712",
"checksum": "55b2485dcd54f9f988f88c3dfc7010f5194d0d89ee45845836ef86ff893f88d4"
}
create_upi_s2s() also accepts the same optional order fields as create() — customer_id, metadata, language, billing_address, shipping_address, shipping_same_as_billing, line_items, order_summary, and udf — and forwards them on the S2S body (omitted when None; they don't affect the checksum). The address country/state code rule above applies here too.
Full payload with addresses, line items and order summary:
session = client.payments.create(
merchant_txn_id="ORDER-2026-05-29-0001",
amount=130900,
customer_name="Shubham Saurav",
customer_email="shubham@example.com",
customer_phone="+919876543210",
return_url="https://shop.example.com/payments/return",
customer_id="CUST-12345",
description="Order #1234 for 2 items",
metadata={"orderId": "ORD-1234", "campaign": "diwali-2026"},
language="en",
billing_address={
"name": "Shubham Saurav", "line1": "Flat 101, Tower B",
"city": "Bengaluru", "state": "KA", "postalCode": "560001", "country": "IN",
},
shipping_same_as_billing=True,
line_items=[
{
"name": "Wireless Mouse", "sku": "MOUSE-LOGI-M185",
"category": "electronics", "hsnCode": "84716060",
"quantity": 1, "unitPrice": 80000, "discount": 5000,
"taxPercent": 18.00, "tax": 13500,
},
{"name": "USB-C Cable", "quantity": 2, "unitPrice": 15000, "tax": 5400},
],
order_summary={
"subtotal": 110000, "shippingAmount": 5000, "shippingMethod": "Standard Delivery",
"discountAmount": 5000, "discountCode": "DIWALI10", "taxAmount": 18900,
"convenienceFee": 2000, "totalAmount": 130900,
},
udf={"udf1": "campaign-diwali-2026", "udf2": "channel-web"},
)
Checksum generation
The SDK signs every outbound request automatically. Documented here for support escalations.
Algorithm: HMAC-SHA256, lowercase hex, 64 characters.
Base string:
merchantId|merchantTxnId|amount|currency|timestamp
Reference implementation (what the SDK does):
import hashlib, hmac
base = f"{merchant_id}|{merchant_txn_id}|{amount}|{currency}|{timestamp}"
checksum = hmac.new(secret_key.encode(), base.encode(), hashlib.sha256).hexdigest()
For S2S the body field timestamp is sent as a JSON string, but the base string concatenates the same numeric value.
Direct helper:
from sabpaisa_sdk import generate_checksum, current_timestamp_seconds
ts = current_timestamp_seconds() # Unix seconds
checksum = generate_checksum(
secret_key,
merchant_id=merchant_id,
merchant_txn_id=merchant_txn_id,
amount=amount,
currency="INR",
timestamp=ts,
)
API endpoints
| Operation | Method | Path | Auth |
|---|---|---|---|
| Create hosted-checkout session | POST |
/api/v2/payments |
X-Api-Key |
| Native UPI S2S | POST |
/api/v2/payments/s2s |
X-Api-Key + X-Merchant-Id |
| Transaction enquiry | POST |
/api/v2/payments/enquiry |
X-Api-Key + X-Merchant-Id |
| Create refund | POST |
/api/v2/refunds |
X-Api-Key + X-Merchant-Id |
| Get refund | GET |
/api/v2/refunds/{refund_id} |
X-Api-Key + X-Merchant-Id |
| List refunds | GET |
/api/v2/refunds |
X-Api-Key + X-Merchant-Id |
Base URLs:
staging: https://staging-sb-merchant-api.sabpaisa.in
production: https://merchant-api.sabpaisa.in
The SDK picks the base URL from environment. You don't construct URLs by hand.
Checkout redirection flow
1. Customer clicks Pay
└─> your backend: client.payments.create(...) → POST /api/v2/payments
returns { paymentId, clientSecret, checkoutUrl }
─┐
2. Your backend redirects to session.redirect_url
(= checkoutUrl?clientSecret=...) <─┘
3. Customer pays on SabPaisa's hosted page (cards / UPI / net banking / wallets).
4. SabPaisa redirects the customer back to your `return_url`
with query params: merchant_txn_id, status, paid_amount, signature, ...
*** do NOT trust these query params ***
5. Your backend's return-URL handler: see "Callback handling" below.
Code for steps 1–2 (Flask):
@app.post("/pay")
def pay():
session = client.payments.create(
merchant_txn_id="ORDER-1001",
amount=50_000,
customer_name="Shubham Saurav",
customer_email="shubham@example.com",
customer_phone="9876543210",
return_url="https://shop.example.com/payments/return",
)
return redirect(session.redirect_url)
For native UPI (no redirect) use create_upi_s2s() — see Payment request payload and Webhook handling.
Callback handling
When the customer returns from SabPaisa, the query string carries a status hint and signature. Treat them as a hint, not proof. The authoritative check is a server-side payments.status() enquiry:
from flask import abort, request, redirect
from sabpaisa_sdk import SabPaisaError
@app.get("/payments/return")
def payment_return():
txn_id = request.args.get("merchant_txn_id")
if not txn_id:
abort(400)
try:
state = client.payments.status(txn_id)
except SabPaisaError as exc:
# network / 5xx / etc. — log + retry shortly; do NOT fail the order yet.
app.logger.warning("SabPaisa enquiry failed: %s trace=%s", exc.code, exc.trace_id)
abort(503)
# See "Response handling" for how to interpret state.status.
The SDK does not ship a return-URL signature verifier — re-enquiry is the trust boundary.
For a reconciliation cron (recommended): every N minutes, re-enquire every order non-terminal > 30 min and apply the same response-handling logic.
Response handling
payments.status() returns a typed TransactionEnquiryResponse with status: PaymentStatus:
state.status |
Order action |
|---|---|
SUCCESS |
mark paid; fulfil |
FAILED |
mark failed; allow retry |
CANCELLED |
mark failed/cancelled |
EXPIRED |
mark failed |
TIMEOUT |
mark failed |
PENDING |
leave pending; rely on webhook or recon cron |
from sabpaisa_sdk import PaymentStatus
if state.status == PaymentStatus.SUCCESS:
mark_order_paid(order_id, state.paid_amount, state.bank_txn_id)
elif state.status in (PaymentStatus.FAILED, PaymentStatus.CANCELLED,
PaymentStatus.EXPIRED, PaymentStatus.TIMEOUT):
mark_order_failed(order_id, state.status.value)
else: # PENDING — wait for webhook
leave_pending_for(order_id)
Also useful on the response:
state.paid_amount— rupees (Decimal)state.amount_paise— paise (int)state.bank_txn_id— bank's referencestate.payment_mode—"UPI"/"CARD"/"NET_BANKING"/"WALLET"state.trace_id— SabPaisa's correlation IDstate.is_success— convenience boolean
Match by merchant_txn_id, not amount — SabPaisa may add a convenience fee.
Webhook handling
SabPaisa POSTs the authoritative final status to your webhook_url with an
X-SabPaisa-Signature: <timestamp>.<base64sig> header. HMAC-SHA256 over
<timestamp>.<raw_body>, base64-encoded, signed with a webhook secret SabPaisa
provisions for you (distinct from secret_key).
from flask import abort, request
from sabpaisa_sdk import InvalidWebhookSignatureError
WEBHOOK_SECRET = "whsec_…"
@app.post("/sabpaisa/webhook")
def sabpaisa_webhook():
raw = request.get_data() # RAW bytes — never re-encode
header = request.headers.get("X-SabPaisa-Signature")
try:
client.webhooks(WEBHOOK_SECRET).verify_signature(header, raw)
# 300-second replay window. Pass tolerance=0 for offline replay.
except InvalidWebhookSignatureError:
abort(401)
event = request.get_json()
# event["merchantTxnId"], event["status"] (SUCCESS / FAILED / EXPIRED), ...
apply_to_order(event["merchantTxnId"], event["status"])
return "", 200 # ack — even if status didn't change
Always 4xx a bad signature; always 2xx an accepted delivery (even on no-op) so SabPaisa doesn't redeliver indefinitely.
Required in practice for UPI (customer may approve in their UPI app long after leaving your site).
Errors
Every API error is typed:
| HTTP | API code | Exception |
|---|---|---|
| 401 | UNAUTHORIZED / MERCHANT_INACTIVE |
AuthenticationError |
| 403 | CLIENT_CODE_MISMATCH |
ForbiddenError |
| 403 | S2S_NOT_ENABLED |
S2sNotEnabledError (extends ForbiddenError) |
| 400 | INVALID_SIGNATURE |
InvalidSignatureError |
| 400 | REQUEST_EXPIRED / INVALID_TIMESTAMP |
RequestExpiredError |
| 400 | DUPLICATE_TRANSACTION |
DuplicateTransactionError |
| 400 | PAYMENT_FAILED / PROCESSING_ERROR / ORCHESTRATOR_ERROR |
PaymentFailedError |
| 400 | AMOUNT_EXCEEDED |
AmountExceededError |
| 400 | REFUND_* |
RefundError subclasses |
| 404 | TRANSACTION_NOT_FOUND |
NotFoundError |
| 429 | — | RateLimitError (carries retry_after) |
| 5xx | SERVICE_UNAVAILABLE / INTERNAL_ERROR |
ServerError |
Catch SabPaisaError for the base. Every exception carries message, code, http_status, trace_id, response_body — capture all five when escalating.
Local validation raises ValidationError. Webhook verification raises InvalidWebhookSignatureError.
Refunds
refund = client.refunds.create(
txn_id="SP_TXN_ABC123",
amount=25_000, # paise
reason="Customer requested cancellation",
idempotency_key="refund-order-4567", # optional — retry-safe
)
client.refunds.get(refund.refund_id)
client.refunds.list(txn_id="SP_TXN_ABC123")
for r in client.refunds.list_iter(): # paginating generator
...
all_refunds = client.refunds.list_all()
Refunds are asynchronous — create() returns INITIATED and settle in 5–7 business days. Poll get() or let your reconciliation job pick them up.
Idempotency
Every mutating call — payments.create(), payments.create_upi_s2s(), and refunds.create() — accepts an optional idempotency_key. When set, the SDK sends it as the X-Idempotency-Key header, and SabPaisa collapses a retried request carrying the same key onto the original result instead of creating a second payment or refund.
client.refunds.create(
txn_id="SP_TXN_ABC123", amount=25_000, reason="...",
idempotency_key="refund-order-4567",
)
Use a key that's stable per logical operation (e.g. derived from your order id), so an automatic retry, a double-clicked button, or a redelivered job can't double-charge or double-refund. The SDK never auto-retries POSTs, so the key protects your retries. Omit it and the call behaves exactly as before.
Production best practices
- Always HTTPS. SabPaisa rejects HTTP
return_urloutside of localhost. - Unique
merchant_txn_idper attempt. UUID per Pay click. Fresh ID on retry, otherwiseDuplicateTransactionError. - Persist
merchant_txn_id ↔ order_idBEFORE you redirect, not after. - Server-side re-enquiry on every callback. Never trust the redirect alone.
- Reconciliation cron. For any order non-terminal > 30 min, run
payments.status(). - Webhook receiver in production.
client.webhooks(...).verify_signature(...). Required in practice for UPI. - Match by
merchant_txn_id, not amount. SabPaisa may add a convenience fee. - Never log
secret_keyorwebhook_secret. Logcode,http_status,trace_id,merchant_txn_id. - Pin the SDK. Lock to a known-tested version in your requirements. Read the CHANGELOG before bumping.
- TLS verification stays on. Default is on; don't disable it.
- Don't auto-retry POSTs. The SDK already enforces this — preserve the rule if you wrap it.
- Idempotent webhook handler. SabPaisa may redeliver; your handler should be safe to run twice for the same
merchantTxnId.
Trust boundary
Nothing the customer's browser carries proves payment. The return-URL query string can be replayed or spoofed. The authoritative source is the gateway, accessed two ways:
- Server-side enquiry —
client.payments.status(merchant_txn_id)after the customer returns, or from a cron. - Webhook — SabPaisa POSTs the final status to your endpoint with
X-SabPaisa-Signature.
Never mark an order paid based on the redirect alone.
Public surface reference
client.payments.create(...) POST /api/v2/payments
client.payments.create_upi_s2s(...) POST /api/v2/payments/s2s
client.payments.status(merchant_txn_id) POST /api/v2/payments/enquiry
client.payments.enquire(...) alias of status()
client.refunds.create(txn_id=, amount=, reason=) POST /api/v2/refunds
client.refunds.get(refund_id) GET /api/v2/refunds/{id}
client.refunds.status(refund_id) alias of get()
client.refunds.list(...) GET /api/v2/refunds
client.refunds.list_iter(...) paginating generator
client.refunds.list_all(...) materialised
client.webhooks(webhook_secret)
.verify_signature(header, raw_body) local HMAC verification
See INTEGRATION.md for the step-by-step Flask / FastAPI walkthrough.
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 sabpaisa_python-1.0.0.tar.gz.
File metadata
- Download URL: sabpaisa_python-1.0.0.tar.gz
- Upload date:
- Size: 32.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f77d8d1f592196dde175ece470a377ae62fc66b0042eea19a6a57410be187dd6
|
|
| MD5 |
2439c113fda07a7a54386b1d900b82b9
|
|
| BLAKE2b-256 |
f200e82f2d870d41fc3c8b7634a8650b8bc5fad8f57605dce71ec8ee3c94942f
|
File details
Details for the file sabpaisa_python-1.0.0-py3-none-any.whl.
File metadata
- Download URL: sabpaisa_python-1.0.0-py3-none-any.whl
- Upload date:
- Size: 33.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a016316776045eae6bce52e160113caaf513fd8af8174b9c25f9116e7ab23176
|
|
| MD5 |
dfaa3a93f585af5ac4e1946f4aadce94
|
|
| BLAKE2b-256 |
82695b06f3ccebac3b4cb71da0ad28056c83c365f163fb3b7107eae5eae6b1e1
|