Skip to main content

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-python on PyPI
  • Version: 1.0.1
  • 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.typed ships in the wheel so consumers get full IDE / mypy support.
  • Typed errors for every gateway code. InvalidSignatureError, S2sNotEnabledError, PaymentFailedError, DuplicateTransactionError, the refund family, transient RateLimit/Server — each a distinct catchable subclass of SabPaisaError, carrying code, http_status, trace_id, response body.
  • Wire-format correctness baked in. S2S timestamp is sent as a JSON string, webhook_url is 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.SecretStr to 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() plus refunds.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.

  1. Installpip install sabpaisa-python (see Installation).
  2. Configure credentials — API key, secret key, merchant ID, optional webhook secret (Merchant credential configuration).
  3. Build the request — populate the payload from your order (Payment request payload).
  4. Sign it — HMAC-SHA256 over the canonical base string (Checksum generation; the SDK does this for you).
  5. Send itPOST /api/v2/payments for hosted, POST /api/v2/payments/s2s for native UPI (API endpoints; Checkout redirection flow).
  6. Handle the return — verify state server-side via the enquiry endpoint when the customer comes back (Callback handling; Response handling).
  7. 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. TESTMERCH) 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 legacy hmac_api_key (64-char hex). The SDK signs with secret_key. Using hmac_api_key produces InvalidSignatureError.

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 (udf1udf20)
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_addressname, line1, line2, landmark, city, state, postalCode, country, phone
    • ⚠️ When an address is sent, 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.
  • line_items[]name, description, sku, category, hsnCode, quantity, unitPrice, discount, taxPercent, tax, imageUrl, productUrl ( required)
  • order_summarysubtotal, shippingAmount, shippingMethod, discountAmount, discountCode, taxAmount, convenienceFee, totalAmount (* required)

Hosted-checkout body on the wire:

{
  "merchantId":     "TESTMERCH",
  "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":       "<64-char lowercase hex — generated by the SDK>"
}

S2S UPI body — timestamp is a JSON string, webhookUrl is included:

{
  "merchantId":     "TESTMERCH",
  "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":       "<64-char lowercase hex — generated by the SDK>"
}

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 reference
  • state.payment_mode"UPI" / "CARD" / "NET_BANKING" / "WALLET"
  • state.trace_id — SabPaisa's correlation ID
  • state.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_bodycapture 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

  1. Always HTTPS. SabPaisa rejects HTTP return_url outside of localhost.
  2. Unique merchant_txn_id per attempt. UUID per Pay click. Fresh ID on retry, otherwise DuplicateTransactionError.
  3. Persist merchant_txn_id ↔ order_id BEFORE you redirect, not after.
  4. Server-side re-enquiry on every callback. Never trust the redirect alone.
  5. Reconciliation cron. For any order non-terminal > 30 min, run payments.status().
  6. Webhook receiver in production. client.webhooks(...).verify_signature(...). Required in practice for UPI.
  7. Match by merchant_txn_id, not amount. SabPaisa may add a convenience fee.
  8. Never log secret_key or webhook_secret. Log code, http_status, trace_id, merchant_txn_id.
  9. Pin the SDK. Lock to a known-tested version in your requirements. Read the CHANGELOG before bumping.
  10. TLS verification stays on. Default is on; don't disable it.
  11. Don't auto-retry POSTs. The SDK already enforces this — preserve the rule if you wrap it.
  12. 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 enquiryclient.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

MIT — see also the CHANGELOG for release notes.

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

sabpaisa_python-1.0.1.tar.gz (32.5 kB view details)

Uploaded Source

Built Distribution

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

sabpaisa_python-1.0.1-py3-none-any.whl (33.3 kB view details)

Uploaded Python 3

File details

Details for the file sabpaisa_python-1.0.1.tar.gz.

File metadata

  • Download URL: sabpaisa_python-1.0.1.tar.gz
  • Upload date:
  • Size: 32.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for sabpaisa_python-1.0.1.tar.gz
Algorithm Hash digest
SHA256 8f9a2708402dfa8894bbeccfdfc19084f849cc0b160747b279aa75a078365d6d
MD5 6ff723dfb5fbf40b60c1afea086dcf85
BLAKE2b-256 f17b4f8802efc0cf06c475aad53bb2d14ffa8f3edb3191bbfbd585026555093d

See more details on using hashes here.

File details

Details for the file sabpaisa_python-1.0.1-py3-none-any.whl.

File metadata

File hashes

Hashes for sabpaisa_python-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 845720218ac148f584f5cec878ab9d5174996e4d0ef2a21830ca2d2a4c6f09ce
MD5 2645c1515ef7324beac9a42aec627cde
BLAKE2b-256 e9c24bd2d638ce87df5f4b3937333ab296f34f7a9ba1d0e4fe523642a2a21ac0

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