Metadata-Version: 2.4
Name: sabpaisa-python
Version: 1.0.1
Summary: Production-grade Python server-side SDK for the SabPaisa PG 2.0 payment gateway.
Author: SabPaisa SDK Authors
License-Expression: MIT
Project-URL: Homepage, https://sabpaisa.in
Project-URL: Documentation, https://docs.sabpaisa.in
Project-URL: Source, https://github.com/sabpaisa/sabpaisa-python-sdk
Keywords: sabpaisa,payment-gateway,payments,upi,refunds,fintech
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Topic :: Office/Business :: Financial
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1.0,>=0.27
Requires-Dist: pydantic<3.0,>=2.5
Requires-Dist: tenacity<10.0,>=8.2
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: black>=24.0; extra == "dev"
Dynamic: license-file

# SabPaisa Python SDK

Server-side Python SDK for the [SabPaisa Payment Gateway](https://sabpaisa.in)
— 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`](../sabpaisa-php-sdk/). For **OpenCart**, use the [ready-built plugins](../sabpaisa-opencart/). For mobile apps, use the [Android](../sabpaisa-android-sdk/) or [React Native](../sabpaisa-react-native/) SDK.

## Installation

```bash
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. **Install** — `pip 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 it** — `POST /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:**

```python
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):

```bash
# .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=
```

```python
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, **`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_summary`** — `subtotal`*, `shippingAmount`, `shippingMethod`, `discountAmount`, `discountCode`, `taxAmount`, `convenienceFee`, `totalAmount`*  (* required)

Hosted-checkout body on the wire:

```json
{
  "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:

```json
{
  "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:

```python
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):

```python
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:

```python
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):

```python
@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:

```python
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 |

```python
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`).

```python
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

```python
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.

```python
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](CHANGELOG.md) 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 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

```text
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`](INTEGRATION.md) for the step-by-step Flask / FastAPI walkthrough.

## License

[MIT](LICENSE) — see also the [CHANGELOG](CHANGELOG.md) for release notes.
