Python · PyPI · Python 3.11+
Overview
The official server-side Python SDK wraps the full SabPaisa PG 3.0 API — hosted checkout and native UPI S2S — into a single, thread-safe client built on httpx. Requests and responses are modelled with pydantic, every documented gateway error code maps to a typed exception, and the secret is held as a SecretStr so it never leaks into tracebacks.
Hosted + Native UPI
One SDK for hosted checkout and UPI S2S
Fully Typed
pydantic models + py.typed for mypy / IDEs
Auto Checksum
HMAC-SHA256 signing baked into every request
Typed Errors
A catchable subclass for every gateway code
Safe by Default
TLS on; POSTs never auto-retried (no double charges)
Framework-Agnostic
Django, Flask, FastAPI, or plain scripts
Requirements
sabpaisa-python on PyPI. Type hints ship via py.typed; there are no native dependencies. License: MIT.Installation
1pip install sabpaisa-pythonConfiguration
SabPaisa onboarding gives you three credentials, plus an optional fourth for webhooks.
| Field | Format | Used as |
|---|---|---|
| api_key | sp_… | X-Api-Key header |
| secret_key | sec_… | HMAC-SHA256 signing key (server-side only) |
| merchant_id | short code | X-Merchant-Id header + request body field |
| webhook_secret | whsec_… (optional) | HMAC key for verifying webhook signatures |
secret_key (sec_…) and a legacy hmac_api_key (64-char hex). The SDK signs with secret_key — using hmac_api_key produces InvalidSignatureError.Explicit construction
1from sabpaisa_sdk import SabPaisaClient
2
3client = SabPaisaClient(
4 api_key="sp_...",
5 secret_key="sec_...",
6 merchant_id="YOUR_MERCHANT_CODE",
7 environment="staging", # or "production"
8)From environment variables (recommended)
1# .env (NEVER commit)
2SABPAISA_API_KEY=sp_...
3SABPAISA_SECRET_KEY=sec_...
4SABPAISA_MERCHANT_ID=YOUR_MERCHANT_CODE
5SABPAISA_ENV=staging
6# optional:
7SABPAISA_BASE_URL=
8SABPAISA_TIMEOUT=30
9SABPAISA_MAX_RETRIES=31client = SabPaisaClient.from_env()pydantic.SecretStr — the raw value never appears in frame dumps (Sentry, debuggers, rich tracebacks).Environments
| Environment | Base URL |
|---|---|
| Staging | staging-sb-merchant-api.sabpaisa.in |
| Production | merchant-api.sabpaisa.in |
The SDK picks the base URL from environment — you don't construct URLs by hand.
Quick Start
Create a hosted-checkout session and redirect the customer. The SDK signs the request and returns a ready-to-use redirect_url.
1from flask import redirect
2
3@app.post("/pay")
4def pay():
5 session = client.payments.create(
6 merchant_txn_id="ORDER-1001",
7 amount=50_000, # paise — 50000 = Rs 500.00
8 customer_name="Shubham Saurav",
9 customer_email="[email protected]",
10 customer_phone="9876543210",
11 return_url="https://shop.example.com/payments/return",
12 )
13 # session.redirect_url == checkoutUrl?clientSecret=...
14 return redirect(session.redirect_url)50000 = Rs 500.00. Never hardcode keys — load them from the environment or a secrets manager, and never commit them.Create Payment Session
Beyond the required fields, payments.create() accepts structured order data — addresses, line items, an order summary, metadata, and user-defined fields (udf1–udf20).
1session = client.payments.create(
2 merchant_txn_id="ORDER-2026-05-29-0001",
3 amount=130900,
4 customer_name="Shubham Saurav",
5 customer_email="[email protected]",
6 customer_phone="+919876543210",
7 return_url="https://shop.example.com/payments/return",
8 customer_id="CUST-12345",
9 description="Order #1234 for 2 items",
10 metadata={"orderId": "ORD-1234", "campaign": "diwali-2026"},
11 language="en",
12 billing_address={
13 "name": "Shubham Saurav", "line1": "Flat 101, Tower B",
14 "city": "Bengaluru", "state": "KA", # 2-letter state code
15 "postalCode": "560001", "country": "IN", # 2-letter ISO country
16 },
17 shipping_same_as_billing=True,
18 line_items=[
19 {
20 "name": "Wireless Mouse", "sku": "MOUSE-LOGI-M185",
21 "category": "electronics", "hsnCode": "84716060",
22 "quantity": 1, "unitPrice": 80000, "discount": 5000,
23 "taxPercent": 18.00, "tax": 13500,
24 },
25 {"name": "USB-C Cable", "quantity": 2, "unitPrice": 15000, "tax": 5400},
26 ],
27 order_summary={
28 "subtotal": 110000, "shippingAmount": 5000, "discountAmount": 5000,
29 "discountCode": "DIWALI10", "taxAmount": 18900,
30 "convenienceFee": 2000, "totalAmount": 130900,
31 },
32 udf={"udf1": "campaign-diwali-2026", "udf2": "channel-web"},
33)country must be a 2-letter ISO code (e.g. IN) and state a valid state code (e.g. KA). The gateway rejects full names like India / Karnataka with VALIDATION_ERROR.Checksum (reference)
The SDK signs every request for you. Documented here for support escalations — HMAC-SHA256, lowercase hex, 64 chars, over merchantId|merchantTxnId|amount|currency|timestamp.
1import hashlib, hmac
2
3base = f"{merchant_id}|{merchant_txn_id}|{amount}|{currency}|{timestamp}"
4checksum = hmac.new(secret_key.encode(), base.encode(), hashlib.sha256).hexdigest()Native UPI (S2S)
For server-to-server UPI (no redirect), use create_upi_s2s() with UpiPaymentMode.QR or UpiPaymentMode.INTENT. It accepts the same optional order fields as create().
1from sabpaisa_sdk import UpiPaymentMode
2
3session = client.payments.create_upi_s2s(
4 merchant_txn_id="ORDER-1001",
5 amount=50_000,
6 customer_name="Shubham Saurav",
7 customer_email="[email protected]",
8 customer_phone="9876543210",
9 mode=UpiPaymentMode.QR, # or UpiPaymentMode.INTENT
10 webhook_url="https://shop.example.com/sabpaisa/webhook",
11)
12# The customer approves the collect/QR/intent in their UPI app.
13# The final status arrives via webhook — see "Webhook Handling".Callback Handling
When the customer returns, 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. The SDK ships no return-URL signature verifier — re-enquiry is the trust boundary.
1from flask import abort, request
2from sabpaisa_sdk import SabPaisaError
3
4@app.get("/payments/return")
5def payment_return():
6 txn_id = request.args.get("merchant_txn_id")
7 if not txn_id:
8 abort(400)
9
10 try:
11 # Authoritative check — NEVER trust the redirect query params.
12 state = client.payments.status(txn_id)
13 except SabPaisaError as exc:
14 # network / 5xx — log + retry shortly; do NOT fail the order yet.
15 app.logger.warning("enquiry failed: %s trace=%s", exc.code, exc.trace_id)
16 abort(503)
17
18 # interpret state.status — see "Response Handling".Response Handling
payments.status() returns a typed TransactionEnquiryResponse with a status: PaymentStatus enum.
| 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 |
1from sabpaisa_sdk import PaymentStatus
2
3if state.status == PaymentStatus.SUCCESS:
4 mark_order_paid(order_id, state.paid_amount, state.bank_txn_id)
5elif state.status in (PaymentStatus.FAILED, PaymentStatus.CANCELLED,
6 PaymentStatus.EXPIRED, PaymentStatus.TIMEOUT):
7 mark_order_failed(order_id, state.status.value)
8else: # PENDING — wait for webhook
9 leave_pending_for(order_id)Also on the response: state.paid_amount (rupees, Decimal), state.amount_paise (int), state.bank_txn_id, state.payment_mode, state.trace_id, and state.is_success.
merchant_txn_id, not amount — SabPaisa may add a convenience fee.Refunds
Create refunds, fetch a single refund, or list them — including a paginating generator and a materialised helper.
1refund = client.refunds.create(
2 txn_id="SP_TXN_ABC123",
3 amount=25_000, # paise
4 reason="Customer requested cancellation",
5 idempotency_key="refund-order-4567", # optional — retry-safe
6)
7
8client.refunds.get(refund.refund_id) # check status
9client.refunds.list(txn_id="SP_TXN_ABC123") # one page
10for r in client.refunds.list_iter(): # paginating generator
11 ...
12all_refunds = client.refunds.list_all() # materialised listcreate() returns INITIATED and settles 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, sent as the X-Idempotency-Key header. SabPaisa collapses a retried request with the same key onto the original result instead of creating a second payment or refund.
1client.refunds.create(
2 txn_id="SP_TXN_ABC123", amount=25_000, reason="...",
3 idempotency_key="refund-order-4567", # sent as X-Idempotency-Key
4)Error Handling
Every API error is a typed, catchable subclass of SabPaisaError. Each carries message, code, http_status, trace_id, and response_body — capture all five when escalating.
| HTTP | API code | Exception |
|---|---|---|
| 401 | UNAUTHORIZED / MERCHANT_INACTIVE | AuthenticationError |
| 403 | CLIENT_CODE_MISMATCH | ForbiddenError |
| 403 | S2S_NOT_ENABLED | S2sNotEnabledError |
| 400 | INVALID_SIGNATURE | InvalidSignatureError |
| 400 | REQUEST_EXPIRED / INVALID_TIMESTAMP | RequestExpiredError |
| 400 | DUPLICATE_TRANSACTION | DuplicateTransactionError |
| 400 | PAYMENT_FAILED / PROCESSING_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 |
1from sabpaisa_sdk import (
2 SabPaisaError, DuplicateTransactionError, RateLimitError,
3)
4
5try:
6 session = client.payments.create(...)
7except DuplicateTransactionError:
8 # merchant_txn_id reused — generate a fresh one and retry
9 ...
10except RateLimitError as exc:
11 wait = exc.retry_after
12 ...
13except SabPaisaError as exc:
14 # base class — capture all five fields when escalating to support
15 log.error("%s %s %s %s %s", exc.message, exc.code,
16 exc.http_status, exc.trace_id, exc.response_body)
17 raiseValidationError; webhook verification raises InvalidWebhookSignatureError.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>, signed with a webhook secret distinct from secret_key.
1from flask import abort, request
2from sabpaisa_sdk import InvalidWebhookSignatureError
3
4WEBHOOK_SECRET = "whsec_..."
5
6@app.post("/sabpaisa/webhook")
7def sabpaisa_webhook():
8 raw = request.get_data() # RAW bytes — never re-encode
9 header = request.headers.get("X-SabPaisa-Signature")
10 try:
11 # 300-second replay window. Pass tolerance=0 for offline replay.
12 client.webhooks(WEBHOOK_SECRET).verify_signature(header, raw)
13 except InvalidWebhookSignatureError:
14 abort(401)
15
16 event = request.get_json()
17 # event["merchantTxnId"], event["status"] (SUCCESS / FAILED / EXPIRED)
18 apply_to_order(event["merchantTxnId"], event["status"])
19 return "", 200 # ack — even on a no-opProduction Best Practices
- •Always HTTPS — SabPaisa rejects HTTP return_url outside of localhost.
- •Unique merchant_txn_id per attempt (a fresh UUID per Pay click), or you'll hit DuplicateTransactionError.
- •Persist the merchant_txn_id ↔ order_id mapping BEFORE you redirect, not after.
- •Server-side re-enquiry on every callback — never trust the redirect alone.
- •Run a reconciliation cron for any order non-terminal more than 30 minutes.
- •Webhook receiver in production — required in practice for UPI.
- •Match by merchant_txn_id, not amount (SabPaisa may add a convenience fee).
- •Never log secret_key or webhook_secret. Log code, http_status, trace_id, merchant_txn_id.
- •Pin the SDK to a known-tested version; read the CHANGELOG before bumping.
- •Keep TLS verification on, and never auto-retry POSTs.
Public Surface
1client.payments.create(...) POST /api/v2/payments
2client.payments.create_upi_s2s(...) POST /api/v2/payments/s2s
3client.payments.status(merchant_txn_id) POST /api/v2/payments/enquiry
4client.payments.enquire(...) alias of status()
5client.refunds.create(txn_id=, amount=, reason=) POST /api/v2/refunds
6client.refunds.get(refund_id) GET /api/v2/refunds/{id}
7client.refunds.status(refund_id) alias of get()
8client.refunds.list(...) GET /api/v2/refunds
9client.refunds.list_iter(...) paginating generator
10client.refunds.list_all(...) materialised
11client.webhooks(webhook_secret)
12 .verify_signature(header, raw_body) local HMAC verificationNeed Help?
For integration support, contact the SabPaisa technical team with:
- •Your merchant ID
- •The trace ID from the error (
SabPaisaError.trace_id) - •The SDK version (
1.0.1)
Was this page helpful?